Skip to content

Commit 21a13c9

Browse files
committed
feat(labels): add issue label CRUD commands
Refs DBL-191
1 parent aa91a15 commit 21a13c9

8 files changed

Lines changed: 914 additions & 12 deletions

File tree

graphql/mutations/labels.graphql

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
# ------------------------------------------------------------
2+
# GraphQL mutations for Linear issue labels
3+
# ------------------------------------------------------------
4+
5+
mutation CreateIssueLabel($input: IssueLabelCreateInput!) {
6+
issueLabelCreate(input: $input) {
7+
success
8+
issueLabel {
9+
...LabelFields
10+
}
11+
}
12+
}
13+
14+
mutation UpdateIssueLabel($id: String!, $input: IssueLabelUpdateInput!) {
15+
issueLabelUpdate(id: $id, input: $input) {
16+
success
17+
issueLabel {
18+
...LabelFields
19+
}
20+
}
21+
}
22+
23+
mutation DeleteIssueLabel($id: String!) {
24+
issueLabelDelete(id: $id) {
25+
success
26+
entityId
27+
}
28+
}

graphql/queries/labels.graphql

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,12 @@ fragment ProjectLabelFields on ProjectLabel {
2929
description
3030
}
3131

32+
query GetIssueLabel($id: String!) {
33+
issueLabel(id: $id) {
34+
...LabelFields
35+
}
36+
}
37+
3238
# List labels in the workspace
3339
#
3440
# Fetches a list of issue labels with optional team filtering.

src/commands/labels.ts

Lines changed: 218 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,24 @@ import {
77
import { invalidParameterError } from "../common/errors.js";
88
import { handleCommand, outputSuccess, parseLimit } from "../common/output.js";
99
import { type DomainMeta, formatDomainUsage } from "../common/usage.js";
10+
import type {
11+
IssueLabelCreateInput,
12+
IssueLabelUpdateInput,
13+
} from "../gql/graphql.js";
14+
import {
15+
type LabelResolverScope,
16+
resolveLabelId,
17+
} from "../resolvers/label-resolver.js";
1018
import { resolveTeamId } from "../resolvers/team-resolver.js";
1119
import {
20+
createLabel,
21+
deleteLabel,
22+
getLabel,
1223
type LabelScope,
1324
type LabelType,
1425
listLabels,
1526
listProjectLabels,
27+
updateLabel,
1628
} from "../services/label-service.js";
1729

1830
interface ListLabelsOptions extends CommandOptions {
@@ -23,6 +35,23 @@ interface ListLabelsOptions extends CommandOptions {
2335
after?: string;
2436
}
2537

38+
interface LabelLookupOptions extends CommandOptions {
39+
team?: string;
40+
scope?: string;
41+
}
42+
43+
interface CreateLabelOptions extends CommandOptions {
44+
team?: string;
45+
color?: string;
46+
description?: string;
47+
}
48+
49+
interface UpdateLabelOptions extends LabelLookupOptions {
50+
name?: string;
51+
color?: string;
52+
description?: string;
53+
}
54+
2655
function parseLabelType(value?: string): LabelType {
2756
if (value === undefined || value === "issue" || value === "project") {
2857
return value ?? "issue";
@@ -42,16 +71,89 @@ function parseLabelScope(value?: string): LabelScope | undefined {
4271
);
4372
}
4473

74+
function parseLabelColor(value?: string): string | undefined {
75+
if (value === undefined) {
76+
return undefined;
77+
}
78+
79+
if (!/^#[0-9a-fA-F]{6}$/.test(value)) {
80+
throw invalidParameterError("--color", "must be a hex color like #B45309");
81+
}
82+
83+
return value;
84+
}
85+
86+
async function resolveIssueLabelLookup(
87+
command: Command,
88+
label: string,
89+
options: LabelLookupOptions,
90+
): Promise<{ ctx: ReturnType<typeof createContext>; labelId: string }> {
91+
const ctx = createContext(getRootOpts(command));
92+
const scope = parseLabelScope(options.scope);
93+
94+
if (scope === "team" && !options.team) {
95+
throw invalidParameterError("--scope", "team scope requires --team");
96+
}
97+
98+
if (scope === "workspace" && options.team) {
99+
throw invalidParameterError(
100+
"--team",
101+
"cannot be used with --scope workspace",
102+
);
103+
}
104+
105+
const teamId = options.team
106+
? await resolveTeamId(ctx.sdk, options.team)
107+
: undefined;
108+
const labelId = await resolveLabelId(ctx.sdk, label, {
109+
teamId,
110+
scope: scope as LabelResolverScope | undefined,
111+
});
112+
113+
return { ctx, labelId };
114+
}
115+
116+
function buildUpdateInput(options: UpdateLabelOptions): IssueLabelUpdateInput {
117+
const input: IssueLabelUpdateInput = {};
118+
const color = parseLabelColor(options.color);
119+
120+
if (options.name) {
121+
input.name = options.name;
122+
}
123+
124+
if (color) {
125+
input.color = color;
126+
}
127+
128+
if (options.description) {
129+
input.description = options.description;
130+
}
131+
132+
if (Object.keys(input).length === 0) {
133+
throw invalidParameterError(
134+
"label update",
135+
"at least one option must be provided",
136+
);
137+
}
138+
139+
return input;
140+
}
141+
45142
export const LABELS_META: DomainMeta = {
46143
name: "labels",
47144
summary: "categorization tags for issues and projects",
48145
context: [
49146
"issue labels can exist at workspace level or be scoped to a specific",
50-
"team. project labels are workspace-level only. use with issues",
51-
"create/update --labels and projects create/update --labels.",
147+
"team. project labels are workspace-level only. use labels list to",
148+
"inspect existing labels, labels create/read/update/delete for issue",
149+
"labels, and issues/projects create/update --labels to apply them.",
52150
].join("\n"),
53-
arguments: {},
151+
arguments: { name: "label name or UUID" },
54152
seeAlso: [
153+
"labels create <name>",
154+
"labels read <label>",
155+
"labels update <label>",
156+
"labels delete <label>",
55157
"issues create --labels",
56158
"issues update --labels",
57159
"projects create --labels",
@@ -122,6 +224,119 @@ export function setupLabelsCommands(program: Command): void {
122224
}),
123225
);
124226

227+
labels
228+
.command("create <name>")
229+
.description("create an issue label")
230+
.option("--team <team>", "create a team-scoped label (key, name, or UUID)")
231+
.option("--color <hex>", "label color as a hex code (for example #B45309)")
232+
.option("--description <text>", "label description")
233+
.action(
234+
handleCommand(async (...args: unknown[]) => {
235+
const [name, options, command] = args as [
236+
string,
237+
CreateLabelOptions,
238+
Command,
239+
];
240+
const ctx = createContext(getRootOpts(command));
241+
242+
const input: IssueLabelCreateInput = { name };
243+
const color = parseLabelColor(options.color);
244+
245+
if (options.team) {
246+
input.teamId = await resolveTeamId(ctx.sdk, options.team);
247+
}
248+
249+
if (color) {
250+
input.color = color;
251+
}
252+
253+
if (options.description) {
254+
input.description = options.description;
255+
}
256+
257+
outputSuccess(await createLabel(ctx.gql, input));
258+
}),
259+
);
260+
261+
labels
262+
.command("read <label>")
263+
.description("read an issue label")
264+
.option(
265+
"--team <team>",
266+
"resolve a team-scoped label by team (key, name, or UUID)",
267+
)
268+
.option("--scope <scope>", "resolve within workspace or team scope")
269+
.action(
270+
handleCommand(async (...args: unknown[]) => {
271+
const [label, options, command] = args as [
272+
string,
273+
LabelLookupOptions,
274+
Command,
275+
];
276+
const { ctx, labelId } = await resolveIssueLabelLookup(
277+
command,
278+
label,
279+
options,
280+
);
281+
282+
outputSuccess(await getLabel(ctx.gql, labelId));
283+
}),
284+
);
285+
286+
labels
287+
.command("update <label>")
288+
.description("update an issue label")
289+
.option(
290+
"--team <team>",
291+
"resolve a team-scoped label by team (key, name, or UUID)",
292+
)
293+
.option("--scope <scope>", "resolve within workspace or team scope")
294+
.option("--name <name>", "new label name")
295+
.option("--color <hex>", "new label color as a hex code")
296+
.option("--description <text>", "new label description")
297+
.action(
298+
handleCommand(async (...args: unknown[]) => {
299+
const [label, options, command] = args as [
300+
string,
301+
UpdateLabelOptions,
302+
Command,
303+
];
304+
const input = buildUpdateInput(options);
305+
const { ctx, labelId } = await resolveIssueLabelLookup(
306+
command,
307+
label,
308+
options,
309+
);
310+
311+
outputSuccess(await updateLabel(ctx.gql, labelId, input));
312+
}),
313+
);
314+
315+
labels
316+
.command("delete <label>")
317+
.description("delete an issue label")
318+
.option(
319+
"--team <team>",
320+
"resolve a team-scoped label by team (key, name, or UUID)",
321+
)
322+
.option("--scope <scope>", "resolve within workspace or team scope")
323+
.action(
324+
handleCommand(async (...args: unknown[]) => {
325+
const [label, options, command] = args as [
326+
string,
327+
LabelLookupOptions,
328+
Command,
329+
];
330+
const { ctx, labelId } = await resolveIssueLabelLookup(
331+
command,
332+
label,
333+
options,
334+
);
335+
336+
outputSuccess(await deleteLabel(ctx.gql, labelId));
337+
}),
338+
);
339+
125340
labels
126341
.command("usage")
127342
.description("show detailed usage for labels")

src/resolvers/label-resolver.ts

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,50 @@ import type { LinearSdkClient } from "../client/linear-client.js";
22
import { notFoundError } from "../common/errors.js";
33
import { isUuid } from "../common/identifier.js";
44

5+
export type LabelResolverScope = "workspace" | "team";
6+
7+
export interface ResolveLabelOptions {
8+
teamId?: string;
9+
scope?: LabelResolverScope;
10+
}
11+
12+
function buildLabelFilter(
13+
nameOrId: string,
14+
options: ResolveLabelOptions,
15+
): Record<string, unknown> {
16+
if (options.scope === "workspace") {
17+
return {
18+
name: { eqIgnoreCase: nameOrId },
19+
team: { null: true },
20+
};
21+
}
22+
23+
if (options.scope === "team" && options.teamId) {
24+
return {
25+
name: { eqIgnoreCase: nameOrId },
26+
team: { id: { eq: options.teamId }, null: false },
27+
};
28+
}
29+
30+
if (options.teamId) {
31+
return {
32+
name: { eqIgnoreCase: nameOrId },
33+
team: { id: { eq: options.teamId } },
34+
};
35+
}
36+
37+
return { name: { eqIgnoreCase: nameOrId } };
38+
}
39+
540
export async function resolveLabelId(
641
client: LinearSdkClient,
742
nameOrId: string,
43+
options: ResolveLabelOptions = {},
844
): Promise<string> {
945
if (isUuid(nameOrId)) return nameOrId;
1046

1147
const result = await client.sdk.issueLabels({
12-
filter: { name: { eqIgnoreCase: nameOrId } },
48+
filter: buildLabelFilter(nameOrId, options),
1349
first: 1,
1450
});
1551

0 commit comments

Comments
 (0)