linear-sdk-cli 0.1.3 → 0.2.3
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 +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +96 -58
- package/COVERAGE.md +5 -5
- package/MIGRATING.md +71 -69
- package/README.md +115 -90
- package/package.json +6 -7
- package/skills/README.md +15 -0
- package/skills/linear-sdk-cli/SKILL.md +27 -3
- package/skills/linear-sdk-cli/references/api.md +9 -9
- package/skills/linear-sdk-cli/references/auth.md +18 -0
- package/skills/linear-sdk-cli/references/commands.md +2 -2
- package/skills/linear-sdk-cli/references/comment.md +9 -6
- package/skills/linear-sdk-cli/references/config.md +9 -11
- package/skills/linear-sdk-cli/references/issue.md +72 -18
- package/skills/linear-sdk-cli/references/open.md +29 -0
- package/skills/linear-sdk-cli/references/team.md +16 -0
- package/skills/linear-sdk-cli/scripts/generate-docs.ts +6 -2
- package/skills/linear-sdk-cli-maintenance/SKILL.md +97 -0
- package/src/bin/linear.ts +16 -6
- package/src/cli.ts +3 -1
- package/src/commands/agent-session.ts +13 -5
- package/src/commands/api.ts +15 -9
- package/src/commands/attach.ts +15 -4
- package/src/commands/comment.ts +50 -11
- package/src/commands/completion.ts +2 -6
- package/src/commands/cycle.ts +6 -1
- package/src/commands/discover.ts +6 -2
- package/src/commands/document.ts +4 -1
- package/src/commands/initiative.ts +5 -1
- package/src/commands/issue.ts +392 -91
- package/src/commands/meta.ts +139 -31
- package/src/commands/milestone.ts +4 -1
- package/src/commands/notification.ts +15 -6
- package/src/commands/open.ts +84 -0
- package/src/commands/project.ts +17 -4
- package/src/commands/team.ts +13 -0
- package/src/commands/user.ts +2 -2
- package/src/commands/webhook.ts +12 -2
- package/src/config.ts +501 -184
- package/src/context.ts +13 -4
- package/src/git.ts +28 -3
- package/src/lib/body.ts +26 -2
- package/src/lib/errors.ts +33 -8
- package/src/lib/introspect.ts +76 -7
- package/src/lib/keyring.ts +51 -9
- package/src/lib/mentions.ts +49 -0
- package/src/lib/open.ts +32 -0
- package/src/lib/options.ts +108 -12
- package/src/lib/output-shapes.ts +81 -9
- package/src/lib/pagination.ts +50 -17
- package/src/lib/resolve.ts +40 -7
- package/src/lib/shape.ts +16 -12
- package/src/lib/upload.ts +3 -1
- package/src/output/color.ts +52 -0
- package/src/output/format.ts +96 -18
- package/src/output/markdown.ts +133 -0
- package/src/output/pager.ts +131 -0
- package/src/output/sanitize.ts +1 -1
- package/src/output/table.ts +7 -7
- package/src/services/agent-session.ts +11 -3
- package/src/services/attachment.ts +14 -5
- package/src/services/comment.ts +37 -9
- package/src/services/cycle.ts +11 -4
- package/src/services/document.ts +29 -10
- package/src/services/favorite.ts +15 -9
- package/src/services/initiative-update.ts +16 -15
- package/src/services/initiative.ts +15 -10
- package/src/services/issue.ts +263 -19
- package/src/services/label.ts +12 -2
- package/src/services/milestone.ts +8 -5
- package/src/services/notification.ts +16 -2
- package/src/services/organization.ts +23 -18
- package/src/services/project.ts +55 -34
- package/src/services/roadmap.ts +7 -6
- package/src/services/state.ts +9 -3
- package/src/services/team.ts +67 -24
- package/src/services/user.ts +2 -2
- package/src/services/webhook.ts +28 -17
package/src/commands/meta.ts
CHANGED
|
@@ -10,6 +10,8 @@ import { withRetry } from "../client.js";
|
|
|
10
10
|
import { resolve } from "node:path";
|
|
11
11
|
import {
|
|
12
12
|
writeCredential,
|
|
13
|
+
probeKeyringCredential,
|
|
14
|
+
adoptKeyringCredential,
|
|
13
15
|
removeCredential,
|
|
14
16
|
setDefaultWorkspace,
|
|
15
17
|
listCredentials,
|
|
@@ -25,8 +27,8 @@ import {
|
|
|
25
27
|
} from "../config.js";
|
|
26
28
|
import { keyring } from "../lib/keyring.js";
|
|
27
29
|
import { readStdinSync } from "../lib/body.js";
|
|
28
|
-
import { authError, usageError } from "../lib/errors.js";
|
|
29
|
-
import { promptSecret, promptSelect } from "../lib/prompt.js";
|
|
30
|
+
import { authError, normalizeError, usageError } from "../lib/errors.js";
|
|
31
|
+
import { confirmDestructive, promptSecret, promptSelect } from "../lib/prompt.js";
|
|
30
32
|
import { listTeams } from "../services/team.js";
|
|
31
33
|
import { ISSUE_SORTS } from "../services/issue.js";
|
|
32
34
|
import { firstTeam, type Context } from "../context.js";
|
|
@@ -58,6 +60,79 @@ const whoamiAction = action(async (ctx: Context) => {
|
|
|
58
60
|
);
|
|
59
61
|
});
|
|
60
62
|
|
|
63
|
+
interface AuthIdentity {
|
|
64
|
+
user: { id: string; name: string; email: string };
|
|
65
|
+
organization: { id: string; name: string; urlKey: string };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
interface AuthValidationClient {
|
|
69
|
+
viewer: Promise<{ id: string; name: string; email: string }>;
|
|
70
|
+
organization: Promise<{ id: string; name: string; urlKey: string }>;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export type AuthValidationClientFactory = (apiKey: string) => AuthValidationClient;
|
|
74
|
+
|
|
75
|
+
const defaultAuthValidationClientFactory: AuthValidationClientFactory = (apiKey) =>
|
|
76
|
+
new LinearClient({ apiKey }) as unknown as AuthValidationClient;
|
|
77
|
+
let authValidationClientFactory = defaultAuthValidationClientFactory;
|
|
78
|
+
|
|
79
|
+
/** Test seam for command-level auth tests; production always uses LinearClient. */
|
|
80
|
+
export function setAuthValidationClientFactoryForTests(
|
|
81
|
+
factory: AuthValidationClientFactory | undefined,
|
|
82
|
+
): void {
|
|
83
|
+
authValidationClientFactory = factory ?? defaultAuthValidationClientFactory;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Merge the local and global login spellings without silently choosing one. */
|
|
87
|
+
export function selectLoginKey(
|
|
88
|
+
localKey: string | undefined,
|
|
89
|
+
globalKey: string | undefined,
|
|
90
|
+
): string | undefined {
|
|
91
|
+
if (localKey !== undefined && globalKey !== undefined) {
|
|
92
|
+
throw usageError("Pass only one of --key or --api-key to `auth login`.");
|
|
93
|
+
}
|
|
94
|
+
return localKey ?? globalKey;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Validate a credential and prove that an explicitly requested slug matches it. */
|
|
98
|
+
export async function validateAuthCredential(
|
|
99
|
+
apiKey: string,
|
|
100
|
+
requestedWorkspace?: string,
|
|
101
|
+
): Promise<AuthIdentity> {
|
|
102
|
+
const client = authValidationClientFactory(apiKey);
|
|
103
|
+
let me;
|
|
104
|
+
let org;
|
|
105
|
+
try {
|
|
106
|
+
me = await withRetry(() => client.viewer);
|
|
107
|
+
org = await withRetry(() => client.organization);
|
|
108
|
+
} catch (err) {
|
|
109
|
+
const normalized = normalizeError(err);
|
|
110
|
+
if (normalized.code === "auth" || normalized.code === "forbidden") {
|
|
111
|
+
throw authError("That API key was rejected by Linear.");
|
|
112
|
+
}
|
|
113
|
+
throw normalized;
|
|
114
|
+
}
|
|
115
|
+
if (requestedWorkspace && requestedWorkspace !== org.urlKey) {
|
|
116
|
+
throw usageError(
|
|
117
|
+
`--workspace '${requestedWorkspace}' does not match the key's workspace '${org.urlKey}'. Store credentials under their Linear workspace slug.`,
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
return {
|
|
121
|
+
user: { id: me.id, name: me.name, email: me.email },
|
|
122
|
+
organization: { id: org.id, name: org.name, urlKey: org.urlKey },
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function explicitWorkspaceSlug(input: string): string {
|
|
127
|
+
const slug = input.trim();
|
|
128
|
+
if (!/^[a-z0-9](?:[a-z0-9-]{0,98}[a-z0-9])?$/.test(slug)) {
|
|
129
|
+
throw usageError(
|
|
130
|
+
"Workspace slug must be its lowercase Linear URL key (letters, numbers, and hyphens).",
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
return slug;
|
|
134
|
+
}
|
|
135
|
+
|
|
61
136
|
export function registerMeta(program: Command): void {
|
|
62
137
|
program.command("whoami").description("Show the authenticated user").action(whoamiAction);
|
|
63
138
|
|
|
@@ -75,10 +150,7 @@ export function registerMeta(program: Command): void {
|
|
|
75
150
|
.command("login")
|
|
76
151
|
.description("Validate and store a Linear API key for a workspace")
|
|
77
152
|
.option("--key <key>", "API key (otherwise prompted; '-' reads it from stdin)")
|
|
78
|
-
.option(
|
|
79
|
-
"--plaintext",
|
|
80
|
-
"Store the key in the config file (0600) instead of the system keyring",
|
|
81
|
-
)
|
|
153
|
+
.option("--plaintext", "Store the key in the config file (0600) instead of the system keyring")
|
|
82
154
|
.action(
|
|
83
155
|
// The global `--workspace <slug>` selects the slug to store under
|
|
84
156
|
// (default: derived from the key's organization urlKey).
|
|
@@ -87,14 +159,14 @@ export function registerMeta(program: Command): void {
|
|
|
87
159
|
// it is a credential, and it must not reach the screen or scrollback.
|
|
88
160
|
// Nothing below echoes it either: the receipt reports the user and
|
|
89
161
|
// where the key went, never the value.
|
|
90
|
-
let key: string | undefined = opts.key;
|
|
162
|
+
let key: string | undefined = selectLoginKey(opts.key, ctx.options.apiKey);
|
|
91
163
|
if (key === "-") {
|
|
92
164
|
key = readStdinSync();
|
|
93
165
|
} else if (key) {
|
|
94
166
|
// argv is world-readable (`ps`) and lands in shell history; say so
|
|
95
167
|
// once, quietly, and carry on — the script author chose it.
|
|
96
168
|
ctx.output.warn(
|
|
97
|
-
"
|
|
169
|
+
"An API key on the command line is visible to other processes and shell history; prefer the prompt or `--key -` (stdin).",
|
|
98
170
|
);
|
|
99
171
|
} else {
|
|
100
172
|
key = await promptSecret(ctx, "Linear API key:", { required: true });
|
|
@@ -102,25 +174,18 @@ export function registerMeta(program: Command): void {
|
|
|
102
174
|
key = key.trim();
|
|
103
175
|
if (!key) throw usageError("No API key provided.");
|
|
104
176
|
// Validate before persisting and learn the workspace slug.
|
|
105
|
-
const
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
try {
|
|
109
|
-
me = await client.viewer;
|
|
110
|
-
org = await client.organization;
|
|
111
|
-
} catch {
|
|
112
|
-
throw authError("That API key was rejected by Linear.");
|
|
113
|
-
}
|
|
114
|
-
const slug: string = ctx.options.workspace ?? org.urlKey;
|
|
177
|
+
const identity = await validateAuthCredential(key, ctx.options.workspace);
|
|
178
|
+
const { user: me, organization: org } = identity;
|
|
179
|
+
const slug: string = org.urlKey;
|
|
115
180
|
const { path, storage, keyringLabel } = writeCredential(slug, key, {
|
|
116
181
|
plaintext: opts.plaintext === true,
|
|
117
182
|
});
|
|
118
183
|
const where =
|
|
119
184
|
storage === "keychain"
|
|
120
|
-
? `
|
|
185
|
+
? `Credential saved to the ${keyringLabel} (service linear-cli, account '${slug}'); ${path} records the workspace.`
|
|
121
186
|
: opts.plaintext
|
|
122
|
-
? `
|
|
123
|
-
: `
|
|
187
|
+
? `Credential saved to ${path} (plaintext, 0600).`
|
|
188
|
+
: `Credential saved to ${path} (plaintext, 0600 — no system keyring on this platform).`;
|
|
124
189
|
ctx.output.emit(
|
|
125
190
|
{
|
|
126
191
|
success: true,
|
|
@@ -137,6 +202,45 @@ export function registerMeta(program: Command): void {
|
|
|
137
202
|
}),
|
|
138
203
|
);
|
|
139
204
|
|
|
205
|
+
// adopt ------------------------------------------------------------------
|
|
206
|
+
auth
|
|
207
|
+
.command("adopt <slug>")
|
|
208
|
+
.description("Adopt an existing named credential from the shared OS keyring")
|
|
209
|
+
.action(
|
|
210
|
+
action(async (ctx: Context, _opts, input: string) => {
|
|
211
|
+
const slug = explicitWorkspaceSlug(input);
|
|
212
|
+
if (ctx.options.apiKey !== undefined) {
|
|
213
|
+
throw usageError(
|
|
214
|
+
"`auth adopt` reads only the named OS keyring entry; remove --api-key (or use `auth login --key -` to supply a key on stdin).",
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
if (ctx.options.workspace && ctx.options.workspace !== slug) {
|
|
218
|
+
throw usageError(
|
|
219
|
+
`The positional workspace '${slug}' conflicts with --workspace '${ctx.options.workspace}'.`,
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Probe one exact account only. No keyring enumeration and no secret
|
|
224
|
+
// on argv, stdout, stderr, or disk.
|
|
225
|
+
const found = probeKeyringCredential(slug);
|
|
226
|
+
const identity = await validateAuthCredential(found.apiKey, slug);
|
|
227
|
+
const adopted = adoptKeyringCredential(slug, found.apiKey);
|
|
228
|
+
ctx.output.emit(
|
|
229
|
+
{
|
|
230
|
+
success: true,
|
|
231
|
+
workspace: slug,
|
|
232
|
+
user: identity.user,
|
|
233
|
+
storage: adopted.storage,
|
|
234
|
+
path: adopted.path,
|
|
235
|
+
},
|
|
236
|
+
() =>
|
|
237
|
+
ctx.output.success(
|
|
238
|
+
`Adopted workspace '${slug}' from the ${adopted.keyringLabel}; ${adopted.path} now records the workspace without storing the secret.`,
|
|
239
|
+
),
|
|
240
|
+
);
|
|
241
|
+
}),
|
|
242
|
+
);
|
|
243
|
+
|
|
140
244
|
// list --------------------------------------------------------------------
|
|
141
245
|
auth
|
|
142
246
|
.command("list")
|
|
@@ -263,6 +367,7 @@ export function registerMeta(program: Command): void {
|
|
|
263
367
|
.join(", ")}). Pass --workspace <slug> to choose which to remove.`,
|
|
264
368
|
);
|
|
265
369
|
}
|
|
370
|
+
if (!(await confirmDestructive(ctx, `Remove credential for workspace '${slug}'?`))) return;
|
|
266
371
|
const removed = removeCredential(slug);
|
|
267
372
|
ctx.output.emit({ success: true, workspace: slug, removed }, () =>
|
|
268
373
|
removed
|
|
@@ -279,7 +384,9 @@ export function registerMeta(program: Command): void {
|
|
|
279
384
|
// show (default) ----------------------------------------------------------
|
|
280
385
|
config
|
|
281
386
|
.command("show", { isDefault: true })
|
|
282
|
-
.description(
|
|
387
|
+
.description(
|
|
388
|
+
"Show the resolved configuration and where each value came from (secrets redacted)",
|
|
389
|
+
)
|
|
283
390
|
.action(
|
|
284
391
|
action(async (ctx) => {
|
|
285
392
|
// Re-resolve to expose sources/paths beyond what Context keeps.
|
|
@@ -299,8 +406,10 @@ export function registerMeta(program: Command): void {
|
|
|
299
406
|
if (o.source === "none") return "default";
|
|
300
407
|
return o.path ? `${o.source}: ${o.path}` : o.source;
|
|
301
408
|
};
|
|
302
|
-
const show = (
|
|
303
|
-
|
|
409
|
+
const show = (
|
|
410
|
+
key: keyof typeof c.origins,
|
|
411
|
+
value: string | undefined,
|
|
412
|
+
): string | undefined => (value === undefined ? undefined : `${value} (${from(key)})`);
|
|
304
413
|
ctx.output.detail(
|
|
305
414
|
{
|
|
306
415
|
apiKey: redactKey(c.apiKey),
|
|
@@ -309,7 +418,6 @@ export function registerMeta(program: Command): void {
|
|
|
309
418
|
team: c.team ?? null,
|
|
310
419
|
workspace: c.workspace ?? null,
|
|
311
420
|
sort: c.sort,
|
|
312
|
-
vcs: c.vcs,
|
|
313
421
|
origins: c.origins,
|
|
314
422
|
userConfigPath: c.userConfigPath,
|
|
315
423
|
projectConfigPath: c.projectConfigPath ?? null,
|
|
@@ -321,7 +429,6 @@ export function registerMeta(program: Command): void {
|
|
|
321
429
|
["Team", show("team", c.team)],
|
|
322
430
|
["Workspace", show("workspace", c.workspace)],
|
|
323
431
|
["Sort", show("sort", c.sort)],
|
|
324
|
-
["VCS", show("vcs", c.vcs)],
|
|
325
432
|
["User config", c.userConfigPath],
|
|
326
433
|
["Project config", c.projectConfigPath],
|
|
327
434
|
["Global config", c.globalConfigPath],
|
|
@@ -358,11 +465,15 @@ export function registerMeta(program: Command): void {
|
|
|
358
465
|
.map((t) => ({ name: `${t.name} (${t.key})`, value: t.key })),
|
|
359
466
|
);
|
|
360
467
|
}
|
|
361
|
-
const settings: Partial<Record<SettableKey, string>> = {
|
|
468
|
+
const settings: Partial<Record<SettableKey, string>> = {
|
|
469
|
+
team: validateSetting("team", team),
|
|
470
|
+
};
|
|
362
471
|
if (opts.sort !== undefined) settings.sort = validateSetting("sort", opts.sort);
|
|
363
472
|
initProjectConfig(path, settings, { force: opts.force === true });
|
|
364
473
|
ctx.output.emit({ success: true, path, ...settings }, () =>
|
|
365
|
-
ctx.output.success(
|
|
474
|
+
ctx.output.success(
|
|
475
|
+
`Wrote ${path} (team ${settings.team}${settings.sort ? `, sort ${settings.sort}` : ""}).`,
|
|
476
|
+
),
|
|
366
477
|
);
|
|
367
478
|
}),
|
|
368
479
|
);
|
|
@@ -411,9 +522,6 @@ function validateSetting(key: SettableKey, value: string): string {
|
|
|
411
522
|
throw usageError(`Invalid sort '${v}'. Valid values: ${ISSUE_SORTS.join(", ")}.`);
|
|
412
523
|
}
|
|
413
524
|
return v;
|
|
414
|
-
case "vcs":
|
|
415
|
-
if (!["git", "jj"].includes(v)) throw usageError(`Invalid vcs '${v}'. Valid values: git, jj.`);
|
|
416
|
-
return v;
|
|
417
525
|
case "team":
|
|
418
526
|
// A team key: letters and digits, as Linear issues them (TES, ENG2).
|
|
419
527
|
if (!/^[A-Za-z][A-Za-z0-9]*$/.test(v)) {
|
|
@@ -34,7 +34,10 @@ export function formatMilestoneProgress(progress: number | null): string {
|
|
|
34
34
|
return `${Math.round(progress)}%`;
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
-
const PROJECT_SCOPE = [
|
|
37
|
+
const PROJECT_SCOPE = [
|
|
38
|
+
"-p, --project <name>",
|
|
39
|
+
"the milestone's project, when <id> is a name (names are unique per project only)",
|
|
40
|
+
] as const;
|
|
38
41
|
|
|
39
42
|
export function registerMilestone(program: Command): void {
|
|
40
43
|
const milestone = program
|
|
@@ -8,6 +8,7 @@ import { confirmDestructive } from "../lib/prompt.js";
|
|
|
8
8
|
import type { Context } from "../context.js";
|
|
9
9
|
import * as svc from "../services/notification.js";
|
|
10
10
|
import type { Column } from "../output/table.js";
|
|
11
|
+
import { ExitCode } from "../lib/errors.js";
|
|
11
12
|
|
|
12
13
|
const ROW_COLUMNS: Column<svc.NotificationRow>[] = [
|
|
13
14
|
{ key: "type", header: "Type", value: (r) => r.type, max: 24 },
|
|
@@ -67,10 +68,20 @@ export function registerNotification(program: Command): void {
|
|
|
67
68
|
action(async (ctx: Context) => {
|
|
68
69
|
const res = await svc.markAllRead(ctx.client);
|
|
69
70
|
ctx.output.emit(res, () => {
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
71
|
+
if (res.success) {
|
|
72
|
+
ctx.output.success(`Marked ${res.count} of ${res.attempted} notification(s) read`);
|
|
73
|
+
} else {
|
|
74
|
+
ctx.output.warn(
|
|
75
|
+
`Marked ${res.count} of ${res.attempted} notification(s) read; ${res.failed.length} failed`,
|
|
76
|
+
);
|
|
77
|
+
for (const f of res.failed) ctx.output.warn(`${f.id}: ${f.error}`);
|
|
78
|
+
}
|
|
73
79
|
});
|
|
80
|
+
// A batch receipt belongs on stdout even when only part of the batch
|
|
81
|
+
// succeeded, but exit zero would let `read-all && ...` treat that as a
|
|
82
|
+
// complete success. Preserve the receipt and make the process outcome
|
|
83
|
+
// unambiguous.
|
|
84
|
+
if (!res.success) process.exitCode = ExitCode.Runtime;
|
|
74
85
|
}),
|
|
75
86
|
);
|
|
76
87
|
|
|
@@ -82,9 +93,7 @@ export function registerNotification(program: Command): void {
|
|
|
82
93
|
action(async (ctx: Context, _opts, id: string) => {
|
|
83
94
|
if (!(await confirmDestructive(ctx, `Archive notification ${id}?`))) return;
|
|
84
95
|
const success = await svc.archiveNotification(ctx.client, id);
|
|
85
|
-
ctx.output.emit({ id, archived: success }, () =>
|
|
86
|
-
ctx.output.success(`Archived ${id}`),
|
|
87
|
-
);
|
|
96
|
+
ctx.output.emit({ id, archived: success }, () => ctx.output.success(`Archived ${id}`));
|
|
88
97
|
}),
|
|
89
98
|
);
|
|
90
99
|
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/** `linear open` — open a workspace, team, project, issue, or explicit URL. */
|
|
2
|
+
|
|
3
|
+
import { Command } from "commander";
|
|
4
|
+
import { action } from "../lib/action.js";
|
|
5
|
+
import { usageError } from "../lib/errors.js";
|
|
6
|
+
import { openUrl } from "../lib/open.js";
|
|
7
|
+
import { normalizeIssueReference, resolveProjectId, resolveTeam } from "../lib/resolve.js";
|
|
8
|
+
import type { Context } from "../context.js";
|
|
9
|
+
import { getIssueDetail } from "../services/issue.js";
|
|
10
|
+
import { getOrganizationDetail } from "../services/organization.js";
|
|
11
|
+
import { getProjectDetail } from "../services/project.js";
|
|
12
|
+
|
|
13
|
+
const ISSUE_REFERENCE = /^[a-zA-Z][a-zA-Z0-9]*-\d+$/;
|
|
14
|
+
const URL = /^https?:\/\//i;
|
|
15
|
+
type OpenTarget = "workspace" | "team" | "project" | "issue" | "url";
|
|
16
|
+
|
|
17
|
+
export function registerOpen(program: Command): void {
|
|
18
|
+
program
|
|
19
|
+
.command("open [target]")
|
|
20
|
+
.description("Open the workspace, an issue, team, project, or URL")
|
|
21
|
+
.option("--app", "open in Linear.app (macOS)")
|
|
22
|
+
.option("-w, --web", "open in the default browser (the default)")
|
|
23
|
+
.addHelpText(
|
|
24
|
+
"after",
|
|
25
|
+
[
|
|
26
|
+
"",
|
|
27
|
+
"Targets: an issue id (TES-123), `team:<key>`, `project:<name>`, or an https URL.",
|
|
28
|
+
"With no target, opens the current workspace. A bare team key opens that team.",
|
|
29
|
+
].join("\n"),
|
|
30
|
+
)
|
|
31
|
+
.action(
|
|
32
|
+
action(async (ctx: Context, opts, target?: string) => {
|
|
33
|
+
if (opts.app && opts.web) throw usageError("Pass either --web or --app, not both.");
|
|
34
|
+
const resolved = await resolveOpenTarget(ctx, target);
|
|
35
|
+
await openUrl(resolved.url, { app: opts.app === true });
|
|
36
|
+
ctx.output.emit({ ...resolved, opened: true }, () =>
|
|
37
|
+
ctx.output.success(
|
|
38
|
+
`Opened ${resolved.label} in ${opts.app ? "Linear.app" : "the browser"}`,
|
|
39
|
+
),
|
|
40
|
+
);
|
|
41
|
+
}),
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function resolveOpenTarget(
|
|
46
|
+
ctx: Context,
|
|
47
|
+
target: string | undefined,
|
|
48
|
+
): Promise<{ target: OpenTarget; url: string; label: string }> {
|
|
49
|
+
if (!target) {
|
|
50
|
+
const org = await getOrganizationDetail(ctx.client);
|
|
51
|
+
return { target: "workspace", url: `https://linear.app/${org.urlKey}`, label: org.name };
|
|
52
|
+
}
|
|
53
|
+
if (URL.test(target)) return { target: "url", url: target, label: target };
|
|
54
|
+
if (target.startsWith("team:")) {
|
|
55
|
+
const team = await resolveTeam(ctx.client, target.slice("team:".length), ctx.defaultTeam);
|
|
56
|
+
const org = await getOrganizationDetail(ctx.client);
|
|
57
|
+
return {
|
|
58
|
+
target: "team",
|
|
59
|
+
url: `https://linear.app/${org.urlKey}/team/${team.key}/all`,
|
|
60
|
+
label: `${team.key} (${team.name})`,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
if (target.startsWith("project:")) {
|
|
64
|
+
const project = await getProjectDetail(
|
|
65
|
+
ctx.client,
|
|
66
|
+
await resolveProjectId(ctx.client, target.slice("project:".length)),
|
|
67
|
+
);
|
|
68
|
+
return { target: "project", url: project.url, label: project.name };
|
|
69
|
+
}
|
|
70
|
+
const issueReference = /^\d+$/.test(target)
|
|
71
|
+
? normalizeIssueReference(target, ctx.defaultTeam)
|
|
72
|
+
: target;
|
|
73
|
+
if (ISSUE_REFERENCE.test(issueReference)) {
|
|
74
|
+
const issue = await getIssueDetail(ctx.client, issueReference, { includeComments: false });
|
|
75
|
+
return { target: "issue", url: issue.url, label: issue.identifier };
|
|
76
|
+
}
|
|
77
|
+
const team = await resolveTeam(ctx.client, target, ctx.defaultTeam);
|
|
78
|
+
const org = await getOrganizationDetail(ctx.client);
|
|
79
|
+
return {
|
|
80
|
+
target: "team",
|
|
81
|
+
url: `https://linear.app/${org.urlKey}/team/${team.key}/all`,
|
|
82
|
+
label: `${team.key} (${team.name})`,
|
|
83
|
+
};
|
|
84
|
+
}
|
package/src/commands/project.ts
CHANGED
|
@@ -36,7 +36,10 @@ function formatProgress(p: number | null): string {
|
|
|
36
36
|
* stderr, once; `--quiet` silences it, `--json` stdout never carries it.
|
|
37
37
|
* Shared by `project list` and the issue queries.
|
|
38
38
|
*/
|
|
39
|
-
export function noteWorkspaceWide(
|
|
39
|
+
export function noteWorkspaceWide(
|
|
40
|
+
ctx: Context,
|
|
41
|
+
opts: { team?: unknown; allTeams?: boolean },
|
|
42
|
+
): void {
|
|
40
43
|
const teamGiven = Array.isArray(opts.team) ? opts.team.length > 0 : opts.team !== undefined;
|
|
41
44
|
if (!teamGiven && !opts.allTeams && !ctx.defaultTeam) {
|
|
42
45
|
ctx.output.info(
|
|
@@ -97,8 +100,14 @@ export function registerProject(program: Command): void {
|
|
|
97
100
|
["Progress", detail.progress !== null ? formatProgress(detail.progress) : null],
|
|
98
101
|
["Priority", detail.priorityLabel],
|
|
99
102
|
["Lead", detail.lead?.displayName ?? null],
|
|
100
|
-
[
|
|
101
|
-
|
|
103
|
+
[
|
|
104
|
+
"Teams",
|
|
105
|
+
detail.teams.length ? detail.teams.map((t) => `${t.key} ${t.name}`).join(", ") : null,
|
|
106
|
+
],
|
|
107
|
+
[
|
|
108
|
+
"Members",
|
|
109
|
+
detail.members.length ? detail.members.map((m) => m.displayName).join(", ") : null,
|
|
110
|
+
],
|
|
102
111
|
["Labels", detail.labels.length ? detail.labels.map((l) => l.name).join(", ") : null],
|
|
103
112
|
["Start", detail.startDate],
|
|
104
113
|
["Target", detail.targetDate],
|
|
@@ -317,7 +326,11 @@ export function registerProject(program: Command): void {
|
|
|
317
326
|
{ key: "name", header: "Name", value: (m) => m.name, max: 40 },
|
|
318
327
|
{ key: "target", header: "Target", value: (m) => m.targetDate ?? "—" },
|
|
319
328
|
// A milestone's progress is already a percentage (TES-648), unlike a project's.
|
|
320
|
-
{
|
|
329
|
+
{
|
|
330
|
+
key: "progress",
|
|
331
|
+
header: "Progress",
|
|
332
|
+
value: (m) => formatMilestoneProgress(m.progress),
|
|
333
|
+
},
|
|
321
334
|
],
|
|
322
335
|
rows,
|
|
323
336
|
);
|
package/src/commands/team.ts
CHANGED
|
@@ -59,6 +59,19 @@ export function registerTeam(program: Command): void {
|
|
|
59
59
|
}),
|
|
60
60
|
);
|
|
61
61
|
|
|
62
|
+
// id ----------------------------------------------------------------------
|
|
63
|
+
team
|
|
64
|
+
.command("id [key]")
|
|
65
|
+
.description("Print a team's UUID (defaults to the configured team)")
|
|
66
|
+
.action(
|
|
67
|
+
action(async (ctx: Context, _opts, keyArg?: string) => {
|
|
68
|
+
const resolved = await svc.getTeamIdentity(ctx.client, keyArg, ctx.defaultTeam);
|
|
69
|
+
// Human mode is deliberately a single UUID for command substitution;
|
|
70
|
+
// JSON retains the resolved key and name as useful context.
|
|
71
|
+
ctx.output.emit(resolved, () => process.stdout.write(resolved.id + "\n"));
|
|
72
|
+
}),
|
|
73
|
+
);
|
|
74
|
+
|
|
62
75
|
// view --------------------------------------------------------------------
|
|
63
76
|
team
|
|
64
77
|
.command("view [key]")
|
package/src/commands/user.ts
CHANGED
|
@@ -25,8 +25,8 @@ const ROW_COLUMNS: Column<svc.UserRow>[] = [
|
|
|
25
25
|
* list` and `team members`. Ours is the global `--all` — exhaust pagination —
|
|
26
26
|
* so a transplanted `linear user list --all` succeeds, and the deactivated
|
|
27
27
|
* users it was asking for are quietly missing (TES-637 item 1). The global
|
|
28
|
-
* keeps its one meaning (making it mean "and deactivated" here alone would
|
|
29
|
-
*
|
|
28
|
+
* keeps its one meaning (making it mean "and deactivated" here alone would give
|
|
29
|
+
* one flag two meanings); instead the
|
|
30
30
|
* combination says what it did and names the flag that does the other thing.
|
|
31
31
|
* A warning, not `info`: it survives `--quiet`, because a script is exactly
|
|
32
32
|
* where a wrong result set goes unnoticed. Shared with `team members`.
|
package/src/commands/webhook.ts
CHANGED
|
@@ -91,7 +91,12 @@ export function registerWebhook(program: Command): void {
|
|
|
91
91
|
secret: opts.secret,
|
|
92
92
|
});
|
|
93
93
|
ctx.output.emit(
|
|
94
|
-
{
|
|
94
|
+
{
|
|
95
|
+
id: created.id,
|
|
96
|
+
url: created.url,
|
|
97
|
+
enabled: created.enabled,
|
|
98
|
+
resourceTypes: created.resourceTypes,
|
|
99
|
+
},
|
|
95
100
|
() => ctx.output.success(`Created webhook ${created.id}`),
|
|
96
101
|
);
|
|
97
102
|
}),
|
|
@@ -121,7 +126,12 @@ export function registerWebhook(program: Command): void {
|
|
|
121
126
|
secret: opts.secret,
|
|
122
127
|
});
|
|
123
128
|
ctx.output.emit(
|
|
124
|
-
{
|
|
129
|
+
{
|
|
130
|
+
id: updated.id,
|
|
131
|
+
url: updated.url,
|
|
132
|
+
enabled: updated.enabled,
|
|
133
|
+
resourceTypes: updated.resourceTypes,
|
|
134
|
+
},
|
|
125
135
|
() => ctx.output.success(`Updated webhook ${updated.id}`),
|
|
126
136
|
);
|
|
127
137
|
}),
|