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.
- package/.claude-plugin/marketplace.json +20 -0
- package/.claude-plugin/plugin.json +9 -0
- package/CHANGELOG.md +807 -0
- package/COVERAGE.md +495 -0
- package/LICENSE +21 -0
- package/MIGRATING.md +220 -0
- package/README.md +524 -0
- package/package.json +87 -0
- package/skills/linear-sdk-cli/SKILL.md +534 -0
- package/skills/linear-sdk-cli/references/api.md +27 -0
- package/skills/linear-sdk-cli/references/attachment.md +82 -0
- package/skills/linear-sdk-cli/references/auth.md +174 -0
- package/skills/linear-sdk-cli/references/commands.md +37 -0
- package/skills/linear-sdk-cli/references/comment.md +154 -0
- package/skills/linear-sdk-cli/references/completion.md +17 -0
- package/skills/linear-sdk-cli/references/config.md +99 -0
- package/skills/linear-sdk-cli/references/cycle.md +131 -0
- package/skills/linear-sdk-cli/references/document.md +180 -0
- package/skills/linear-sdk-cli/references/favorite.md +78 -0
- package/skills/linear-sdk-cli/references/initiative-update.md +67 -0
- package/skills/linear-sdk-cli/references/initiative.md +256 -0
- package/skills/linear-sdk-cli/references/issue.md +928 -0
- package/skills/linear-sdk-cli/references/label.md +112 -0
- package/skills/linear-sdk-cli/references/milestone.md +139 -0
- package/skills/linear-sdk-cli/references/notification.md +123 -0
- package/skills/linear-sdk-cli/references/open.md +29 -0
- package/skills/linear-sdk-cli/references/organization.md +99 -0
- package/skills/linear-sdk-cli/references/project-update.md +67 -0
- package/skills/linear-sdk-cli/references/project.md +234 -0
- package/skills/linear-sdk-cli/references/roadmap.md +150 -0
- package/skills/linear-sdk-cli/references/schema.md +21 -0
- package/skills/linear-sdk-cli/references/state.md +61 -0
- package/skills/linear-sdk-cli/references/team.md +227 -0
- package/skills/linear-sdk-cli/references/user.md +103 -0
- package/skills/linear-sdk-cli/references/webhook.md +151 -0
- package/skills/linear-sdk-cli/references/whoami.md +26 -0
- package/skills/linear-sdk-cli/scripts/generate-docs.ts +339 -0
- package/src/bin/linear.ts +97 -0
- package/src/cli.ts +232 -0
- package/src/client.ts +172 -0
- package/src/commands/agent-session.ts +121 -0
- package/src/commands/api.ts +317 -0
- package/src/commands/attach.ts +90 -0
- package/src/commands/attachment.ts +74 -0
- package/src/commands/comment.ts +225 -0
- package/src/commands/completion.ts +69 -0
- package/src/commands/cycle.ts +125 -0
- package/src/commands/discover.ts +170 -0
- package/src/commands/document.ts +211 -0
- package/src/commands/favorite.ts +72 -0
- package/src/commands/initiative-update.ts +47 -0
- package/src/commands/initiative.ts +261 -0
- package/src/commands/issue.ts +1263 -0
- package/src/commands/label.ts +119 -0
- package/src/commands/meta.ts +534 -0
- package/src/commands/milestone.ts +163 -0
- package/src/commands/notification.ts +110 -0
- package/src/commands/open.ts +84 -0
- package/src/commands/organization.ts +79 -0
- package/src/commands/project-update.ts +47 -0
- package/src/commands/project.ts +339 -0
- package/src/commands/roadmap.ts +131 -0
- package/src/commands/state.ts +58 -0
- package/src/commands/team.ts +243 -0
- package/src/commands/user.ts +107 -0
- package/src/commands/webhook.ts +156 -0
- package/src/config.ts +1236 -0
- package/src/context.ts +117 -0
- package/src/git.ts +174 -0
- package/src/index.ts +10 -0
- package/src/lib/action.ts +26 -0
- package/src/lib/body.ts +172 -0
- package/src/lib/errors.ts +195 -0
- package/src/lib/introspect.ts +150 -0
- package/src/lib/keyring.ts +270 -0
- package/src/lib/mutation.ts +53 -0
- package/src/lib/open.ts +32 -0
- package/src/lib/options.ts +640 -0
- package/src/lib/output-shapes.ts +562 -0
- package/src/lib/pagination.ts +145 -0
- package/src/lib/prompt.ts +91 -0
- package/src/lib/resolve.ts +596 -0
- package/src/lib/shape.ts +273 -0
- package/src/lib/status-update.ts +91 -0
- package/src/lib/upload.ts +329 -0
- package/src/output/color.ts +52 -0
- package/src/output/format.ts +238 -0
- package/src/output/markdown.ts +133 -0
- package/src/output/pager.ts +131 -0
- package/src/output/sanitize.ts +76 -0
- package/src/output/table.ts +198 -0
- package/src/services/agent-session.ts +289 -0
- package/src/services/attachment.ts +198 -0
- package/src/services/comment.ts +262 -0
- package/src/services/cycle.ts +224 -0
- package/src/services/document.ts +357 -0
- package/src/services/favorite.ts +147 -0
- package/src/services/initiative-update.ts +59 -0
- package/src/services/initiative.ts +449 -0
- package/src/services/issue.ts +1153 -0
- package/src/services/label.ts +193 -0
- package/src/services/milestone.ts +262 -0
- package/src/services/notification.ts +190 -0
- package/src/services/organization.ts +144 -0
- package/src/services/project-update.ts +36 -0
- package/src/services/project.ts +494 -0
- package/src/services/roadmap.ts +177 -0
- package/src/services/state.ts +127 -0
- package/src/services/team.ts +386 -0
- package/src/services/user.ts +137 -0
- package/src/services/webhook.ts +203 -0
|
@@ -0,0 +1,449 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Initiative service: all SDK access for initiatives lives here so commands stay thin.
|
|
3
|
+
*
|
|
4
|
+
* Initiatives are workspace-scoped (no team). The list uses a tailored GraphQL
|
|
5
|
+
* query (one round-trip, no N+1 on the owner's name); the single `view` and all
|
|
6
|
+
* mutations use the typed SDK models. Mutations unwrap the `{ success, initiative }`
|
|
7
|
+
* payload. Initiatives may be plan-gated; errors propagate as CliError.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { LinearClient } from "@linear/sdk";
|
|
11
|
+
import { withRetry } from "../client.js";
|
|
12
|
+
import { shape } from "../lib/shape.js";
|
|
13
|
+
import { collect, collectRawQuery } from "../lib/pagination.js";
|
|
14
|
+
import { usageError, notFound, ambiguous } from "../lib/errors.js";
|
|
15
|
+
import { assertMutation, unwrapMutation } from "../lib/mutation.js";
|
|
16
|
+
import {
|
|
17
|
+
resolveUserId,
|
|
18
|
+
resolveInitiativeLabelIds,
|
|
19
|
+
resolveProjectId,
|
|
20
|
+
isUuid,
|
|
21
|
+
} from "../lib/resolve.js";
|
|
22
|
+
|
|
23
|
+
/** The five status values Linear accepts for an initiative (InitiativeStatus enum). */
|
|
24
|
+
const STATUSES = ["Planned", "Active", "Completed", "Canceled", "Proposed"] as const;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Initiative priority mirrors issue priority (0 = none … 4 = low) but, unlike
|
|
28
|
+
* Issue, the Initiative type exposes no `priorityLabel`, so we name it here.
|
|
29
|
+
*/
|
|
30
|
+
const PRIORITY_LABELS = ["No priority", "Urgent", "High", "Medium", "Low"] as const;
|
|
31
|
+
|
|
32
|
+
/** Validate an initiative priority and return it. */
|
|
33
|
+
export function resolvePriority(input: number): number {
|
|
34
|
+
if (!Number.isInteger(input) || input < 0 || input > 4)
|
|
35
|
+
throw usageError(
|
|
36
|
+
`Invalid priority '${input}'. Valid: 0 (none), 1 (urgent), 2 (high), 3 (medium), 4 (low).`,
|
|
37
|
+
);
|
|
38
|
+
return input;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Human name for an initiative priority, for table/detail output. */
|
|
42
|
+
export function priorityLabel(priority: number | null | undefined): string {
|
|
43
|
+
return PRIORITY_LABELS[priority ?? 0] ?? String(priority);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface InitiativeRow {
|
|
47
|
+
id: string;
|
|
48
|
+
name: string;
|
|
49
|
+
status: string | null;
|
|
50
|
+
priority: number;
|
|
51
|
+
targetDate: string | null;
|
|
52
|
+
health: string | null;
|
|
53
|
+
url: string;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** The row's shape as `linear commands` advertises it (TES-610); checked against the interface. */
|
|
57
|
+
export const INITIATIVE_ROW_SHAPE = shape<InitiativeRow>({
|
|
58
|
+
id: "string",
|
|
59
|
+
name: "string",
|
|
60
|
+
status: "string|null",
|
|
61
|
+
priority: "number",
|
|
62
|
+
targetDate: "string|null",
|
|
63
|
+
health: "string|null",
|
|
64
|
+
url: "string",
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
const LIST_QUERY = `
|
|
68
|
+
query CliInitiatives($filter: InitiativeFilter, $first: Int!, $after: String, $includeArchived: Boolean) {
|
|
69
|
+
initiatives(filter: $filter, first: $first, after: $after, includeArchived: $includeArchived) {
|
|
70
|
+
nodes {
|
|
71
|
+
id name status priority targetDate health url
|
|
72
|
+
}
|
|
73
|
+
pageInfo { hasNextPage endCursor }
|
|
74
|
+
}
|
|
75
|
+
}`;
|
|
76
|
+
|
|
77
|
+
export interface ListFilters {
|
|
78
|
+
/** One of the InitiativeStatus values, any case (validated by `resolveStatus`). */
|
|
79
|
+
status?: string;
|
|
80
|
+
/** me|email|name|id — resolved to a user id. */
|
|
81
|
+
owner?: string;
|
|
82
|
+
/** Include archived initiatives (the API excludes them by default). */
|
|
83
|
+
archived?: boolean;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Build an InitiativeFilter from human options, resolving names to ids. Exported for tests. */
|
|
87
|
+
export async function buildFilter(
|
|
88
|
+
client: LinearClient,
|
|
89
|
+
f: ListFilters,
|
|
90
|
+
): Promise<Record<string, unknown>> {
|
|
91
|
+
const filter: Record<string, any> = {};
|
|
92
|
+
if (f.status) filter.status = { eq: resolveStatus(f.status) };
|
|
93
|
+
if (f.owner) filter.owner = { id: { eq: await resolveUserId(client, f.owner) } };
|
|
94
|
+
return filter;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** List workspace initiatives (most-recently created come from the API order). */
|
|
98
|
+
export async function listInitiatives(
|
|
99
|
+
client: LinearClient,
|
|
100
|
+
limit: number,
|
|
101
|
+
filters: ListFilters = {},
|
|
102
|
+
): Promise<InitiativeRow[]> {
|
|
103
|
+
const filter = await buildFilter(client, filters);
|
|
104
|
+
return collectRawQuery<InitiativeRow>(
|
|
105
|
+
client as any,
|
|
106
|
+
LIST_QUERY,
|
|
107
|
+
{ filter, includeArchived: filters.archived === true },
|
|
108
|
+
"initiatives",
|
|
109
|
+
limit,
|
|
110
|
+
(n) => ({
|
|
111
|
+
id: n.id,
|
|
112
|
+
name: n.name,
|
|
113
|
+
status: n.status ?? null,
|
|
114
|
+
priority: n.priority ?? 0,
|
|
115
|
+
targetDate: n.targetDate ?? null,
|
|
116
|
+
health: n.health ?? null,
|
|
117
|
+
url: n.url,
|
|
118
|
+
}),
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export interface InitiativeDetail {
|
|
123
|
+
id: string;
|
|
124
|
+
name: string;
|
|
125
|
+
description: string | null;
|
|
126
|
+
status: string | null;
|
|
127
|
+
priority: number;
|
|
128
|
+
priorityLabel: string;
|
|
129
|
+
labels: string[];
|
|
130
|
+
health: string | null;
|
|
131
|
+
targetDate: string | null;
|
|
132
|
+
color: string | null;
|
|
133
|
+
icon: string | null;
|
|
134
|
+
url: string;
|
|
135
|
+
createdAt: string;
|
|
136
|
+
updatedAt: string;
|
|
137
|
+
startedAt: string | null;
|
|
138
|
+
completedAt: string | null;
|
|
139
|
+
archivedAt: string | null;
|
|
140
|
+
owner: string | null;
|
|
141
|
+
creator: string | null;
|
|
142
|
+
/** The projects linked to the initiative (`initiative add-project`), API order. */
|
|
143
|
+
projects: Array<{ id: string; name: string; status: { name: string; type: string } | null }>;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** The detail's shape; checked against `InitiativeDetail`. */
|
|
147
|
+
export const INITIATIVE_DETAIL_SHAPE = shape<InitiativeDetail>({
|
|
148
|
+
id: "string",
|
|
149
|
+
name: "string",
|
|
150
|
+
description: "string|null",
|
|
151
|
+
status: "string|null",
|
|
152
|
+
priority: "number",
|
|
153
|
+
priorityLabel: "string",
|
|
154
|
+
labels: ["string"],
|
|
155
|
+
health: "string|null",
|
|
156
|
+
targetDate: "string|null",
|
|
157
|
+
color: "string|null",
|
|
158
|
+
icon: "string|null",
|
|
159
|
+
url: "string",
|
|
160
|
+
createdAt: "string",
|
|
161
|
+
updatedAt: "string",
|
|
162
|
+
startedAt: "string|null",
|
|
163
|
+
completedAt: "string|null",
|
|
164
|
+
archivedAt: "string|null",
|
|
165
|
+
owner: "string|null",
|
|
166
|
+
creator: "string|null",
|
|
167
|
+
projects: [
|
|
168
|
+
{ id: "string", name: "string", status: { nullable: { name: "string", type: "string" } } },
|
|
169
|
+
],
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
/** How many linked projects `view` reads; more than this is unusual and shows a truncation note. */
|
|
173
|
+
const PROJECTS_PAGE = 100;
|
|
174
|
+
|
|
175
|
+
export async function getInitiativeDetail(
|
|
176
|
+
client: LinearClient,
|
|
177
|
+
idArg: string,
|
|
178
|
+
): Promise<InitiativeDetail> {
|
|
179
|
+
// An id finds an archived initiative too (`view` after `archive` still
|
|
180
|
+
// works); a name matches live ones only, so an archived namesake cannot make
|
|
181
|
+
// a live initiative ambiguous.
|
|
182
|
+
const initiative = await resolveInitiative(client, idArg, { includeArchived: isUuid(idArg) });
|
|
183
|
+
const [owner, creator, labels, projects] = await Promise.all([
|
|
184
|
+
initiative.owner,
|
|
185
|
+
initiative.creator,
|
|
186
|
+
initiative.labels(),
|
|
187
|
+
initiative.projects({ first: PROJECTS_PAGE }),
|
|
188
|
+
]);
|
|
189
|
+
const projectRows = await Promise.all(
|
|
190
|
+
(projects?.nodes ?? []).map(async (p: any) => {
|
|
191
|
+
const status = await p.status;
|
|
192
|
+
return {
|
|
193
|
+
id: p.id,
|
|
194
|
+
name: p.name,
|
|
195
|
+
status: status ? { name: status.name, type: status.type } : null,
|
|
196
|
+
};
|
|
197
|
+
}),
|
|
198
|
+
);
|
|
199
|
+
return {
|
|
200
|
+
id: initiative.id,
|
|
201
|
+
name: initiative.name,
|
|
202
|
+
description: initiative.description ?? null,
|
|
203
|
+
status: initiative.status ?? null,
|
|
204
|
+
priority: initiative.priority ?? 0,
|
|
205
|
+
priorityLabel: priorityLabel(initiative.priority),
|
|
206
|
+
labels: (labels?.nodes ?? []).map((l: any) => l.name),
|
|
207
|
+
health: initiative.health ?? null,
|
|
208
|
+
targetDate: initiative.targetDate ?? null,
|
|
209
|
+
color: initiative.color ?? null,
|
|
210
|
+
icon: initiative.icon ?? null,
|
|
211
|
+
url: initiative.url,
|
|
212
|
+
createdAt: initiative.createdAt.toISOString(),
|
|
213
|
+
updatedAt: initiative.updatedAt.toISOString(),
|
|
214
|
+
startedAt: initiative.startedAt ? initiative.startedAt.toISOString() : null,
|
|
215
|
+
completedAt: initiative.completedAt ? initiative.completedAt.toISOString() : null,
|
|
216
|
+
archivedAt: initiative.archivedAt ? initiative.archivedAt.toISOString() : null,
|
|
217
|
+
owner: owner?.displayName ?? null,
|
|
218
|
+
creator: creator?.displayName ?? null,
|
|
219
|
+
projects: projectRows,
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
export interface CreateOptions {
|
|
224
|
+
name: string;
|
|
225
|
+
description?: string;
|
|
226
|
+
targetDate?: string;
|
|
227
|
+
owner?: string;
|
|
228
|
+
status?: string;
|
|
229
|
+
priority?: number;
|
|
230
|
+
label?: string[];
|
|
231
|
+
icon?: string;
|
|
232
|
+
color?: string;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
export async function createInitiative(client: LinearClient, opts: CreateOptions) {
|
|
236
|
+
const input: Record<string, any> = { name: opts.name };
|
|
237
|
+
if (opts.description !== undefined) input.description = opts.description;
|
|
238
|
+
if (opts.targetDate) input.targetDate = opts.targetDate;
|
|
239
|
+
if (opts.owner) input.ownerId = await resolveUserId(client, opts.owner);
|
|
240
|
+
if (opts.status) input.status = resolveStatus(opts.status);
|
|
241
|
+
if (opts.priority !== undefined) input.priority = resolvePriority(opts.priority);
|
|
242
|
+
if (opts.label?.length) input.labelIds = await resolveInitiativeLabelIds(client, opts.label);
|
|
243
|
+
if (opts.icon) input.icon = opts.icon;
|
|
244
|
+
if (opts.color) input.color = opts.color;
|
|
245
|
+
|
|
246
|
+
return unwrapMutation(
|
|
247
|
+
withRetry(() => client.createInitiative(input as any)),
|
|
248
|
+
"initiative",
|
|
249
|
+
"Initiative creation",
|
|
250
|
+
);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
export interface UpdateOptions {
|
|
254
|
+
name?: string;
|
|
255
|
+
description?: string;
|
|
256
|
+
targetDate?: string;
|
|
257
|
+
owner?: string;
|
|
258
|
+
status?: string;
|
|
259
|
+
priority?: number;
|
|
260
|
+
/** Replaces the whole label set (matching `issue update --label`). */
|
|
261
|
+
label?: string[];
|
|
262
|
+
icon?: string;
|
|
263
|
+
color?: string;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
export async function updateInitiative(client: LinearClient, idArg: string, opts: UpdateOptions) {
|
|
267
|
+
const initiative = await resolveInitiative(client, idArg);
|
|
268
|
+
const input: Record<string, any> = {};
|
|
269
|
+
if (opts.name !== undefined) input.name = opts.name;
|
|
270
|
+
if (opts.description !== undefined) input.description = opts.description;
|
|
271
|
+
if (opts.targetDate) input.targetDate = opts.targetDate;
|
|
272
|
+
if (opts.owner) input.ownerId = await resolveUserId(client, opts.owner);
|
|
273
|
+
if (opts.status) input.status = resolveStatus(opts.status);
|
|
274
|
+
if (opts.priority !== undefined) input.priority = resolvePriority(opts.priority);
|
|
275
|
+
if (opts.label) input.labelIds = await resolveInitiativeLabelIds(client, opts.label);
|
|
276
|
+
if (opts.icon) input.icon = opts.icon;
|
|
277
|
+
if (opts.color) input.color = opts.color;
|
|
278
|
+
|
|
279
|
+
if (Object.keys(input).length === 0)
|
|
280
|
+
throw usageError("Nothing to update; pass at least one field.");
|
|
281
|
+
|
|
282
|
+
return unwrapMutation(
|
|
283
|
+
withRetry(() => client.updateInitiative(initiative.id, input as any)),
|
|
284
|
+
"initiative",
|
|
285
|
+
"Initiative update",
|
|
286
|
+
);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
export async function archiveInitiative(client: LinearClient, idArg: string) {
|
|
290
|
+
const initiative = await resolveInitiative(client, idArg);
|
|
291
|
+
await assertMutation(
|
|
292
|
+
withRetry(() => client.archiveInitiative(initiative.id)),
|
|
293
|
+
"Initiative archive",
|
|
294
|
+
);
|
|
295
|
+
return initiative;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
export async function deleteInitiative(client: LinearClient, idArg: string) {
|
|
299
|
+
const initiative = await resolveInitiative(client, idArg);
|
|
300
|
+
await assertMutation(
|
|
301
|
+
withRetry(() => client.deleteInitiative(initiative.id)),
|
|
302
|
+
"Initiative deletion",
|
|
303
|
+
);
|
|
304
|
+
return initiative;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Unarchive an initiative. Resolution includes archived initiatives — the
|
|
309
|
+
* whole point is that the target is one — where every other resolver here
|
|
310
|
+
* matches live ones only.
|
|
311
|
+
*/
|
|
312
|
+
export async function unarchiveInitiative(client: LinearClient, idArg: string) {
|
|
313
|
+
const initiative = await resolveInitiative(client, idArg, { includeArchived: true });
|
|
314
|
+
if (!initiative.archivedAt) {
|
|
315
|
+
throw usageError(`Initiative ${initiative.name} is not archived.`);
|
|
316
|
+
}
|
|
317
|
+
await assertMutation(
|
|
318
|
+
withRetry(() => client.unarchiveInitiative(initiative.id)),
|
|
319
|
+
"Initiative unarchive",
|
|
320
|
+
);
|
|
321
|
+
return initiative;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/** The link `add-project` made / `remove-project` removes, plus both ends for the receipt. */
|
|
325
|
+
export interface ProjectLink {
|
|
326
|
+
/** The InitiativeToProject id. */
|
|
327
|
+
id: string;
|
|
328
|
+
initiative: { id: string; name: string };
|
|
329
|
+
project: { id: string; name: string };
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/** The `add-project`/`remove-project` receipt body; checked against `ProjectLink`. */
|
|
333
|
+
export const PROJECT_LINK_SHAPE = shape<ProjectLink>({
|
|
334
|
+
id: "string",
|
|
335
|
+
initiative: { id: "string", name: "string" },
|
|
336
|
+
project: { id: "string", name: "string" },
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* Link a project to an initiative (`initiativeToProjectCreate`). Linear allows
|
|
341
|
+
* a project in one initiative at a time; a second link is refused by the API
|
|
342
|
+
* ("Project already related to a parent or child initiative"), which reaches
|
|
343
|
+
* the user as a validation error.
|
|
344
|
+
*/
|
|
345
|
+
export async function addProject(
|
|
346
|
+
client: LinearClient,
|
|
347
|
+
initiativeArg: string,
|
|
348
|
+
projectArg: string,
|
|
349
|
+
opts: { sortOrder?: number } = {},
|
|
350
|
+
): Promise<ProjectLink> {
|
|
351
|
+
const initiative = await resolveInitiative(client, initiativeArg);
|
|
352
|
+
const projectId = await resolveProjectId(client, projectArg);
|
|
353
|
+
const project = await withRetry(() => client.project(projectId));
|
|
354
|
+
const input: Record<string, any> = { initiativeId: initiative.id, projectId };
|
|
355
|
+
if (opts.sortOrder !== undefined) input.sortOrder = opts.sortOrder;
|
|
356
|
+
const link = await unwrapMutation(
|
|
357
|
+
withRetry(() => client.createInitiativeToProject(input as any)),
|
|
358
|
+
"initiativeToProject",
|
|
359
|
+
"Linking the project",
|
|
360
|
+
);
|
|
361
|
+
return {
|
|
362
|
+
id: link.id,
|
|
363
|
+
initiative: { id: initiative.id, name: initiative.name },
|
|
364
|
+
project: { id: project.id, name: project.name },
|
|
365
|
+
};
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* Find the link between an initiative and a project, for `remove-project` to
|
|
370
|
+
* name before it asks and to delete after. Read off the project's links (a
|
|
371
|
+
* project has at most a handful) rather than the workspace-wide
|
|
372
|
+
* `initiativeToProjects` feed, which has no filter and would have to be paged
|
|
373
|
+
* in full.
|
|
374
|
+
*/
|
|
375
|
+
export async function findProjectLink(
|
|
376
|
+
client: LinearClient,
|
|
377
|
+
initiativeArg: string,
|
|
378
|
+
projectArg: string,
|
|
379
|
+
): Promise<ProjectLink> {
|
|
380
|
+
const initiative = await resolveInitiative(client, initiativeArg);
|
|
381
|
+
const projectId = await resolveProjectId(client, projectArg);
|
|
382
|
+
const project = await withRetry(() => client.project(projectId));
|
|
383
|
+
const links: any[] = await collect(
|
|
384
|
+
(await withRetry(() => project.initiativeToProjects({ first: 50 }))) as any,
|
|
385
|
+
Infinity,
|
|
386
|
+
);
|
|
387
|
+
const link = links.find((l) => l.initiativeId === initiative.id);
|
|
388
|
+
if (!link) {
|
|
389
|
+
throw notFound(
|
|
390
|
+
`Project ${project.name} is not linked to initiative ${initiative.name}. ` +
|
|
391
|
+
`Run 'linear initiative view ${initiative.name}' to see its projects.`,
|
|
392
|
+
);
|
|
393
|
+
}
|
|
394
|
+
return {
|
|
395
|
+
id: link.id,
|
|
396
|
+
initiative: { id: initiative.id, name: initiative.name },
|
|
397
|
+
project: { id: project.id, name: project.name },
|
|
398
|
+
};
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/** Remove a link found by `findProjectLink` (`initiativeToProjectDelete`). */
|
|
402
|
+
export async function removeProjectLink(client: LinearClient, link: ProjectLink): Promise<void> {
|
|
403
|
+
await assertMutation(
|
|
404
|
+
withRetry(() => client.deleteInitiativeToProject(link.id)),
|
|
405
|
+
"Unlinking the project",
|
|
406
|
+
);
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/**
|
|
410
|
+
* Resolve an initiative by id (UUID, fetched directly) or by name (listed and
|
|
411
|
+
* matched case-insensitively; ambiguity is an error). By name, archived
|
|
412
|
+
* initiatives are considered only when `includeArchived` says so.
|
|
413
|
+
*/
|
|
414
|
+
export async function resolveInitiative(
|
|
415
|
+
client: LinearClient,
|
|
416
|
+
idArg: string,
|
|
417
|
+
opts: { includeArchived?: boolean } = {},
|
|
418
|
+
) {
|
|
419
|
+
if (isUuid(idArg)) return withRetry(() => client.initiative(idArg));
|
|
420
|
+
const lower = idArg.toLowerCase();
|
|
421
|
+
let after: string | undefined;
|
|
422
|
+
const matches: any[] = [];
|
|
423
|
+
for (;;) {
|
|
424
|
+
const conn = await withRetry(() =>
|
|
425
|
+
client.initiatives({
|
|
426
|
+
first: 100,
|
|
427
|
+
after,
|
|
428
|
+
includeArchived: opts.includeArchived === true,
|
|
429
|
+
} as any),
|
|
430
|
+
);
|
|
431
|
+
for (const n of conn.nodes) {
|
|
432
|
+
if (n.name.toLowerCase() === lower) matches.push(n);
|
|
433
|
+
}
|
|
434
|
+
if (!conn.pageInfo.hasNextPage) break;
|
|
435
|
+
after = conn.pageInfo.endCursor ?? undefined;
|
|
436
|
+
}
|
|
437
|
+
if (matches.length === 0) throw notFound(`No initiative matching '${idArg}'.`);
|
|
438
|
+
if (matches.length > 1)
|
|
439
|
+
throw ambiguous(`Multiple initiatives match '${idArg}'; pass the initiative id instead.`);
|
|
440
|
+
return matches[0]!;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/** Normalize a human status into the InitiativeStatus enum value Linear expects. */
|
|
444
|
+
export function resolveStatus(input: string): string {
|
|
445
|
+
const lower = input.toLowerCase();
|
|
446
|
+
const match = STATUSES.find((s) => s.toLowerCase() === lower);
|
|
447
|
+
if (!match) throw usageError(`Invalid status '${input}'. Valid: ${STATUSES.join(", ")}.`);
|
|
448
|
+
return match;
|
|
449
|
+
}
|