linear-sdk-cli 0.4.0 → 0.5.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/CHANGELOG.md +9 -0
- package/COVERAGE.md +4 -0
- package/MIGRATING.md +6 -0
- package/README.md +188 -37
- package/package.json +5 -5
- package/skills/linear-sdk-cli/SKILL.md +1 -0
- package/skills/linear-sdk-cli/references/auth.md +1 -0
- package/skills/linear-sdk-cli/references/config.md +10 -7
- package/skills/linear-sdk-cli/references/initiative.md +10 -5
- package/skills/linear-sdk-cli/references/issue.md +222 -77
- package/skills/linear-sdk-cli/references/notification.md +3 -3
- package/skills/linear-sdk-cli/references/project.md +9 -4
- package/src/commands/initiative.ts +23 -6
- package/src/commands/issue.ts +313 -12
- package/src/commands/meta.ts +55 -9
- package/src/commands/notification.ts +5 -3
- package/src/commands/project.ts +21 -3
- package/src/config.ts +111 -10
- package/src/lib/delegation.ts +240 -0
- package/src/lib/options.ts +17 -3
- package/src/lib/output-shapes.ts +77 -3
- package/src/output/lifecycle.ts +10 -0
- package/src/services/initiative.ts +12 -3
- package/src/services/issue.ts +498 -21
- package/src/services/project.ts +14 -3
package/src/commands/project.ts
CHANGED
|
@@ -4,7 +4,13 @@
|
|
|
4
4
|
|
|
5
5
|
import { Command, Option } from "commander";
|
|
6
6
|
import { action } from "../lib/action.js";
|
|
7
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
parseList,
|
|
9
|
+
parseIntOption,
|
|
10
|
+
addAliasOption,
|
|
11
|
+
readAlias,
|
|
12
|
+
addIncludeArchivedOption,
|
|
13
|
+
} from "../lib/options.js";
|
|
8
14
|
import { usageError } from "../lib/errors.js";
|
|
9
15
|
import { resolveBody } from "../lib/body.js";
|
|
10
16
|
import { confirmDestructive, promptInput } from "../lib/prompt.js";
|
|
@@ -12,10 +18,16 @@ import type { Context } from "../context.js";
|
|
|
12
18
|
import * as svc from "../services/project.js";
|
|
13
19
|
import { formatMilestoneProgress } from "./milestone.js";
|
|
14
20
|
import type { Column } from "../output/table.js";
|
|
21
|
+
import { lifecycleSuffix } from "../output/lifecycle.js";
|
|
15
22
|
|
|
16
23
|
const ROW_COLUMNS: Column<svc.ProjectRow>[] = [
|
|
17
24
|
{ key: "name", header: "Name", value: (r) => r.name, max: 36 },
|
|
18
|
-
{
|
|
25
|
+
{
|
|
26
|
+
key: "state",
|
|
27
|
+
header: "State",
|
|
28
|
+
value: (r) => `${r.status?.name ?? r.state ?? "—"}${lifecycleSuffix(r)}`,
|
|
29
|
+
max: 26,
|
|
30
|
+
},
|
|
19
31
|
{ key: "progress", header: "Progress", value: (r) => formatProgress(r.progress) },
|
|
20
32
|
{ key: "lead", header: "Lead", value: (r) => r.lead?.displayName ?? "—", max: 16 },
|
|
21
33
|
{ key: "target", header: "Target", value: (r) => r.targetDate ?? "—" },
|
|
@@ -72,6 +84,7 @@ export function registerProject(program: Command): void {
|
|
|
72
84
|
team: opts.allTeams ? undefined : (opts.team ?? ctx.defaultTeam),
|
|
73
85
|
allTeams: !!opts.allTeams,
|
|
74
86
|
state: readAlias(opts, "--state", "--status"),
|
|
87
|
+
includeArchived: !!opts.includeArchived,
|
|
75
88
|
},
|
|
76
89
|
ctx.limit,
|
|
77
90
|
ctx.defaultTeam,
|
|
@@ -79,6 +92,7 @@ export function registerProject(program: Command): void {
|
|
|
79
92
|
ctx.output.list(rows, ROW_COLUMNS, rows);
|
|
80
93
|
}),
|
|
81
94
|
);
|
|
95
|
+
addIncludeArchivedOption(list);
|
|
82
96
|
// `--status` is the reference CLI's spelling for the same thing; it shipped
|
|
83
97
|
// earlier as a visible duplicate and now goes through the shared alias
|
|
84
98
|
// mechanism, so `--help` shows one canonical spelling here like everywhere else.
|
|
@@ -94,7 +108,11 @@ export function registerProject(program: Command): void {
|
|
|
94
108
|
const detail = await svc.getProjectDetail(ctx.client, idArg);
|
|
95
109
|
ctx.output.detail(detail, [
|
|
96
110
|
["Project", detail.name],
|
|
97
|
-
[
|
|
111
|
+
[
|
|
112
|
+
"Trashed",
|
|
113
|
+
detail.trashed ? `YES (deleted ${detail.archivedAt ?? "at an unknown time"})` : null,
|
|
114
|
+
],
|
|
115
|
+
["Archived", !detail.trashed && detail.archivedAt ? `YES (${detail.archivedAt})` : null],
|
|
98
116
|
["State", detail.status?.name ?? detail.state],
|
|
99
117
|
["Health", detail.health],
|
|
100
118
|
["Progress", detail.progress !== null ? formatProgress(detail.progress) : null],
|
package/src/config.ts
CHANGED
|
@@ -3,8 +3,9 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Two tiers with different trust boundaries:
|
|
5
5
|
* - Non-secret settings (team, workspace, sort): flag > env > project
|
|
6
|
-
* config >
|
|
7
|
-
*
|
|
6
|
+
* config > selected workspace profile (team only) > user config
|
|
7
|
+
* (`~/.config/linear/config.toml`) > the reference CLI's global config
|
|
8
|
+
* (`~/.config/linear/linear.toml`, read-only).
|
|
8
9
|
* The project config is the first file found walking cwd → filesystem
|
|
9
10
|
* root, checking `linear.toml`, `.linear.toml`, `.config/linear.toml` in
|
|
10
11
|
* each directory — every location schpet/linear-cli 2.5 reads (it checks
|
|
@@ -70,7 +71,15 @@ export interface RawSettings {
|
|
|
70
71
|
* `global` is the reference CLI's `~/.config/linear/linear.toml`, read for
|
|
71
72
|
* non-secret settings only; `keychain` is the OS keyring (API key or human OAuth session).
|
|
72
73
|
*/
|
|
73
|
-
export type ConfigSource =
|
|
74
|
+
export type ConfigSource =
|
|
75
|
+
| "flag"
|
|
76
|
+
| "env"
|
|
77
|
+
| "project"
|
|
78
|
+
| "workspace-profile"
|
|
79
|
+
| "user"
|
|
80
|
+
| "global"
|
|
81
|
+
| "keychain"
|
|
82
|
+
| "none";
|
|
74
83
|
|
|
75
84
|
/** Where a stored workspace credential's secret lives. */
|
|
76
85
|
export type CredentialStorage = "file" | "keychain";
|
|
@@ -81,6 +90,8 @@ export interface SettingOrigin {
|
|
|
81
90
|
path?: string;
|
|
82
91
|
/** Exact spelling found in the file (`issue_sort`, not just canonical `sort`). */
|
|
83
92
|
key?: string;
|
|
93
|
+
/** Workspace slug for a value read from `[workspaces."<slug>"]`. */
|
|
94
|
+
workspace?: string;
|
|
84
95
|
}
|
|
85
96
|
|
|
86
97
|
/** The non-secret settings, each with its provenance. */
|
|
@@ -104,6 +115,8 @@ export interface ResolvedConfig {
|
|
|
104
115
|
apiKeyError?: CliError;
|
|
105
116
|
/** Workspace slug whose stored credential was used, if any. */
|
|
106
117
|
credentialWorkspace?: string;
|
|
118
|
+
/** Selected stored profile whose non-secret metadata may participate. */
|
|
119
|
+
workspaceProfile?: string;
|
|
107
120
|
team?: string;
|
|
108
121
|
/** Non-secret display workspace setting (separate from credentialWorkspace). */
|
|
109
122
|
workspace?: string;
|
|
@@ -174,6 +187,8 @@ interface WorkspaceEntry {
|
|
|
174
187
|
apiKey?: string;
|
|
175
188
|
/** This workspace uses the isolated `oauth:<slug>` keyring account. */
|
|
176
189
|
oauth?: boolean;
|
|
190
|
+
/** Non-secret default team for this workspace. */
|
|
191
|
+
team?: string;
|
|
177
192
|
}
|
|
178
193
|
|
|
179
194
|
/** Parsed view of the user config: top-level settings + nested workspace creds. */
|
|
@@ -328,6 +343,7 @@ function readUserConfig(path: string): UserConfig {
|
|
|
328
343
|
workspaces[slug] = {
|
|
329
344
|
apiKey: asString(table.api_key),
|
|
330
345
|
oauth: table.oauth === true,
|
|
346
|
+
team: asString(table.team),
|
|
331
347
|
};
|
|
332
348
|
}
|
|
333
349
|
}
|
|
@@ -450,6 +466,7 @@ export function resolveConfig(inputs: ConfigInputs = {}): ResolvedConfig {
|
|
|
450
466
|
let accessTokenSource: ConfigSource = "none";
|
|
451
467
|
let oauthCredential: OAuthUserCredential | undefined;
|
|
452
468
|
let credentialWorkspace: string | undefined;
|
|
469
|
+
let workspaceProfile: string | undefined;
|
|
453
470
|
let apiKeyError: CliError | undefined;
|
|
454
471
|
|
|
455
472
|
if (flags.apiKey && flags.accessToken) {
|
|
@@ -531,8 +548,27 @@ export function resolveConfig(inputs: ConfigInputs = {}): ResolvedConfig {
|
|
|
531
548
|
}
|
|
532
549
|
}
|
|
533
550
|
|
|
551
|
+
// A stored credential binds its own profile. An invocation-scoped credential
|
|
552
|
+
// never inherits the default/project workspace implicitly; it may bind only
|
|
553
|
+
// when this invocation explicitly selected a profile by flag or environment
|
|
554
|
+
// and that profile is actually configured.
|
|
555
|
+
if (credentialWorkspace && user.workspaces[credentialWorkspace]) {
|
|
556
|
+
workspaceProfile = credentialWorkspace;
|
|
557
|
+
} else {
|
|
558
|
+
const invocationCredential =
|
|
559
|
+
apiKeySource === "flag" ||
|
|
560
|
+
apiKeySource === "env" ||
|
|
561
|
+
accessTokenSource === "flag" ||
|
|
562
|
+
accessTokenSource === "env";
|
|
563
|
+
const explicitWorkspace = flags.workspace ?? envSettings.workspace;
|
|
564
|
+
if (invocationCredential && explicitWorkspace && user.workspaces[explicitWorkspace]) {
|
|
565
|
+
workspaceProfile = explicitWorkspace;
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
|
|
534
569
|
// ----- Non-secret settings (project + global config MAY participate) -----
|
|
535
|
-
//
|
|
570
|
+
// Workspace and sort keep their existing order. Team inserts the selected
|
|
571
|
+
// workspace profile between project and the legacy top-level user fallback.
|
|
536
572
|
const tiers: Array<[ConfigSource, RawSettings, string | undefined, SettingKeys | undefined]> = [
|
|
537
573
|
["flag", flags, undefined, undefined],
|
|
538
574
|
["env", envSettings, undefined, undefined],
|
|
@@ -543,13 +579,30 @@ export function resolveConfig(inputs: ConfigInputs = {}): ResolvedConfig {
|
|
|
543
579
|
const pick = <K extends "team" | "workspace" | "sort">(key: K): string | undefined =>
|
|
544
580
|
tiers.find(([, settings]) => settings[key] !== undefined)?.[1][key];
|
|
545
581
|
|
|
582
|
+
const profileTeam = workspaceProfile ? user.workspaces[workspaceProfile]?.team : undefined;
|
|
583
|
+
const teamTiers: typeof tiers = [
|
|
584
|
+
["flag", flags, undefined, undefined],
|
|
585
|
+
["env", envSettings, undefined, undefined],
|
|
586
|
+
["project", project.settings, projectPath, project.keys],
|
|
587
|
+
[
|
|
588
|
+
"workspace-profile",
|
|
589
|
+
profileTeam ? { team: profileTeam } : {},
|
|
590
|
+
userPath,
|
|
591
|
+
profileTeam ? { team: "team" } : undefined,
|
|
592
|
+
],
|
|
593
|
+
["user", user.settings, userPath, user.settingKeys],
|
|
594
|
+
["global", global.settings, globalPath, global.keys],
|
|
595
|
+
];
|
|
596
|
+
|
|
546
597
|
/** Which tier `pick` took the value from, and which file if it was one. */
|
|
547
598
|
const originOf = (key: "team" | "workspace" | "sort"): SettingOrigin => {
|
|
548
|
-
const
|
|
599
|
+
const candidates = key === "team" ? teamTiers : tiers;
|
|
600
|
+
const hit = candidates.find(([, settings]) => settings[key] !== undefined);
|
|
549
601
|
if (!hit) return { source: "none" };
|
|
550
602
|
const origin: SettingOrigin = { source: hit[0], path: hit[2] };
|
|
551
603
|
const spelling = hit[3]?.[key];
|
|
552
604
|
if (spelling !== undefined) origin.key = spelling;
|
|
605
|
+
if (hit[0] === "workspace-profile") origin.workspace = workspaceProfile;
|
|
553
606
|
return origin;
|
|
554
607
|
};
|
|
555
608
|
|
|
@@ -567,7 +620,8 @@ export function resolveConfig(inputs: ConfigInputs = {}): ResolvedConfig {
|
|
|
567
620
|
oauthCredential,
|
|
568
621
|
apiKeyError,
|
|
569
622
|
credentialWorkspace,
|
|
570
|
-
|
|
623
|
+
workspaceProfile,
|
|
624
|
+
team: teamTiers.find(([, settings]) => settings.team !== undefined)?.[1].team,
|
|
571
625
|
// The display setting walks every tier. Credential selection above uses
|
|
572
626
|
// the flag, env, and project tiers, then the auth-specific user default.
|
|
573
627
|
workspace: pick("workspace"),
|
|
@@ -823,6 +877,7 @@ interface WorkspaceTable {
|
|
|
823
877
|
api_key?: string;
|
|
824
878
|
keyring?: boolean;
|
|
825
879
|
oauth?: boolean;
|
|
880
|
+
team?: string;
|
|
826
881
|
}
|
|
827
882
|
|
|
828
883
|
function workspacesTable(obj: Record<string, unknown>): Record<string, WorkspaceTable> {
|
|
@@ -1151,6 +1206,31 @@ export function setDefaultWorkspace(slug: string): string {
|
|
|
1151
1206
|
});
|
|
1152
1207
|
}
|
|
1153
1208
|
|
|
1209
|
+
/** Set non-secret team metadata on one existing workspace profile. */
|
|
1210
|
+
export function setWorkspaceTeam(
|
|
1211
|
+
slug: string,
|
|
1212
|
+
team: string,
|
|
1213
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
1214
|
+
): string {
|
|
1215
|
+
const path = userConfigPath(env);
|
|
1216
|
+
return withUserConfigLock(path, () => {
|
|
1217
|
+
const known = readUserConfig(path).workspaces;
|
|
1218
|
+
if (!known[slug]) {
|
|
1219
|
+
throw new CliError(
|
|
1220
|
+
`Workspace '${sanitize(slug)}' is not configured. Run \`linear auth login --workspace ${sanitize(slug)}\` first.`,
|
|
1221
|
+
"not_found",
|
|
1222
|
+
);
|
|
1223
|
+
}
|
|
1224
|
+
const obj = readUserObject(path);
|
|
1225
|
+
const ws = workspacesTable(obj);
|
|
1226
|
+
const table: WorkspaceTable = { ...(ws[slug] ?? {}) };
|
|
1227
|
+
table.team = team;
|
|
1228
|
+
ws[slug] = table;
|
|
1229
|
+
writeUserObject(path, obj);
|
|
1230
|
+
return path;
|
|
1231
|
+
});
|
|
1232
|
+
}
|
|
1233
|
+
|
|
1154
1234
|
/**
|
|
1155
1235
|
* Drop `slug` from the reference CLI's keyring-format credentials list, if it
|
|
1156
1236
|
* is there, in that file's own layout (`default` first, sorted `workspaces`).
|
|
@@ -1176,10 +1256,17 @@ function removeFromReferenceCredentials(slug: string): boolean {
|
|
|
1176
1256
|
* removed slug was the default, the default is repointed to a remaining
|
|
1177
1257
|
* workspace (or cleared). Returns true if anything was removed.
|
|
1178
1258
|
*/
|
|
1179
|
-
export
|
|
1259
|
+
export interface RemoveCredentialResult {
|
|
1260
|
+
removed: boolean;
|
|
1261
|
+
teamMetadataRemoved: boolean;
|
|
1262
|
+
}
|
|
1263
|
+
|
|
1264
|
+
/** Detailed removal receipt used by `auth logout`. */
|
|
1265
|
+
export function removeCredentialWithMetadata(slug: string): RemoveCredentialResult {
|
|
1180
1266
|
const path = userConfigPath();
|
|
1181
1267
|
return withUserConfigLock(path, () => {
|
|
1182
1268
|
let removed = false;
|
|
1269
|
+
let teamMetadataRemoved = false;
|
|
1183
1270
|
|
|
1184
1271
|
const backend = keyring();
|
|
1185
1272
|
if (backend) {
|
|
@@ -1188,11 +1275,12 @@ export function removeCredential(slug: string): boolean {
|
|
|
1188
1275
|
}
|
|
1189
1276
|
if (removeFromReferenceCredentials(slug)) removed = true;
|
|
1190
1277
|
|
|
1191
|
-
if (!existsSync(path)) return removed;
|
|
1278
|
+
if (!existsSync(path)) return { removed, teamMetadataRemoved };
|
|
1192
1279
|
const obj = readUserObject(path);
|
|
1193
1280
|
const ws = workspacesTable(obj);
|
|
1194
1281
|
const listed = Boolean(ws[slug]);
|
|
1195
1282
|
if (listed) {
|
|
1283
|
+
teamMetadataRemoved = asString(ws[slug]?.team) !== undefined;
|
|
1196
1284
|
delete ws[slug];
|
|
1197
1285
|
removed = true;
|
|
1198
1286
|
}
|
|
@@ -1206,13 +1294,19 @@ export function removeCredential(slug: string): boolean {
|
|
|
1206
1294
|
} else if (listed) {
|
|
1207
1295
|
writeUserObject(path, obj);
|
|
1208
1296
|
}
|
|
1209
|
-
return removed;
|
|
1297
|
+
return { removed, teamMetadataRemoved };
|
|
1210
1298
|
});
|
|
1211
1299
|
}
|
|
1212
1300
|
|
|
1301
|
+
/** Backward-compatible boolean removal API. */
|
|
1302
|
+
export function removeCredential(slug: string): boolean {
|
|
1303
|
+
return removeCredentialWithMetadata(slug).removed;
|
|
1304
|
+
}
|
|
1305
|
+
|
|
1213
1306
|
export interface RemoveOAuthCredentialResult {
|
|
1214
1307
|
removed: boolean;
|
|
1215
1308
|
fallbackCredentialType: "api-key" | null;
|
|
1309
|
+
teamMetadataRemoved: boolean;
|
|
1216
1310
|
}
|
|
1217
1311
|
|
|
1218
1312
|
/** Remove only the human OAuth lifecycle, restoring a pre-existing API-key profile if present. */
|
|
@@ -1223,6 +1317,7 @@ export function removeOAuthCredential(slug: string): RemoveOAuthCredentialResult
|
|
|
1223
1317
|
let oauthRaw: string | null = null;
|
|
1224
1318
|
let keyringApiKey = false;
|
|
1225
1319
|
let removed = false;
|
|
1320
|
+
let teamMetadataRemoved = false;
|
|
1226
1321
|
if (backend) {
|
|
1227
1322
|
try {
|
|
1228
1323
|
oauthRaw = backend.get(oauthAccount(slug));
|
|
@@ -1237,7 +1332,11 @@ export function removeOAuthCredential(slug: string): RemoveOAuthCredentialResult
|
|
|
1237
1332
|
}
|
|
1238
1333
|
|
|
1239
1334
|
if (!existsSync(path)) {
|
|
1240
|
-
return {
|
|
1335
|
+
return {
|
|
1336
|
+
removed,
|
|
1337
|
+
fallbackCredentialType: keyringApiKey ? "api-key" : null,
|
|
1338
|
+
teamMetadataRemoved,
|
|
1339
|
+
};
|
|
1241
1340
|
}
|
|
1242
1341
|
try {
|
|
1243
1342
|
const obj = readUserObject(path);
|
|
@@ -1255,6 +1354,7 @@ export function removeOAuthCredential(slug: string): RemoveOAuthCredentialResult
|
|
|
1255
1354
|
else fallback.keyring = true;
|
|
1256
1355
|
ws[slug] = fallback;
|
|
1257
1356
|
} else if (table) {
|
|
1357
|
+
teamMetadataRemoved = asString(table.team) !== undefined;
|
|
1258
1358
|
delete ws[slug];
|
|
1259
1359
|
}
|
|
1260
1360
|
if (Object.keys(ws).length === 0) delete obj.workspaces;
|
|
@@ -1270,6 +1370,7 @@ export function removeOAuthCredential(slug: string): RemoveOAuthCredentialResult
|
|
|
1270
1370
|
return {
|
|
1271
1371
|
removed,
|
|
1272
1372
|
fallbackCredentialType: apiKeyFallback ? "api-key" : null,
|
|
1373
|
+
teamMetadataRemoved,
|
|
1273
1374
|
};
|
|
1274
1375
|
} catch (err) {
|
|
1275
1376
|
try {
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Linear for Agents (Developer Preview) helpers.
|
|
3
|
+
*
|
|
4
|
+
* Delegates are deliberately resolved separately from assignees. A human can
|
|
5
|
+
* own an issue; only an active, assignable app user with access to the issue's
|
|
6
|
+
* team can be delegated the work.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { LinearClient } from "@linear/sdk";
|
|
10
|
+
import { withRetry } from "../client.js";
|
|
11
|
+
import { CliError, ambiguous, normalizeError, notFound } from "./errors.js";
|
|
12
|
+
import { isUuid } from "./resolve.js";
|
|
13
|
+
|
|
14
|
+
const PAGE_SIZE = 250;
|
|
15
|
+
const SCAN_CAP = 2000;
|
|
16
|
+
const MAX_LISTED = 25;
|
|
17
|
+
|
|
18
|
+
export interface DelegateRef {
|
|
19
|
+
id: string;
|
|
20
|
+
displayName: string;
|
|
21
|
+
name: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function delegationMessages(err: unknown): { normalized: CliError; messages: string } {
|
|
25
|
+
const normalized = normalizeError(err);
|
|
26
|
+
return {
|
|
27
|
+
normalized,
|
|
28
|
+
messages: [normalized.message, JSON.stringify(normalized.detail ?? "")].join(" "),
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function missingDelegationSchema(messages: string): boolean {
|
|
33
|
+
return (
|
|
34
|
+
/(cannot query field|unknown (argument|field)|is not defined by type).*(delegate|isAssignable|canAccessAnyPublicTeam)/i.test(
|
|
35
|
+
messages,
|
|
36
|
+
) ||
|
|
37
|
+
/(delegateId|delegate|isAssignable|canAccessAnyPublicTeam).*(cannot query field|unknown (argument|field)|is not defined by type)/i.test(
|
|
38
|
+
messages,
|
|
39
|
+
)
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Whether an issue read can safely retry without its one preview-only field. */
|
|
44
|
+
export function isDelegationReadUnavailable(err: unknown): boolean {
|
|
45
|
+
const { normalized, messages } = delegationMessages(err);
|
|
46
|
+
return (
|
|
47
|
+
missingDelegationSchema(messages) ||
|
|
48
|
+
(normalized.code === "feature_not_accessible" && /delegate/i.test(messages))
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
interface DelegateCandidate extends DelegateRef {
|
|
53
|
+
active: boolean;
|
|
54
|
+
app: boolean;
|
|
55
|
+
isAssignable: boolean;
|
|
56
|
+
canAccessAnyPublicTeam: boolean;
|
|
57
|
+
teams: { nodes: Array<{ id: string }> };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const CANDIDATE_FIELDS = `
|
|
61
|
+
id displayName name active app isAssignable canAccessAnyPublicTeam
|
|
62
|
+
teams(filter: { id: { eq: $teamId } }, first: 1) { nodes { id } }
|
|
63
|
+
`;
|
|
64
|
+
|
|
65
|
+
const CANDIDATES_QUERY = `
|
|
66
|
+
query CliDelegateCandidates($teamId: String!, $after: String) {
|
|
67
|
+
users(first: ${PAGE_SIZE}, after: $after, includeDisabled: true, includeArchived: true) {
|
|
68
|
+
nodes { ${CANDIDATE_FIELDS} }
|
|
69
|
+
pageInfo { hasNextPage endCursor }
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
`;
|
|
73
|
+
|
|
74
|
+
const CANDIDATE_QUERY = `
|
|
75
|
+
query CliDelegateCandidate($id: String!, $teamId: String!) {
|
|
76
|
+
user(id: $id) { ${CANDIDATE_FIELDS} }
|
|
77
|
+
}
|
|
78
|
+
`;
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Turn an unavailable Developer Preview schema/feature into a stable boundary.
|
|
82
|
+
* Other validation failures retain their original code and wording.
|
|
83
|
+
*/
|
|
84
|
+
export function delegationFeatureError(err: unknown): CliError {
|
|
85
|
+
const { normalized, messages } = delegationMessages(err);
|
|
86
|
+
const schemaMissing = missingDelegationSchema(messages);
|
|
87
|
+
if (normalized.code !== "feature_not_accessible" && !schemaMissing) return normalized;
|
|
88
|
+
return new CliError(
|
|
89
|
+
"Issue delegation is unavailable in this Linear workspace or API schema (Linear for Agents is in Developer Preview).",
|
|
90
|
+
"feature_not_accessible",
|
|
91
|
+
normalized.detail,
|
|
92
|
+
"Enable an assignable agent integration with access to this team, or retry when the workspace supports issue delegation.",
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async function request<T>(client: LinearClient, query: string, variables: object): Promise<T> {
|
|
97
|
+
try {
|
|
98
|
+
const response: any = await withRetry(() =>
|
|
99
|
+
(client as any).client.rawRequest(query, variables),
|
|
100
|
+
);
|
|
101
|
+
return response.data as T;
|
|
102
|
+
} catch (err) {
|
|
103
|
+
throw delegationFeatureError(err);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async function allCandidates(client: LinearClient, teamId: string): Promise<DelegateCandidate[]> {
|
|
108
|
+
const candidates: DelegateCandidate[] = [];
|
|
109
|
+
let after: string | undefined;
|
|
110
|
+
for (;;) {
|
|
111
|
+
const data = await request<{
|
|
112
|
+
users: {
|
|
113
|
+
nodes: DelegateCandidate[];
|
|
114
|
+
pageInfo: { hasNextPage: boolean; endCursor: string | null };
|
|
115
|
+
};
|
|
116
|
+
}>(client, CANDIDATES_QUERY, { teamId, after });
|
|
117
|
+
candidates.push(...data.users.nodes);
|
|
118
|
+
if (!data.users.pageInfo.hasNextPage) return candidates;
|
|
119
|
+
if (candidates.length >= SCAN_CAP || !data.users.pageInfo.endCursor) {
|
|
120
|
+
throw new CliError(
|
|
121
|
+
`More than ${SCAN_CAP} workspace users to search; pass the agent user UUID instead.`,
|
|
122
|
+
"usage",
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
after = data.users.pageInfo.endCursor;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function isBaseEligible(candidate: DelegateCandidate): boolean {
|
|
130
|
+
return candidate.active && candidate.app && candidate.isAssignable;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function hasTeamAccess(candidate: DelegateCandidate, privateTeam: boolean): boolean {
|
|
134
|
+
return candidate.teams.nodes.length > 0 || (!privateTeam && candidate.canAccessAnyPublicTeam);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function label(candidate: DelegateCandidate): string {
|
|
138
|
+
const names =
|
|
139
|
+
candidate.displayName === candidate.name
|
|
140
|
+
? candidate.displayName
|
|
141
|
+
: `${candidate.displayName} (${candidate.name})`;
|
|
142
|
+
return `${names} [${candidate.id}]`;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function validateCandidate(
|
|
146
|
+
candidate: DelegateCandidate,
|
|
147
|
+
team: { key: string; private: boolean },
|
|
148
|
+
): DelegateRef {
|
|
149
|
+
if (!candidate.active) {
|
|
150
|
+
throw new CliError(
|
|
151
|
+
`'${candidate.displayName}' is inactive and cannot be delegated issues.`,
|
|
152
|
+
"validation",
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
if (!candidate.app) {
|
|
156
|
+
throw new CliError(
|
|
157
|
+
`'${candidate.displayName}' is a human user. Use --assignee for people; --delegate accepts agent app users only.`,
|
|
158
|
+
"validation",
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
if (!candidate.isAssignable) {
|
|
162
|
+
throw new CliError(
|
|
163
|
+
`'${candidate.displayName}' is an app user but is not eligible for issue delegation.`,
|
|
164
|
+
"validation",
|
|
165
|
+
undefined,
|
|
166
|
+
"Reinstall or reconfigure the agent with Linear's app:assignable scope.",
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
if (!hasTeamAccess(candidate, team.private)) {
|
|
170
|
+
throw new CliError(
|
|
171
|
+
`'${candidate.displayName}' cannot access team ${team.key} and cannot be delegated this issue.`,
|
|
172
|
+
"validation",
|
|
173
|
+
undefined,
|
|
174
|
+
"Ask a Linear workspace admin to grant the agent access to this team.",
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
return { id: candidate.id, displayName: candidate.displayName, name: candidate.name };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Resolve an agent by UUID, exact display name, or exact full name. Exact case
|
|
182
|
+
* wins over case-insensitive matching; ambiguity considers eligible app users
|
|
183
|
+
* only and lists safe names plus IDs.
|
|
184
|
+
*/
|
|
185
|
+
export async function resolveDelegate(
|
|
186
|
+
client: LinearClient,
|
|
187
|
+
input: string,
|
|
188
|
+
teamId: string,
|
|
189
|
+
): Promise<DelegateRef> {
|
|
190
|
+
let team: { key: string; private: boolean };
|
|
191
|
+
try {
|
|
192
|
+
const model: any = await withRetry(() => client.team(teamId));
|
|
193
|
+
team = { key: model.key, private: !!model.private };
|
|
194
|
+
} catch (err) {
|
|
195
|
+
throw delegationFeatureError(err);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if (isUuid(input)) {
|
|
199
|
+
const data = await request<{ user: DelegateCandidate | null }>(client, CANDIDATE_QUERY, {
|
|
200
|
+
id: input,
|
|
201
|
+
teamId,
|
|
202
|
+
});
|
|
203
|
+
if (!data.user) throw notFound(`No agent user with id '${input}'.`);
|
|
204
|
+
return validateCandidate(data.user, team);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const candidates = await allCandidates(client, teamId);
|
|
208
|
+
const exact = candidates.filter(
|
|
209
|
+
(candidate) => candidate.displayName === input || candidate.name === input,
|
|
210
|
+
);
|
|
211
|
+
const lower = input.toLocaleLowerCase();
|
|
212
|
+
const matching = exact.length
|
|
213
|
+
? exact
|
|
214
|
+
: candidates.filter(
|
|
215
|
+
(candidate) =>
|
|
216
|
+
candidate.displayName.toLocaleLowerCase() === lower ||
|
|
217
|
+
candidate.name.toLocaleLowerCase() === lower,
|
|
218
|
+
);
|
|
219
|
+
const eligible = matching.filter(
|
|
220
|
+
(candidate) => isBaseEligible(candidate) && hasTeamAccess(candidate, team.private),
|
|
221
|
+
);
|
|
222
|
+
if (eligible.length > 1) {
|
|
223
|
+
throw ambiguous(
|
|
224
|
+
`Multiple eligible agents match '${input}': ${eligible.map(label).join(", ")}. Pass the agent user UUID.`,
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
if (eligible.length === 1) return validateCandidate(eligible[0]!, team);
|
|
228
|
+
if (matching.length > 0) return validateCandidate(matching[0]!, team);
|
|
229
|
+
|
|
230
|
+
const available = candidates.filter(
|
|
231
|
+
(candidate) => isBaseEligible(candidate) && hasTeamAccess(candidate, team.private),
|
|
232
|
+
);
|
|
233
|
+
const suffix =
|
|
234
|
+
available.length === 0
|
|
235
|
+
? " No eligible agents can access this team."
|
|
236
|
+
: available.length > MAX_LISTED
|
|
237
|
+
? ` Run 'linear user list --all' to inspect workspace users.`
|
|
238
|
+
: ` Available agents: ${available.map(label).join(", ")}.`;
|
|
239
|
+
throw notFound(`No eligible agent matching '${input}'.${suffix}`);
|
|
240
|
+
}
|
package/src/lib/options.ts
CHANGED
|
@@ -210,6 +210,20 @@ export function readAlias<T = string>(
|
|
|
210
210
|
return (canonicalValue ?? aliasValue) as T | undefined;
|
|
211
211
|
}
|
|
212
212
|
|
|
213
|
+
/**
|
|
214
|
+
* Add the canonical lifecycle-listing option. Linear may return both archived
|
|
215
|
+
* and trashed records when this is true, so the help does not imply an
|
|
216
|
+
* archived-only filter.
|
|
217
|
+
*/
|
|
218
|
+
export function addIncludeArchivedOption(cmd: Command): Command {
|
|
219
|
+
return cmd.addOption(
|
|
220
|
+
new Option(
|
|
221
|
+
"--include-archived",
|
|
222
|
+
"include archived resources (the API may also return trashed records)",
|
|
223
|
+
),
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
|
|
213
227
|
/**
|
|
214
228
|
* A `--no-…` global registered as an ordinary boolean flag rather than as
|
|
215
229
|
* commander's *negation* of a positive option. Two bugs came out of leaving
|
|
@@ -603,7 +617,7 @@ export function addCoreFilterOptions(cmd: Command, set: FilterOptionSet = {}): C
|
|
|
603
617
|
cmd.addOption(new Option("-a, --assignee <who>", "filter by assignee (me|email|name)"));
|
|
604
618
|
cmd.addOption(new Option("-U, --unassigned", "only issues with no assignee"));
|
|
605
619
|
}
|
|
606
|
-
|
|
620
|
+
cmd
|
|
607
621
|
.addOption(new Option("-p, --project <name>", "filter by project"))
|
|
608
622
|
.addOption(
|
|
609
623
|
new Option("--project-label <name>", "filter by the project's label (excludes --project)"),
|
|
@@ -622,8 +636,8 @@ export function addCoreFilterOptions(cmd: Command, set: FilterOptionSet = {}): C
|
|
|
622
636
|
.addOption(
|
|
623
637
|
new Option("--updated-after <date>", "only issues updated at/after a date (YYYY-MM-DD)"),
|
|
624
638
|
)
|
|
625
|
-
.addOption(new Option("--all-teams", "search every team, ignoring the default team"))
|
|
626
|
-
|
|
639
|
+
.addOption(new Option("--all-teams", "search every team, ignoring the default team"));
|
|
640
|
+
return addIncludeArchivedOption(cmd);
|
|
627
641
|
}
|
|
628
642
|
|
|
629
643
|
/**
|