linear-sdk-cli 0.5.3 → 0.7.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.
@@ -399,6 +399,10 @@ export const FIELDS_COMMANDS: ReadonlySet<string> = new Set([
399
399
  "project list",
400
400
  "project view",
401
401
  "project milestones",
402
+ "project-status list",
403
+ "project-status view",
404
+ "project-label list",
405
+ "project-label view",
402
406
  "project-update list",
403
407
  "roadmap list",
404
408
  "roadmap view",
@@ -418,8 +422,9 @@ export const FIELDS_COMMANDS: ReadonlySet<string> = new Set([
418
422
  "webhook view",
419
423
  ]);
420
424
 
421
- /** Commands that page through a query: `--limit <n>` caps them, `--all` (or `--limit 0`) exhausts them. */
425
+ /** Commands with bounded lists: `--limit <n>` caps them, `--all` (or `--limit 0`) exhausts them. */
422
426
  export const LIMIT_COMMANDS: ReadonlySet<string> = new Set([
427
+ "changelog",
423
428
  "attachment list",
424
429
  "comment list",
425
430
  "cycle list",
@@ -444,6 +449,8 @@ export const LIMIT_COMMANDS: ReadonlySet<string> = new Set([
444
449
  "organization invites",
445
450
  "project list",
446
451
  "project milestones",
452
+ "project-status list",
453
+ "project-label list",
447
454
  "project-update list",
448
455
  "roadmap list",
449
456
  "state list",
@@ -510,7 +517,10 @@ export const YES_COMMANDS: ReadonlySet<string> = new Set([
510
517
  "milestone delete",
511
518
  "notification archive",
512
519
  "project archive",
520
+ "project-status archive",
513
521
  "project delete",
522
+ "project-label retire",
523
+ "project-label delete",
514
524
  "roadmap delete",
515
525
  "team delete",
516
526
  "webhook delete",
@@ -51,6 +51,11 @@ import {
51
51
  PROJECT_MILESTONE_ROW_SHAPE,
52
52
  PROJECT_ROW_SHAPE,
53
53
  } from "../services/project.js";
54
+ import {
55
+ PROJECT_STATUS_DETAIL_SHAPE,
56
+ PROJECT_STATUS_ROW_SHAPE,
57
+ } from "../services/project-status.js";
58
+ import { PROJECT_LABEL_DETAIL_SHAPE, PROJECT_LABEL_ROW_SHAPE } from "../services/project-label.js";
54
59
  import { ROADMAP_DETAIL_SHAPE, ROADMAP_ROW_SHAPE } from "../services/roadmap.js";
55
60
  import { STATE_DETAIL_SHAPE, STATE_ROW_SHAPE } from "../services/state.js";
56
61
  import {
@@ -140,6 +145,14 @@ const UPDATE_RECEIPT_SHAPE = shape<UpdateRow & { url: string }>({
140
145
  url: "string",
141
146
  });
142
147
 
148
+ const AUTHORIZATION_CAPABILITIES: ObjectFields = {
149
+ credentialType: "string|null",
150
+ scopeVisibility: "string",
151
+ scopes: { nullable: ["string"] },
152
+ adminScope: "boolean|null",
153
+ note: "string",
154
+ };
155
+
143
156
  /** `whoami` / `auth whoami`. */
144
157
  const WHOAMI_SHAPE: ObjectFields = {
145
158
  id: "string",
@@ -147,6 +160,7 @@ const WHOAMI_SHAPE: ObjectFields = {
147
160
  displayName: "string",
148
161
  email: "string",
149
162
  admin: "boolean",
163
+ authorization: AUTHORIZATION_CAPABILITIES,
150
164
  organization: { id: "string", name: "string", urlKey: "string" },
151
165
  };
152
166
 
@@ -299,6 +313,13 @@ const RAW_API: OutputShape = {
299
313
  export const OUTPUT_SHAPES: Record<string, OutputShape | null> = {
300
314
  api: RAW_API,
301
315
 
316
+ changelog: list({
317
+ version: "string",
318
+ date: "string",
319
+ url: "string",
320
+ sections: [{ title: "string", items: ["string"] }],
321
+ }),
322
+
302
323
  attachment: null,
303
324
  "attachment create": receipt({ id: "string", title: "string", url: "string" }),
304
325
  "attachment delete": receipt({ id: "string", title: "string", deleted: "boolean" }),
@@ -348,6 +369,9 @@ export const OUTPUT_SHAPES: Record<string, OutputShape | null> = {
348
369
  keyring: "string|null",
349
370
  scopes: { nullable: ["string"] },
350
371
  expiresAt: "string|null",
372
+ scopeVisibility: "string",
373
+ adminScope: "boolean|null",
374
+ scopeNote: "string",
351
375
  }),
352
376
  "auth token": object({ apiKey: "string", workspace: "string|null" }),
353
377
  "auth whoami": WHOAMI,
@@ -620,6 +644,31 @@ export const OUTPUT_SHAPES: Record<string, OutputShape | null> = {
620
644
  "project update": receipt(NAMED_URL_RECEIPT),
621
645
  "project view": object(PROJECT_DETAIL_SHAPE),
622
646
 
647
+ "project-status": null,
648
+ "project-status archive": receipt({ id: "string", name: "string", archived: "boolean" }),
649
+ "project-status create": receipt({
650
+ id: "string",
651
+ name: "string",
652
+ type: "string",
653
+ }),
654
+ "project-status list": list(PROJECT_STATUS_ROW_SHAPE),
655
+ "project-status unarchive": receipt({ id: "string", name: "string", archived: "boolean" }),
656
+ "project-status update": receipt({
657
+ id: "string",
658
+ name: "string",
659
+ type: "string",
660
+ }),
661
+ "project-status view": object(PROJECT_STATUS_DETAIL_SHAPE),
662
+
663
+ "project-label": null,
664
+ "project-label create": receipt({ id: "string", name: "string", color: "string" }),
665
+ "project-label delete": receipt(NAMED_DELETED),
666
+ "project-label list": list(PROJECT_LABEL_ROW_SHAPE),
667
+ "project-label restore": receipt({ id: "string", name: "string", restored: "boolean" }),
668
+ "project-label retire": receipt({ id: "string", name: "string", retired: "boolean" }),
669
+ "project-label update": receipt({ id: "string", name: "string", color: "string" }),
670
+ "project-label view": object(PROJECT_LABEL_DETAIL_SHAPE),
671
+
623
672
  "project-update": null,
624
673
  "project-update create": receipt(UPDATE_RECEIPT_SHAPE),
625
674
  "project-update list": list(UPDATE_ROW_SHAPE),
@@ -315,10 +315,15 @@ export async function resolveProjectLabelIds(
315
315
  client: LinearClient,
316
316
  names: string[],
317
317
  ): Promise<string[]> {
318
- const ids: string[] = [];
318
+ const labels: any[] = [];
319
319
  for (const name of names) {
320
320
  if (isUuid(name)) {
321
- ids.push(name);
321
+ const label: any = await withRetry(() => (client as any).projectLabel(name));
322
+ if (label.isGroup)
323
+ throw usageError(`Project label '${label.name}' is a group and cannot be assigned.`);
324
+ if (label.retiredAt || label.retiredById)
325
+ throw usageError(`Project label '${label.name}' is retired and cannot be assigned.`);
326
+ labels.push(label);
322
327
  continue;
323
328
  }
324
329
  const conn: any = await withRetry(() =>
@@ -327,21 +332,33 @@ export async function resolveProjectLabelIds(
327
332
  first: RESOLVE_PAGE,
328
333
  }),
329
334
  );
330
- const nodes = await scanAll<any>(conn, "project labels", "linear project label list");
331
- const candidates = nodes.filter((l) => !l.isGroup);
335
+ const nodes = await scanAll<any>(conn, "project labels", "linear project-label list");
336
+ const candidates = nodes.filter((l) => !l.isGroup && !l.retiredAt && !l.retiredById);
332
337
  if (candidates.length === 0)
333
338
  throw notFound(
334
- `No project label matching '${name}'. Run 'linear project label list' to see the options.`,
339
+ `No project label matching '${name}'. Run 'linear project-label list' to see the options.`,
335
340
  );
336
341
 
337
342
  const exact = candidates.filter((l) => l.name === name);
338
343
  const finalists = exact.length ? exact : candidates;
339
344
  if (finalists.length > 1)
340
345
  throw ambiguous(`Multiple project labels named '${name}'; pass the label id instead.`);
341
- ids.push(finalists[0]!.id);
346
+ labels.push(finalists[0]!);
347
+ }
348
+ const byParent = new Map<string, any>();
349
+ for (const label of labels) {
350
+ const parentId = label.parentId;
351
+ if (!parentId) continue;
352
+ const other = byParent.get(parentId);
353
+ if (other && other.id !== label.id) {
354
+ throw usageError(
355
+ `Project labels '${other.name}' and '${label.name}' belong to the same mutually exclusive group; choose one.`,
356
+ );
357
+ }
358
+ byParent.set(parentId, label);
342
359
  }
343
360
  // The same label named twice (or by name and id) must not be sent twice.
344
- return [...new Set(ids)];
361
+ return [...new Set(labels.map((label) => label.id))];
345
362
  }
346
363
 
347
364
  /** First workflow state of a given type (lowest position) within a team. */
@@ -217,6 +217,7 @@ export class Output {
217
217
  // absent entirely without --debug.
218
218
  const error: Record<string, unknown> = { message: err.message, code: err.code };
219
219
  if (err.suggestion !== undefined) error.suggestion = err.suggestion;
220
+ if (err.details !== undefined) error.details = err.details;
220
221
  if (showDetail) error.detail = err.detail;
221
222
  process.stderr.write(JSON.stringify({ error }) + "\n");
222
223
  return;
@@ -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
+ }