linear-sdk-cli 0.3.3 → 0.4.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,376 @@
1
+ /**
2
+ * Custom-view service: typed @linear/sdk access, input construction, reference
3
+ * validation, pagination, and stable result shaping.
4
+ *
5
+ * Linear's `customViews` query deliberately excludes views scoped to a project
6
+ * or initiative. UUID lookup remains complete, so every single-view operation
7
+ * requires a UUID rather than offering a name lookup over an incomplete set.
8
+ */
9
+
10
+ import type { CustomView, LinearClient } from "@linear/sdk";
11
+ import { withRetry } from "../client.js";
12
+ import { usageError } from "../lib/errors.js";
13
+ import { assertMutation, unwrapMutation } from "../lib/mutation.js";
14
+ import { collect, inheritPaginationMetadata, pageSize } from "../lib/pagination.js";
15
+ import { isUuid, resolveProjectId, resolveTeam, resolveUserId } from "../lib/resolve.js";
16
+ import { shape } from "../lib/shape.js";
17
+ import { resolveInitiative } from "./initiative.js";
18
+
19
+ export const CUSTOM_VIEW_TYPES = ["issue", "project", "initiative"] as const;
20
+ export type CustomViewType = (typeof CUSTOM_VIEW_TYPES)[number];
21
+ type CustomViewCreateInput = Parameters<LinearClient["createCustomView"]>[0];
22
+ type CustomViewUpdateInput = Parameters<LinearClient["updateCustomView"]>[1];
23
+
24
+ export interface CustomViewPerson {
25
+ id: string;
26
+ displayName: string;
27
+ }
28
+
29
+ export interface CustomViewTeam {
30
+ id: string;
31
+ key: string;
32
+ name: string;
33
+ }
34
+
35
+ export interface CustomViewRow {
36
+ id: string;
37
+ name: string;
38
+ type: string;
39
+ shared: boolean;
40
+ owner: CustomViewPerson | null;
41
+ team: CustomViewTeam | null;
42
+ slugId: string;
43
+ updatedAt: string;
44
+ }
45
+
46
+ export const CUSTOM_VIEW_ROW_SHAPE = shape<CustomViewRow>({
47
+ id: "string",
48
+ name: "string",
49
+ type: "string",
50
+ shared: "boolean",
51
+ owner: { nullable: { id: "string", displayName: "string" } },
52
+ team: { nullable: { id: "string", key: "string", name: "string" } },
53
+ slugId: "string",
54
+ updatedAt: "string",
55
+ });
56
+
57
+ export interface CustomViewDetail extends CustomViewRow {
58
+ description: string | null;
59
+ filter: Record<string, unknown>;
60
+ color: string | null;
61
+ icon: string | null;
62
+ creator: CustomViewPerson | null;
63
+ createdAt: string;
64
+ archivedAt: string | null;
65
+ }
66
+
67
+ export const CUSTOM_VIEW_DETAIL_SHAPE = shape<CustomViewDetail>({
68
+ ...CUSTOM_VIEW_ROW_SHAPE,
69
+ description: "string|null",
70
+ filter: "object",
71
+ color: "string|null",
72
+ icon: "string|null",
73
+ creator: { nullable: { id: "string", displayName: "string" } },
74
+ createdAt: "string",
75
+ archivedAt: "string|null",
76
+ });
77
+
78
+ export interface CustomViewResultRow {
79
+ type: CustomViewType;
80
+ id: string;
81
+ identifier: string | null;
82
+ name: string;
83
+ url: string;
84
+ }
85
+
86
+ export const CUSTOM_VIEW_RESULT_ROW_SHAPE = shape<CustomViewResultRow>({
87
+ type: "string",
88
+ id: "string",
89
+ identifier: "string|null",
90
+ name: "string",
91
+ url: "string",
92
+ });
93
+
94
+ function iso(value: Date | string): string {
95
+ return value instanceof Date ? value.toISOString() : new Date(value).toISOString();
96
+ }
97
+
98
+ /** Stable CLI spelling of the API's modelName. */
99
+ export function customViewType(modelName: string): string {
100
+ switch (modelName.toLowerCase()) {
101
+ case "issue":
102
+ return "issue";
103
+ case "project":
104
+ return "project";
105
+ case "initiative":
106
+ return "initiative";
107
+ case "feeditem":
108
+ return "feed-item";
109
+ default:
110
+ return modelName;
111
+ }
112
+ }
113
+
114
+ function supportedType(modelName: string): CustomViewType {
115
+ const type = customViewType(modelName);
116
+ if ((CUSTOM_VIEW_TYPES as readonly string[]).includes(type)) return type as CustomViewType;
117
+ throw usageError(
118
+ `Custom view type '${type}' has no matched-entity command in the typed SDK. Supported: ${CUSTOM_VIEW_TYPES.join(
119
+ ", ",
120
+ )}.`,
121
+ );
122
+ }
123
+
124
+ function person(user: any): CustomViewPerson | null {
125
+ return user ? { id: user.id, displayName: user.displayName } : null;
126
+ }
127
+
128
+ function teamRow(team: any): CustomViewTeam | null {
129
+ return team ? { id: team.id, key: team.key, name: team.name } : null;
130
+ }
131
+
132
+ /** Project a model to a row, resolving the two displayed relations. */
133
+ export async function toRow(view: CustomView | any): Promise<CustomViewRow> {
134
+ const [owner, team] = await Promise.all([view.owner, view.team]);
135
+ return {
136
+ id: view.id,
137
+ name: view.name,
138
+ type: customViewType(view.modelName),
139
+ shared: view.shared,
140
+ owner: person(owner),
141
+ team: teamRow(team),
142
+ slugId: view.slugId,
143
+ updatedAt: iso(view.updatedAt),
144
+ };
145
+ }
146
+
147
+ export async function listCustomViews(
148
+ client: LinearClient,
149
+ limit: number,
150
+ ): Promise<CustomViewRow[]> {
151
+ const conn = await withRetry(() => client.customViews({ first: pageSize(limit) }));
152
+ const nodes = await collect(conn as any, limit);
153
+ return inheritPaginationMetadata(await Promise.all(nodes.map(toRow)), nodes);
154
+ }
155
+
156
+ /** UUID-only by design: the list query is not a complete namespace for names. */
157
+ export async function resolveCustomView(client: LinearClient, reference: string) {
158
+ if (!isUuid(reference)) {
159
+ throw usageError(
160
+ `A custom view must be referenced by UUID; names are not unique and scoped views are absent from 'custom-view list'.`,
161
+ );
162
+ }
163
+ return withRetry(() => client.customView(reference));
164
+ }
165
+
166
+ function filterOf(view: CustomView | any): Record<string, unknown> {
167
+ switch (customViewType(view.modelName)) {
168
+ case "project":
169
+ return (view.projectFilterData ?? {}) as Record<string, unknown>;
170
+ case "initiative":
171
+ return (view.initiativeFilterData ?? {}) as Record<string, unknown>;
172
+ case "feed-item":
173
+ return (view.feedItemFilterData ?? {}) as Record<string, unknown>;
174
+ default:
175
+ return (view.filterData ?? {}) as Record<string, unknown>;
176
+ }
177
+ }
178
+
179
+ export async function getCustomViewDetail(
180
+ client: LinearClient,
181
+ reference: string,
182
+ ): Promise<CustomViewDetail> {
183
+ const view = await resolveCustomView(client, reference);
184
+ const [row, creator] = await Promise.all([toRow(view), view.creator]);
185
+ return {
186
+ ...row,
187
+ description: view.description ?? null,
188
+ filter: filterOf(view),
189
+ color: view.color ?? null,
190
+ icon: view.icon ?? null,
191
+ creator: person(creator),
192
+ createdAt: iso(view.createdAt),
193
+ archivedAt: view.archivedAt ? iso(view.archivedAt) : null,
194
+ };
195
+ }
196
+
197
+ export async function listCustomViewResults(
198
+ client: LinearClient,
199
+ reference: string,
200
+ limit: number,
201
+ ): Promise<CustomViewResultRow[]> {
202
+ const view = await resolveCustomView(client, reference);
203
+ const type = supportedType(view.modelName);
204
+ const first = pageSize(limit);
205
+ const conn =
206
+ type === "issue"
207
+ ? await withRetry(() => view.issues({ first }))
208
+ : type === "project"
209
+ ? await withRetry(() => view.projects({ first }))
210
+ : await withRetry(() => view.initiatives({ first }));
211
+ const nodes = await collect<any>(conn as any, limit);
212
+ const rows = nodes.map((entity: any): CustomViewResultRow => ({
213
+ type,
214
+ id: entity.id,
215
+ identifier: type === "issue" ? entity.identifier : null,
216
+ name: type === "issue" ? entity.title : entity.name,
217
+ url: entity.url,
218
+ }));
219
+ return inheritPaginationMetadata(rows, nodes);
220
+ }
221
+
222
+ export interface CreateCustomViewOptions {
223
+ name: string;
224
+ type: CustomViewType;
225
+ filter?: Record<string, unknown>;
226
+ description?: string;
227
+ color?: string;
228
+ icon?: string;
229
+ owner?: string;
230
+ shared?: boolean;
231
+ scopeTeam?: string;
232
+ scopeProject?: string;
233
+ scopeInitiative?: string;
234
+ }
235
+
236
+ function applyFilter(
237
+ input: CustomViewCreateInput | CustomViewUpdateInput,
238
+ type: CustomViewType,
239
+ filter: Record<string, unknown>,
240
+ ): void {
241
+ switch (type) {
242
+ case "issue":
243
+ input.filterData = filter as NonNullable<CustomViewCreateInput["filterData"]>;
244
+ break;
245
+ case "project":
246
+ input.projectFilterData = filter as NonNullable<CustomViewCreateInput["projectFilterData"]>;
247
+ break;
248
+ case "initiative":
249
+ input.initiativeFilterData = filter as NonNullable<
250
+ CustomViewCreateInput["initiativeFilterData"]
251
+ >;
252
+ break;
253
+ }
254
+ }
255
+
256
+ async function createScopeInput(
257
+ client: LinearClient,
258
+ opts: Pick<CreateCustomViewOptions, "scopeTeam" | "scopeProject" | "scopeInitiative">,
259
+ ): Promise<Partial<Pick<CustomViewCreateInput, "teamId" | "projectId" | "initiativeId">>> {
260
+ const scopes = [opts.scopeTeam, opts.scopeProject, opts.scopeInitiative].filter(
261
+ (value) => value !== undefined,
262
+ );
263
+ if (scopes.length > 1) {
264
+ throw usageError("Pass at most one of --scope-team, --scope-project, or --scope-initiative.");
265
+ }
266
+ if (opts.scopeTeam) {
267
+ return { teamId: (await resolveTeam(client, opts.scopeTeam, undefined)).id };
268
+ }
269
+ if (opts.scopeProject) return { projectId: await resolveProjectId(client, opts.scopeProject) };
270
+ if (opts.scopeInitiative) {
271
+ return { initiativeId: (await resolveInitiative(client, opts.scopeInitiative)).id };
272
+ }
273
+ return {};
274
+ }
275
+
276
+ /** Exported for tests: constructs exactly the SDK input sent by create. */
277
+ export async function buildCreateCustomViewInput(
278
+ client: LinearClient,
279
+ opts: CreateCustomViewOptions,
280
+ ): Promise<CustomViewCreateInput> {
281
+ const input: CustomViewCreateInput = { name: opts.name };
282
+ applyFilter(input, opts.type, opts.filter ?? {});
283
+ if (opts.description !== undefined) input.description = opts.description;
284
+ if (opts.color !== undefined) input.color = opts.color;
285
+ if (opts.icon !== undefined) input.icon = opts.icon;
286
+ if (opts.owner) input.ownerId = await resolveUserId(client, opts.owner);
287
+ if (opts.shared !== undefined) input.shared = opts.shared;
288
+ Object.assign(input, await createScopeInput(client, opts));
289
+ return input;
290
+ }
291
+
292
+ export async function createCustomView(client: LinearClient, opts: CreateCustomViewOptions) {
293
+ const input = await buildCreateCustomViewInput(client, opts);
294
+ return unwrapMutation(
295
+ withRetry(() => client.createCustomView(input)),
296
+ "customView",
297
+ "Custom view creation",
298
+ );
299
+ }
300
+
301
+ export interface UpdateCustomViewOptions {
302
+ name?: string;
303
+ filter?: Record<string, unknown>;
304
+ description?: string;
305
+ clearDescription?: boolean;
306
+ color?: string;
307
+ clearColor?: boolean;
308
+ icon?: string;
309
+ clearIcon?: boolean;
310
+ owner?: string;
311
+ shared?: boolean;
312
+ scopeTeam?: string;
313
+ clearTeamScope?: boolean;
314
+ }
315
+
316
+ /** Exported for tests: omits every field the caller did not explicitly set. */
317
+ export async function buildUpdateCustomViewInput(
318
+ client: LinearClient,
319
+ view: CustomView | any,
320
+ opts: UpdateCustomViewOptions,
321
+ ): Promise<CustomViewUpdateInput> {
322
+ const input: CustomViewUpdateInput = {};
323
+ if (opts.name !== undefined) input.name = opts.name;
324
+ if (opts.filter !== undefined) applyFilter(input, supportedType(view.modelName), opts.filter);
325
+ if (opts.description !== undefined && opts.clearDescription) {
326
+ throw usageError("Pass either --description or --clear-description, not both.");
327
+ }
328
+ if (opts.description !== undefined) input.description = opts.description;
329
+ if (opts.clearDescription) input.description = null;
330
+ if (opts.color !== undefined && opts.clearColor) {
331
+ throw usageError("Pass either --color or --clear-color, not both.");
332
+ }
333
+ if (opts.color !== undefined) input.color = opts.color;
334
+ if (opts.clearColor) input.color = null;
335
+ if (opts.icon !== undefined && opts.clearIcon) {
336
+ throw usageError("Pass either --icon or --clear-icon, not both.");
337
+ }
338
+ if (opts.icon !== undefined) input.icon = opts.icon;
339
+ if (opts.clearIcon) input.icon = null;
340
+ if (opts.owner) input.ownerId = await resolveUserId(client, opts.owner);
341
+ if (opts.shared !== undefined) input.shared = opts.shared;
342
+ if (opts.scopeTeam !== undefined && opts.clearTeamScope) {
343
+ throw usageError("Pass either --scope-team or --clear-team-scope, not both.");
344
+ }
345
+ if (opts.scopeTeam !== undefined) {
346
+ input.teamId = (await resolveTeam(client, opts.scopeTeam, undefined)).id;
347
+ }
348
+ if (opts.clearTeamScope) input.teamId = null;
349
+ if (Object.keys(input).length === 0) {
350
+ throw usageError("Nothing to update; pass at least one field.");
351
+ }
352
+ return input;
353
+ }
354
+
355
+ export async function updateCustomView(
356
+ client: LinearClient,
357
+ reference: string,
358
+ opts: UpdateCustomViewOptions,
359
+ ) {
360
+ const view = await resolveCustomView(client, reference);
361
+ const input = await buildUpdateCustomViewInput(client, view, opts);
362
+ return unwrapMutation(
363
+ withRetry(() => client.updateCustomView(view.id, input)),
364
+ "customView",
365
+ "Custom view update",
366
+ );
367
+ }
368
+
369
+ export async function deleteCustomView(client: LinearClient, reference: string) {
370
+ const view = await resolveCustomView(client, reference);
371
+ await assertMutation(
372
+ withRetry(() => client.deleteCustomView(view.id)),
373
+ "Custom view deletion",
374
+ );
375
+ return view;
376
+ }