Skip to content

Commit fc18e60

Browse files
committed
feat(labels): consolidate 5 label tools into 2 CQRS tools
- browse_labels (Query): list, get actions - manage_label (Command): create, update, delete actions Uses flat z.object() with .refine() pattern for Claude API compatibility. Added JSON content type for POST/PUT requests. Closes #8
1 parent 578be93 commit fc18e60

File tree

5 files changed

+874
-637
lines changed

5 files changed

+874
-637
lines changed

src/entities/labels/registry.ts

Lines changed: 107 additions & 79 deletions
Original file line numberDiff line numberDiff line change
@@ -1,117 +1,145 @@
11
import * as z from "zod";
2-
import { ListLabelsSchema, GetLabelSchema } from "./schema-readonly";
3-
import { CreateLabelSchema, UpdateLabelSchema, DeleteLabelSchema } from "./schema";
2+
import { BrowseLabelsSchema } from "./schema-readonly";
3+
import { ManageLabelSchema } from "./schema";
44
import { gitlab, toQuery } from "../../utils/gitlab-api";
55
import { resolveNamespaceForAPI } from "../../utils/namespace";
66
import { ToolRegistry, EnhancedToolDefinition } from "../../types";
7+
import { assertDefined } from "../utils";
78

89
/**
9-
* Labels tools registry - unified registry containing all labels tools with their handlers
10+
* Labels tools registry - 2 CQRS tools replacing 5 individual tools
11+
*
12+
* browse_labels (Query): list, get
13+
* manage_label (Command): create, update, delete
1014
*/
1115
export const labelsToolRegistry: ToolRegistry = new Map<string, EnhancedToolDefinition>([
16+
// ============================================================================
17+
// browse_labels - CQRS Query Tool
18+
// ============================================================================
1219
[
13-
"list_labels",
20+
"browse_labels",
1421
{
15-
name: "list_labels",
22+
name: "browse_labels",
1623
description:
17-
"DISCOVER FIRST: Browse all existing labels in a project or group - RUN THIS BEFORE creating new labels! Use when: Choosing labels for issues/MRs, Understanding established taxonomy, Avoiding duplicate label creation. Returns label names, colors, descriptions, and priorities. Group labels are inherited by all projects. See also: create_label (only after checking existing labels).",
18-
inputSchema: z.toJSONSchema(ListLabelsSchema),
24+
'BROWSE labels. Actions: "list" shows all labels in project/group with filtering, "get" retrieves single label details by ID or name.',
25+
inputSchema: z.toJSONSchema(BrowseLabelsSchema),
1926
handler: async (args: unknown) => {
20-
const options = ListLabelsSchema.parse(args);
21-
const { namespace } = options;
22-
const entityType = namespace.includes("/") ? "projects" : "groups";
27+
const input = BrowseLabelsSchema.parse(args);
28+
const { entityType, encodedPath } = await resolveNamespaceForAPI(input.namespace);
2329

24-
return gitlab.get(`${entityType}/${encodeURIComponent(namespace)}/labels`, {
25-
query: toQuery(options, ["namespace"]),
26-
});
27-
},
28-
},
29-
],
30-
[
31-
"get_label",
32-
{
33-
name: "get_label",
34-
description:
35-
"READ: Retrieve details of a specific label by ID or name. Use when: Getting full label information including color and description, Checking label usage statistics, Validating label properties. Works for both project-specific and group-inherited labels. See also: list_labels to browse all available labels first.",
36-
inputSchema: z.toJSONSchema(GetLabelSchema),
37-
handler: async (args: unknown) => {
38-
const options = GetLabelSchema.parse(args);
39-
const { entityType, encodedPath } = await resolveNamespaceForAPI(options.namespace);
30+
switch (input.action) {
31+
case "list": {
32+
const { action: _action, namespace: _namespace, label_id: _labelId, ...rest } = input;
33+
const query = toQuery(rest, []);
4034

41-
return gitlab.get(
42-
`${entityType}/${encodedPath}/labels/${encodeURIComponent(options.label_id)}`
43-
);
44-
},
45-
},
46-
],
47-
[
48-
"create_label",
49-
{
50-
name: "create_label",
51-
description:
52-
"CREATE CAREFULLY: Add a new label ONLY after running list_labels to check existing taxonomy! Use when: Existing labels do not fit your needs, Establishing new project taxonomy. AVOID: Creating duplicates of existing labels with slight variations. Requires name and color (hex format like #FF0000). Group labels automatically become available to all child projects. See also: list_labels (run first to discover existing labels).",
53-
inputSchema: z.toJSONSchema(CreateLabelSchema),
54-
handler: async (args: unknown) => {
55-
const options = CreateLabelSchema.parse(args);
56-
const { entityType, encodedPath } = await resolveNamespaceForAPI(options.namespace);
57-
58-
return gitlab.post(`${entityType}/${encodedPath}/labels`, {
59-
body: {
60-
name: options.name,
61-
color: options.color,
62-
description: options.description,
63-
priority: options.priority,
64-
},
65-
});
66-
},
67-
},
68-
],
69-
[
70-
"update_label",
71-
{
72-
name: "update_label",
73-
description:
74-
"UPDATE: Modify label properties including name, color, description, or priority. Use when: Refining categorization system, Updating label appearance, Standardizing label naming. Changes apply immediately to all tagged items. Renaming updates all existing references automatically. See also: list_labels to understand current taxonomy before changes.",
75-
inputSchema: z.toJSONSchema(UpdateLabelSchema),
76-
handler: async (args: unknown) => {
77-
const options = UpdateLabelSchema.parse(args);
78-
const { entityType, encodedPath } = await resolveNamespaceForAPI(options.namespace);
35+
return gitlab.get(`${entityType}/${encodedPath}/labels`, { query });
36+
}
37+
38+
case "get": {
39+
// label_id is required for get action (validated by .refine())
40+
assertDefined(input.label_id, "label_id");
41+
const query = input.include_ancestor_groups
42+
? toQuery({ include_ancestor_groups: input.include_ancestor_groups }, [])
43+
: undefined;
44+
45+
return gitlab.get(
46+
`${entityType}/${encodedPath}/labels/${encodeURIComponent(input.label_id)}`,
47+
{ query }
48+
);
49+
}
7950

80-
const { namespace: _namespace, label_id, ...body } = options;
81-
return gitlab.put(`${entityType}/${encodedPath}/labels/${encodeURIComponent(label_id)}`, {
82-
body,
83-
});
51+
/* istanbul ignore next -- unreachable with Zod validation */
52+
default:
53+
throw new Error(`Unknown action: ${(input as { action: string }).action}`);
54+
}
8455
},
8556
},
8657
],
58+
59+
// ============================================================================
60+
// manage_label - CQRS Command Tool
61+
// ============================================================================
8762
[
88-
"delete_label",
63+
"manage_label",
8964
{
90-
name: "delete_label",
65+
name: "manage_label",
9166
description:
92-
"DELETE: Remove a label permanently from project or group. Use when: Cleaning up unused labels, Reorganizing taxonomy. WARNING: Removes label from all issues and MRs without replacement. Consider updating items before deletion. Cannot be undone. See also: list_labels to check label usage before deletion.",
93-
inputSchema: z.toJSONSchema(DeleteLabelSchema),
67+
'MANAGE labels. Actions: "create" adds new label (requires name and color), "update" modifies existing label, "delete" removes label permanently.',
68+
inputSchema: z.toJSONSchema(ManageLabelSchema),
9469
handler: async (args: unknown) => {
95-
const options = DeleteLabelSchema.parse(args);
96-
const { entityType, encodedPath } = await resolveNamespaceForAPI(options.namespace);
70+
const input = ManageLabelSchema.parse(args);
71+
const { entityType, encodedPath } = await resolveNamespaceForAPI(input.namespace);
72+
73+
switch (input.action) {
74+
case "create": {
75+
// name and color are required for create action (validated by .refine())
76+
assertDefined(input.name, "name");
77+
assertDefined(input.color, "color");
78+
79+
return gitlab.post(`${entityType}/${encodedPath}/labels`, {
80+
body: {
81+
name: input.name,
82+
color: input.color,
83+
description: input.description,
84+
priority: input.priority,
85+
},
86+
contentType: "json",
87+
});
88+
}
89+
90+
case "update": {
91+
// label_id is required for update action (validated by .refine())
92+
assertDefined(input.label_id, "label_id");
93+
const {
94+
action: _action,
95+
namespace: _namespace,
96+
label_id,
97+
name: _name,
98+
...body
99+
} = input;
97100

98-
await gitlab.delete(
99-
`${entityType}/${encodedPath}/labels/${encodeURIComponent(options.label_id)}`
100-
);
101-
return { success: true, message: "Label deleted successfully" };
101+
return gitlab.put(
102+
`${entityType}/${encodedPath}/labels/${encodeURIComponent(label_id)}`,
103+
{ body, contentType: "json" }
104+
);
105+
}
106+
107+
case "delete": {
108+
// label_id is required for delete action (validated by .refine())
109+
assertDefined(input.label_id, "label_id");
110+
111+
await gitlab.delete(
112+
`${entityType}/${encodedPath}/labels/${encodeURIComponent(input.label_id)}`
113+
);
114+
return { deleted: true };
115+
}
116+
117+
/* istanbul ignore next -- unreachable with Zod validation */
118+
default:
119+
throw new Error(`Unknown action: ${(input as { action: string }).action}`);
120+
}
102121
},
103122
},
104123
],
105124
]);
106125

126+
/**
127+
* Get read-only tool names from the registry
128+
*/
107129
export function getLabelsReadOnlyToolNames(): string[] {
108-
return ["list_labels", "get_label"];
130+
return ["browse_labels"];
109131
}
110132

133+
/**
134+
* Get all tool definitions from the registry
135+
*/
111136
export function getLabelsToolDefinitions(): EnhancedToolDefinition[] {
112137
return Array.from(labelsToolRegistry.values());
113138
}
114139

140+
/**
141+
* Get filtered tools based on read-only mode
142+
*/
115143
export function getFilteredLabelsTools(readOnlyMode: boolean = false): EnhancedToolDefinition[] {
116144
if (readOnlyMode) {
117145
const readOnlyNames = getLabelsReadOnlyToolNames();
Lines changed: 35 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,40 @@
11
import { z } from "zod";
2-
import { flexibleBoolean } from "../utils";
2+
import { flexibleBoolean, requiredId } from "../utils";
33

4-
// READ-ONLY LABEL OPERATION SCHEMAS
4+
// ============================================================================
5+
// browse_labels - CQRS Query Tool (flat schema for Claude API compatibility)
6+
// Actions: list, get
7+
// NOTE: Uses flat z.object() with .refine() instead of z.discriminatedUnion()
8+
// because Claude API doesn't support oneOf/allOf/anyOf at JSON Schema root level.
9+
// ============================================================================
510

6-
// Labels (read-only)
7-
export const ListLabelsSchema = z.object({
8-
namespace: z.string().describe("Namespace path (group or project) to list labels from"),
9-
with_counts: flexibleBoolean
10-
.optional()
11-
.describe("Whether or not to include issue and merge request counts"),
12-
include_ancestor_groups: flexibleBoolean.optional().describe("Include ancestor groups"),
13-
search: z.string().optional().describe("Keyword to filter labels by"),
14-
});
11+
export const BrowseLabelsSchema = z
12+
.object({
13+
action: z.enum(["list", "get"]).describe("Action to perform"),
14+
namespace: z.string().describe("Namespace path (group or project)"),
15+
// get action fields
16+
label_id: requiredId
17+
.optional()
18+
.describe("The ID or title of the label. Required for 'get' action."),
19+
// list action fields
20+
search: z.string().optional().describe("For 'list': keyword to filter labels by"),
21+
with_counts: flexibleBoolean
22+
.optional()
23+
.describe("For 'list': include issue and merge request counts"),
24+
include_ancestor_groups: flexibleBoolean
25+
.optional()
26+
.describe("Include ancestor groups when listing or getting labels"),
27+
// pagination fields (for list)
28+
per_page: z.number().optional().describe("Number of items per page"),
29+
page: z.number().optional().describe("Page number"),
30+
})
31+
.refine(data => data.action !== "get" || data.label_id !== undefined, {
32+
message: "label_id is required for 'get' action",
33+
path: ["label_id"],
34+
});
1535

16-
export const GetLabelSchema = z.object({
17-
namespace: z.string().describe("Namespace path (group or project) containing the label"),
18-
label_id: z.union([z.coerce.string(), z.string()]).describe("The ID or title of the label"),
19-
include_ancestor_groups: flexibleBoolean.optional().describe("Include ancestor groups"),
20-
});
36+
// ============================================================================
37+
// Type exports
38+
// ============================================================================
2139

22-
// Export type definitions
23-
export type ListLabelsOptions = z.infer<typeof ListLabelsSchema>;
24-
export type GetLabelOptions = z.infer<typeof GetLabelSchema>;
40+
export type BrowseLabelsInput = z.infer<typeof BrowseLabelsSchema>;

src/entities/labels/schema.ts

Lines changed: 48 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -1,50 +1,53 @@
11
import { z } from "zod";
2+
import { requiredId } from "../utils";
23

3-
// WRITE LABEL OPERATION SCHEMAS
4+
// ============================================================================
5+
// manage_label - CQRS Command Tool (flat schema for Claude API compatibility)
6+
// Actions: create, update, delete
7+
// NOTE: Uses flat z.object() with .refine() instead of z.discriminatedUnion()
8+
// because Claude API doesn't support oneOf/allOf/anyOf at JSON Schema root level.
9+
// ============================================================================
410

5-
// Label operations (write)
6-
export const CreateLabelSchema = z.object({
7-
namespace: z.string().describe("Namespace path (group or project) to create label in"),
8-
name: z.string().describe("The name of the label"),
9-
color: z
10-
.string()
11-
.describe(
12-
"The color of the label given in 6-digit hex notation with leading '#' sign (e.g. #FFAABB) or one of the CSS color names"
13-
),
14-
description: z.string().optional().describe("The description of the label"),
15-
priority: z
16-
.number()
17-
.optional()
18-
.describe(
19-
"The priority of the label. Must be greater or equal than zero or null to remove the priority"
20-
),
21-
});
11+
export const ManageLabelSchema = z
12+
.object({
13+
action: z.enum(["create", "update", "delete"]).describe("Action to perform"),
14+
namespace: z.string().describe("Namespace path (group or project)"),
15+
// update/delete action fields
16+
label_id: requiredId
17+
.optional()
18+
.describe("The ID or title of the label. Required for 'update' and 'delete' actions."),
19+
// create/update action fields
20+
name: z.string().optional().describe("The name of the label. Required for 'create' action."),
21+
new_name: z.string().optional().describe("For 'update': the new name of the label"),
22+
color: z
23+
.string()
24+
.optional()
25+
.describe(
26+
"The color of the label in 6-digit hex notation with leading '#' (e.g. #FFAABB) or CSS color name. Required for 'create' action."
27+
),
28+
description: z.string().optional().describe("The description of the label"),
29+
priority: z
30+
.number()
31+
.optional()
32+
.describe(
33+
"The priority of the label. Must be greater or equal than zero or null to remove the priority."
34+
),
35+
})
36+
.refine(data => data.action === "create" || data.label_id !== undefined, {
37+
message: "label_id is required for 'update' and 'delete' actions",
38+
path: ["label_id"],
39+
})
40+
.refine(data => data.action !== "create" || data.name !== undefined, {
41+
message: "name is required for 'create' action",
42+
path: ["name"],
43+
})
44+
.refine(data => data.action !== "create" || data.color !== undefined, {
45+
message: "color is required for 'create' action",
46+
path: ["color"],
47+
});
2248

23-
export const UpdateLabelSchema = z.object({
24-
namespace: z.string().describe("Namespace path (group or project) containing the label"),
25-
label_id: z.union([z.coerce.string(), z.string()]).describe("The ID or title of the label"),
26-
new_name: z.string().optional().describe("The new name of the label"),
27-
color: z
28-
.string()
29-
.optional()
30-
.describe(
31-
"The color of the label given in 6-digit hex notation with leading '#' sign (e.g. #FFAABB) or one of the CSS color names"
32-
),
33-
description: z.string().optional().describe("The description of the label"),
34-
priority: z
35-
.number()
36-
.optional()
37-
.describe(
38-
"The priority of the label. Must be greater or equal than zero or null to remove the priority"
39-
),
40-
});
49+
// ============================================================================
50+
// Type exports
51+
// ============================================================================
4152

42-
export const DeleteLabelSchema = z.object({
43-
namespace: z.string().describe("Namespace path (group or project) containing the label"),
44-
label_id: z.union([z.coerce.string(), z.string()]).describe("The ID or title of the label"),
45-
});
46-
47-
// Export type definitions
48-
export type CreateLabelOptions = z.infer<typeof CreateLabelSchema>;
49-
export type UpdateLabelOptions = z.infer<typeof UpdateLabelSchema>;
50-
export type DeleteLabelOptions = z.infer<typeof DeleteLabelSchema>;
53+
export type ManageLabelInput = z.infer<typeof ManageLabelSchema>;

0 commit comments

Comments
 (0)