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,596 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolve friendly human inputs into the IDs the SDK needs.
|
|
3
|
+
*
|
|
4
|
+
* These are the ergonomics layer: `TES` → team id, `me`/email → user id, a
|
|
5
|
+
* state name → state id, label names → ids, `TES-123` → the Issue, etc. Every
|
|
6
|
+
* resolver throws a CliError (not_found/ambiguous) with a helpful message.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { LinearClient, Issue } from "@linear/sdk";
|
|
10
|
+
import { withRetry } from "../client.js";
|
|
11
|
+
import { notFound, ambiguous, usageError } from "./errors.js";
|
|
12
|
+
import { collectWithMore, type Connection } from "./pagination.js";
|
|
13
|
+
|
|
14
|
+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
15
|
+
export const isUuid = (v: string): boolean => UUID_RE.test(v);
|
|
16
|
+
|
|
17
|
+
const IDENTIFIER_RE = /^([a-zA-Z][a-zA-Z0-9]*)-(\d+)$/;
|
|
18
|
+
const ISSUE_NUMBER_RE = /^\d+$/;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Expand a bare issue number with the configured team (`42` + `TES` →
|
|
22
|
+
* `TES-42`). This is deliberately pure: commands can normalize every place an
|
|
23
|
+
* issue reference appears before handing it to the existing resolvers.
|
|
24
|
+
*/
|
|
25
|
+
export function normalizeIssueReference(input: string, defaultTeamKey?: string): string {
|
|
26
|
+
if (!ISSUE_NUMBER_RE.test(input)) return input;
|
|
27
|
+
if (!defaultTeamKey) {
|
|
28
|
+
throw usageError(
|
|
29
|
+
`Bare issue number '${input}' needs a default team. Pass --team <KEY> or set one in config.`,
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
if (!/^[a-zA-Z][a-zA-Z0-9]*$/.test(defaultTeamKey)) {
|
|
33
|
+
throw usageError(
|
|
34
|
+
`Cannot expand issue number '${input}' with default team '${defaultTeamKey}'; configure a team key such as TES.`,
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
return `${defaultTeamKey.toUpperCase()}-${Number.parseInt(input, 10)}`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Known workflow-state types, used to decide name-vs-type filtering. */
|
|
41
|
+
export const STATE_TYPES = ["triage", "backlog", "unstarted", "started", "completed", "canceled"];
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Page size and hard bound for the resolvers that match a name client-side.
|
|
45
|
+
*
|
|
46
|
+
* Those resolvers used to ask for a fixed `first: 100`/`first: 250` and match
|
|
47
|
+
* within whatever came back, so on a large workspace a team/state/cycle/
|
|
48
|
+
* milestone that existed past the cap resolved to a false `not_found` — and an
|
|
49
|
+
* ambiguity check only ever considered a prefix of the candidates. They now
|
|
50
|
+
* follow the connection instead.
|
|
51
|
+
*
|
|
52
|
+
* 250 is Linear's per-page maximum, so the common workspace still costs exactly
|
|
53
|
+
* the one request it always did; the extra requests are paid only by the
|
|
54
|
+
* workspaces that actually have more than 250 of something. The scan is bounded
|
|
55
|
+
* at 2000 (8 requests) rather than being unbounded: past that, guessing at a
|
|
56
|
+
* name is not the right tool, and the error says so instead of quietly
|
|
57
|
+
* truncating.
|
|
58
|
+
*/
|
|
59
|
+
const RESOLVE_PAGE = 250;
|
|
60
|
+
const RESOLVE_SCAN_CAP = 2000;
|
|
61
|
+
|
|
62
|
+
/** How many candidate names an error message will list before pointing at a command. */
|
|
63
|
+
const MAX_LISTED = 25;
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Follow a connection to the end (bounded), for a resolver that has to match
|
|
67
|
+
* client-side. Hitting the bound is an honest error, not a silent prefix.
|
|
68
|
+
*/
|
|
69
|
+
async function scanAll<T>(conn: Connection<T>, plural: string, discovery: string): Promise<T[]> {
|
|
70
|
+
const { items, hasMore } = await collectWithMore(conn, RESOLVE_SCAN_CAP);
|
|
71
|
+
if (hasMore) {
|
|
72
|
+
throw usageError(
|
|
73
|
+
`More than ${RESOLVE_SCAN_CAP} ${plural} to search; pass the id instead (see '${discovery}').`,
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
return items;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* The tail of a not-found message: the candidates when the set is small enough
|
|
81
|
+
* to be useful, otherwise the command that lists them. Both are free — the
|
|
82
|
+
* candidates are already in hand, and no message here costs a round-trip.
|
|
83
|
+
*/
|
|
84
|
+
function available(names: Array<string | null | undefined>, discovery: string): string {
|
|
85
|
+
const shown = names.filter((n): n is string => !!n);
|
|
86
|
+
if (shown.length === 0) return ` None to choose from (see '${discovery}').`;
|
|
87
|
+
if (shown.length > MAX_LISTED) return ` Run '${discovery}' to see the ${shown.length} options.`;
|
|
88
|
+
return ` Available: ${shown.join(", ")}.`;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export interface ResolvedTeam {
|
|
92
|
+
id: string;
|
|
93
|
+
key: string;
|
|
94
|
+
name: string;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Resolve a team by key (preferred), name, or id. Falls back to `fallbackKey`. */
|
|
98
|
+
export async function resolveTeam(
|
|
99
|
+
client: LinearClient,
|
|
100
|
+
input: string | undefined,
|
|
101
|
+
fallbackKey: string | undefined,
|
|
102
|
+
): Promise<ResolvedTeam> {
|
|
103
|
+
const value = input ?? fallbackKey;
|
|
104
|
+
if (!value) {
|
|
105
|
+
throw usageError("No team specified. Pass --team <KEY> or set a default team in config.");
|
|
106
|
+
}
|
|
107
|
+
if (isUuid(value)) {
|
|
108
|
+
const team = await withRetry(() => client.team(value));
|
|
109
|
+
return { id: team.id, key: team.key, name: team.name };
|
|
110
|
+
}
|
|
111
|
+
const conn = await withRetry(() => client.teams({ first: RESOLVE_PAGE }));
|
|
112
|
+
const nodes = await scanAll<any>(conn as any, "teams", "linear team list");
|
|
113
|
+
const lower = value.toLowerCase();
|
|
114
|
+
const byKey = nodes.filter((t: any) => t.key.toLowerCase() === lower);
|
|
115
|
+
const matches: any[] = byKey.length
|
|
116
|
+
? byKey
|
|
117
|
+
: nodes.filter((t: any) => t.name.toLowerCase() === lower);
|
|
118
|
+
if (matches.length === 0)
|
|
119
|
+
throw notFound(
|
|
120
|
+
`No team matching '${value}'.${available(
|
|
121
|
+
nodes.map((t: any) => t.key),
|
|
122
|
+
"linear team list",
|
|
123
|
+
)}`,
|
|
124
|
+
);
|
|
125
|
+
if (matches.length > 1)
|
|
126
|
+
throw ambiguous(`Multiple teams match '${value}': ${matches.map((t) => t.key).join(", ")}`);
|
|
127
|
+
const team = matches[0]!;
|
|
128
|
+
return { id: team.id, key: team.key, name: team.name };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** The spellings that mean "the authenticated user": ours (`me`, `@me`) and the reference CLI's (`self`). */
|
|
132
|
+
export const isSelf = (input: string): boolean =>
|
|
133
|
+
input === "me" || input === "@me" || input === "self";
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Resolve an assignee reference (`me`, email, name, or id) to a user id.
|
|
137
|
+
*
|
|
138
|
+
* `self` is the reference CLI's spelling of the same sentinel; it is accepted
|
|
139
|
+
* alongside `me`/`@me` so transplanted commands assign to the viewer instead of
|
|
140
|
+
* failing to find a user literally named "self".
|
|
141
|
+
*/
|
|
142
|
+
export async function resolveUserId(client: LinearClient, input: string): Promise<string> {
|
|
143
|
+
if (isSelf(input)) {
|
|
144
|
+
const me = await withRetry(() => client.viewer);
|
|
145
|
+
return me.id;
|
|
146
|
+
}
|
|
147
|
+
if (isUuid(input)) return input;
|
|
148
|
+
const isEmail = input.includes("@");
|
|
149
|
+
const filter = isEmail ? { email: { eq: input } } : { displayName: { eqIgnoreCase: input } };
|
|
150
|
+
const lookup = async (f: any) =>
|
|
151
|
+
scanAll(
|
|
152
|
+
(await withRetry(() => client.users({ filter: f, first: RESOLVE_PAGE }))) as any,
|
|
153
|
+
"users",
|
|
154
|
+
"linear user list",
|
|
155
|
+
);
|
|
156
|
+
let nodes: any[] = await lookup(filter);
|
|
157
|
+
if (nodes.length === 0 && !isEmail) nodes = await lookup({ name: { eqIgnoreCase: input } });
|
|
158
|
+
// The candidate set here is a server-side exact match, so there is nothing
|
|
159
|
+
// useful to list on a miss — point at the command that would show it instead
|
|
160
|
+
// of spending a round-trip on error text.
|
|
161
|
+
if (nodes.length === 0)
|
|
162
|
+
throw notFound(`No user matching '${input}'. Run 'linear user list' to see workspace members.`);
|
|
163
|
+
if (nodes.length > 1)
|
|
164
|
+
throw ambiguous(
|
|
165
|
+
`Multiple users match '${input}': ${nodes.map((u: any) => u.email).join(", ")}`,
|
|
166
|
+
);
|
|
167
|
+
return nodes[0]!.id;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Resolve a workflow state (by name or type) within a team to a state id. */
|
|
171
|
+
export async function resolveStateId(
|
|
172
|
+
client: LinearClient,
|
|
173
|
+
teamId: string,
|
|
174
|
+
input: string,
|
|
175
|
+
): Promise<string> {
|
|
176
|
+
if (isUuid(input)) return input;
|
|
177
|
+
const nodes = await teamStates(client, teamId);
|
|
178
|
+
const lower = input.toLowerCase();
|
|
179
|
+
const byName = nodes.filter((s: any) => s.name.toLowerCase() === lower);
|
|
180
|
+
const matches: any[] = byName.length
|
|
181
|
+
? byName
|
|
182
|
+
: nodes.filter((s: any) => s.type.toLowerCase() === lower);
|
|
183
|
+
if (matches.length === 0)
|
|
184
|
+
throw notFound(
|
|
185
|
+
`No workflow state '${input}' in team.${available(
|
|
186
|
+
nodes.map((s: any) => s.name),
|
|
187
|
+
"linear state list",
|
|
188
|
+
)}`,
|
|
189
|
+
);
|
|
190
|
+
if (matches.length > 1)
|
|
191
|
+
throw ambiguous(`Multiple states match '${input}': ${matches.map((s) => s.name).join(", ")}`);
|
|
192
|
+
return matches[0]!.id;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** Every workflow state in a team, following the connection past the first page. */
|
|
196
|
+
async function teamStates(client: LinearClient, teamId: string): Promise<any[]> {
|
|
197
|
+
const team = await withRetry(() => client.team(teamId));
|
|
198
|
+
const conn = await withRetry(() => team.states({ first: RESOLVE_PAGE }));
|
|
199
|
+
return scanAll<any>(conn as any, "workflow states", "linear state list");
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Resolve label names to ids. When `teamId` is given, labels are matched within
|
|
204
|
+
* that team's scope plus workspace-level (team-less) labels, and ambiguity is an
|
|
205
|
+
* error — important in multi-team workspaces where the same name exists twice.
|
|
206
|
+
*/
|
|
207
|
+
export async function resolveLabelIds(
|
|
208
|
+
client: LinearClient,
|
|
209
|
+
names: string[],
|
|
210
|
+
teamId?: string,
|
|
211
|
+
): Promise<string[]> {
|
|
212
|
+
const ids: string[] = [];
|
|
213
|
+
for (const name of names) {
|
|
214
|
+
if (isUuid(name)) {
|
|
215
|
+
ids.push(name);
|
|
216
|
+
continue;
|
|
217
|
+
}
|
|
218
|
+
const conn = await withRetry(() =>
|
|
219
|
+
client.issueLabels({ filter: { name: { eqIgnoreCase: name } } as any, first: RESOLVE_PAGE }),
|
|
220
|
+
);
|
|
221
|
+
// Scanned rather than capped: the team narrowing below can discard every
|
|
222
|
+
// label on the first page, so a fixed page cap could turn a label that
|
|
223
|
+
// exists into a not-found.
|
|
224
|
+
const nodes = await scanAll<any>(conn as any, "labels", "linear label list");
|
|
225
|
+
if (nodes.length === 0)
|
|
226
|
+
throw notFound(`No label matching '${name}'. Run 'linear label list' to see the options.`);
|
|
227
|
+
|
|
228
|
+
// Narrow to this team's labels + workspace-level labels when a team is known.
|
|
229
|
+
// If a team is known and nothing is in scope, that's not-found (do NOT fall
|
|
230
|
+
// back to an out-of-scope label from another team).
|
|
231
|
+
// Label groups are containers, not labels that can be applied to an issue.
|
|
232
|
+
let candidates: any[] = nodes.filter((label: any) => !label.isGroup);
|
|
233
|
+
if (teamId) {
|
|
234
|
+
const scoped = await Promise.all(
|
|
235
|
+
nodes.map(async (l: any) => ({ label: l, team: await l.team })),
|
|
236
|
+
);
|
|
237
|
+
candidates = scoped.filter((s) => !s.team || s.team.id === teamId).map((s) => s.label);
|
|
238
|
+
if (candidates.length === 0) throw notFound(`No label '${name}' in this team or workspace.`);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// Prefer an exact (case-sensitive) match.
|
|
242
|
+
const exact = candidates.filter((l: any) => l.name === name);
|
|
243
|
+
const finalists = exact.length ? exact : candidates;
|
|
244
|
+
if (finalists.length > 1) {
|
|
245
|
+
throw ambiguous(
|
|
246
|
+
`Multiple labels named '${name}'${teamId ? " in scope" : ""}; pass the label id instead.`,
|
|
247
|
+
);
|
|
248
|
+
}
|
|
249
|
+
ids.push(finalists[0]!.id);
|
|
250
|
+
}
|
|
251
|
+
return ids;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Resolve initiative label names to ids. Initiative labels are their own
|
|
256
|
+
* workspace-scoped entity (`initiativeLabels`, public since @linear/sdk 88.2) —
|
|
257
|
+
* unrelated to issue labels, and with no team scoping to narrow by.
|
|
258
|
+
*
|
|
259
|
+
* Label *groups* (`isGroup`) are containers, not applicable labels, so they are
|
|
260
|
+
* excluded from matching.
|
|
261
|
+
*/
|
|
262
|
+
export async function resolveInitiativeLabelIds(
|
|
263
|
+
client: LinearClient,
|
|
264
|
+
names: string[],
|
|
265
|
+
): Promise<string[]> {
|
|
266
|
+
const ids: string[] = [];
|
|
267
|
+
for (const name of names) {
|
|
268
|
+
if (isUuid(name)) {
|
|
269
|
+
ids.push(name);
|
|
270
|
+
continue;
|
|
271
|
+
}
|
|
272
|
+
const conn: any = await withRetry(() =>
|
|
273
|
+
(client as any).initiativeLabels({
|
|
274
|
+
filter: { name: { eqIgnoreCase: name } },
|
|
275
|
+
first: RESOLVE_PAGE,
|
|
276
|
+
}),
|
|
277
|
+
);
|
|
278
|
+
const nodes = await scanAll<any>(conn, "initiative labels", "linear initiative label list");
|
|
279
|
+
const candidates = nodes.filter((l) => !l.isGroup);
|
|
280
|
+
if (candidates.length === 0)
|
|
281
|
+
throw notFound(
|
|
282
|
+
`No initiative label matching '${name}'. Run 'linear initiative label list' to see the options.`,
|
|
283
|
+
);
|
|
284
|
+
|
|
285
|
+
// Prefer an exact (case-sensitive) match before declaring ambiguity.
|
|
286
|
+
const exact = candidates.filter((l) => l.name === name);
|
|
287
|
+
const finalists = exact.length ? exact : candidates;
|
|
288
|
+
if (finalists.length > 1)
|
|
289
|
+
throw ambiguous(`Multiple initiative labels named '${name}'; pass the label id instead.`);
|
|
290
|
+
ids.push(finalists[0]!.id);
|
|
291
|
+
}
|
|
292
|
+
return ids;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Resolve project label names to ids. Like initiative labels, project labels are
|
|
297
|
+
* a workspace-scoped entity of their own (`projectLabels`), and label *groups*
|
|
298
|
+
* are containers rather than applicable labels, so they are excluded.
|
|
299
|
+
*/
|
|
300
|
+
export async function resolveProjectLabelIds(
|
|
301
|
+
client: LinearClient,
|
|
302
|
+
names: string[],
|
|
303
|
+
): Promise<string[]> {
|
|
304
|
+
const ids: string[] = [];
|
|
305
|
+
for (const name of names) {
|
|
306
|
+
if (isUuid(name)) {
|
|
307
|
+
ids.push(name);
|
|
308
|
+
continue;
|
|
309
|
+
}
|
|
310
|
+
const conn: any = await withRetry(() =>
|
|
311
|
+
(client as any).projectLabels({
|
|
312
|
+
filter: { name: { eqIgnoreCase: name } },
|
|
313
|
+
first: RESOLVE_PAGE,
|
|
314
|
+
}),
|
|
315
|
+
);
|
|
316
|
+
const nodes = await scanAll<any>(conn, "project labels", "linear project label list");
|
|
317
|
+
const candidates = nodes.filter((l) => !l.isGroup);
|
|
318
|
+
if (candidates.length === 0)
|
|
319
|
+
throw notFound(
|
|
320
|
+
`No project label matching '${name}'. Run 'linear project label list' to see the options.`,
|
|
321
|
+
);
|
|
322
|
+
|
|
323
|
+
const exact = candidates.filter((l) => l.name === name);
|
|
324
|
+
const finalists = exact.length ? exact : candidates;
|
|
325
|
+
if (finalists.length > 1)
|
|
326
|
+
throw ambiguous(`Multiple project labels named '${name}'; pass the label id instead.`);
|
|
327
|
+
ids.push(finalists[0]!.id);
|
|
328
|
+
}
|
|
329
|
+
// The same label named twice (or by name and id) must not be sent twice.
|
|
330
|
+
return [...new Set(ids)];
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/** First workflow state of a given type (lowest position) within a team. */
|
|
334
|
+
export async function firstStateOfType(
|
|
335
|
+
client: LinearClient,
|
|
336
|
+
teamId: string,
|
|
337
|
+
type: string,
|
|
338
|
+
): Promise<string> {
|
|
339
|
+
const nodes = await teamStates(client, teamId);
|
|
340
|
+
const ofType = nodes
|
|
341
|
+
.filter((s: any) => s.type === type)
|
|
342
|
+
.sort((a: any, b: any) => a.position - b.position);
|
|
343
|
+
if (ofType.length === 0)
|
|
344
|
+
throw notFound(
|
|
345
|
+
`No '${type}' workflow state in this team.${available(
|
|
346
|
+
[...new Set(nodes.map((s: any) => s.type as string))],
|
|
347
|
+
"linear state list",
|
|
348
|
+
)}`,
|
|
349
|
+
);
|
|
350
|
+
return ofType[0]!.id;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/** Resolve a project by name or id. */
|
|
354
|
+
export async function resolveProjectId(client: LinearClient, input: string): Promise<string> {
|
|
355
|
+
if (isUuid(input)) return input;
|
|
356
|
+
const conn = await withRetry(() =>
|
|
357
|
+
client.projects({ filter: { name: { eqIgnoreCase: input } } as any, first: RESOLVE_PAGE }),
|
|
358
|
+
);
|
|
359
|
+
const nodes = await scanAll<any>(conn as any, "projects", "linear project list");
|
|
360
|
+
if (nodes.length === 0)
|
|
361
|
+
throw notFound(`No project matching '${input}'. Run 'linear project list' to see the options.`);
|
|
362
|
+
if (nodes.length > 1)
|
|
363
|
+
throw ambiguous(
|
|
364
|
+
`Multiple projects match '${input}': ${nodes.map((p: any) => p.name).join(", ")}`,
|
|
365
|
+
);
|
|
366
|
+
return nodes[0]!.id;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/** The reserved cycle words, so help text and tests share one list. */
|
|
370
|
+
export const CYCLE_SENTINELS = ["current", "active", "now", "next", "previous"] as const;
|
|
371
|
+
|
|
372
|
+
/** A whole-token integer: the only shape that means "cycle number". */
|
|
373
|
+
const CYCLE_NUMBER_RE = /^\d+$/;
|
|
374
|
+
/** A signed offset from the active cycle: `+1`, `-2`. */
|
|
375
|
+
const CYCLE_OFFSET_RE = /^[+-]\d+$/;
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* Resolve a cycle within a team by number, name, id, a sentinel, or an offset.
|
|
379
|
+
*
|
|
380
|
+
* The union of both CLIs' vocabularies, plus the reference's 2.2 relative
|
|
381
|
+
* references:
|
|
382
|
+
*
|
|
383
|
+
* - `current` / `active` / `now` — the team's active cycle
|
|
384
|
+
* - `next` / `previous` — the cycle Linear flags `isNext` / `isPrevious`
|
|
385
|
+
* - `+N` / `-N` — N cycles after / before the active one, by cycle number
|
|
386
|
+
* - a whole-token integer — that cycle number
|
|
387
|
+
* - anything else — a cycle *name*, matched case-insensitively
|
|
388
|
+
*
|
|
389
|
+
* Reserved words win over coincidental cycle names; a cycle literally named
|
|
390
|
+
* "next" is reachable by number or id.
|
|
391
|
+
*
|
|
392
|
+
* Only a *complete* integer token is a number. `Number.parseInt` used to decide
|
|
393
|
+
* this, so `--cycle 3.9` and `--cycle 3abc` quietly resolved to cycle #3 (and
|
|
394
|
+
* `issue update` moved the issue there), while a cycle *named* with a leading
|
|
395
|
+
* digit — `2024 Q1`, `24W03` — could never be reached by name at all, because it
|
|
396
|
+
* parsed as #2024 first.
|
|
397
|
+
*/
|
|
398
|
+
export async function resolveCycleId(
|
|
399
|
+
client: LinearClient,
|
|
400
|
+
teamId: string,
|
|
401
|
+
input: string,
|
|
402
|
+
): Promise<string> {
|
|
403
|
+
if (isUuid(input)) return input;
|
|
404
|
+
const team = await withRetry(() => client.team(teamId));
|
|
405
|
+
// Only an explicit `false` — the fakes in unit tests (and any partial model)
|
|
406
|
+
// leave the field undefined, and the API rejects nothing on `undefined`.
|
|
407
|
+
if ((team as any).cyclesEnabled === false) {
|
|
408
|
+
throw usageError(
|
|
409
|
+
`Cycles are not enabled for team ${team.key ?? teamId}. Enable them in Linear's team settings before assigning or filtering by cycle.`,
|
|
410
|
+
);
|
|
411
|
+
}
|
|
412
|
+
const keyword = input.toLowerCase();
|
|
413
|
+
|
|
414
|
+
if (keyword === "current" || keyword === "active" || keyword === "now") {
|
|
415
|
+
const cycle = await team.activeCycle;
|
|
416
|
+
if (!cycle)
|
|
417
|
+
throw notFound("No active cycle for this team. Try 'next', a cycle number, or a cycle name.");
|
|
418
|
+
return cycle.id;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
if (keyword === "next" || keyword === "previous") {
|
|
422
|
+
// Linear computes these flags server-side relative to the active cycle, so
|
|
423
|
+
// the lookup is one filtered request rather than a scan and a guess.
|
|
424
|
+
const flag = keyword === "next" ? "isNext" : "isPrevious";
|
|
425
|
+
const cycles = await withRetry(() =>
|
|
426
|
+
team.cycles({ filter: { [flag]: { eq: true } } as any, first: 1 }),
|
|
427
|
+
);
|
|
428
|
+
if (cycles.nodes.length === 0)
|
|
429
|
+
throw notFound(`No ${keyword} cycle for this team. Use a cycle number or name instead.`);
|
|
430
|
+
return cycles.nodes[0]!.id;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
if (CYCLE_OFFSET_RE.test(input)) {
|
|
434
|
+
const offset = Number(input);
|
|
435
|
+
if (!Number.isSafeInteger(offset)) throw usageError(`Cycle offset '${input}' is out of range.`);
|
|
436
|
+
const active = await team.activeCycle;
|
|
437
|
+
if (!active)
|
|
438
|
+
throw notFound(
|
|
439
|
+
`Cannot resolve relative cycle '${input}': the team has no active cycle. Use 'next', a cycle number, or a cycle name instead.`,
|
|
440
|
+
);
|
|
441
|
+
const target = active.number + offset;
|
|
442
|
+
if (target < 1)
|
|
443
|
+
throw notFound(`No cycle ${input} from the active cycle (#${active.number}) in this team.`);
|
|
444
|
+
return cycleByNumber(team, target, `${input} (cycle #${target})`);
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
if (CYCLE_NUMBER_RE.test(input)) {
|
|
448
|
+
return cycleByNumber(team, Number.parseInt(input, 10), `#${input}`);
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
// Not a number, offset, or sentinel → a cycle name. Names are optional in
|
|
452
|
+
// Linear, so the candidate set is filtered client-side over the team's
|
|
453
|
+
// cycles rather than through a server-side name filter.
|
|
454
|
+
const conn = await withRetry(() => team.cycles({ first: RESOLVE_PAGE }));
|
|
455
|
+
const nodes = await scanAll<any>(conn as any, "cycles", "linear cycle list");
|
|
456
|
+
const matches = nodes.filter((c: any) => c.name?.toLowerCase() === keyword);
|
|
457
|
+
if (matches.length === 0)
|
|
458
|
+
throw notFound(
|
|
459
|
+
`No cycle named '${input}' in this team (try a number, id, or one of ${CYCLE_SENTINELS.join("/")}).${available(
|
|
460
|
+
nodes.map((c: any) => c.name),
|
|
461
|
+
"linear cycle list",
|
|
462
|
+
)}`,
|
|
463
|
+
);
|
|
464
|
+
if (matches.length > 1)
|
|
465
|
+
throw ambiguous(`Multiple cycles named '${input}'; pass the cycle number or id instead.`);
|
|
466
|
+
return matches[0]!.id;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
/** One filtered request for a cycle number; `label` names it in the error. */
|
|
470
|
+
async function cycleByNumber(team: any, number: number, label: string): Promise<string> {
|
|
471
|
+
const cycles: any = await withRetry(() =>
|
|
472
|
+
team.cycles({ filter: { number: { eq: number } }, first: 1 }),
|
|
473
|
+
);
|
|
474
|
+
if (cycles.nodes.length === 0) throw notFound(`No cycle ${label} in this team.`);
|
|
475
|
+
return cycles.nodes[0]!.id;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/** Resolve a project milestone by name or id within a project. */
|
|
479
|
+
export async function resolveMilestoneId(
|
|
480
|
+
client: LinearClient,
|
|
481
|
+
projectId: string,
|
|
482
|
+
input: string,
|
|
483
|
+
): Promise<string> {
|
|
484
|
+
if (isUuid(input)) return input;
|
|
485
|
+
const project = await withRetry(() => client.project(projectId));
|
|
486
|
+
const conn = await withRetry(() => project.projectMilestones({ first: RESOLVE_PAGE }));
|
|
487
|
+
const nodes = await scanAll<any>(conn as any, "milestones", "linear milestone list");
|
|
488
|
+
const lower = input.toLowerCase();
|
|
489
|
+
const matches = nodes.filter((m: any) => m.name.toLowerCase() === lower);
|
|
490
|
+
if (matches.length === 0)
|
|
491
|
+
throw notFound(
|
|
492
|
+
`No milestone '${input}' in this project.${available(
|
|
493
|
+
nodes.map((m: any) => m.name),
|
|
494
|
+
"linear milestone list",
|
|
495
|
+
)}`,
|
|
496
|
+
);
|
|
497
|
+
if (matches.length > 1) throw ambiguous(`Multiple milestones match '${input}'.`);
|
|
498
|
+
return matches[0]!.id;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
/**
|
|
502
|
+
* Resolve a release by name, version, or id. Releases are workspace-scoped
|
|
503
|
+
* (under release pipelines) and matched server-side by exact name (case-
|
|
504
|
+
* insensitive) or exact version, both in one filtered request; ambiguity —
|
|
505
|
+
* the same name on two pipelines, or a name that is also another release's
|
|
506
|
+
* version — is an error rather than a guess.
|
|
507
|
+
*/
|
|
508
|
+
export async function resolveReleaseId(client: LinearClient, input: string): Promise<string> {
|
|
509
|
+
if (isUuid(input)) return input;
|
|
510
|
+
const conn = await withRetry(() =>
|
|
511
|
+
client.releases({
|
|
512
|
+
filter: { or: [{ name: { eqIgnoreCase: input } }, { version: { eq: input } }] } as any,
|
|
513
|
+
first: RESOLVE_PAGE,
|
|
514
|
+
}),
|
|
515
|
+
);
|
|
516
|
+
const nodes = await scanAll<any>(
|
|
517
|
+
conn as any,
|
|
518
|
+
"releases",
|
|
519
|
+
"linear api '{ releases { nodes { id name version } } }'",
|
|
520
|
+
);
|
|
521
|
+
if (nodes.length === 0) throw notFound(`No release named or versioned '${input}'.`);
|
|
522
|
+
if (nodes.length > 1)
|
|
523
|
+
throw ambiguous(
|
|
524
|
+
`Multiple releases match '${input}': ${nodes
|
|
525
|
+
.map((r) => (r.version ? `${r.name} (${r.version})` : r.name))
|
|
526
|
+
.join(", ")}; pass the release id instead.`,
|
|
527
|
+
);
|
|
528
|
+
return nodes[0]!.id;
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
/**
|
|
532
|
+
* `templates` is a plain list in the schema (no arguments, no pages), so one
|
|
533
|
+
* request is the whole workspace: team-scoped and shared templates alike.
|
|
534
|
+
*/
|
|
535
|
+
const TEMPLATES_QUERY = `
|
|
536
|
+
query CliTemplates {
|
|
537
|
+
templates { id name type team { id } }
|
|
538
|
+
}`;
|
|
539
|
+
|
|
540
|
+
/**
|
|
541
|
+
* Resolve an issue template by name or id, within a team's scope: the team's
|
|
542
|
+
* own templates plus the workspace-shared ones (`team: null`), `type: "issue"`
|
|
543
|
+
* only. A team template outranks a shared one of the same name — it is the more
|
|
544
|
+
* specific of the two, and it is what Linear's own picker offers first.
|
|
545
|
+
*/
|
|
546
|
+
export async function resolveTemplateId(
|
|
547
|
+
client: LinearClient,
|
|
548
|
+
teamId: string,
|
|
549
|
+
input: string,
|
|
550
|
+
): Promise<string> {
|
|
551
|
+
if (isUuid(input)) return input;
|
|
552
|
+
const data: any = await withRetry(() => (client as any).client.rawRequest(TEMPLATES_QUERY, {}));
|
|
553
|
+
const all: any[] = data?.data?.templates ?? [];
|
|
554
|
+
const inScope = all.filter((t) => t.type === "issue" && (!t.team || t.team.id === teamId));
|
|
555
|
+
const lower = input.toLowerCase();
|
|
556
|
+
const byName = inScope.filter((t) => t.name.toLowerCase() === lower);
|
|
557
|
+
// Exact case first, then the team's own before the workspace's.
|
|
558
|
+
const exact = byName.filter((t) => t.name === input);
|
|
559
|
+
const candidates = exact.length ? exact : byName;
|
|
560
|
+
const teamOwned = candidates.filter((t) => t.team);
|
|
561
|
+
const finalists = teamOwned.length ? teamOwned : candidates;
|
|
562
|
+
if (finalists.length === 0)
|
|
563
|
+
throw notFound(
|
|
564
|
+
`No issue template '${input}' for this team.${available(
|
|
565
|
+
inScope.map((t) => t.name),
|
|
566
|
+
`linear api '{ templates { id name type team { key } } }'`,
|
|
567
|
+
)}`,
|
|
568
|
+
);
|
|
569
|
+
if (finalists.length > 1)
|
|
570
|
+
throw ambiguous(`Multiple issue templates named '${input}'; pass the template id instead.`);
|
|
571
|
+
return finalists[0]!.id;
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
/**
|
|
575
|
+
* Resolve an issue by identifier (`TES-123`) or UUID. Identifier lookups go via
|
|
576
|
+
* the team key + number filter so they are unambiguous and don't depend on the
|
|
577
|
+
* SDK accepting human identifiers.
|
|
578
|
+
*/
|
|
579
|
+
export async function resolveIssue(client: LinearClient, input: string): Promise<Issue> {
|
|
580
|
+
if (isUuid(input)) return withRetry(() => client.issue(input));
|
|
581
|
+
const match = input.match(IDENTIFIER_RE);
|
|
582
|
+
if (!match)
|
|
583
|
+
throw usageError(`'${input}' is not a valid issue id (expected e.g. TES-123 or a UUID).`);
|
|
584
|
+
const key = match[1]!.toUpperCase();
|
|
585
|
+
const number = Number.parseInt(match[2]!, 10);
|
|
586
|
+
// includeArchived so archive/unarchive/delete can operate on archived issues.
|
|
587
|
+
const issues = await withRetry(() =>
|
|
588
|
+
client.issues({
|
|
589
|
+
filter: { team: { key: { eq: key } }, number: { eq: number } } as any,
|
|
590
|
+
first: 1,
|
|
591
|
+
includeArchived: true,
|
|
592
|
+
}),
|
|
593
|
+
);
|
|
594
|
+
if (issues.nodes.length === 0) throw notFound(`No issue ${key}-${number}.`);
|
|
595
|
+
return issues.nodes[0]!;
|
|
596
|
+
}
|