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,357 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Document service: all SDK access for documents lives here so commands stay thin.
|
|
3
|
+
*
|
|
4
|
+
* A Linear document is attached to exactly one *target* — a project, issue,
|
|
5
|
+
* initiative, team, cycle or release (`DocumentCreateInput` carries the six
|
|
6
|
+
* ids; the API enforces "exactly one"). This module owns the CLI side of that
|
|
7
|
+
* rule — parsing the six flags, resolving the one that is set, and mapping it
|
|
8
|
+
* onto the create/update input or the list filter — so `create`, `update` and
|
|
9
|
+
* `list` cannot drift apart. `update` re-points: setting a new target makes the
|
|
10
|
+
* server clear the old one (verified live for every pair).
|
|
11
|
+
*
|
|
12
|
+
* The list and the detail use tailored GraphQL queries: the SDK's `Document`
|
|
13
|
+
* model exposes no `team` or `cycle` getter (both are `[Internal]` in the
|
|
14
|
+
* schema, though they work with a plain API key), and one round-trip beats six
|
|
15
|
+
* lazy fetches. Mutations use the typed SDK models and unwrap the
|
|
16
|
+
* `{ success, document }` payload.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import type { LinearClient } from "@linear/sdk";
|
|
20
|
+
import { withRetry } from "../client.js";
|
|
21
|
+
import { collectRawQuery } from "../lib/pagination.js";
|
|
22
|
+
import { usageError, notFound, normalizeError } from "../lib/errors.js";
|
|
23
|
+
import { assertMutation, unwrapMutation } from "../lib/mutation.js";
|
|
24
|
+
import {
|
|
25
|
+
resolveProjectId,
|
|
26
|
+
resolveIssue,
|
|
27
|
+
resolveTeam,
|
|
28
|
+
resolveCycleId,
|
|
29
|
+
resolveReleaseId,
|
|
30
|
+
isUuid,
|
|
31
|
+
} from "../lib/resolve.js";
|
|
32
|
+
import { resolveInitiative } from "./initiative.js";
|
|
33
|
+
|
|
34
|
+
// --- Targets ----------------------------------------------------------------
|
|
35
|
+
|
|
36
|
+
/** The six target flags as the user typed them (names, keys, identifiers, or ids). */
|
|
37
|
+
export interface DocumentTargetOptions {
|
|
38
|
+
project?: string;
|
|
39
|
+
issue?: string;
|
|
40
|
+
initiative?: string;
|
|
41
|
+
/** A team key — the target itself, or, together with `cycle`, the team the cycle is looked up in. */
|
|
42
|
+
team?: string;
|
|
43
|
+
cycle?: string;
|
|
44
|
+
release?: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export type DocumentTargetKind = "project" | "issue" | "initiative" | "team" | "cycle" | "release";
|
|
48
|
+
|
|
49
|
+
/** One resolved target: which kind, and the entity's UUID. */
|
|
50
|
+
export interface DocumentTarget {
|
|
51
|
+
kind: DocumentTargetKind;
|
|
52
|
+
id: string;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const TARGET_FLAGS = "--project, --issue, --initiative, --team, --cycle, or --release";
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Which one target the flags name, before any network work. `--team` together
|
|
59
|
+
* with `--cycle` scopes the cycle lookup (cycle numbers restart per team) and
|
|
60
|
+
* does not count as a second target — the same reading the reference CLI has.
|
|
61
|
+
* Two targets is a usage error whatever the command: a document has one, and a
|
|
62
|
+
* list filtered by two could never match.
|
|
63
|
+
*/
|
|
64
|
+
export function selectTarget(
|
|
65
|
+
o: DocumentTargetOptions,
|
|
66
|
+
): { kind: DocumentTargetKind; value: string; team?: string } | undefined {
|
|
67
|
+
const picked: Array<{ kind: DocumentTargetKind; value: string; team?: string }> = [];
|
|
68
|
+
if (o.project != null) picked.push({ kind: "project", value: o.project });
|
|
69
|
+
if (o.issue != null) picked.push({ kind: "issue", value: o.issue });
|
|
70
|
+
if (o.initiative != null) picked.push({ kind: "initiative", value: o.initiative });
|
|
71
|
+
if (o.cycle != null) picked.push({ kind: "cycle", value: o.cycle, team: o.team });
|
|
72
|
+
else if (o.team != null) picked.push({ kind: "team", value: o.team });
|
|
73
|
+
if (o.release != null) picked.push({ kind: "release", value: o.release });
|
|
74
|
+
if (picked.length > 1) {
|
|
75
|
+
throw usageError(
|
|
76
|
+
`A document has one target; got ${picked.map((p) => `--${p.kind}`).join(" and ")}. Pass exactly one of ${TARGET_FLAGS}.`,
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
return picked[0];
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Resolve the selected target to its id. `defaultTeamKey` is the configured
|
|
84
|
+
* team, used only to scope a `--cycle` lookup when `--team` was not given.
|
|
85
|
+
*/
|
|
86
|
+
export async function resolveTarget(
|
|
87
|
+
client: LinearClient,
|
|
88
|
+
sel: NonNullable<ReturnType<typeof selectTarget>>,
|
|
89
|
+
defaultTeamKey: string | undefined,
|
|
90
|
+
): Promise<DocumentTarget> {
|
|
91
|
+
switch (sel.kind) {
|
|
92
|
+
case "project":
|
|
93
|
+
return { kind: "project", id: await resolveProjectId(client, sel.value) };
|
|
94
|
+
case "issue":
|
|
95
|
+
return { kind: "issue", id: (await resolveIssue(client, sel.value)).id };
|
|
96
|
+
case "initiative":
|
|
97
|
+
return { kind: "initiative", id: (await resolveInitiative(client, sel.value)).id };
|
|
98
|
+
case "team":
|
|
99
|
+
return { kind: "team", id: (await resolveTeam(client, sel.value, undefined)).id };
|
|
100
|
+
case "cycle": {
|
|
101
|
+
const teamKey = sel.team ?? defaultTeamKey;
|
|
102
|
+
if (!teamKey)
|
|
103
|
+
throw usageError(
|
|
104
|
+
"--cycle needs a team to look the cycle up in: pass --team <KEY> or set a default team.",
|
|
105
|
+
);
|
|
106
|
+
const team = await resolveTeam(client, teamKey, undefined);
|
|
107
|
+
return { kind: "cycle", id: await resolveCycleId(client, team.id, sel.value) };
|
|
108
|
+
}
|
|
109
|
+
case "release":
|
|
110
|
+
return { kind: "release", id: await resolveReleaseId(client, sel.value) };
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** The one `…Id` field of DocumentCreateInput / DocumentUpdateInput a target sets. */
|
|
115
|
+
export function targetInput(t: DocumentTarget): Record<string, string> {
|
|
116
|
+
return { [`${t.kind}Id`]: t.id };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** The one relation clause of DocumentFilter a target filters by. */
|
|
120
|
+
export function targetFilter(t: DocumentTarget): Record<string, unknown> {
|
|
121
|
+
return { [t.kind]: { id: { eq: t.id } } };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// --- Rows and detail --------------------------------------------------------
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* A document's target relations, one of which is set. Objects (not display
|
|
128
|
+
* strings) with ids, the same shapes the issue/project rows use, so a script
|
|
129
|
+
* reads `.issue.identifier` / `.team.key` here as it does anywhere else.
|
|
130
|
+
*/
|
|
131
|
+
export interface DocumentTargets {
|
|
132
|
+
project: { id: string; name: string } | null;
|
|
133
|
+
issue: { id: string; identifier: string } | null;
|
|
134
|
+
initiative: { id: string; name: string } | null;
|
|
135
|
+
team: { id: string; key: string; name: string } | null;
|
|
136
|
+
cycle: { id: string; number: number; name: string | null } | null;
|
|
137
|
+
release: { id: string; name: string; version: string | null } | null;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export interface DocumentRow extends DocumentTargets {
|
|
141
|
+
id: string;
|
|
142
|
+
title: string;
|
|
143
|
+
url: string;
|
|
144
|
+
updatedAt: string;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** The target selection every document query makes, so row and detail agree. */
|
|
148
|
+
const TARGET_FIELDS = `
|
|
149
|
+
project { id name }
|
|
150
|
+
issue { id identifier }
|
|
151
|
+
initiative { id name }
|
|
152
|
+
team { id key name }
|
|
153
|
+
cycle { id number name }
|
|
154
|
+
release { id name version }`;
|
|
155
|
+
|
|
156
|
+
const LIST_QUERY = `
|
|
157
|
+
query CliDocuments($filter: DocumentFilter, $first: Int!, $after: String, $includeArchived: Boolean) {
|
|
158
|
+
documents(filter: $filter, first: $first, after: $after, includeArchived: $includeArchived) {
|
|
159
|
+
nodes {
|
|
160
|
+
id title url updatedAt${TARGET_FIELDS}
|
|
161
|
+
}
|
|
162
|
+
pageInfo { hasNextPage endCursor }
|
|
163
|
+
}
|
|
164
|
+
}`;
|
|
165
|
+
|
|
166
|
+
/** The target relations off a raw document node, absent ones nulled. */
|
|
167
|
+
function targets(n: any): DocumentTargets {
|
|
168
|
+
return {
|
|
169
|
+
project: n.project ?? null,
|
|
170
|
+
issue: n.issue ?? null,
|
|
171
|
+
initiative: n.initiative ?? null,
|
|
172
|
+
team: n.team ?? null,
|
|
173
|
+
cycle: n.cycle ? { id: n.cycle.id, number: n.cycle.number, name: n.cycle.name ?? null } : null,
|
|
174
|
+
release: n.release
|
|
175
|
+
? { id: n.release.id, name: n.release.name, version: n.release.version ?? null }
|
|
176
|
+
: null,
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* One line naming a document's target, typed — the six come from six
|
|
182
|
+
* namespaces, so a bare name would be ambiguous. Shared by the list column and
|
|
183
|
+
* the detail view.
|
|
184
|
+
*/
|
|
185
|
+
export function describeTarget(t: DocumentTargets): string | null {
|
|
186
|
+
if (t.project) return `Project: ${t.project.name}`;
|
|
187
|
+
if (t.issue) return `Issue: ${t.issue.identifier}`;
|
|
188
|
+
if (t.initiative) return `Initiative: ${t.initiative.name}`;
|
|
189
|
+
if (t.team) return `Team: ${t.team.key} ${t.team.name}`;
|
|
190
|
+
if (t.cycle) return `Cycle: #${t.cycle.number}${t.cycle.name ? ` ${t.cycle.name}` : ""}`;
|
|
191
|
+
if (t.release)
|
|
192
|
+
return `Release: ${t.release.name}${t.release.version ? ` (${t.release.version})` : ""}`;
|
|
193
|
+
return null;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* List workspace documents (most-recently updated come from the API order),
|
|
198
|
+
* optionally narrowed to one target. DocumentFilter matches targets by id, so a
|
|
199
|
+
* human `--issue TES-1` / `--project Roadmap` is resolved first.
|
|
200
|
+
*/
|
|
201
|
+
export async function listDocuments(
|
|
202
|
+
client: LinearClient,
|
|
203
|
+
limit: number,
|
|
204
|
+
filters: DocumentTargetOptions = {},
|
|
205
|
+
defaultTeamKey?: string,
|
|
206
|
+
): Promise<DocumentRow[]> {
|
|
207
|
+
const sel = selectTarget(filters);
|
|
208
|
+
const filter = sel ? targetFilter(await resolveTarget(client, sel, defaultTeamKey)) : undefined;
|
|
209
|
+
return collectRawQuery<DocumentRow>(
|
|
210
|
+
client as any,
|
|
211
|
+
LIST_QUERY,
|
|
212
|
+
{ filter, includeArchived: false },
|
|
213
|
+
"documents",
|
|
214
|
+
limit,
|
|
215
|
+
(n) => ({ id: n.id, title: n.title, url: n.url, updatedAt: n.updatedAt, ...targets(n) }),
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
export interface DocumentDetail extends DocumentTargets {
|
|
220
|
+
id: string;
|
|
221
|
+
title: string;
|
|
222
|
+
content: string | null;
|
|
223
|
+
url: string;
|
|
224
|
+
slugId: string;
|
|
225
|
+
icon: string | null;
|
|
226
|
+
color: string | null;
|
|
227
|
+
createdAt: string;
|
|
228
|
+
updatedAt: string;
|
|
229
|
+
creator: { id: string; displayName: string } | null;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** `document(id:)` takes a UUID or a slugId, so either is one request. */
|
|
233
|
+
const DETAIL_QUERY = `
|
|
234
|
+
query CliDocumentDetail($id: String!) {
|
|
235
|
+
document(id: $id) {
|
|
236
|
+
id title content url slugId icon color createdAt updatedAt
|
|
237
|
+
creator { id displayName }${TARGET_FIELDS}
|
|
238
|
+
}
|
|
239
|
+
}`;
|
|
240
|
+
|
|
241
|
+
export async function getDocumentDetail(
|
|
242
|
+
client: LinearClient,
|
|
243
|
+
idArg: string,
|
|
244
|
+
): Promise<DocumentDetail> {
|
|
245
|
+
// An unknown id is an API error ("Could not find referenced Document"), which
|
|
246
|
+
// the error boundary already reads as not-found (exit 3); a null is guarded too.
|
|
247
|
+
const data: any = await withRetry(() =>
|
|
248
|
+
(client as any).client.rawRequest(DETAIL_QUERY, { id: idArg }),
|
|
249
|
+
);
|
|
250
|
+
const n = data?.data?.document;
|
|
251
|
+
if (!n) throw notFound(`No document matching '${idArg}'.`);
|
|
252
|
+
return {
|
|
253
|
+
id: n.id,
|
|
254
|
+
title: n.title,
|
|
255
|
+
content: n.content ?? null,
|
|
256
|
+
url: n.url,
|
|
257
|
+
slugId: n.slugId,
|
|
258
|
+
icon: n.icon ?? null,
|
|
259
|
+
color: n.color ?? null,
|
|
260
|
+
createdAt: n.createdAt,
|
|
261
|
+
updatedAt: n.updatedAt,
|
|
262
|
+
creator: n.creator ?? null,
|
|
263
|
+
...targets(n),
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// --- Mutations ----------------------------------------------------------------
|
|
268
|
+
|
|
269
|
+
export interface CreateOptions extends DocumentTargetOptions {
|
|
270
|
+
title: string;
|
|
271
|
+
content?: string;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Build a DocumentCreateInput. Exactly one target is required — an error names
|
|
276
|
+
* the six flags when none (or two) are given. The configured default team is
|
|
277
|
+
* NOT a fallback here: the command decides that, so the service never turns
|
|
278
|
+
* "no target" into "the team from config" behind the caller's back.
|
|
279
|
+
*/
|
|
280
|
+
export async function createDocument(
|
|
281
|
+
client: LinearClient,
|
|
282
|
+
opts: CreateOptions,
|
|
283
|
+
defaultTeamKey?: string,
|
|
284
|
+
) {
|
|
285
|
+
const sel = selectTarget(opts);
|
|
286
|
+
if (!sel) throw usageError(`A document needs a target: pass one of ${TARGET_FLAGS}.`);
|
|
287
|
+
const input: Record<string, any> = { title: opts.title };
|
|
288
|
+
if (opts.content !== undefined) input.content = opts.content;
|
|
289
|
+
Object.assign(input, targetInput(await resolveTarget(client, sel, defaultTeamKey)));
|
|
290
|
+
|
|
291
|
+
return unwrapMutation(
|
|
292
|
+
withRetry(() => client.createDocument(input as any)),
|
|
293
|
+
"document",
|
|
294
|
+
"Document creation",
|
|
295
|
+
);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
export interface UpdateOptions extends DocumentTargetOptions {
|
|
299
|
+
title?: string;
|
|
300
|
+
content?: string;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Update a document: title, content, and/or re-point it to a new target (which
|
|
305
|
+
* clears the old one). At most one target; none means a metadata-only edit.
|
|
306
|
+
*/
|
|
307
|
+
export async function updateDocument(
|
|
308
|
+
client: LinearClient,
|
|
309
|
+
idArg: string,
|
|
310
|
+
opts: UpdateOptions,
|
|
311
|
+
defaultTeamKey?: string,
|
|
312
|
+
) {
|
|
313
|
+
// Validate the flags before the lookup, so a bad combination fails first.
|
|
314
|
+
const sel = selectTarget(opts);
|
|
315
|
+
const document = await resolveDocument(client, idArg);
|
|
316
|
+
const input: Record<string, any> = {};
|
|
317
|
+
if (opts.title !== undefined) input.title = opts.title;
|
|
318
|
+
if (opts.content !== undefined) input.content = opts.content;
|
|
319
|
+
if (sel) Object.assign(input, targetInput(await resolveTarget(client, sel, defaultTeamKey)));
|
|
320
|
+
|
|
321
|
+
if (Object.keys(input).length === 0)
|
|
322
|
+
throw usageError(
|
|
323
|
+
`Nothing to update; pass at least one of --title, --content, or a new target (${TARGET_FLAGS}).`,
|
|
324
|
+
);
|
|
325
|
+
|
|
326
|
+
return unwrapMutation(
|
|
327
|
+
withRetry(() => client.updateDocument(document.id, input as any)),
|
|
328
|
+
"document",
|
|
329
|
+
"Document update",
|
|
330
|
+
);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
export async function deleteDocument(client: LinearClient, idArg: string) {
|
|
334
|
+
const document = await resolveDocument(client, idArg);
|
|
335
|
+
await assertMutation(
|
|
336
|
+
withRetry(() => client.deleteDocument(document.id)),
|
|
337
|
+
"Document deletion",
|
|
338
|
+
);
|
|
339
|
+
return document;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* Resolve a document by id. A UUID is fetched directly; a slugId is matched
|
|
344
|
+
* against the workspace documents (the API also accepts a slugId on the lookup,
|
|
345
|
+
* but we normalize to the typed model for consistent unwrapping).
|
|
346
|
+
*/
|
|
347
|
+
async function resolveDocument(client: LinearClient, idArg: string) {
|
|
348
|
+
if (isUuid(idArg)) return withRetry(() => client.document(idArg));
|
|
349
|
+
// The SDK's document() also resolves a slugId; fall back to it directly.
|
|
350
|
+
try {
|
|
351
|
+
return await withRetry(() => client.document(idArg));
|
|
352
|
+
} catch (err) {
|
|
353
|
+
const normalized = normalizeError(err);
|
|
354
|
+
if (normalized.code === "not_found") throw notFound(`No document matching '${idArg}'.`);
|
|
355
|
+
throw normalized;
|
|
356
|
+
}
|
|
357
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Favorite service: all SDK access for the viewer's favorites lives here so
|
|
3
|
+
* commands stay thin.
|
|
4
|
+
*
|
|
5
|
+
* A favorite references one of many entity kinds (issue, project, document,
|
|
6
|
+
* cycle, …); `type` says which, and the matching getter resolves the referenced
|
|
7
|
+
* model. The list is relation-light and personal (small), so awaiting the one
|
|
8
|
+
* relevant getter per row with Promise.all is acceptable (no tailored GraphQL).
|
|
9
|
+
* `add` resolves an issue/project to its id and builds a single-target
|
|
10
|
+
* FavoriteCreateInput; `remove` deletes by id.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { LinearClient } from "@linear/sdk";
|
|
14
|
+
import { withRetry } from "../client.js";
|
|
15
|
+
import { shape } from "../lib/shape.js";
|
|
16
|
+
import { collect, inheritPaginationMetadata, pageSize } from "../lib/pagination.js";
|
|
17
|
+
import { usageError } from "../lib/errors.js";
|
|
18
|
+
import { assertMutation, unwrapMutation } from "../lib/mutation.js";
|
|
19
|
+
import { resolveIssue, resolveProjectId, isUuid } from "../lib/resolve.js";
|
|
20
|
+
|
|
21
|
+
export interface FavoriteRow {
|
|
22
|
+
id: string;
|
|
23
|
+
type: string;
|
|
24
|
+
name: string;
|
|
25
|
+
url: string | null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** The row's shape as `linear commands` advertises it (TES-610); checked against the interface. */
|
|
29
|
+
export const FAVORITE_ROW_SHAPE = shape<FavoriteRow>({
|
|
30
|
+
id: "string",
|
|
31
|
+
type: "string",
|
|
32
|
+
name: "string",
|
|
33
|
+
url: "string|null",
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Resolve the human-facing title/name of the entity a favorite points at.
|
|
38
|
+
* Picks the getter matching `type` (falling back to a scan of the common ones),
|
|
39
|
+
* then reads a name-like field off the resolved model.
|
|
40
|
+
*/
|
|
41
|
+
export async function favoriteName(fav: any): Promise<string> {
|
|
42
|
+
// Folders carry their own name and reference no entity.
|
|
43
|
+
if (fav.type === "folder") return fav.folderName ?? "(folder)";
|
|
44
|
+
if (fav.type === "predefinedView") return fav.predefinedViewType ?? "(view)";
|
|
45
|
+
|
|
46
|
+
// The getter named after `type` holds the referenced entity for the common
|
|
47
|
+
// kinds; otherwise probe the handful we care about in a stable order.
|
|
48
|
+
const candidates =
|
|
49
|
+
fav.type && typeof fav[fav.type] !== "undefined"
|
|
50
|
+
? [fav.type]
|
|
51
|
+
: ["issue", "project", "document", "cycle", "label", "customView", "user", "team"];
|
|
52
|
+
for (const key of candidates) {
|
|
53
|
+
const getter = fav[key];
|
|
54
|
+
if (getter === undefined || getter === null) continue;
|
|
55
|
+
const entity = await getter;
|
|
56
|
+
if (!entity) continue;
|
|
57
|
+
return entityLabel(entity);
|
|
58
|
+
}
|
|
59
|
+
return fav.folderName ?? "—";
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** A display label for a referenced entity, tolerant of its concrete kind. */
|
|
63
|
+
export function entityLabel(entity: any): string {
|
|
64
|
+
if (entity.identifier && entity.title) return `${entity.identifier} ${entity.title}`;
|
|
65
|
+
return entity.title ?? entity.name ?? entity.displayName ?? entity.key ?? entity.id ?? "—";
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** The viewer's favorites: type, referenced entity name, and id. */
|
|
69
|
+
export async function listFavorites(client: LinearClient, limit: number): Promise<FavoriteRow[]> {
|
|
70
|
+
const conn = await withRetry(() => client.favorites({ first: pageSize(limit) }));
|
|
71
|
+
const nodes = await collect(conn as any, limit);
|
|
72
|
+
return inheritPaginationMetadata(
|
|
73
|
+
await Promise.all(
|
|
74
|
+
nodes.map(async (f: any) => ({
|
|
75
|
+
id: f.id,
|
|
76
|
+
type: f.type,
|
|
77
|
+
name: await favoriteName(f),
|
|
78
|
+
url: f.url ?? null,
|
|
79
|
+
})),
|
|
80
|
+
),
|
|
81
|
+
nodes,
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export interface AddOptions {
|
|
86
|
+
issue?: string;
|
|
87
|
+
project?: string;
|
|
88
|
+
document?: string;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Build a FavoriteCreateInput from exactly one target. Throws a usage error if
|
|
93
|
+
* zero or more than one target is given. Resolves the id for the chosen target
|
|
94
|
+
* (issue/project via the shared resolvers; document is taken as a UUID).
|
|
95
|
+
* Exported for unit testing.
|
|
96
|
+
*/
|
|
97
|
+
export async function buildFavoriteInput(
|
|
98
|
+
client: LinearClient,
|
|
99
|
+
opts: AddOptions,
|
|
100
|
+
): Promise<Record<string, string>> {
|
|
101
|
+
const targets = [
|
|
102
|
+
["issue", opts.issue],
|
|
103
|
+
["project", opts.project],
|
|
104
|
+
["document", opts.document],
|
|
105
|
+
].filter(([, v]) => v !== undefined && v !== "") as Array<[string, string]>;
|
|
106
|
+
|
|
107
|
+
if (targets.length === 0) {
|
|
108
|
+
throw usageError("Specify exactly one target: --issue, --project, or --document.");
|
|
109
|
+
}
|
|
110
|
+
if (targets.length > 1) {
|
|
111
|
+
throw usageError(
|
|
112
|
+
`Specify exactly one target, not ${targets.length} (${targets.map(([k]) => `--${k}`).join(", ")}).`,
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const [kind, value] = targets[0]!;
|
|
117
|
+
switch (kind) {
|
|
118
|
+
case "issue":
|
|
119
|
+
return { issueId: (await resolveIssue(client, value)).id };
|
|
120
|
+
case "project":
|
|
121
|
+
return { projectId: await resolveProjectId(client, value) };
|
|
122
|
+
case "document":
|
|
123
|
+
if (!isUuid(value)) throw usageError("A document must be referenced by its UUID.");
|
|
124
|
+
return { documentId: value };
|
|
125
|
+
default:
|
|
126
|
+
throw usageError("Unknown favorite target.");
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Create a favorite for the single resolved target; unwraps the payload. */
|
|
131
|
+
export async function addFavorite(client: LinearClient, opts: AddOptions) {
|
|
132
|
+
const input = await buildFavoriteInput(client, opts);
|
|
133
|
+
return unwrapMutation(
|
|
134
|
+
withRetry(() => client.createFavorite(input as any)),
|
|
135
|
+
"favorite",
|
|
136
|
+
"Favorite creation",
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Delete a favorite by id. */
|
|
141
|
+
export async function removeFavorite(client: LinearClient, id: string) {
|
|
142
|
+
await assertMutation(
|
|
143
|
+
withRetry(() => client.deleteFavorite(id)),
|
|
144
|
+
"Favorite removal",
|
|
145
|
+
);
|
|
146
|
+
return { id };
|
|
147
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Initiative-update service: create an initiative status update, and list an
|
|
3
|
+
* initiative's updates. Mirrors the project-update service. All SDK calls are
|
|
4
|
+
* wrapped in `withRetry`; the initiative is resolved by name or id via the
|
|
5
|
+
* existing `resolveInitiative`.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { LinearClient } from "@linear/sdk";
|
|
9
|
+
import { withRetry } from "../client.js";
|
|
10
|
+
import { collect, inheritPaginationMetadata, pageSize } from "../lib/pagination.js";
|
|
11
|
+
import { normalizeUpdatePayload, type Health, type UpdateRow } from "../lib/status-update.js";
|
|
12
|
+
import { resolveInitiative } from "./initiative.js";
|
|
13
|
+
|
|
14
|
+
export type { UpdateRow };
|
|
15
|
+
|
|
16
|
+
/** List an initiative's status updates (by initiative name or id). */
|
|
17
|
+
export async function listInitiativeUpdates(
|
|
18
|
+
client: LinearClient,
|
|
19
|
+
idArg: string,
|
|
20
|
+
limit: number,
|
|
21
|
+
): Promise<UpdateRow[]> {
|
|
22
|
+
const initiative = await resolveInitiative(client, idArg);
|
|
23
|
+
const conn = await withRetry(() => initiative.initiativeUpdates({ first: pageSize(limit) }));
|
|
24
|
+
const nodes = await collect(conn as any, limit);
|
|
25
|
+
return inheritPaginationMetadata(
|
|
26
|
+
await Promise.all(
|
|
27
|
+
nodes.map(async (u: any) => {
|
|
28
|
+
const user = await u.user;
|
|
29
|
+
return {
|
|
30
|
+
id: u.id,
|
|
31
|
+
createdAt: u.createdAt?.toISOString?.() ?? String(u.createdAt),
|
|
32
|
+
user: user?.displayName ?? "unknown",
|
|
33
|
+
body: u.body ?? "",
|
|
34
|
+
health: u.health ?? null,
|
|
35
|
+
};
|
|
36
|
+
}),
|
|
37
|
+
),
|
|
38
|
+
nodes,
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface CreateInitiativeUpdateOptions {
|
|
43
|
+
body: string;
|
|
44
|
+
health?: Health;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Create a status update on an initiative (resolved by name or id). Returns a normalized row. */
|
|
48
|
+
export async function createInitiativeUpdate(
|
|
49
|
+
client: LinearClient,
|
|
50
|
+
idArg: string,
|
|
51
|
+
opts: CreateInitiativeUpdateOptions,
|
|
52
|
+
): Promise<UpdateRow & { url: string }> {
|
|
53
|
+
const initiative = await resolveInitiative(client, idArg);
|
|
54
|
+
const input: Record<string, any> = { initiativeId: initiative.id, body: opts.body };
|
|
55
|
+
if (opts.health) input.health = opts.health;
|
|
56
|
+
|
|
57
|
+
const payload = await withRetry(() => client.createInitiativeUpdate(input as any));
|
|
58
|
+
return normalizeUpdatePayload(payload, "initiativeUpdate");
|
|
59
|
+
}
|