linear-sdk-cli 0.1.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.
Files changed (111) hide show
  1. package/.claude-plugin/marketplace.json +20 -0
  2. package/.claude-plugin/plugin.json +9 -0
  3. package/CHANGELOG.md +807 -0
  4. package/COVERAGE.md +495 -0
  5. package/LICENSE +21 -0
  6. package/MIGRATING.md +220 -0
  7. package/README.md +524 -0
  8. package/package.json +87 -0
  9. package/skills/linear-sdk-cli/SKILL.md +534 -0
  10. package/skills/linear-sdk-cli/references/api.md +27 -0
  11. package/skills/linear-sdk-cli/references/attachment.md +82 -0
  12. package/skills/linear-sdk-cli/references/auth.md +174 -0
  13. package/skills/linear-sdk-cli/references/commands.md +37 -0
  14. package/skills/linear-sdk-cli/references/comment.md +154 -0
  15. package/skills/linear-sdk-cli/references/completion.md +17 -0
  16. package/skills/linear-sdk-cli/references/config.md +99 -0
  17. package/skills/linear-sdk-cli/references/cycle.md +131 -0
  18. package/skills/linear-sdk-cli/references/document.md +180 -0
  19. package/skills/linear-sdk-cli/references/favorite.md +78 -0
  20. package/skills/linear-sdk-cli/references/initiative-update.md +67 -0
  21. package/skills/linear-sdk-cli/references/initiative.md +256 -0
  22. package/skills/linear-sdk-cli/references/issue.md +928 -0
  23. package/skills/linear-sdk-cli/references/label.md +112 -0
  24. package/skills/linear-sdk-cli/references/milestone.md +139 -0
  25. package/skills/linear-sdk-cli/references/notification.md +123 -0
  26. package/skills/linear-sdk-cli/references/open.md +29 -0
  27. package/skills/linear-sdk-cli/references/organization.md +99 -0
  28. package/skills/linear-sdk-cli/references/project-update.md +67 -0
  29. package/skills/linear-sdk-cli/references/project.md +234 -0
  30. package/skills/linear-sdk-cli/references/roadmap.md +150 -0
  31. package/skills/linear-sdk-cli/references/schema.md +21 -0
  32. package/skills/linear-sdk-cli/references/state.md +61 -0
  33. package/skills/linear-sdk-cli/references/team.md +227 -0
  34. package/skills/linear-sdk-cli/references/user.md +103 -0
  35. package/skills/linear-sdk-cli/references/webhook.md +151 -0
  36. package/skills/linear-sdk-cli/references/whoami.md +26 -0
  37. package/skills/linear-sdk-cli/scripts/generate-docs.ts +339 -0
  38. package/src/bin/linear.ts +97 -0
  39. package/src/cli.ts +232 -0
  40. package/src/client.ts +172 -0
  41. package/src/commands/agent-session.ts +121 -0
  42. package/src/commands/api.ts +317 -0
  43. package/src/commands/attach.ts +90 -0
  44. package/src/commands/attachment.ts +74 -0
  45. package/src/commands/comment.ts +225 -0
  46. package/src/commands/completion.ts +69 -0
  47. package/src/commands/cycle.ts +125 -0
  48. package/src/commands/discover.ts +170 -0
  49. package/src/commands/document.ts +211 -0
  50. package/src/commands/favorite.ts +72 -0
  51. package/src/commands/initiative-update.ts +47 -0
  52. package/src/commands/initiative.ts +261 -0
  53. package/src/commands/issue.ts +1263 -0
  54. package/src/commands/label.ts +119 -0
  55. package/src/commands/meta.ts +534 -0
  56. package/src/commands/milestone.ts +163 -0
  57. package/src/commands/notification.ts +110 -0
  58. package/src/commands/open.ts +84 -0
  59. package/src/commands/organization.ts +79 -0
  60. package/src/commands/project-update.ts +47 -0
  61. package/src/commands/project.ts +339 -0
  62. package/src/commands/roadmap.ts +131 -0
  63. package/src/commands/state.ts +58 -0
  64. package/src/commands/team.ts +243 -0
  65. package/src/commands/user.ts +107 -0
  66. package/src/commands/webhook.ts +156 -0
  67. package/src/config.ts +1236 -0
  68. package/src/context.ts +117 -0
  69. package/src/git.ts +174 -0
  70. package/src/index.ts +10 -0
  71. package/src/lib/action.ts +26 -0
  72. package/src/lib/body.ts +172 -0
  73. package/src/lib/errors.ts +195 -0
  74. package/src/lib/introspect.ts +150 -0
  75. package/src/lib/keyring.ts +270 -0
  76. package/src/lib/mutation.ts +53 -0
  77. package/src/lib/open.ts +32 -0
  78. package/src/lib/options.ts +640 -0
  79. package/src/lib/output-shapes.ts +562 -0
  80. package/src/lib/pagination.ts +145 -0
  81. package/src/lib/prompt.ts +91 -0
  82. package/src/lib/resolve.ts +596 -0
  83. package/src/lib/shape.ts +273 -0
  84. package/src/lib/status-update.ts +91 -0
  85. package/src/lib/upload.ts +329 -0
  86. package/src/output/color.ts +52 -0
  87. package/src/output/format.ts +238 -0
  88. package/src/output/markdown.ts +133 -0
  89. package/src/output/pager.ts +131 -0
  90. package/src/output/sanitize.ts +76 -0
  91. package/src/output/table.ts +198 -0
  92. package/src/services/agent-session.ts +289 -0
  93. package/src/services/attachment.ts +198 -0
  94. package/src/services/comment.ts +262 -0
  95. package/src/services/cycle.ts +224 -0
  96. package/src/services/document.ts +357 -0
  97. package/src/services/favorite.ts +147 -0
  98. package/src/services/initiative-update.ts +59 -0
  99. package/src/services/initiative.ts +449 -0
  100. package/src/services/issue.ts +1153 -0
  101. package/src/services/label.ts +193 -0
  102. package/src/services/milestone.ts +262 -0
  103. package/src/services/notification.ts +190 -0
  104. package/src/services/organization.ts +144 -0
  105. package/src/services/project-update.ts +36 -0
  106. package/src/services/project.ts +494 -0
  107. package/src/services/roadmap.ts +177 -0
  108. package/src/services/state.ts +127 -0
  109. package/src/services/team.ts +386 -0
  110. package/src/services/user.ts +137 -0
  111. package/src/services/webhook.ts +203 -0
@@ -0,0 +1,144 @@
1
+ /**
2
+ * Organization service: all SDK access for the workspace lives here so commands
3
+ * stay thin. This is a read-only group — workspace `view`, member listing, and
4
+ * pending-invite listing. Admin/destructive operations are intentionally out of
5
+ * scope.
6
+ *
7
+ * The single `view` uses the typed `client.organization` getter; members and
8
+ * invites use the typed SDK connections and paginate via `collect()`.
9
+ */
10
+
11
+ import type { LinearClient } from "@linear/sdk";
12
+ import { withRetry } from "../client.js";
13
+ import { shape } from "../lib/shape.js";
14
+ import { collect, inheritPaginationMetadata, pageSize } from "../lib/pagination.js";
15
+
16
+ export interface OrganizationDetail {
17
+ id: string;
18
+ name: string;
19
+ urlKey: string;
20
+ userCount: number;
21
+ createdIssueCount: number;
22
+ samlEnabled: boolean;
23
+ scimEnabled: boolean;
24
+ roadmapEnabled: boolean;
25
+ logoUrl: string | null;
26
+ createdAt: string;
27
+ updatedAt: string;
28
+ }
29
+
30
+ /** The detail's shape as `linear commands` advertises it (TES-610); checked against the interface. */
31
+ export const ORGANIZATION_DETAIL_SHAPE = shape<OrganizationDetail>({
32
+ id: "string",
33
+ name: "string",
34
+ urlKey: "string",
35
+ userCount: "number",
36
+ createdIssueCount: "number",
37
+ samlEnabled: "boolean",
38
+ scimEnabled: "boolean",
39
+ roadmapEnabled: "boolean",
40
+ logoUrl: "string|null",
41
+ createdAt: "string",
42
+ updatedAt: "string",
43
+ });
44
+
45
+ export async function getOrganizationDetail(client: LinearClient): Promise<OrganizationDetail> {
46
+ // `client.organization` is a getter that returns a LinearFetch<Organization>.
47
+ const org = await withRetry(() => client.organization);
48
+ return {
49
+ id: org.id,
50
+ name: org.name,
51
+ urlKey: org.urlKey,
52
+ userCount: org.userCount,
53
+ createdIssueCount: org.createdIssueCount,
54
+ samlEnabled: !!org.samlEnabled,
55
+ scimEnabled: !!org.scimEnabled,
56
+ roadmapEnabled: !!org.roadmapEnabled,
57
+ logoUrl: org.logoUrl ?? null,
58
+ createdAt: org.createdAt.toISOString(),
59
+ updatedAt: org.updatedAt.toISOString(),
60
+ };
61
+ }
62
+
63
+ export interface MemberRow {
64
+ id: string;
65
+ displayName: string;
66
+ name: string;
67
+ email: string;
68
+ admin: boolean;
69
+ active: boolean;
70
+ }
71
+
72
+ export const ORGANIZATION_MEMBER_ROW_SHAPE = shape<MemberRow>({
73
+ id: "string",
74
+ displayName: "string",
75
+ name: "string",
76
+ email: "string",
77
+ admin: "boolean",
78
+ active: "boolean",
79
+ });
80
+
81
+ /** List workspace users (members). Paginates to the requested limit. */
82
+ export async function listMembers(client: LinearClient, limit: number): Promise<MemberRow[]> {
83
+ const conn = await withRetry(() => client.users({ first: pageSize(limit) }));
84
+ const nodes = await collect(conn as any, limit);
85
+ return inheritPaginationMetadata(
86
+ nodes.map((u: any) => ({
87
+ id: u.id,
88
+ displayName: u.displayName,
89
+ name: u.name,
90
+ email: u.email,
91
+ admin: !!u.admin,
92
+ active: !!u.active,
93
+ })),
94
+ nodes,
95
+ );
96
+ }
97
+
98
+ export interface InviteRow {
99
+ id: string;
100
+ email: string;
101
+ status: string;
102
+ role: string;
103
+ external: boolean;
104
+ createdAt: string;
105
+ }
106
+
107
+ export const INVITE_ROW_SHAPE = shape<InviteRow>({
108
+ id: "string",
109
+ email: "string",
110
+ status: "string",
111
+ role: "string",
112
+ external: "boolean",
113
+ createdAt: "string",
114
+ });
115
+
116
+ /**
117
+ * Derive a human status for an invite. The `OrganizationInvite` model exposes
118
+ * `acceptedAt`/`expiresAt` rather than a status enum, so we classify from those:
119
+ * accepted > expired > pending.
120
+ */
121
+ export function inviteStatus(invite: { acceptedAt?: unknown; expiresAt?: unknown }): string {
122
+ if (invite.acceptedAt) return "accepted";
123
+ if (invite.expiresAt && new Date(invite.expiresAt as any).getTime() < Date.now()) {
124
+ return "expired";
125
+ }
126
+ return "pending";
127
+ }
128
+
129
+ /** List organization invites. Tolerates an empty list (returns `[]`). */
130
+ export async function listInvites(client: LinearClient, limit: number): Promise<InviteRow[]> {
131
+ const conn = await withRetry(() => client.organizationInvites({ first: pageSize(limit) }));
132
+ const nodes = await collect(conn as any, limit);
133
+ return inheritPaginationMetadata(
134
+ nodes.map((i: any) => ({
135
+ id: i.id,
136
+ email: i.email,
137
+ status: inviteStatus(i),
138
+ role: i.role ?? "member",
139
+ external: !!i.external,
140
+ createdAt: i.createdAt?.toISOString?.() ?? String(i.createdAt),
141
+ })),
142
+ nodes,
143
+ );
144
+ }
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Project-update service: create a project status update, and list a project's
3
+ * updates. The list reuses the existing `listUpdates` in `services/project.ts`
4
+ * (re-exported here as `listProjectUpdates` so the new command group owns a
5
+ * coherent surface). All SDK calls are wrapped in `withRetry`.
6
+ */
7
+
8
+ import type { LinearClient } from "@linear/sdk";
9
+ import { withRetry } from "../client.js";
10
+ import { resolveProjectId } from "../lib/resolve.js";
11
+ import { normalizeUpdatePayload, type Health, type UpdateRow } from "../lib/status-update.js";
12
+ import { listUpdates } from "./project.js";
13
+
14
+ export type { UpdateRow };
15
+
16
+ /** List a project's status updates (by project name or id). */
17
+ export const listProjectUpdates = listUpdates;
18
+
19
+ export interface CreateProjectUpdateOptions {
20
+ body: string;
21
+ health?: Health;
22
+ }
23
+
24
+ /** Create a status update on a project (resolved by name or id). Returns a normalized row. */
25
+ export async function createProjectUpdate(
26
+ client: LinearClient,
27
+ idArg: string,
28
+ opts: CreateProjectUpdateOptions,
29
+ ): Promise<UpdateRow & { url: string }> {
30
+ const projectId = await resolveProjectId(client, idArg);
31
+ const input: Record<string, any> = { projectId, body: opts.body };
32
+ if (opts.health) input.health = opts.health;
33
+
34
+ const payload = await withRetry(() => client.createProjectUpdate(input as any));
35
+ return normalizeUpdatePayload(payload, "projectUpdate");
36
+ }
@@ -0,0 +1,494 @@
1
+ /**
2
+ * Project service: all SDK access for projects lives here so commands stay thin.
3
+ *
4
+ * Lists use a tailored GraphQL query (one round-trip, no N+1 on status/lead);
5
+ * single `view`, milestones, updates and all mutations use the typed SDK models.
6
+ */
7
+
8
+ import type { LinearClient } from "@linear/sdk";
9
+ import { withRetry } from "../client.js";
10
+ import { shape } from "../lib/shape.js";
11
+ import {
12
+ collect,
13
+ collectRawQuery,
14
+ inheritPaginationMetadata,
15
+ pageSize,
16
+ } from "../lib/pagination.js";
17
+ import { usageError, notFound, ambiguous } from "../lib/errors.js";
18
+ import { assertMutation, unwrapMutation } from "../lib/mutation.js";
19
+ import {
20
+ resolveTeam,
21
+ resolveUserId,
22
+ resolveProjectId,
23
+ resolveProjectLabelIds,
24
+ isUuid,
25
+ } from "../lib/resolve.js";
26
+
27
+ export interface ProjectRow {
28
+ id: string;
29
+ name: string;
30
+ state: string | null;
31
+ progress: number | null;
32
+ url: string;
33
+ startDate: string | null;
34
+ targetDate: string | null;
35
+ status: { name: string } | null;
36
+ lead: { displayName: string } | null;
37
+ }
38
+
39
+ /** The row's shape as `linear commands` advertises it (TES-610); checked against the interface. */
40
+ export const PROJECT_ROW_SHAPE = shape<ProjectRow>({
41
+ id: "string",
42
+ name: "string",
43
+ state: "string|null",
44
+ progress: "number|null",
45
+ url: "string",
46
+ startDate: "string|null",
47
+ targetDate: "string|null",
48
+ status: { nullable: { name: "string" } },
49
+ lead: { nullable: { displayName: "string" } },
50
+ });
51
+
52
+ export interface ListFilters {
53
+ team?: string;
54
+ /** Every team's projects: no team clause at all, and the default team is not applied. */
55
+ allTeams?: boolean;
56
+ state?: string;
57
+ }
58
+
59
+ const LIST_QUERY = `
60
+ query CliProjects($filter: ProjectFilter, $first: Int!, $after: String, $includeArchived: Boolean) {
61
+ projects(filter: $filter, first: $first, after: $after, includeArchived: $includeArchived) {
62
+ nodes {
63
+ id name state progress url startDate targetDate
64
+ status { name }
65
+ lead { displayName }
66
+ }
67
+ pageInfo { hasNextPage endCursor }
68
+ }
69
+ }`;
70
+
71
+ /** Build a ProjectFilter from human options, resolving names to ids. Exported for tests. */
72
+ export async function buildFilter(
73
+ client: LinearClient,
74
+ f: ListFilters,
75
+ defaultTeamKey: string | undefined,
76
+ ): Promise<Record<string, unknown>> {
77
+ const filter: Record<string, any> = {};
78
+ // Without a team the list is scoped to the configured default, so a project
79
+ // in another team is invisible unless `--all-teams` says the whole workspace
80
+ // (the only other way out used to be the accidental `--team ''`).
81
+ const teamKey = f.allTeams ? undefined : (f.team ?? defaultTeamKey);
82
+ if (teamKey) {
83
+ filter.accessibleTeams = { some: { key: { eq: teamKey.toUpperCase() } } };
84
+ }
85
+ if (f.state) {
86
+ // Match either the custom status NAME shown in the UI ("In QA") or the
87
+ // underlying status TYPE ("started"), case-insensitively, so both
88
+ // vocabularies work.
89
+ //
90
+ // NOT `filter.state`: that targets the deprecated legacy `Project.state`
91
+ // field, which the API silently ignores — every value, valid or not,
92
+ // returned the unfiltered list.
93
+ filter.status = {
94
+ or: [{ name: { eqIgnoreCase: f.state } }, { type: { eqIgnoreCase: f.state } }],
95
+ };
96
+ }
97
+ return filter;
98
+ }
99
+
100
+ export async function listProjects(
101
+ client: LinearClient,
102
+ filters: ListFilters,
103
+ limit: number,
104
+ defaultTeamKey: string | undefined,
105
+ ): Promise<ProjectRow[]> {
106
+ const filter = await buildFilter(client, filters, defaultTeamKey);
107
+ return collectRawQuery<ProjectRow>(
108
+ client as any,
109
+ LIST_QUERY,
110
+ { filter, includeArchived: false },
111
+ "projects",
112
+ limit,
113
+ (n) => ({
114
+ id: n.id,
115
+ name: n.name,
116
+ state: n.state ?? null,
117
+ progress: n.progress ?? null,
118
+ url: n.url,
119
+ startDate: n.startDate ?? null,
120
+ targetDate: n.targetDate ?? null,
121
+ status: n.status ?? null,
122
+ lead: n.lead ?? null,
123
+ }),
124
+ );
125
+ }
126
+
127
+ /**
128
+ * A single project with every relation `view` shows. Relations are objects
129
+ * with ids — `status` matches the list row's `status: { name }` and adds
130
+ * `id`/`type`; `lead` matches the row's `lead: { displayName }` and adds
131
+ * `id`/`email`. `teams` used to be `"KEY name"` strings, which a script could
132
+ * not split back apart (team names contain spaces).
133
+ */
134
+ export interface ProjectDetail {
135
+ id: string;
136
+ name: string;
137
+ description: string | null;
138
+ /** The project's markdown body (Project.content), not the one-line description. */
139
+ content: string | null;
140
+ labels: Array<{ id: string; name: string }>;
141
+ state: string | null;
142
+ status: { id: string; name: string; type: string } | null;
143
+ health: string | null;
144
+ progress: number | null;
145
+ priority: number;
146
+ priorityLabel: string;
147
+ url: string;
148
+ startDate: string | null;
149
+ targetDate: string | null;
150
+ createdAt: string;
151
+ updatedAt: string;
152
+ completedAt: string | null;
153
+ archivedAt: string | null;
154
+ lead: { id: string; displayName: string; email: string } | null;
155
+ teams: Array<{ id: string; key: string; name: string }>;
156
+ members: Array<{ id: string; displayName: string; email: string }>;
157
+ }
158
+
159
+ /** The detail's shape; checked against `ProjectDetail`. */
160
+ export const PROJECT_DETAIL_SHAPE = shape<ProjectDetail>({
161
+ id: "string",
162
+ name: "string",
163
+ description: "string|null",
164
+ content: "string|null",
165
+ labels: [{ id: "string", name: "string" }],
166
+ state: "string|null",
167
+ status: { nullable: { id: "string", name: "string", type: "string" } },
168
+ health: "string|null",
169
+ progress: "number|null",
170
+ priority: "number",
171
+ priorityLabel: "string",
172
+ url: "string",
173
+ startDate: "string|null",
174
+ targetDate: "string|null",
175
+ createdAt: "string",
176
+ updatedAt: "string",
177
+ completedAt: "string|null",
178
+ archivedAt: "string|null",
179
+ lead: { nullable: { id: "string", displayName: "string", email: "string" } },
180
+ teams: [{ id: "string", key: "string", name: "string" }],
181
+ members: [{ id: "string", displayName: "string", email: "string" }],
182
+ });
183
+
184
+ /**
185
+ * Everything `project view` shows, in one round-trip: the name lookup, the
186
+ * project, and its relations, which the SDK-model path fetched one request
187
+ * each (7 requests, measured). A UUID filters by id; anything else is a
188
+ * case-insensitive name match — the same rule as `resolveProjectId`, with the
189
+ * same not-found/ambiguous outcomes. An id finds an archived project too (as
190
+ * `client.project(id)` did); a name matches live projects only (as
191
+ * `resolveProjectId` does), so an archived namesake cannot make a live project
192
+ * ambiguous.
193
+ *
194
+ * `first: 2`, not a full page: a second match already means "ambiguous", and
195
+ * Linear prices a query by its worst case — 250 projects × three nested
196
+ * 50-item connections was refused as too complex (49 975 against a cap of
197
+ * 10 000, verified live).
198
+ */
199
+ const DETAIL_QUERY = `
200
+ query CliProjectDetail($filter: ProjectFilter!, $includeArchived: Boolean!) {
201
+ projects(filter: $filter, first: 2, includeArchived: $includeArchived) {
202
+ nodes {
203
+ id name description content state health progress priority priorityLabel url
204
+ startDate targetDate createdAt updatedAt completedAt archivedAt
205
+ status { id name type }
206
+ lead { id displayName email }
207
+ labels(first: 50) { nodes { id name } }
208
+ teams(first: 50) { nodes { id key name } }
209
+ members(first: 50) { nodes { id displayName email } }
210
+ }
211
+ }
212
+ }`;
213
+
214
+ export async function getProjectDetail(
215
+ client: LinearClient,
216
+ idArg: string,
217
+ ): Promise<ProjectDetail> {
218
+ const byId = isUuid(idArg);
219
+ const filter = byId ? { id: { eq: idArg } } : { name: { eqIgnoreCase: idArg } };
220
+ const data: any = await withRetry(() =>
221
+ (client as any).client.rawRequest(DETAIL_QUERY, { filter, includeArchived: byId }),
222
+ );
223
+ const nodes: any[] = data.data?.projects?.nodes ?? [];
224
+ if (nodes.length === 0)
225
+ throw notFound(`No project matching '${idArg}'. Run 'linear project list' to see the options.`);
226
+ if (nodes.length > 1)
227
+ throw ambiguous(
228
+ `Multiple projects match '${idArg}': ${nodes.map((p) => p.name).join(", ")}. Pass the project id instead.`,
229
+ );
230
+ const p = nodes[0];
231
+ return {
232
+ id: p.id,
233
+ name: p.name,
234
+ description: p.description || null,
235
+ content: p.content || null,
236
+ labels: p.labels?.nodes ?? [],
237
+ state: p.state ?? null,
238
+ status: p.status ?? null,
239
+ health: p.health ?? null,
240
+ progress: p.progress ?? null,
241
+ priority: p.priority,
242
+ priorityLabel: p.priorityLabel,
243
+ url: p.url,
244
+ startDate: p.startDate ?? null,
245
+ targetDate: p.targetDate ?? null,
246
+ createdAt: p.createdAt,
247
+ updatedAt: p.updatedAt,
248
+ completedAt: p.completedAt ?? null,
249
+ archivedAt: p.archivedAt ?? null,
250
+ lead: p.lead ?? null,
251
+ teams: p.teams?.nodes ?? [],
252
+ members: p.members?.nodes ?? [],
253
+ };
254
+ }
255
+
256
+ export interface CreateOptions {
257
+ name: string;
258
+ description?: string;
259
+ /** The project's markdown body — distinct from the one-line `description`. */
260
+ content?: string;
261
+ team?: string[];
262
+ lead?: string;
263
+ state?: string;
264
+ startDate?: string;
265
+ targetDate?: string;
266
+ priority?: number;
267
+ label?: string[];
268
+ member?: string[];
269
+ icon?: string;
270
+ color?: string;
271
+ }
272
+
273
+ /** Build a ProjectCreateInput, resolving every human reference to an id. */
274
+ export async function createProject(
275
+ client: LinearClient,
276
+ opts: CreateOptions,
277
+ defaultTeamKey: string | undefined,
278
+ ) {
279
+ const teamIds = await resolveTeamIds(client, opts.team, defaultTeamKey);
280
+ const input: Record<string, any> = { name: opts.name, teamIds };
281
+ if (opts.description !== undefined) input.description = opts.description;
282
+ if (opts.content !== undefined) input.content = opts.content;
283
+ if (opts.state) input.statusId = await resolveStatusId(client, opts.state);
284
+ if (opts.lead) input.leadId = await resolveUserId(client, opts.lead);
285
+ if (opts.startDate) input.startDate = opts.startDate;
286
+ if (opts.targetDate) input.targetDate = opts.targetDate;
287
+ if (opts.priority !== undefined) input.priority = resolvePriority(opts.priority);
288
+ if (opts.label?.length) input.labelIds = await resolveProjectLabelIds(client, opts.label);
289
+ if (opts.member?.length) input.memberIds = await resolveMemberIds(client, opts.member);
290
+ if (opts.icon) input.icon = opts.icon;
291
+ if (opts.color) input.color = opts.color;
292
+
293
+ return unwrapMutation(
294
+ withRetry(() => client.createProject(input as any)),
295
+ "project",
296
+ "Project creation",
297
+ );
298
+ }
299
+
300
+ export interface UpdateOptions {
301
+ name?: string;
302
+ description?: string;
303
+ /** The project's markdown body — distinct from the one-line `description`. */
304
+ content?: string;
305
+ team?: string[];
306
+ lead?: string;
307
+ state?: string;
308
+ startDate?: string;
309
+ targetDate?: string;
310
+ priority?: number;
311
+ /** Replaces the whole label set. */
312
+ label?: string[];
313
+ /** Replaces the whole member set. */
314
+ member?: string[];
315
+ icon?: string;
316
+ color?: string;
317
+ }
318
+
319
+ export async function updateProject(client: LinearClient, idArg: string, opts: UpdateOptions) {
320
+ const projectId = await resolveProjectId(client, idArg);
321
+ const input: Record<string, any> = {};
322
+ if (opts.name !== undefined) input.name = opts.name;
323
+ if (opts.description !== undefined) input.description = opts.description;
324
+ if (opts.content !== undefined) input.content = opts.content;
325
+ if (opts.team?.length) input.teamIds = await resolveTeamIds(client, opts.team, undefined);
326
+ if (opts.lead) input.leadId = await resolveUserId(client, opts.lead);
327
+ if (opts.state) input.statusId = await resolveStatusId(client, opts.state);
328
+ if (opts.startDate) input.startDate = opts.startDate;
329
+ if (opts.targetDate) input.targetDate = opts.targetDate;
330
+ if (opts.priority !== undefined) input.priority = resolvePriority(opts.priority);
331
+ if (opts.label) input.labelIds = await resolveProjectLabelIds(client, opts.label);
332
+ if (opts.member) input.memberIds = await resolveMemberIds(client, opts.member);
333
+ if (opts.icon) input.icon = opts.icon;
334
+ if (opts.color) input.color = opts.color;
335
+
336
+ if (Object.keys(input).length === 0)
337
+ throw usageError("Nothing to update; pass at least one field.");
338
+ return unwrapMutation(
339
+ withRetry(() => client.updateProject(projectId, input as any)),
340
+ "project",
341
+ "Project update",
342
+ );
343
+ }
344
+
345
+ export async function archiveProject(client: LinearClient, idArg: string) {
346
+ const projectId = await resolveProjectId(client, idArg);
347
+ const project = await withRetry(() => client.project(projectId));
348
+ await assertMutation(
349
+ withRetry(() => client.archiveProject(projectId)),
350
+ "Project archive",
351
+ );
352
+ return project;
353
+ }
354
+
355
+ /**
356
+ * Trash a project (`projectDelete`). Distinct from `archiveProject`: an
357
+ * archived project stays in the workspace, read-only; a trashed one is gone
358
+ * from every list and is purged after Linear's grace period. Returns the
359
+ * project as it was, for the receipt.
360
+ */
361
+ export async function deleteProject(client: LinearClient, idArg: string) {
362
+ const projectId = await resolveProjectId(client, idArg);
363
+ const project = await withRetry(() => client.project(projectId));
364
+ await assertMutation(
365
+ withRetry(() => client.deleteProject(projectId)),
366
+ "Project deletion",
367
+ );
368
+ return project;
369
+ }
370
+
371
+ export interface MilestoneRow {
372
+ id: string;
373
+ name: string;
374
+ targetDate: string | null;
375
+ progress: number | null;
376
+ }
377
+
378
+ /** `project milestones` rows; checked against this file's `MilestoneRow`. */
379
+ export const PROJECT_MILESTONE_ROW_SHAPE = shape<MilestoneRow>({
380
+ id: "string",
381
+ name: "string",
382
+ targetDate: "string|null",
383
+ progress: "number|null",
384
+ });
385
+
386
+ export async function listMilestones(
387
+ client: LinearClient,
388
+ idArg: string,
389
+ limit: number,
390
+ ): Promise<MilestoneRow[]> {
391
+ const projectId = await resolveProjectId(client, idArg);
392
+ const project = await withRetry(() => client.project(projectId));
393
+ const conn = await withRetry(() => project.projectMilestones({ first: pageSize(limit) }));
394
+ const nodes = await collect(conn as any, limit);
395
+ return inheritPaginationMetadata(
396
+ nodes.map((m: any) => ({
397
+ id: m.id,
398
+ name: m.name,
399
+ targetDate: m.targetDate ?? null,
400
+ progress: m.progress ?? null,
401
+ })),
402
+ nodes,
403
+ );
404
+ }
405
+
406
+ export interface UpdateRow {
407
+ id: string;
408
+ createdAt: string;
409
+ user: string;
410
+ body: string;
411
+ health: string | null;
412
+ }
413
+
414
+ export async function listUpdates(
415
+ client: LinearClient,
416
+ idArg: string,
417
+ limit: number,
418
+ ): Promise<UpdateRow[]> {
419
+ const projectId = await resolveProjectId(client, idArg);
420
+ const project = await withRetry(() => client.project(projectId));
421
+ const conn = await withRetry(() => project.projectUpdates({ first: pageSize(limit) }));
422
+ const nodes = await collect(conn as any, limit);
423
+ return inheritPaginationMetadata(
424
+ await Promise.all(
425
+ nodes.map(async (u: any) => {
426
+ const user = await u.user;
427
+ return {
428
+ id: u.id,
429
+ createdAt: u.createdAt?.toISOString?.() ?? String(u.createdAt),
430
+ user: user?.displayName ?? "unknown",
431
+ body: u.body ?? "",
432
+ health: u.health ?? null,
433
+ };
434
+ }),
435
+ ),
436
+ nodes,
437
+ );
438
+ }
439
+
440
+ /** Resolve one or more team references (key/name/id) to team ids; default when none. */
441
+ /** Validate a project priority (same 0-4 scale as issues). */
442
+ export function resolvePriority(input: number): number {
443
+ if (!Number.isInteger(input) || input < 0 || input > 4)
444
+ throw usageError(
445
+ `Invalid priority '${input}'. Valid: 0 (none), 1 (urgent), 2 (high), 3 (medium), 4 (low).`,
446
+ );
447
+ return input;
448
+ }
449
+
450
+ /** Resolve project members (me|email|name|id), deduplicated. */
451
+ async function resolveMemberIds(client: LinearClient, members: string[]): Promise<string[]> {
452
+ const ids: string[] = [];
453
+ for (const m of members) ids.push(await resolveUserId(client, m));
454
+ return [...new Set(ids)];
455
+ }
456
+
457
+ async function resolveTeamIds(
458
+ client: LinearClient,
459
+ teams: string[] | undefined,
460
+ defaultTeamKey: string | undefined,
461
+ ): Promise<string[]> {
462
+ const inputs = teams?.length ? teams : defaultTeamKey ? [defaultTeamKey] : [];
463
+ if (inputs.length === 0) {
464
+ throw usageError("No team specified. Pass --team <KEY> or set a default team in config.");
465
+ }
466
+ const ids: string[] = [];
467
+ for (const t of inputs) {
468
+ ids.push((await resolveTeam(client, t, undefined)).id);
469
+ }
470
+ return ids;
471
+ }
472
+
473
+ /** Resolve a project status (by name or type) to a status id. */
474
+ async function resolveStatusId(client: LinearClient, input: string): Promise<string> {
475
+ if (isUuid(input)) return input;
476
+ const conn = await withRetry(() => client.projectStatuses({ first: 250 }));
477
+ const statuses = (await collect(conn as any, Infinity)) as Array<{
478
+ id: string;
479
+ name: string;
480
+ type: string;
481
+ }>;
482
+ const lower = input.toLowerCase();
483
+ const byName = statuses.filter((s) => s.name.toLowerCase() === lower);
484
+ const matches = byName.length ? byName : statuses.filter((s) => s.type.toLowerCase() === lower);
485
+ if (matches.length === 0)
486
+ throw notFound(
487
+ `No project status '${input}'. Available: ${statuses.map((s) => s.name).join(", ")}`,
488
+ );
489
+ if (matches.length > 1)
490
+ throw ambiguous(
491
+ `Multiple project statuses match '${input}': ${matches.map((s) => s.name).join(", ")}`,
492
+ );
493
+ return matches[0]!.id;
494
+ }