linear-sdk-cli 0.8.3 → 0.9.1
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/CHANGELOG.md +13 -0
- package/COVERAGE.md +26 -25
- package/MIGRATING.md +11 -3
- package/README.md +201 -41
- package/package.json +1 -1
- package/skills/linear-sdk-cli/SKILL.md +118 -21
- package/skills/linear-sdk-cli/references/auth.md +34 -9
- package/skills/linear-sdk-cli/references/config.md +13 -9
- package/skills/linear-sdk-cli/references/issue.md +3 -2
- package/skills/linear-sdk-cli/references/release-note.md +148 -0
- package/skills/linear-sdk-cli/references/release-pipeline.md +137 -0
- package/skills/linear-sdk-cli/references/release-stage.md +121 -0
- package/skills/linear-sdk-cli/references/release.md +348 -0
- package/src/cli.ts +8 -0
- package/src/commands/issue.ts +9 -3
- package/src/commands/meta.ts +145 -166
- package/src/commands/release-note.ts +90 -0
- package/src/commands/release-pipeline.ts +122 -0
- package/src/commands/release-stage.ts +100 -0
- package/src/commands/release.ts +254 -0
- package/src/config.ts +260 -12
- package/src/lib/authorization.ts +1 -1
- package/src/lib/keyring.ts +30 -2
- package/src/lib/options.ts +18 -0
- package/src/lib/output-shapes.ts +68 -3
- package/src/lib/release-input.ts +138 -0
- package/src/services/issue.ts +12 -3
- package/src/services/release.ts +691 -0
package/src/commands/meta.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Top-level meta commands: whoami, auth (login/list/default/token/status/
|
|
3
|
-
* logout/migrate), config.
|
|
3
|
+
* logout/rename/migrate), config.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import { Command } from "commander";
|
|
@@ -18,8 +18,9 @@ import {
|
|
|
18
18
|
setDefaultWorkspace,
|
|
19
19
|
listCredentials,
|
|
20
20
|
readOAuthCredential,
|
|
21
|
-
writeOAuthCredential,
|
|
22
21
|
migrateCredentials,
|
|
22
|
+
renameApiKeyProfile,
|
|
23
|
+
profileRenameProjectUpdates,
|
|
23
24
|
redactKey,
|
|
24
25
|
resolveConfig,
|
|
25
26
|
defaultProjectConfigPath,
|
|
@@ -33,12 +34,11 @@ import {
|
|
|
33
34
|
import { keyring } from "../lib/keyring.js";
|
|
34
35
|
import { readStdinSync } from "../lib/body.js";
|
|
35
36
|
import { CliError, authError, normalizeError, usageError } from "../lib/errors.js";
|
|
36
|
-
import { confirmDestructive, promptSelect } from "../lib/prompt.js";
|
|
37
|
+
import { confirmDestructive, promptSecret, promptSelect } from "../lib/prompt.js";
|
|
37
38
|
import { listTeams } from "../services/team.js";
|
|
38
39
|
import { resolveTeam } from "../lib/resolve.js";
|
|
39
40
|
import { ISSUE_SORTS } from "../services/issue.js";
|
|
40
41
|
import { firstTeam, type Context } from "../context.js";
|
|
41
|
-
import { openUrl } from "../lib/open.js";
|
|
42
42
|
import {
|
|
43
43
|
authorizationCapabilities,
|
|
44
44
|
credentialType as resolvedCredentialType,
|
|
@@ -46,19 +46,9 @@ import {
|
|
|
46
46
|
import {
|
|
47
47
|
ClientCredentialsTokenProvider,
|
|
48
48
|
clientCredentialsScopes,
|
|
49
|
-
DEFAULT_OAUTH_SCOPES,
|
|
50
|
-
buildAuthorizationUrl,
|
|
51
|
-
createPkceRequest,
|
|
52
|
-
exchangeAuthorizationCode,
|
|
53
49
|
revokeOAuthToken,
|
|
54
|
-
startLoopbackCallback,
|
|
55
|
-
type OAuthUserCredential,
|
|
56
50
|
} from "../oauth.js";
|
|
57
51
|
|
|
58
|
-
export const DEFAULT_OAUTH_REDIRECT_URI = "http://127.0.0.1:43821/oauth/callback";
|
|
59
|
-
/** Public PKCE client identity. This is not a credential; no client secret is shipped or used. */
|
|
60
|
-
export const DEFAULT_OAUTH_CLIENT_ID = "eda60862e4bb8b8af82cfb3193b65c2f";
|
|
61
|
-
|
|
62
52
|
/**
|
|
63
53
|
* The `whoami` handler, shared by the top-level `linear whoami` and the
|
|
64
54
|
* reference CLI's spelling `linear auth whoami` so the two cannot drift.
|
|
@@ -111,6 +101,12 @@ const defaultAuthValidationClientFactory: AuthValidationClientFactory = (credent
|
|
|
111
101
|
kind === "oauth-access-token" ? { accessToken: credential } : { apiKey: credential },
|
|
112
102
|
) as unknown as AuthValidationClient;
|
|
113
103
|
let authValidationClientFactory = defaultAuthValidationClientFactory;
|
|
104
|
+
let authSecretPrompt = promptSecret;
|
|
105
|
+
|
|
106
|
+
/** Test seam that does not depend on Bun's module-namespace spying behavior. */
|
|
107
|
+
export function setAuthSecretPromptForTests(prompt: typeof promptSecret | undefined): void {
|
|
108
|
+
authSecretPrompt = prompt ?? promptSecret;
|
|
109
|
+
}
|
|
114
110
|
|
|
115
111
|
/** Test seam for command-level auth tests; production always uses LinearClient. */
|
|
116
112
|
export function setAuthValidationClientFactoryForTests(
|
|
@@ -229,17 +225,14 @@ export function registerMeta(program: Command): void {
|
|
|
229
225
|
// login -------------------------------------------------------------------
|
|
230
226
|
auth
|
|
231
227
|
.command("login")
|
|
232
|
-
.description("
|
|
228
|
+
.description("Sign in with a personal API key (default) or app credentials")
|
|
233
229
|
.option("--no-project", "save credentials without changing the project workspace")
|
|
234
230
|
.option("--key <key>", "use a personal API key ('-' reads it from stdin)")
|
|
235
231
|
.option(
|
|
236
232
|
"--plaintext",
|
|
237
233
|
"Store API-key or app credentials in the private user config file (0600)",
|
|
238
234
|
)
|
|
239
|
-
.option("--
|
|
240
|
-
.option("--read-only", "request read-only OAuth access")
|
|
241
|
-
.option("--admin", "explicitly add the OAuth admin scope")
|
|
242
|
-
.option("--timeout <seconds>", "seconds to wait for the loopback callback", "120")
|
|
235
|
+
.option("--read-only", "request read-only access with --app")
|
|
243
236
|
.option("--app", "sign in as an app using client credentials")
|
|
244
237
|
.option("--client-credentials", "alias for --app")
|
|
245
238
|
.option("--scope <scopes>", "comma-separated app scopes (default: read,write)")
|
|
@@ -247,30 +240,18 @@ export function registerMeta(program: Command): void {
|
|
|
247
240
|
"--client-secret <secret>",
|
|
248
241
|
"app client secret ('-' reads stdin; defaults to LINEAR_CLIENT_SECRET)",
|
|
249
242
|
)
|
|
250
|
-
.option(
|
|
251
|
-
"--client-id <id>",
|
|
252
|
-
"OAuth client ID (app: LINEAR_CLIENT_ID; browser: packaged CLI app)",
|
|
253
|
-
)
|
|
254
|
-
.option("--redirect-uri <uri>", "registered HTTP loopback callback URI")
|
|
243
|
+
.option("--client-id <id>", "app client ID (defaults to LINEAR_CLIENT_ID)")
|
|
255
244
|
.action(
|
|
256
245
|
action(async (ctx: Context, opts) => {
|
|
257
246
|
if (ctx.options.accessToken !== undefined) {
|
|
258
247
|
throw usageError(
|
|
259
|
-
"`auth login` cannot persist an injected access token.
|
|
248
|
+
"`auth login` cannot persist an injected access token. Use LINEAR_ACCESS_TOKEN or --access-token with a data command, or use `auth login --key -` for a personal API key.",
|
|
260
249
|
);
|
|
261
250
|
}
|
|
262
251
|
if (opts.app || opts.clientCredentials) {
|
|
263
|
-
if (
|
|
264
|
-
|
|
265
|
-
ctx.options.apiKey !== undefined ||
|
|
266
|
-
opts.redirectUri ||
|
|
267
|
-
opts.browser === false
|
|
268
|
-
) {
|
|
269
|
-
throw usageError(
|
|
270
|
-
"--app cannot be combined with personal API-key or browser callback options.",
|
|
271
|
-
);
|
|
252
|
+
if (opts.key !== undefined || ctx.options.apiKey !== undefined) {
|
|
253
|
+
throw usageError("--app cannot be combined with --key or --api-key.");
|
|
272
254
|
}
|
|
273
|
-
if (opts.admin) throw usageError("App actors cannot request the admin scope.");
|
|
274
255
|
if (!opts.plaintext && !keyring())
|
|
275
256
|
throw new CliError(
|
|
276
257
|
"App authentication requires a system keyring; use --plaintext for explicit private-file storage.",
|
|
@@ -335,144 +316,64 @@ export function registerMeta(program: Command): void {
|
|
|
335
316
|
);
|
|
336
317
|
return;
|
|
337
318
|
}
|
|
338
|
-
if (
|
|
339
|
-
|
|
319
|
+
if (
|
|
320
|
+
opts.clientId !== undefined ||
|
|
321
|
+
opts.clientSecret !== undefined ||
|
|
322
|
+
opts.scope !== undefined ||
|
|
323
|
+
opts.readOnly
|
|
324
|
+
)
|
|
325
|
+
throw usageError(
|
|
326
|
+
"--client-id, --client-secret, --scope, and --read-only require --app or --client-credentials. Set personal API-key permissions in Linear's Security & Access settings.",
|
|
327
|
+
);
|
|
340
328
|
let key: string | undefined = selectLoginKey(opts.key, ctx.options.apiKey);
|
|
341
|
-
if (key
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
329
|
+
if (key === undefined && !ctx.isTTY)
|
|
330
|
+
throw usageError(
|
|
331
|
+
"Personal API-key login requires interactive input. Pipe a key to `linear auth login --key -`, or use --app for app credentials. To authenticate commands without saving credentials, set LINEAR_API_KEY or LINEAR_ACCESS_TOKEN.",
|
|
332
|
+
);
|
|
333
|
+
if (!opts.plaintext && !keyring())
|
|
334
|
+
throw new CliError(
|
|
335
|
+
"API-key login requires a system keyring; use --plaintext for explicit private-file storage.",
|
|
336
|
+
"runtime",
|
|
337
|
+
);
|
|
338
|
+
if (key === undefined) {
|
|
339
|
+
ctx.output.info(
|
|
340
|
+
"Create a personal API key at https://linear.app/settings/account/security. Choose its permissions and team access in Linear; workspace policy may restrict key creation.",
|
|
341
|
+
);
|
|
342
|
+
key = await authSecretPrompt(ctx, "Linear API key:", { required: true });
|
|
343
|
+
} else {
|
|
351
344
|
if (key === "-") key = readStdinSync();
|
|
352
345
|
else
|
|
353
346
|
ctx.output.warn(
|
|
354
347
|
"An API key on the command line is visible to other processes and shell history; prefer --key - (stdin).",
|
|
355
348
|
);
|
|
356
|
-
key = key.trim();
|
|
357
|
-
if (!key) throw usageError("No API key provided.");
|
|
358
|
-
const identity = await validateAuthCredential(key, ctx.options.workspace);
|
|
359
|
-
const { user: me, organization: org } = identity;
|
|
360
|
-
const { path, storage, keyringLabel } = writeCredential(org.urlKey, key, {
|
|
361
|
-
plaintext: opts.plaintext === true,
|
|
362
|
-
});
|
|
363
|
-
const projectConfigPath = saveLoginProject(ctx, org.urlKey, opts.project !== false);
|
|
364
|
-
const where =
|
|
365
|
-
storage === "keychain"
|
|
366
|
-
? `Credential saved to the ${keyringLabel}; ${path} records the workspace.`
|
|
367
|
-
: `Credential saved to ${path} (plaintext, 0600).`;
|
|
368
|
-
ctx.output.emit(
|
|
369
|
-
{
|
|
370
|
-
success: true,
|
|
371
|
-
credentialType: "api-key",
|
|
372
|
-
workspace: org.urlKey,
|
|
373
|
-
user: me,
|
|
374
|
-
storage,
|
|
375
|
-
path,
|
|
376
|
-
projectConfigPath,
|
|
377
|
-
},
|
|
378
|
-
() =>
|
|
379
|
-
ctx.output.success(
|
|
380
|
-
`Authenticated as ${me.name} <${me.email}> for workspace '${org.urlKey}'. ${where}${projectConfigPath ? ` Project workspace saved to ${projectConfigPath}.` : ""}`,
|
|
381
|
-
),
|
|
382
|
-
);
|
|
383
|
-
return;
|
|
384
349
|
}
|
|
385
|
-
|
|
386
|
-
if (
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
if (opts.readOnly && opts.admin) {
|
|
392
|
-
throw usageError("--read-only cannot be combined with --admin.");
|
|
393
|
-
}
|
|
394
|
-
const timeoutSeconds = Number(opts.timeout);
|
|
395
|
-
if (!Number.isFinite(timeoutSeconds) || timeoutSeconds <= 0) {
|
|
396
|
-
throw usageError("--timeout must be a positive number of seconds.");
|
|
397
|
-
}
|
|
398
|
-
const clientId = String(
|
|
399
|
-
opts.clientId ?? process.env.LINEAR_OAUTH_CLIENT_ID ?? DEFAULT_OAUTH_CLIENT_ID,
|
|
400
|
-
).trim();
|
|
401
|
-
const redirectUri = String(
|
|
402
|
-
opts.redirectUri ?? process.env.LINEAR_OAUTH_REDIRECT_URI ?? DEFAULT_OAUTH_REDIRECT_URI,
|
|
403
|
-
);
|
|
404
|
-
const scopes = opts.readOnly
|
|
405
|
-
? ["read"]
|
|
406
|
-
: opts.admin
|
|
407
|
-
? [...DEFAULT_OAUTH_SCOPES, "admin"]
|
|
408
|
-
: [...DEFAULT_OAUTH_SCOPES];
|
|
409
|
-
const pkce = createPkceRequest();
|
|
410
|
-
const callback = await startLoopbackCallback({
|
|
411
|
-
redirectUri,
|
|
412
|
-
state: pkce.state,
|
|
413
|
-
timeoutMs: timeoutSeconds * 1000,
|
|
350
|
+
key = key.trim();
|
|
351
|
+
if (!key) throw usageError("No API key provided.");
|
|
352
|
+
const identity = await validateAuthCredential(key, ctx.options.workspace);
|
|
353
|
+
const { user: me, organization: org } = identity;
|
|
354
|
+
const { path, storage, keyringLabel } = writeCredential(org.urlKey, key, {
|
|
355
|
+
plaintext: opts.plaintext === true,
|
|
414
356
|
});
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
});
|
|
436
|
-
const identity = await validateAuthCredential(
|
|
437
|
-
token.accessToken,
|
|
438
|
-
ctx.options.workspace,
|
|
439
|
-
"oauth-access-token",
|
|
440
|
-
);
|
|
441
|
-
const credential: OAuthUserCredential = {
|
|
442
|
-
version: 1,
|
|
443
|
-
kind: "oauth-user",
|
|
444
|
-
actor: "user",
|
|
445
|
-
...token,
|
|
446
|
-
clientId,
|
|
447
|
-
workspace: identity.organization,
|
|
448
|
-
user: identity.user,
|
|
449
|
-
};
|
|
450
|
-
const saved = writeOAuthCredential(credential);
|
|
451
|
-
const projectConfigPath = saveLoginProject(
|
|
452
|
-
ctx,
|
|
453
|
-
credential.workspace.urlKey,
|
|
454
|
-
opts.project !== false,
|
|
455
|
-
);
|
|
456
|
-
ctx.output.emit(
|
|
457
|
-
{
|
|
458
|
-
success: true,
|
|
459
|
-
credentialType: "oauth-user",
|
|
460
|
-
workspace: credential.workspace.urlKey,
|
|
461
|
-
user: credential.user,
|
|
462
|
-
storage: saved.storage,
|
|
463
|
-
scopes: credential.scopes,
|
|
464
|
-
expiresAt: new Date(credential.expiresAt).toISOString(),
|
|
465
|
-
path: saved.path,
|
|
466
|
-
projectConfigPath,
|
|
467
|
-
},
|
|
468
|
-
() =>
|
|
469
|
-
ctx.output.success(
|
|
470
|
-
`Authenticated as ${credential.user.name} <${credential.user.email}> for workspace '${credential.workspace.urlKey}'. OAuth credentials saved to the ${saved.keyringLabel}.${projectConfigPath ? ` Project workspace saved to ${projectConfigPath}.` : ""}`,
|
|
471
|
-
),
|
|
472
|
-
);
|
|
473
|
-
} finally {
|
|
474
|
-
await callback.close();
|
|
475
|
-
}
|
|
357
|
+
const projectConfigPath = saveLoginProject(ctx, org.urlKey, opts.project !== false);
|
|
358
|
+
const where =
|
|
359
|
+
storage === "keychain"
|
|
360
|
+
? `Credential saved to the ${keyringLabel}; ${path} records the workspace.`
|
|
361
|
+
: `Credential saved to ${path} (plaintext, 0600).`;
|
|
362
|
+
ctx.output.emit(
|
|
363
|
+
{
|
|
364
|
+
success: true,
|
|
365
|
+
credentialType: "api-key",
|
|
366
|
+
workspace: org.urlKey,
|
|
367
|
+
user: me,
|
|
368
|
+
storage,
|
|
369
|
+
path,
|
|
370
|
+
projectConfigPath,
|
|
371
|
+
},
|
|
372
|
+
() =>
|
|
373
|
+
ctx.output.success(
|
|
374
|
+
`Authenticated as ${me.name} <${me.email}> for workspace '${org.urlKey}'. ${where}${projectConfigPath ? ` Project workspace saved to ${projectConfigPath}.` : ""}`,
|
|
375
|
+
),
|
|
376
|
+
);
|
|
476
377
|
}),
|
|
477
378
|
);
|
|
478
379
|
|
|
@@ -631,6 +532,80 @@ export function registerMeta(program: Command): void {
|
|
|
631
532
|
}),
|
|
632
533
|
);
|
|
633
534
|
|
|
535
|
+
// rename ------------------------------------------------------------------
|
|
536
|
+
auth
|
|
537
|
+
.command("rename <old-slug> <new-slug>")
|
|
538
|
+
.description("Rename a stored API-key profile after its Linear workspace URL slug changes")
|
|
539
|
+
.option(
|
|
540
|
+
"--project-config <paths...>",
|
|
541
|
+
"also inspect these project configs for old workspace selectors (read-only)",
|
|
542
|
+
)
|
|
543
|
+
.addHelpText(
|
|
544
|
+
"after",
|
|
545
|
+
`
|
|
546
|
+
Validates the exact stored API key against the current Linear workspace slug.
|
|
547
|
+
Preserves the key, profile team, user workspace/default, and shared keyring list.
|
|
548
|
+
Refuses configured or unlisted keyring destinations; never overwrites or revokes.
|
|
549
|
+
Keyring moves require atomic create-only support (macOS Keychain). Linux Secret
|
|
550
|
+
Service entries are refused; file-only API-key profiles are supported.
|
|
551
|
+
An interrupted rename can be resumed by repeating the same command.
|
|
552
|
+
OAuth user/app profiles are not supported; their credentials and grants stay unchanged.
|
|
553
|
+
Project files are never edited. Reports the discovered project config and any
|
|
554
|
+
--project-config paths needing updates; other repositories must be checked separately.
|
|
555
|
+
Example: linear auth rename old-workspace new-workspace --json`,
|
|
556
|
+
)
|
|
557
|
+
.action(
|
|
558
|
+
action(async (ctx: Context, opts, oldSlug: string, newSlug: string) => {
|
|
559
|
+
const from = explicitWorkspaceSlug(oldSlug);
|
|
560
|
+
const to = explicitWorkspaceSlug(newSlug);
|
|
561
|
+
if (ctx.options.workspace && ctx.options.workspace !== from)
|
|
562
|
+
throw usageError("--workspace must match <old-slug> when renaming a profile.");
|
|
563
|
+
if (ctx.options.apiKey || ctx.options.accessToken)
|
|
564
|
+
throw usageError(
|
|
565
|
+
"`auth rename` uses only the named stored profile; remove credential flags.",
|
|
566
|
+
);
|
|
567
|
+
const checkedProjectConfigs = [
|
|
568
|
+
...new Set([
|
|
569
|
+
...(ctx.config.projectConfigPath ? [ctx.config.projectConfigPath] : []),
|
|
570
|
+
...(opts.projectConfig ?? []).map((path: string) => resolve(path)),
|
|
571
|
+
]),
|
|
572
|
+
] as string[];
|
|
573
|
+
const projectConfigUpdates = profileRenameProjectUpdates(from, to, checkedProjectConfigs);
|
|
574
|
+
const globalConfigUpdates = profileRenameProjectUpdates(
|
|
575
|
+
from,
|
|
576
|
+
to,
|
|
577
|
+
ctx.config.globalConfigPath ? [ctx.config.globalConfigPath] : [],
|
|
578
|
+
);
|
|
579
|
+
let organization: AuthIdentity["organization"] | undefined;
|
|
580
|
+
const saved = await renameApiKeyProfile(from, to, async (key) => {
|
|
581
|
+
organization = (await validateAuthCredential(key, to)).organization;
|
|
582
|
+
});
|
|
583
|
+
const otherProjects = `Update workspace = "${from}" to "${to}" in other project configs and LINEAR_WORKSPACE overrides. Only the listed project paths were checked.`;
|
|
584
|
+
ctx.output.emit(
|
|
585
|
+
{
|
|
586
|
+
success: true,
|
|
587
|
+
from,
|
|
588
|
+
workspace: to,
|
|
589
|
+
credentialType: "api-key",
|
|
590
|
+
organization: organization!,
|
|
591
|
+
...saved,
|
|
592
|
+
checkedProjectConfigs,
|
|
593
|
+
projectConfigUpdates,
|
|
594
|
+
globalConfigUpdates,
|
|
595
|
+
otherProjects,
|
|
596
|
+
},
|
|
597
|
+
() => {
|
|
598
|
+
ctx.output.success(
|
|
599
|
+
`Renamed API-key profile '${from}' to '${to}'. Credential preserved; no grant revoked.`,
|
|
600
|
+
);
|
|
601
|
+
for (const update of [...projectConfigUpdates, ...globalConfigUpdates])
|
|
602
|
+
ctx.output.info(`Update ${update.path}: workspace = "${to}" (currently "${from}").`);
|
|
603
|
+
ctx.output.info(otherProjects);
|
|
604
|
+
},
|
|
605
|
+
);
|
|
606
|
+
}),
|
|
607
|
+
);
|
|
608
|
+
|
|
634
609
|
// migrate -----------------------------------------------------------------
|
|
635
610
|
auth
|
|
636
611
|
.command("migrate")
|
|
@@ -753,6 +728,7 @@ export function registerMeta(program: Command): void {
|
|
|
753
728
|
workspaceProfile: c.workspaceProfile ?? null,
|
|
754
729
|
team: c.team ?? null,
|
|
755
730
|
workspace: c.workspace ?? null,
|
|
731
|
+
project: c.project ?? null,
|
|
756
732
|
sort: c.sort,
|
|
757
733
|
origins: c.origins,
|
|
758
734
|
userConfigPath: c.userConfigPath,
|
|
@@ -766,6 +742,7 @@ export function registerMeta(program: Command): void {
|
|
|
766
742
|
["Workspace profile", c.workspaceProfile ?? "(none)"],
|
|
767
743
|
["Team", show("team", c.team)],
|
|
768
744
|
["Workspace", show("workspace", c.workspace)],
|
|
745
|
+
["Default issue project", show("project", c.project)],
|
|
769
746
|
["Sort", show("sort", c.sort)],
|
|
770
747
|
["User config", c.userConfigPath],
|
|
771
748
|
["Project config", c.projectConfigPath],
|
|
@@ -780,6 +757,7 @@ export function registerMeta(program: Command): void {
|
|
|
780
757
|
.command("init")
|
|
781
758
|
.description("Write a project .linear.toml (at the git root, or here outside a repository)")
|
|
782
759
|
.option("--team <key>", "default team key (otherwise chosen from a list)")
|
|
760
|
+
.option("--project <name|id>", "default Linear project for new issues")
|
|
783
761
|
.option("--sort <order>", `default issue-list sort (${ISSUE_SORTS.join(" | ")})`)
|
|
784
762
|
.option("--path <file>", "write this file instead of <git root>/.linear.toml")
|
|
785
763
|
.option("--force", "overwrite an existing file")
|
|
@@ -808,10 +786,11 @@ export function registerMeta(program: Command): void {
|
|
|
808
786
|
team: validateSetting("team", team),
|
|
809
787
|
};
|
|
810
788
|
if (opts.sort !== undefined) settings.sort = validateSetting("sort", opts.sort);
|
|
789
|
+
if (opts.project !== undefined) settings.project = validateSetting("project", opts.project);
|
|
811
790
|
initProjectConfig(path, settings, { force: opts.force === true });
|
|
812
791
|
ctx.output.emit({ success: true, path, ...settings }, () =>
|
|
813
792
|
ctx.output.success(
|
|
814
|
-
`Wrote ${path} (team ${settings.team}${settings.sort ? `, sort ${settings.sort}` : ""}).`,
|
|
793
|
+
`Wrote ${path} (team ${settings.team}${settings.project ? `, project ${settings.project}` : ""}${settings.sort ? `, sort ${settings.sort}` : ""}).`,
|
|
815
794
|
),
|
|
816
795
|
);
|
|
817
796
|
}),
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
import { action } from "../lib/action.js";
|
|
3
|
+
import { noteOptions, noteInput } from "../lib/release-input.js";
|
|
4
|
+
import { confirmDestructive } from "../lib/prompt.js";
|
|
5
|
+
import * as svc from "../services/release.js";
|
|
6
|
+
|
|
7
|
+
export function registerReleaseNote(program: Command): void {
|
|
8
|
+
const group = program
|
|
9
|
+
.command("release-note")
|
|
10
|
+
.description("Manage Markdown notes covering one or more releases");
|
|
11
|
+
group
|
|
12
|
+
.command("list")
|
|
13
|
+
.alias("ls")
|
|
14
|
+
.description("List release notes with optional pipeline/release filters")
|
|
15
|
+
.option("--pipeline <id|slug>", "filter by pipeline UUID or slug")
|
|
16
|
+
.option("--release <id>", "filter by covered release UUID or slug")
|
|
17
|
+
.option("--include-archived", "include archived notes")
|
|
18
|
+
.action(
|
|
19
|
+
action(async (ctx, opts) => {
|
|
20
|
+
const rows = await svc.listNotes(ctx.client, ctx.limit, opts);
|
|
21
|
+
ctx.output.list(
|
|
22
|
+
rows,
|
|
23
|
+
[
|
|
24
|
+
{ key: "id", header: "ID", value: (r) => r.id },
|
|
25
|
+
{ key: "title", header: "Title", value: (r) => r.title ?? "—" },
|
|
26
|
+
{ key: "pipeline", header: "Pipeline", value: (r) => r.pipeline.name },
|
|
27
|
+
{ key: "releaseCount", header: "Releases", value: (r) => r.releaseCount },
|
|
28
|
+
],
|
|
29
|
+
rows,
|
|
30
|
+
);
|
|
31
|
+
}),
|
|
32
|
+
);
|
|
33
|
+
group
|
|
34
|
+
.command("view <id>")
|
|
35
|
+
.description("Show a release note and its Markdown body by UUID or slug")
|
|
36
|
+
.action(
|
|
37
|
+
action(async (ctx, _opts, id: string) => {
|
|
38
|
+
const n = await svc.getNote(ctx.client, id);
|
|
39
|
+
ctx.output.detail(n, [
|
|
40
|
+
["ID", n.id],
|
|
41
|
+
["Title", n.title],
|
|
42
|
+
["Pipeline", n.pipeline.name],
|
|
43
|
+
["Releases", n.releaseCount],
|
|
44
|
+
["First release", n.firstRelease?.name],
|
|
45
|
+
["Last release", n.lastRelease?.name],
|
|
46
|
+
["Generation status", n.generationStatus],
|
|
47
|
+
["Archived", n.archivedAt],
|
|
48
|
+
["URL", n.url],
|
|
49
|
+
["Content", n.content],
|
|
50
|
+
]);
|
|
51
|
+
}),
|
|
52
|
+
);
|
|
53
|
+
noteOptions(
|
|
54
|
+
group
|
|
55
|
+
.command("create")
|
|
56
|
+
.description("Create a note for explicit releases or an inclusive createdAt range"),
|
|
57
|
+
)
|
|
58
|
+
.requiredOption("--pipeline <id|slug>", "pipeline UUID or slug")
|
|
59
|
+
.action(
|
|
60
|
+
action(async (ctx, opts) => {
|
|
61
|
+
const input = noteInput(opts);
|
|
62
|
+
svc.validateNoteScope(input, true);
|
|
63
|
+
const n = await svc.createNote(ctx.client, opts.pipeline, input);
|
|
64
|
+
ctx.output.emit(n, () => ctx.output.success(`Created release note ${n.id}`));
|
|
65
|
+
}),
|
|
66
|
+
);
|
|
67
|
+
noteOptions(
|
|
68
|
+
group
|
|
69
|
+
.command("update <id>")
|
|
70
|
+
.description("Update a note's title, Markdown, or covered releases"),
|
|
71
|
+
).action(
|
|
72
|
+
action(async (ctx, opts, id: string) => {
|
|
73
|
+
const input = noteInput(opts);
|
|
74
|
+
svc.validateNoteScope(input, false);
|
|
75
|
+
const n = await svc.updateNote(ctx.client, id, input);
|
|
76
|
+
ctx.output.emit(n, () => ctx.output.success(`Updated release note ${n.id}`));
|
|
77
|
+
}),
|
|
78
|
+
);
|
|
79
|
+
group
|
|
80
|
+
.command("delete <id>")
|
|
81
|
+
.description("Delete a release note by UUID or slug")
|
|
82
|
+
.action(
|
|
83
|
+
action(async (ctx, _opts, id: string) => {
|
|
84
|
+
const n = await svc.getNote(ctx.client, id);
|
|
85
|
+
if (!(await confirmDestructive(ctx, `Delete release note ${n.title ?? n.id}?`))) return;
|
|
86
|
+
const receipt = await svc.deleteNote(ctx.client, n.id);
|
|
87
|
+
ctx.output.emit(receipt, () => ctx.output.success(`Deleted release note ${receipt.id}`));
|
|
88
|
+
}),
|
|
89
|
+
);
|
|
90
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { Command, Option } from "commander";
|
|
2
|
+
import { action } from "../lib/action.js";
|
|
3
|
+
import { booleanValue, repeat, PIPELINE_TYPES } from "../lib/release-input.js";
|
|
4
|
+
import * as svc from "../services/release.js";
|
|
5
|
+
|
|
6
|
+
function options(command: Command, creating = false): Command {
|
|
7
|
+
const result = command
|
|
8
|
+
.option("--name <name>", "pipeline name")
|
|
9
|
+
.addOption(new Option("--type <type>", "pipeline type").choices(PIPELINE_TYPES))
|
|
10
|
+
.option(
|
|
11
|
+
"--production <boolean>",
|
|
12
|
+
"whether pipeline targets production (true|false)",
|
|
13
|
+
booleanValue,
|
|
14
|
+
)
|
|
15
|
+
.option(
|
|
16
|
+
"--auto-release-notes <boolean>",
|
|
17
|
+
"automatically generate notes on completion (true|false)",
|
|
18
|
+
booleanValue,
|
|
19
|
+
)
|
|
20
|
+
.option(
|
|
21
|
+
"--rollover-issues <boolean>",
|
|
22
|
+
"move open issues to the next scheduled release on completion (true|false)",
|
|
23
|
+
booleanValue,
|
|
24
|
+
)
|
|
25
|
+
.addOption(
|
|
26
|
+
new Option("--include-path <glob>", "replace path patterns with these globs (repeatable)")
|
|
27
|
+
.argParser(repeat)
|
|
28
|
+
.conflicts("clearPaths"),
|
|
29
|
+
)
|
|
30
|
+
.addOption(new Option("--clear-paths", "clear all path filters").conflicts("includePath"))
|
|
31
|
+
.addOption(
|
|
32
|
+
new Option("--team-id <uuid>", "replace associated teams with these UUIDs (repeatable)")
|
|
33
|
+
.argParser(repeat)
|
|
34
|
+
.conflicts("clearTeams"),
|
|
35
|
+
)
|
|
36
|
+
.addOption(new Option("--clear-teams", "clear team associations").conflicts("teamId"));
|
|
37
|
+
if (creating)
|
|
38
|
+
for (const flag of ["--name", "--type"])
|
|
39
|
+
result.options.find((o) => o.long === flag)!.makeOptionMandatory();
|
|
40
|
+
return result;
|
|
41
|
+
}
|
|
42
|
+
function input(opts: Record<string, any>): svc.PipelineInput {
|
|
43
|
+
return {
|
|
44
|
+
name: opts.name,
|
|
45
|
+
type: opts.type,
|
|
46
|
+
isProduction: opts.production,
|
|
47
|
+
autoGenerateReleaseNotesOnCompletion: opts.autoReleaseNotes,
|
|
48
|
+
rolloverIssuesOnCompletion: opts.rolloverIssues,
|
|
49
|
+
includePathPatterns: opts.clearPaths ? [] : opts.includePath,
|
|
50
|
+
teamIds: opts.clearTeams ? [] : opts.teamId,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
export function registerReleasePipeline(program: Command): void {
|
|
54
|
+
const group = program
|
|
55
|
+
.command("release-pipeline")
|
|
56
|
+
.description("Manage release pipelines (Business/Enterprise)")
|
|
57
|
+
.addHelpText(
|
|
58
|
+
"after",
|
|
59
|
+
"\nUse normal Linear OAuth/API credentials. Pipeline access keys belong to\nlinear-release and cannot replace these credentials. Creating a pipeline\ncreates default stages; entitlement and workspace permissions are enforced by Linear.",
|
|
60
|
+
);
|
|
61
|
+
group
|
|
62
|
+
.command("list")
|
|
63
|
+
.alias("ls")
|
|
64
|
+
.description("List workspace release pipelines")
|
|
65
|
+
.option("--include-archived", "include archived pipelines")
|
|
66
|
+
.action(
|
|
67
|
+
action(async (ctx, opts) => {
|
|
68
|
+
const rows = await svc.listPipelines(ctx.client, ctx.limit, opts.includeArchived);
|
|
69
|
+
ctx.output.list(
|
|
70
|
+
rows,
|
|
71
|
+
[
|
|
72
|
+
{ key: "id", header: "ID", value: (r) => r.id },
|
|
73
|
+
{ key: "name", header: "Name", value: (r) => r.name },
|
|
74
|
+
{ key: "type", header: "Type", value: (r) => r.type },
|
|
75
|
+
{ key: "slugId", header: "Slug", value: (r) => r.slugId },
|
|
76
|
+
],
|
|
77
|
+
rows,
|
|
78
|
+
);
|
|
79
|
+
}),
|
|
80
|
+
);
|
|
81
|
+
group
|
|
82
|
+
.command("view <id>")
|
|
83
|
+
.description("Show pipeline settings by UUID or slug")
|
|
84
|
+
.action(
|
|
85
|
+
action(async (ctx, _opts, id: string) => {
|
|
86
|
+
const p = await svc.getPipeline(ctx.client, id);
|
|
87
|
+
ctx.output.detail(p, [
|
|
88
|
+
["ID", p.id],
|
|
89
|
+
["Name", p.name],
|
|
90
|
+
["Slug", p.slugId],
|
|
91
|
+
["Type", p.type],
|
|
92
|
+
["Production", p.isProduction],
|
|
93
|
+
["Auto release notes", p.autoGenerateReleaseNotesOnCompletion],
|
|
94
|
+
["Rollover issues", p.rolloverIssuesOnCompletion],
|
|
95
|
+
["Path patterns", p.includePathPatterns.join(", ")],
|
|
96
|
+
["Archived", p.archivedAt],
|
|
97
|
+
["URL", p.url],
|
|
98
|
+
]);
|
|
99
|
+
}),
|
|
100
|
+
);
|
|
101
|
+
options(
|
|
102
|
+
group.command("create").description("Create a pipeline with default stages"),
|
|
103
|
+
true,
|
|
104
|
+
).action(
|
|
105
|
+
action(async (ctx, opts) => {
|
|
106
|
+
const p = await svc.createPipeline(ctx.client, {
|
|
107
|
+
...input(opts),
|
|
108
|
+
name: opts.name,
|
|
109
|
+
type: opts.type,
|
|
110
|
+
});
|
|
111
|
+
ctx.output.emit(p, () => ctx.output.success(`Created release pipeline ${p.name} (${p.id})`));
|
|
112
|
+
}),
|
|
113
|
+
);
|
|
114
|
+
options(
|
|
115
|
+
group.command("update <id>").description("Update pipeline settings by UUID or slug"),
|
|
116
|
+
).action(
|
|
117
|
+
action(async (ctx, opts, id: string) => {
|
|
118
|
+
const p = await svc.updatePipeline(ctx.client, id, input(opts));
|
|
119
|
+
ctx.output.emit(p, () => ctx.output.success(`Updated release pipeline ${p.name} (${p.id})`));
|
|
120
|
+
}),
|
|
121
|
+
);
|
|
122
|
+
}
|