linear-sdk-cli 0.5.2 → 0.6.0

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.
@@ -0,0 +1,238 @@
1
+ /** First-class project-label access. Project labels are distinct from issue labels. */
2
+
3
+ import type { LinearClient } from "@linear/sdk";
4
+ import { withRetry } from "../client.js";
5
+ import { ambiguous, notFound, usageError } from "../lib/errors.js";
6
+ import { assertMutation, unwrapMutation } from "../lib/mutation.js";
7
+ import { collectRawQuery } from "../lib/pagination.js";
8
+ import { shape } from "../lib/shape.js";
9
+ import { isUuid } from "../lib/resolve.js";
10
+
11
+ export interface ProjectLabelRow {
12
+ id: string;
13
+ name: string;
14
+ color: string;
15
+ description: string | null;
16
+ isGroup: boolean;
17
+ parent: { id: string; name: string } | null;
18
+ retiredAt: string | null;
19
+ }
20
+
21
+ export interface ProjectLabelDetail extends ProjectLabelRow {
22
+ children: Array<{ id: string; name: string; color: string; retiredAt: string | null }>;
23
+ }
24
+
25
+ export const PROJECT_LABEL_ROW_SHAPE = shape<ProjectLabelRow>({
26
+ id: "string",
27
+ name: "string",
28
+ color: "string",
29
+ description: "string|null",
30
+ isGroup: "boolean",
31
+ parent: { nullable: { id: "string", name: "string" } },
32
+ retiredAt: "string|null",
33
+ });
34
+
35
+ export const PROJECT_LABEL_DETAIL_SHAPE = shape<ProjectLabelDetail>({
36
+ ...PROJECT_LABEL_ROW_SHAPE,
37
+ children: [{ id: "string", name: "string", color: "string", retiredAt: "string|null" }],
38
+ });
39
+
40
+ const FIELDS = `
41
+ id name color description isGroup retiredAt
42
+ parent { id name }
43
+ `;
44
+
45
+ const LIST_QUERY = `
46
+ query CliProjectLabels($filter: ProjectLabelFilter, $first: Int!, $after: String, $includeArchived: Boolean) {
47
+ projectLabels(filter: $filter, first: $first, after: $after, includeArchived: $includeArchived) {
48
+ nodes { ${FIELDS} }
49
+ pageInfo { hasNextPage endCursor }
50
+ }
51
+ }`;
52
+
53
+ const VIEW_QUERY = `
54
+ query CliProjectLabel($id: String!) {
55
+ projectLabel(id: $id) {
56
+ ${FIELDS}
57
+ children(first: 250, includeArchived: true) {
58
+ nodes { id name color retiredAt }
59
+ }
60
+ }
61
+ }`;
62
+
63
+ function row(node: any): ProjectLabelRow {
64
+ return {
65
+ id: node.id,
66
+ name: node.name,
67
+ color: node.color,
68
+ description: node.description ?? null,
69
+ isGroup: !!node.isGroup,
70
+ parent: node.parent ?? null,
71
+ retiredAt: node.retiredAt ?? null,
72
+ };
73
+ }
74
+
75
+ export async function listProjectLabels(
76
+ client: LinearClient,
77
+ limit: number,
78
+ includeRetired = false,
79
+ ): Promise<ProjectLabelRow[]> {
80
+ return collectRawQuery<ProjectLabelRow>(
81
+ client as any,
82
+ LIST_QUERY,
83
+ { includeArchived: includeRetired },
84
+ "projectLabels",
85
+ limit,
86
+ row,
87
+ );
88
+ }
89
+
90
+ async function resolveProjectLabelId(
91
+ client: LinearClient,
92
+ input: string,
93
+ groupsOnly = false,
94
+ ): Promise<string> {
95
+ if (isUuid(input)) return input;
96
+ const matches = await collectRawQuery<ProjectLabelRow>(
97
+ client as any,
98
+ LIST_QUERY,
99
+ {
100
+ filter: {
101
+ name: { eqIgnoreCase: input },
102
+ ...(groupsOnly ? { isGroup: { eq: true } } : {}),
103
+ },
104
+ includeArchived: true,
105
+ },
106
+ "projectLabels",
107
+ Infinity,
108
+ row,
109
+ );
110
+ const eligible = groupsOnly ? matches.filter((label) => !label.retiredAt) : matches;
111
+ if (eligible.length === 0)
112
+ throw notFound(
113
+ `No project label matching '${input}'. Run 'linear project-label list --include-retired' to see the options.`,
114
+ );
115
+ const exact = eligible.filter((label) => label.name === input);
116
+ const finalists = exact.length ? exact : eligible;
117
+ if (finalists.length > 1)
118
+ throw ambiguous(`Multiple project labels named '${input}'; pass the project label id instead.`);
119
+ return finalists[0]!.id;
120
+ }
121
+
122
+ export async function getProjectLabel(
123
+ client: LinearClient,
124
+ input: string,
125
+ groupsOnly = false,
126
+ ): Promise<ProjectLabelDetail> {
127
+ const id = await resolveProjectLabelId(client, input, groupsOnly);
128
+ const response: any = await withRetry(() =>
129
+ (client as any).client.rawRequest(VIEW_QUERY, { id }),
130
+ );
131
+ const label = response.data?.projectLabel;
132
+ if (!label) throw notFound(`No project label matching '${input}'.`);
133
+ return { ...row(label), children: label.children?.nodes ?? [] };
134
+ }
135
+
136
+ function validateName(name: string | undefined): void {
137
+ if (name !== undefined && name.trim().length === 0)
138
+ throw usageError("Label name cannot be empty.");
139
+ }
140
+
141
+ function validateColor(color: string | undefined): void {
142
+ if (color !== undefined && !/^#[0-9a-f]{6}$/i.test(color))
143
+ throw usageError("Label color must be a six-digit hex value such as #5E6AD2.");
144
+ }
145
+
146
+ async function resolveParent(client: LinearClient, input: string): Promise<ProjectLabelDetail> {
147
+ const parent = await getProjectLabel(client, input, true);
148
+ if (!parent.isGroup) throw usageError(`Project label '${parent.name}' is not a label group.`);
149
+ if (parent.retiredAt) throw usageError(`Project label group '${parent.name}' is retired.`);
150
+ return parent;
151
+ }
152
+
153
+ export interface CreateOptions {
154
+ name: string;
155
+ color?: string;
156
+ description?: string;
157
+ group?: boolean;
158
+ parent?: string;
159
+ }
160
+
161
+ export async function createProjectLabel(client: LinearClient, opts: CreateOptions) {
162
+ validateName(opts.name);
163
+ validateColor(opts.color);
164
+ if (opts.group && opts.parent)
165
+ throw usageError("A project label group cannot itself belong to another group.");
166
+ const input: Record<string, unknown> = { name: opts.name.trim() };
167
+ if (opts.color !== undefined) input.color = opts.color;
168
+ if (opts.description !== undefined) input.description = opts.description;
169
+ if (opts.group) input.isGroup = true;
170
+ if (opts.parent) input.parentId = (await resolveParent(client, opts.parent)).id;
171
+ return unwrapMutation(
172
+ withRetry(() => client.createProjectLabel(input as any)),
173
+ "projectLabel",
174
+ "Project label creation",
175
+ );
176
+ }
177
+
178
+ export interface UpdateOptions {
179
+ name?: string;
180
+ color?: string;
181
+ description?: string;
182
+ parent?: string;
183
+ clearParent?: boolean;
184
+ }
185
+
186
+ export async function updateProjectLabel(client: LinearClient, input: string, opts: UpdateOptions) {
187
+ validateName(opts.name);
188
+ validateColor(opts.color);
189
+ if (opts.parent && opts.clearParent)
190
+ throw usageError("Pass either --parent or --clear-parent, not both.");
191
+ const current = await getProjectLabel(client, input);
192
+ if (current.isGroup && opts.parent)
193
+ throw usageError("A project label group cannot itself belong to another group.");
194
+ const update: Record<string, unknown> = {};
195
+ if (opts.name !== undefined) update.name = opts.name.trim();
196
+ if (opts.color !== undefined) update.color = opts.color;
197
+ if (opts.description !== undefined) update.description = opts.description;
198
+ if (opts.parent) update.parentId = (await resolveParent(client, opts.parent)).id;
199
+ if (opts.clearParent) update.parentId = null;
200
+ if (Object.keys(update).length === 0)
201
+ throw usageError(
202
+ "Nothing to update; pass at least one of --name, --color, --description, --parent, --clear-parent.",
203
+ );
204
+ return unwrapMutation(
205
+ withRetry(() => client.updateProjectLabel(current.id, update as any)),
206
+ "projectLabel",
207
+ "Project label update",
208
+ );
209
+ }
210
+
211
+ export async function retireProjectLabel(client: LinearClient, input: string) {
212
+ const label = await getProjectLabel(client, input);
213
+ if (label.retiredAt) throw usageError(`Project label '${label.name}' is already retired.`);
214
+ return unwrapMutation(
215
+ withRetry(() => client.projectLabelRetire(label.id)),
216
+ "projectLabel",
217
+ "Project label retirement",
218
+ );
219
+ }
220
+
221
+ export async function restoreProjectLabel(client: LinearClient, input: string) {
222
+ const label = await getProjectLabel(client, input);
223
+ if (!label.retiredAt) throw usageError(`Project label '${label.name}' is not retired.`);
224
+ return unwrapMutation(
225
+ withRetry(() => client.projectLabelRestore(label.id)),
226
+ "projectLabel",
227
+ "Project label restoration",
228
+ );
229
+ }
230
+
231
+ export async function deleteProjectLabel(client: LinearClient, input: string) {
232
+ const label = await getProjectLabel(client, input);
233
+ await assertMutation(
234
+ withRetry(() => client.deleteProjectLabel(label.id)),
235
+ "Project label deletion",
236
+ );
237
+ return label;
238
+ }
@@ -0,0 +1,225 @@
1
+ /** Workspace project-status definitions. */
2
+
3
+ import type { LinearClient } from "@linear/sdk";
4
+ import { withRetry } from "../client.js";
5
+ import { ambiguous, notFound, usageError } from "../lib/errors.js";
6
+ import { assertMutation, unwrapMutation } from "../lib/mutation.js";
7
+ import { collect, inheritPaginationMetadata, pageSize } from "../lib/pagination.js";
8
+ import { isUuid } from "../lib/resolve.js";
9
+ import { shape } from "../lib/shape.js";
10
+
11
+ export const PROJECT_STATUS_TYPES = [
12
+ "backlog",
13
+ "planned",
14
+ "started",
15
+ "paused",
16
+ "completed",
17
+ "canceled",
18
+ ] as const;
19
+ export type ProjectStatusType = (typeof PROJECT_STATUS_TYPES)[number];
20
+
21
+ export interface ProjectStatusRow {
22
+ id: string;
23
+ name: string;
24
+ description: string | null;
25
+ color: string;
26
+ position: number;
27
+ type: string;
28
+ indefinite: boolean;
29
+ archivedAt: string | null;
30
+ }
31
+
32
+ export const PROJECT_STATUS_ROW_SHAPE = shape<ProjectStatusRow>({
33
+ id: "string",
34
+ name: "string",
35
+ description: "string|null",
36
+ color: "string",
37
+ position: "number",
38
+ type: "string",
39
+ indefinite: "boolean",
40
+ archivedAt: "string|null",
41
+ });
42
+
43
+ export interface ProjectStatusDetail extends ProjectStatusRow {
44
+ createdAt: string;
45
+ updatedAt: string;
46
+ }
47
+
48
+ export const PROJECT_STATUS_DETAIL_SHAPE = shape<ProjectStatusDetail>({
49
+ ...PROJECT_STATUS_ROW_SHAPE,
50
+ createdAt: "string",
51
+ updatedAt: "string",
52
+ });
53
+
54
+ export async function listProjectStatuses(
55
+ client: LinearClient,
56
+ limit: number,
57
+ includeArchived = false,
58
+ ): Promise<ProjectStatusRow[]> {
59
+ const conn = await withRetry(() =>
60
+ client.projectStatuses({ first: pageSize(limit), includeArchived }),
61
+ );
62
+ const nodes = await collect(conn as any, limit);
63
+ return inheritPaginationMetadata(nodes.map(toRow).sort(compareStatuses), nodes);
64
+ }
65
+
66
+ export async function getProjectStatusDetail(
67
+ client: LinearClient,
68
+ input: string,
69
+ ): Promise<ProjectStatusDetail> {
70
+ const id = await resolveProjectStatusId(client, input, { includeArchived: true });
71
+ const status = await withRetry(() => client.projectStatus(id));
72
+ return {
73
+ ...toRow(status),
74
+ createdAt: toIso(status.createdAt),
75
+ updatedAt: toIso(status.updatedAt),
76
+ };
77
+ }
78
+
79
+ export interface ProjectStatusInput {
80
+ name?: string;
81
+ description?: string;
82
+ color?: string;
83
+ position?: number;
84
+ type?: string;
85
+ indefinite?: boolean;
86
+ }
87
+
88
+ export async function createProjectStatus(client: LinearClient, opts: ProjectStatusInput) {
89
+ const input = buildInput(opts, true);
90
+ return unwrapMutation(
91
+ withRetry(() => client.createProjectStatus(input as any)),
92
+ "status",
93
+ "Project status creation",
94
+ );
95
+ }
96
+
97
+ export async function updateProjectStatus(
98
+ client: LinearClient,
99
+ input: string,
100
+ opts: ProjectStatusInput,
101
+ ) {
102
+ const id = await resolveProjectStatusId(client, input);
103
+ const update = buildInput(opts, false);
104
+ if (Object.keys(update).length === 0)
105
+ throw usageError("Nothing to update; pass at least one project-status field.");
106
+ return unwrapMutation(
107
+ withRetry(() => client.updateProjectStatus(id, update as any)),
108
+ "status",
109
+ "Project status update",
110
+ );
111
+ }
112
+
113
+ export async function archiveProjectStatus(client: LinearClient, input: string) {
114
+ const id = await resolveProjectStatusId(client, input);
115
+ const status = await withRetry(() => client.projectStatus(id));
116
+ await assertMutation(
117
+ withRetry(() => client.archiveProjectStatus(id)),
118
+ "Project status archive",
119
+ );
120
+ return status;
121
+ }
122
+
123
+ export async function unarchiveProjectStatus(client: LinearClient, input: string) {
124
+ const id = await resolveProjectStatusId(client, input, { includeArchived: true });
125
+ const status = await withRetry(() => client.projectStatus(id));
126
+ await assertMutation(
127
+ withRetry(() => client.unarchiveProjectStatus(id)),
128
+ "Project status unarchive",
129
+ );
130
+ return status;
131
+ }
132
+
133
+ /** Shared by project-status commands and project create/update --state. */
134
+ export async function resolveProjectStatusId(
135
+ client: LinearClient,
136
+ input: string,
137
+ opts: { includeArchived?: boolean; allowType?: boolean } = {},
138
+ ): Promise<string> {
139
+ if (isUuid(input)) return input;
140
+ const conn = await withRetry(() =>
141
+ client.projectStatuses({ first: 250, includeArchived: opts.includeArchived === true }),
142
+ );
143
+ const statuses = (await collect(conn as any, Infinity)) as Array<{
144
+ id: string;
145
+ name: string;
146
+ type: string;
147
+ archivedAt?: unknown;
148
+ }>;
149
+ const exact = statuses.filter((s) => s.name === input);
150
+ const insensitive = statuses.filter((s) => s.name.toLowerCase() === input.toLowerCase());
151
+ let matches = exact.length ? exact : insensitive;
152
+ if (matches.length === 0 && opts.allowType) {
153
+ matches = statuses.filter((s) => s.type.toLowerCase() === input.toLowerCase());
154
+ }
155
+ if (matches.length === 0)
156
+ throw notFound(
157
+ `No project status matching '${input}'. Run 'linear project-status list${opts.includeArchived ? " --include-archived" : ""}' to see the options.`,
158
+ );
159
+ if (matches.length > 1)
160
+ throw ambiguous(
161
+ `Multiple project statuses match '${input}': ${matches.map((s) => `${s.name} (${s.id})`).join(", ")}. Pass the project status id instead.`,
162
+ );
163
+ return matches[0]!.id;
164
+ }
165
+
166
+ function buildInput(opts: ProjectStatusInput, create: boolean): Record<string, unknown> {
167
+ const input: Record<string, unknown> = {};
168
+ if (opts.name !== undefined) {
169
+ if (!opts.name.trim()) throw usageError("Project status name must not be empty.");
170
+ input.name = opts.name;
171
+ }
172
+ if (opts.description !== undefined) input.description = opts.description;
173
+ if (opts.color !== undefined) {
174
+ if (!/^#[0-9a-f]{6}$/i.test(opts.color))
175
+ throw usageError(
176
+ `Invalid color '${opts.color}'. Expected a six-digit hex color such as #5E6AD2.`,
177
+ );
178
+ input.color = opts.color;
179
+ }
180
+ if (opts.position !== undefined) {
181
+ if (!Number.isFinite(opts.position) || opts.position < 0)
182
+ throw usageError("Project status position must be a non-negative number.");
183
+ input.position = opts.position;
184
+ }
185
+ if (opts.type !== undefined) {
186
+ if (!PROJECT_STATUS_TYPES.includes(opts.type as ProjectStatusType))
187
+ throw usageError(
188
+ `Invalid project status type '${opts.type}'. Valid: ${PROJECT_STATUS_TYPES.join(", ")}.`,
189
+ );
190
+ input.type = opts.type;
191
+ }
192
+ if (opts.indefinite !== undefined) input.indefinite = opts.indefinite;
193
+ if (create) {
194
+ const missing = ["name", "color", "position", "type"].filter((key) => !(key in input));
195
+ if (missing.length)
196
+ throw usageError(
197
+ `Missing required project-status fields: ${missing.map((k) => `--${k}`).join(", ")}.`,
198
+ );
199
+ }
200
+ return input;
201
+ }
202
+
203
+ function toRow(status: any): ProjectStatusRow {
204
+ return {
205
+ id: status.id,
206
+ name: status.name,
207
+ description: status.description ?? null,
208
+ color: status.color,
209
+ position: status.position,
210
+ type: status.type,
211
+ indefinite: status.indefinite === true,
212
+ archivedAt: status.archivedAt ? toIso(status.archivedAt) : null,
213
+ };
214
+ }
215
+
216
+ function toIso(value: Date | string): string {
217
+ return value instanceof Date ? value.toISOString() : String(value);
218
+ }
219
+
220
+ function compareStatuses(a: ProjectStatusRow, b: ProjectStatusRow): number {
221
+ const type =
222
+ PROJECT_STATUS_TYPES.indexOf(a.type as ProjectStatusType) -
223
+ PROJECT_STATUS_TYPES.indexOf(b.type as ProjectStatusType);
224
+ return type || a.position - b.position || a.name.localeCompare(b.name);
225
+ }
@@ -23,6 +23,7 @@ import {
23
23
  resolveProjectLabelIds,
24
24
  isUuid,
25
25
  } from "../lib/resolve.js";
26
+ import { resolveProjectStatusId } from "./project-status.js";
26
27
 
27
28
  export interface ProjectRow {
28
29
  id: string;
@@ -291,7 +292,8 @@ export async function createProject(
291
292
  const input: Record<string, any> = { name: opts.name, teamIds };
292
293
  if (opts.description !== undefined) input.description = opts.description;
293
294
  if (opts.content !== undefined) input.content = opts.content;
294
- if (opts.state) input.statusId = await resolveStatusId(client, opts.state);
295
+ if (opts.state)
296
+ input.statusId = await resolveProjectStatusId(client, opts.state, { allowType: true });
295
297
  if (opts.lead) input.leadId = await resolveUserId(client, opts.lead);
296
298
  if (opts.startDate) input.startDate = opts.startDate;
297
299
  if (opts.targetDate) input.targetDate = opts.targetDate;
@@ -335,7 +337,8 @@ export async function updateProject(client: LinearClient, idArg: string, opts: U
335
337
  if (opts.content !== undefined) input.content = opts.content;
336
338
  if (opts.team?.length) input.teamIds = await resolveTeamIds(client, opts.team, undefined);
337
339
  if (opts.lead) input.leadId = await resolveUserId(client, opts.lead);
338
- if (opts.state) input.statusId = await resolveStatusId(client, opts.state);
340
+ if (opts.state)
341
+ input.statusId = await resolveProjectStatusId(client, opts.state, { allowType: true });
339
342
  if (opts.startDate) input.startDate = opts.startDate;
340
343
  if (opts.targetDate) input.targetDate = opts.targetDate;
341
344
  if (opts.priority !== undefined) input.priority = resolvePriority(opts.priority);
@@ -480,26 +483,3 @@ async function resolveTeamIds(
480
483
  }
481
484
  return ids;
482
485
  }
483
-
484
- /** Resolve a project status (by name or type) to a status id. */
485
- async function resolveStatusId(client: LinearClient, input: string): Promise<string> {
486
- if (isUuid(input)) return input;
487
- const conn = await withRetry(() => client.projectStatuses({ first: 250 }));
488
- const statuses = (await collect(conn as any, Infinity)) as Array<{
489
- id: string;
490
- name: string;
491
- type: string;
492
- }>;
493
- const lower = input.toLowerCase();
494
- const byName = statuses.filter((s) => s.name.toLowerCase() === lower);
495
- const matches = byName.length ? byName : statuses.filter((s) => s.type.toLowerCase() === lower);
496
- if (matches.length === 0)
497
- throw notFound(
498
- `No project status '${input}'. Available: ${statuses.map((s) => s.name).join(", ")}`,
499
- );
500
- if (matches.length > 1)
501
- throw ambiguous(
502
- `Multiple project statuses match '${input}': ${matches.map((s) => s.name).join(", ")}`,
503
- );
504
- return matches[0]!.id;
505
- }