@enrichlayer/el-linear 1.41.1 → 1.43.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/README.md +16 -0
- package/dist/commands/issues/description.js +11 -2
- package/dist/commands/issues.js +22 -2
- package/dist/config/config.d.ts +25 -0
- package/dist/config/config.js +15 -0
- package/dist/config/identity-resolver.d.ts +40 -0
- package/dist/config/identity-resolver.js +253 -0
- package/dist/config/resolver.d.ts +22 -7
- package/dist/config/resolver.js +31 -7
- package/dist/queries/issues-types.d.ts +11 -0
- package/dist/queries/issues.d.ts +3 -3
- package/dist/queries/issues.js +18 -1
- package/dist/utils/graphql-issues-service.d.ts +15 -0
- package/dist/utils/graphql-issues-service.js +98 -27
- package/dist/utils/issue-envelope-guard.d.ts +50 -0
- package/dist/utils/issue-envelope-guard.js +110 -0
- package/dist/utils/output.js +6 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -489,6 +489,22 @@ el-linear <command> --help # detailed help for one command
|
|
|
489
489
|
All `list` subcommands support `-l, --limit <n>`. All commands accept the
|
|
490
490
|
top-level filters: `--format <json|summary>`, `--raw`, `--jq <expr>`, `--fields <list>`.
|
|
491
491
|
|
|
492
|
+
`issues list` also accepts `--all` (equivalent to `--limit 0`) to fetch **every**
|
|
493
|
+
matching issue. It paginates the full set in safe chunks under the hood, so
|
|
494
|
+
enumerating a large team no longer trips Linear's GraphQL complexity ceiling
|
|
495
|
+
(`Query too complex`) the way a single big `--limit` used to — no manual
|
|
496
|
+
per-status / per-priority bucketing needed:
|
|
497
|
+
|
|
498
|
+
```bash
|
|
499
|
+
el-linear issues list --team DEV --all --format csv --fields identifier # the whole open DEV backlog, one command
|
|
500
|
+
```
|
|
501
|
+
|
|
502
|
+
`issues search` intentionally has no `--all`: Linear's full-text search is a
|
|
503
|
+
relevance-ranked candidate search whose results are filtered client-side, not
|
|
504
|
+
an exhaustive enumeration. Its `--limit` reads at most 200 ranked candidates to
|
|
505
|
+
keep the rich issue query below Linear's complexity ceiling. Use `issues list`
|
|
506
|
+
with structured filters and `--all` when you need every matching issue.
|
|
507
|
+
|
|
492
508
|
### Open by default — `issues list` and `issues search` skip terminal states
|
|
493
509
|
|
|
494
510
|
`el-linear issues list` and `el-linear issues search` **exclude issues in
|
|
@@ -20,6 +20,7 @@ import { loadConfig } from "../../config/config.js";
|
|
|
20
20
|
import { UPDATE_ISSUE_MUTATION } from "../../queries/issues.js";
|
|
21
21
|
import { autoLinkReferences, } from "../../utils/auto-link-references.js";
|
|
22
22
|
import { normalizeInlineTextInput } from "../../utils/inline-text-input.js";
|
|
23
|
+
import { assertNotIssueEnvelope } from "../../utils/issue-envelope-guard.js";
|
|
23
24
|
import { extractIssueReferences } from "../../utils/issue-reference-extractor.js";
|
|
24
25
|
import { wrapIssueReferencesAsLinks } from "../../utils/issue-reference-wrapper.js";
|
|
25
26
|
import { readTextInputFile } from "../../utils/text-input-file.js";
|
|
@@ -53,11 +54,19 @@ export function resolveDescription(options) {
|
|
|
53
54
|
throw new Error("--template is mutually exclusive with --description / --description-file. " +
|
|
54
55
|
"Pick one.");
|
|
55
56
|
}
|
|
57
|
+
// Guard both user-supplied paths (file + inline) against being handed an
|
|
58
|
+
// issue's own JSON envelope, which would silently overwrite the real body
|
|
59
|
+
// (DEV-6315). Template bodies are config-authored, so they're not checked.
|
|
60
|
+
const allow = options.allowJsonDescription === true;
|
|
56
61
|
if (hasFile) {
|
|
57
|
-
|
|
62
|
+
const body = readDescriptionFile(options.descriptionFile);
|
|
63
|
+
assertNotIssueEnvelope(body, { allow });
|
|
64
|
+
return body;
|
|
58
65
|
}
|
|
59
66
|
if (hasInline) {
|
|
60
|
-
|
|
67
|
+
const body = normalizeInlineTextInput(options.description);
|
|
68
|
+
assertNotIssueEnvelope(body, { allow });
|
|
69
|
+
return body;
|
|
61
70
|
}
|
|
62
71
|
if (hasTemplate) {
|
|
63
72
|
const templates = loadConfig().descriptionTemplates ?? {};
|
package/dist/commands/issues.js
CHANGED
|
@@ -16,6 +16,7 @@ import { emitGateEvent } from "../utils/gate-telemetry.js";
|
|
|
16
16
|
import { createGraphQLAttachmentsService } from "../utils/graphql-attachments-service.js";
|
|
17
17
|
import { createGraphQLService, } from "../utils/graphql-service.js";
|
|
18
18
|
import { normalizeInlineTextInput } from "../utils/inline-text-input.js";
|
|
19
|
+
import { assertNotIssueEnvelope } from "../utils/issue-envelope-guard.js";
|
|
19
20
|
import { createIssuesService } from "../utils/issues-service-bootstrap.js";
|
|
20
21
|
import { createLinearService, } from "../utils/linear-service.js";
|
|
21
22
|
import { logger } from "../utils/logger.js";
|
|
@@ -217,7 +218,11 @@ async function handleListIssues(options, command) {
|
|
|
217
218
|
options.project ||
|
|
218
219
|
options.project === false ||
|
|
219
220
|
options.priority;
|
|
220
|
-
|
|
221
|
+
// DEV-6312: `--all` / `--limit 0` mean unlimited. Stored as `limit = 0`,
|
|
222
|
+
// which the service's chunked pagination interprets as "fetch the whole
|
|
223
|
+
// matching set in safe pages" — mirrors `projects list --all` (DEV-4175).
|
|
224
|
+
const unlimited = options.all === true || options.limit === "0";
|
|
225
|
+
const limit = unlimited ? 0 : parsePositiveInt(options.limit, "--limit");
|
|
221
226
|
// Route through searchIssues whenever the CLI needs to control the GraphQL
|
|
222
227
|
// state filter: any explicit filter, the default `excludeTerminalStates`,
|
|
223
228
|
// OR an explicit `--include-closed`. The last case is load-bearing —
|
|
@@ -1100,6 +1105,18 @@ async function handleUpdateIssue(issueId, options, command) {
|
|
|
1100
1105
|
if (typeof options.appendDescription === "string") {
|
|
1101
1106
|
options.appendDescription = normalizeInlineTextInput(options.appendDescription);
|
|
1102
1107
|
}
|
|
1108
|
+
// Guard against overwriting this issue's body with its own JSON envelope
|
|
1109
|
+
// (DEV-6315). The update path resolves description inline rather than via
|
|
1110
|
+
// resolveDescription(), so the guard is applied here too; --description,
|
|
1111
|
+
// --description-file (normalized above), and --append-description are all
|
|
1112
|
+
// checked (append would corrupt just as badly).
|
|
1113
|
+
{
|
|
1114
|
+
const allow = options.allowJsonDescription === true;
|
|
1115
|
+
assertNotIssueEnvelope(typeof options.description === "string" ? options.description : undefined, { allow, targetIssueRef: issueId });
|
|
1116
|
+
assertNotIssueEnvelope(typeof options.appendDescription === "string"
|
|
1117
|
+
? options.appendDescription
|
|
1118
|
+
: undefined, { allow, targetIssueRef: issueId });
|
|
1119
|
+
}
|
|
1103
1120
|
validateUpdateOptions(options);
|
|
1104
1121
|
const rootOpts = getRootOpts(command);
|
|
1105
1122
|
const { graphQLService, linearService, issuesService } = await createIssuesService(rootOpts);
|
|
@@ -1381,6 +1398,7 @@ export function setupIssuesCommands(program) {
|
|
|
1381
1398
|
.command("list")
|
|
1382
1399
|
.description("List issues.")
|
|
1383
1400
|
.option("-l, --limit <number>", "limit results", "25")
|
|
1401
|
+
.option("--all", "fetch every matching issue (paginates fully in safe chunks). Equivalent to --limit 0; overrides --limit when both are given.")
|
|
1384
1402
|
.option("--search <query>", "full-text search term; composes with list filters")
|
|
1385
1403
|
.option("--team <team>", "filter by team key (EL: resolves names)")
|
|
1386
1404
|
.option("--assignee <assignee>", "filter by assignee (name, alias, or ID)")
|
|
@@ -1412,7 +1430,7 @@ export function setupIssuesCommands(program) {
|
|
|
1412
1430
|
.option("--sort <field>", "sort results (priority, status, created, updated)")
|
|
1413
1431
|
.option("--format <format>", "output format (json, summary, table, md, csv)", "json")
|
|
1414
1432
|
.option("--fields <fields>", "columns for table/csv output")
|
|
1415
|
-
.option("-l, --limit <number>", "limit results", "10")
|
|
1433
|
+
.option("-l, --limit <number>", "limit results (full-text search reads at most 200 ranked candidates)", "10")
|
|
1416
1434
|
.action(handleAsyncCommand(handleSearchIssues));
|
|
1417
1435
|
issues
|
|
1418
1436
|
.command("create [title]")
|
|
@@ -1421,6 +1439,7 @@ export function setupIssuesCommands(program) {
|
|
|
1421
1439
|
.option("-d, --description <desc>", "issue description")
|
|
1422
1440
|
.option("--description-file <path>", "read description from file (use - for stdin)")
|
|
1423
1441
|
.option("--template <name>", "use a named description template from config.descriptionTemplates")
|
|
1442
|
+
.option("--allow-json-description", "allow a --description / --description-file body that looks like an issue's JSON envelope (normally blocked to prevent silently overwriting a body with 'issues get --format json' output — DEV-6315)")
|
|
1424
1443
|
.option("--from-template <id>", "instantiate the issue from a Linear server-side template (UUID from `el-linear templates list`). Sets templateId on the underlying issueCreate mutation; Linear copies the template's title, description, labels, priority, etc. as the new issue's defaults. Override any field with the matching --title / --description / --labels flag.")
|
|
1425
1444
|
.option("-a, --assignee <assignee>", "assign to user (name, alias, or UUID)")
|
|
1426
1445
|
.option("--delegate <delegate>", "delegate implementation to an agent app user (name, alias, or UUID)")
|
|
@@ -1503,6 +1522,7 @@ export function setupIssuesCommands(program) {
|
|
|
1503
1522
|
.option("-t, --title <title>", "new title")
|
|
1504
1523
|
.option("-d, --description <desc>", "new description")
|
|
1505
1524
|
.option("--description-file <path>", "read description from file (use - for stdin)")
|
|
1525
|
+
.option("--allow-json-description", "allow a --description / --description-file body that looks like an issue's JSON envelope (normally blocked to prevent silently overwriting a body with 'issues get --format json' output — DEV-6315)")
|
|
1506
1526
|
.option("--append-description <text>", "append text to the existing description")
|
|
1507
1527
|
.option("-s, --status <status>", "new status name or ID")
|
|
1508
1528
|
.option("--state <status>", "alias for --status (new status name or ID)")
|
package/dist/config/config.d.ts
CHANGED
|
@@ -36,6 +36,31 @@ export interface ElLinearConfig {
|
|
|
36
36
|
};
|
|
37
37
|
teamAliases: Record<string, string>;
|
|
38
38
|
teams: Record<string, string>;
|
|
39
|
+
/**
|
|
40
|
+
* Optional identity-resolver hook (DEV-5628). `resolver` is an argv array —
|
|
41
|
+
* el-linear appends the identifier as the final element and reads a Linear
|
|
42
|
+
* user UUID off stdout:
|
|
43
|
+
*
|
|
44
|
+
* "identity": { "resolver": ["el-identity", "resolve"] }
|
|
45
|
+
*
|
|
46
|
+
* Use it when your org has a people registry that knows things Linear does
|
|
47
|
+
* not (that `jd` is a person; that a GitLab handle and a Linear handle are one human).
|
|
48
|
+
*
|
|
49
|
+
* It is a COMMAND rather than a URL + credentials on purpose: el-linear is
|
|
50
|
+
* MIT and most installs are not ours, so it must not bake in anybody's auth
|
|
51
|
+
* scheme. The credential lives entirely inside whatever you point this at —
|
|
52
|
+
* Vault, Infisical, 1Password, a plain env var, SSO. Adding a backend is
|
|
53
|
+
* writing a different script, not patching this package.
|
|
54
|
+
*
|
|
55
|
+
* Entirely optional and fail-open: unconfigured, or on any failure,
|
|
56
|
+
* resolution falls through to Linear's own user lookup exactly as before.
|
|
57
|
+
* See `identity-resolver.ts` for the output contract.
|
|
58
|
+
*/
|
|
59
|
+
identity?: {
|
|
60
|
+
resolver?: string[];
|
|
61
|
+
/** Milliseconds before the resolver is treated as a miss (default 8000). */
|
|
62
|
+
resolverTimeoutMs?: number;
|
|
63
|
+
};
|
|
39
64
|
/**
|
|
40
65
|
* Term-enforcement rules. Each rule has a canonical form and a list of
|
|
41
66
|
* rejected forms; rejected forms in issue titles/descriptions are flagged
|
package/dist/config/config.js
CHANGED
|
@@ -139,6 +139,21 @@ export function loadConfig() {
|
|
|
139
139
|
: {};
|
|
140
140
|
// teamConfigPath inside a team config file would be circular; strip it.
|
|
141
141
|
delete teamRaw.teamConfigPath;
|
|
142
|
+
// `identity.resolver` names a BINARY el-linear will spawn. The team layer is
|
|
143
|
+
// data, not code: it arrives from a different repository via `git pull` (or,
|
|
144
|
+
// for an OSS user, from whatever repo they pointed `teamConfigPath` at), and
|
|
145
|
+
// nobody reviews a config file expecting it to hand them a subprocess. Letting
|
|
146
|
+
// it choose the executable would turn "clone this repo and run el-linear" into
|
|
147
|
+
// arbitrary code execution on every `--assignee` resolution.
|
|
148
|
+
//
|
|
149
|
+
// So the resolver is honored ONLY from the personal config (or the env var) —
|
|
150
|
+
// files the operator owns. Same reasoning as teamConfigPath above, which is
|
|
151
|
+
// stripped for the same "the team layer doesn't get to decide this" reason.
|
|
152
|
+
//
|
|
153
|
+
// An organization that wants to ship a resolver to its developers writes it
|
|
154
|
+
// into their personal config at setup time; that keeps the decision with the
|
|
155
|
+
// machine's owner instead of with whoever can land a commit upstream.
|
|
156
|
+
delete teamRaw.identity;
|
|
142
157
|
// Merge order: defaults → team config → personal config.
|
|
143
158
|
// Arrays (terms, defaultLabels, etc.) are concatenated so personal entries
|
|
144
159
|
// extend team entries rather than replace them.
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { ElLinearConfig } from "./config.js";
|
|
2
|
+
/** Test seam — the memo would otherwise leak between cases in one vitest process. */
|
|
3
|
+
export declare function clearResolverMemoForTests(): void;
|
|
4
|
+
/**
|
|
5
|
+
* The configured resolver argv, or `null` when the hook is off.
|
|
6
|
+
*
|
|
7
|
+
* Env wins over config so a single invocation can point at a different resolver
|
|
8
|
+
* (or disable one) without editing files: `EL_LINEAR_IDENTITY_RESOLVER=""` is an
|
|
9
|
+
* explicit off switch.
|
|
10
|
+
*/
|
|
11
|
+
export declare function resolverCommand(config: Pick<ElLinearConfig, "identity">, env?: NodeJS.ProcessEnv): string[] | null;
|
|
12
|
+
/** True when an identity resolver is configured (env or config). */
|
|
13
|
+
export declare function isResolverConfigured(config: Pick<ElLinearConfig, "identity">, env?: NodeJS.ProcessEnv): boolean;
|
|
14
|
+
/**
|
|
15
|
+
* Pull a Linear user UUID out of whatever the resolver printed.
|
|
16
|
+
*
|
|
17
|
+
* Deliberately generous about the shape, because the point of this hook is that
|
|
18
|
+
* anyone can write the resolver — demanding one exact JSON envelope would make
|
|
19
|
+
* the "just shell out to your own script" promise a lie. Accepted:
|
|
20
|
+
*
|
|
21
|
+
* - a bare UUID: `3f2a…`
|
|
22
|
+
* - `{"linearId": "3f2a…"}` (the el-identity record shape)
|
|
23
|
+
* - `{"data": {"linearId": "…"}}` (an el-* CLI `{data, meta}` envelope)
|
|
24
|
+
* - `{"id": "3f2a…"}` (the obvious alternative spelling)
|
|
25
|
+
*
|
|
26
|
+
* Anything else — including a *non*-UUID string, which is the shape a confused
|
|
27
|
+
* resolver most plausibly emits — is a miss. We would rather fall through to
|
|
28
|
+
* Linear's own lookup than hand a bogus id to the API and produce an opaque
|
|
29
|
+
* "Argument Validation Error" (the DEV-4312 failure, in a new costume).
|
|
30
|
+
*/
|
|
31
|
+
export declare function parseResolverOutput(stdout: string): string | null;
|
|
32
|
+
/**
|
|
33
|
+
* Run the configured resolver for `identifier`. Returns the Linear user UUID, or
|
|
34
|
+
* `null` on any miss/failure. Never throws.
|
|
35
|
+
*
|
|
36
|
+
* Synchronous (`spawnSync`) on purpose: resolution sits on the critical path of
|
|
37
|
+
* `--assignee` for a short-lived CLI, the callers are already `async` so nothing
|
|
38
|
+
* is starved, and it keeps the failure modes to exactly one place.
|
|
39
|
+
*/
|
|
40
|
+
export declare function resolveViaCommand(identifier: string, config: Pick<ElLinearConfig, "identity">, env?: NodeJS.ProcessEnv): string | null;
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
/**
|
|
3
|
+
* Optional **identity resolver hook** (DEV-5628).
|
|
4
|
+
*
|
|
5
|
+
* Some organizations keep a people registry that knows things Linear does not:
|
|
6
|
+
* that `jd` is a person, that a GitLab handle and a Linear handle belong to the
|
|
7
|
+
* same human. el-linear can consult it — but it must never learn *how* to reach it.
|
|
8
|
+
*
|
|
9
|
+
* So the hook is a **command**, not an HTTP client:
|
|
10
|
+
*
|
|
11
|
+
* identity.resolver = ["el-identity", "resolve"]
|
|
12
|
+
*
|
|
13
|
+
* el-linear appends the identifier as the final argv element, runs the command,
|
|
14
|
+
* and reads a Linear user UUID off stdout. That is the entire contract.
|
|
15
|
+
*
|
|
16
|
+
* Why a command rather than a URL + credentials (which is what
|
|
17
|
+
* `registry-resolve.ts` does, and why that path stayed dormant): el-linear is
|
|
18
|
+
* MIT and published on npm. Most installs are not Enrich Layer. Baking in an
|
|
19
|
+
* auth scheme means baking in *somebody's* auth scheme — and the moment we did,
|
|
20
|
+
* the next organization would need Infisical, or 1Password, or a plain env var,
|
|
21
|
+
* or SSO. A command has no such problem: the credential lives entirely inside
|
|
22
|
+
* whatever the operator points this at. Adding a new secret backend is writing a
|
|
23
|
+
* different script, not patching this package.
|
|
24
|
+
*
|
|
25
|
+
* The command is trusted (the operator configured it) but the *identifier* is
|
|
26
|
+
* not, so it is passed as an argv element with `shell: false` — a name like
|
|
27
|
+
* `"; rm -rf /"` is an argument, never a command.
|
|
28
|
+
*
|
|
29
|
+
* Fail-open by design: unconfigured, a miss, a non-zero exit, a timeout, an
|
|
30
|
+
* unparseable answer, or a missing binary all return `null`, and the caller
|
|
31
|
+
* falls through to Linear's own user lookup (which resolves names and emails
|
|
32
|
+
* perfectly well — see `LinearService.resolveUserId`). This hook is an
|
|
33
|
+
* *enhancement*, so a broken resolver must degrade to plain el-linear rather
|
|
34
|
+
* than break every command. It never throws.
|
|
35
|
+
*/
|
|
36
|
+
/** Env override — a whitespace-separated command, e.g. `el-identity resolve`. */
|
|
37
|
+
const RESOLVER_ENV = "EL_LINEAR_IDENTITY_RESOLVER";
|
|
38
|
+
/** A resolver that hasn't answered in this long is not going to. */
|
|
39
|
+
const DEFAULT_TIMEOUT_MS = 8000;
|
|
40
|
+
/**
|
|
41
|
+
* Per-process memo. A single command can resolve the same person several times —
|
|
42
|
+
* `--subscriber a,b,a`, or an assignee who is also a subscriber — and each miss
|
|
43
|
+
* costs a full subprocess round-trip (seconds, against a network-backed
|
|
44
|
+
* resolver). Nothing here is cached across processes: el-linear is short-lived,
|
|
45
|
+
* and a stale identity cache on disk is exactly the drift this hook exists to
|
|
46
|
+
* remove.
|
|
47
|
+
*/
|
|
48
|
+
const memo = new Map();
|
|
49
|
+
/** Test seam — the memo would otherwise leak between cases in one vitest process. */
|
|
50
|
+
export function clearResolverMemoForTests() {
|
|
51
|
+
memo.clear();
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Resolve the effective timeout.
|
|
55
|
+
*
|
|
56
|
+
* `?? DEFAULT` is not enough: Node treats `timeout <= 0` as *no timeout*, and `0`
|
|
57
|
+
* is the most natural thing to type when you mean "off". That would turn the
|
|
58
|
+
* documented fail-open contract into an unkillable hang inside a synchronous
|
|
59
|
+
* call — the one failure mode the surrounding try/catch cannot save you from.
|
|
60
|
+
*/
|
|
61
|
+
function resolveTimeoutMs(config) {
|
|
62
|
+
const configured = config.identity?.resolverTimeoutMs;
|
|
63
|
+
return typeof configured === "number" && configured > 0
|
|
64
|
+
? configured
|
|
65
|
+
: DEFAULT_TIMEOUT_MS;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* The environment handed to the resolver.
|
|
69
|
+
*
|
|
70
|
+
* It inherits the ambient env on purpose — reaching its own secret backend
|
|
71
|
+
* (Vault, Infisical, 1Password, a plain env var) is the entire point, so
|
|
72
|
+
* scrubbing wholesale would defeat the design. But the resolver has no business
|
|
73
|
+
* with *Linear's* token: it resolves people, it never talks to Linear. Dropping
|
|
74
|
+
* it keeps the CLI's most sensitive secret out of the blast radius of a
|
|
75
|
+
* compromised — or merely over-logging — resolver.
|
|
76
|
+
*/
|
|
77
|
+
function resolverEnv(env) {
|
|
78
|
+
const { LINEAR_API_TOKEN: _dropped, ...rest } = env;
|
|
79
|
+
return rest;
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Debug-gated diagnosis. Every failure here is a silent `null` by design, which
|
|
83
|
+
* is right for the user but miserable for the operator whose resolver is broken:
|
|
84
|
+
* all they see is an unexplained pause. `EL_LINEAR_DEBUG=1` is this repo's
|
|
85
|
+
* existing convention for "tell me what actually happened", and stderr never
|
|
86
|
+
* corrupts the JSON on stdout.
|
|
87
|
+
*/
|
|
88
|
+
function debugMiss(reason, env) {
|
|
89
|
+
if (!env.EL_LINEAR_DEBUG)
|
|
90
|
+
return;
|
|
91
|
+
process.stderr.write(`el-linear: identity resolver miss — ${reason}\n`);
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* The configured resolver argv, or `null` when the hook is off.
|
|
95
|
+
*
|
|
96
|
+
* Env wins over config so a single invocation can point at a different resolver
|
|
97
|
+
* (or disable one) without editing files: `EL_LINEAR_IDENTITY_RESOLVER=""` is an
|
|
98
|
+
* explicit off switch.
|
|
99
|
+
*/
|
|
100
|
+
export function resolverCommand(config, env = process.env) {
|
|
101
|
+
const fromEnv = env[RESOLVER_ENV];
|
|
102
|
+
if (fromEnv !== undefined) {
|
|
103
|
+
const argv = fromEnv.trim().split(/\s+/).filter(Boolean);
|
|
104
|
+
return argv.length > 0 ? argv : null;
|
|
105
|
+
}
|
|
106
|
+
const configured = config.identity?.resolver;
|
|
107
|
+
if (!configured || configured.length === 0) {
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
return configured;
|
|
111
|
+
}
|
|
112
|
+
/** True when an identity resolver is configured (env or config). */
|
|
113
|
+
export function isResolverConfigured(config, env = process.env) {
|
|
114
|
+
return resolverCommand(config, env) !== null;
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Pull a Linear user UUID out of whatever the resolver printed.
|
|
118
|
+
*
|
|
119
|
+
* Deliberately generous about the shape, because the point of this hook is that
|
|
120
|
+
* anyone can write the resolver — demanding one exact JSON envelope would make
|
|
121
|
+
* the "just shell out to your own script" promise a lie. Accepted:
|
|
122
|
+
*
|
|
123
|
+
* - a bare UUID: `3f2a…`
|
|
124
|
+
* - `{"linearId": "3f2a…"}` (the el-identity record shape)
|
|
125
|
+
* - `{"data": {"linearId": "…"}}` (an el-* CLI `{data, meta}` envelope)
|
|
126
|
+
* - `{"id": "3f2a…"}` (the obvious alternative spelling)
|
|
127
|
+
*
|
|
128
|
+
* Anything else — including a *non*-UUID string, which is the shape a confused
|
|
129
|
+
* resolver most plausibly emits — is a miss. We would rather fall through to
|
|
130
|
+
* Linear's own lookup than hand a bogus id to the API and produce an opaque
|
|
131
|
+
* "Argument Validation Error" (the DEV-4312 failure, in a new costume).
|
|
132
|
+
*/
|
|
133
|
+
export function parseResolverOutput(stdout) {
|
|
134
|
+
const trimmed = stdout.trim();
|
|
135
|
+
if (!trimmed) {
|
|
136
|
+
return null;
|
|
137
|
+
}
|
|
138
|
+
if (UUID_RE.test(trimmed)) {
|
|
139
|
+
return trimmed;
|
|
140
|
+
}
|
|
141
|
+
let parsed;
|
|
142
|
+
try {
|
|
143
|
+
parsed = JSON.parse(trimmed);
|
|
144
|
+
}
|
|
145
|
+
catch {
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
const candidate = pickLinearId(parsed);
|
|
149
|
+
return candidate && UUID_RE.test(candidate) ? candidate : null;
|
|
150
|
+
}
|
|
151
|
+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
152
|
+
function pickLinearId(parsed) {
|
|
153
|
+
if (typeof parsed === "string") {
|
|
154
|
+
return parsed;
|
|
155
|
+
}
|
|
156
|
+
if (!parsed || typeof parsed !== "object") {
|
|
157
|
+
return null;
|
|
158
|
+
}
|
|
159
|
+
const obj = parsed;
|
|
160
|
+
for (const key of ["linearId", "id"]) {
|
|
161
|
+
const value = obj[key];
|
|
162
|
+
if (typeof value === "string") {
|
|
163
|
+
return value;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
// One level of `{data: …}` unwrapping — the el-* CLI envelope.
|
|
167
|
+
if (obj.data && typeof obj.data === "object") {
|
|
168
|
+
return pickLinearId(obj.data);
|
|
169
|
+
}
|
|
170
|
+
return null;
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* Run the configured resolver for `identifier`. Returns the Linear user UUID, or
|
|
174
|
+
* `null` on any miss/failure. Never throws.
|
|
175
|
+
*
|
|
176
|
+
* Synchronous (`spawnSync`) on purpose: resolution sits on the critical path of
|
|
177
|
+
* `--assignee` for a short-lived CLI, the callers are already `async` so nothing
|
|
178
|
+
* is starved, and it keeps the failure modes to exactly one place.
|
|
179
|
+
*/
|
|
180
|
+
export function resolveViaCommand(identifier, config, env = process.env) {
|
|
181
|
+
const argv = resolverCommand(config, env);
|
|
182
|
+
if (!argv) {
|
|
183
|
+
return null;
|
|
184
|
+
}
|
|
185
|
+
const [command, ...args] = argv;
|
|
186
|
+
if (!command) {
|
|
187
|
+
return null;
|
|
188
|
+
}
|
|
189
|
+
// A leading `-` is never a valid Linear identifier, but it IS a flag to the
|
|
190
|
+
// resolver's own option parser — `--assignee "--output=/tmp/x"` would be
|
|
191
|
+
// presented to it as one. There is no shell escape here (argv, not a shell),
|
|
192
|
+
// but el-linear is increasingly driven by agents over untrusted issue text, so
|
|
193
|
+
// close the class for free rather than trust every resolver to be careful.
|
|
194
|
+
if (identifier.startsWith("-")) {
|
|
195
|
+
debugMiss(`refusing flag-shaped identifier "${identifier}"`, env);
|
|
196
|
+
return null;
|
|
197
|
+
}
|
|
198
|
+
const cached = memo.get(identifier);
|
|
199
|
+
if (cached !== undefined) {
|
|
200
|
+
return cached;
|
|
201
|
+
}
|
|
202
|
+
const resolved = spawnResolver(command, args, identifier, config, env);
|
|
203
|
+
memo.set(identifier, resolved);
|
|
204
|
+
return resolved;
|
|
205
|
+
}
|
|
206
|
+
function spawnResolver(command, args, identifier, config, env) {
|
|
207
|
+
try {
|
|
208
|
+
const result = spawnSync(command, [...args, identifier], {
|
|
209
|
+
encoding: "utf8",
|
|
210
|
+
timeout: resolveTimeoutMs(config),
|
|
211
|
+
// `spawnSync` waits for the child to fully exit after its timeout. Its
|
|
212
|
+
// default kill signal is SIGTERM, which a broken resolver can trap or
|
|
213
|
+
// ignore forever — defeating this hook's fail-open timeout. The resolver
|
|
214
|
+
// is an isolated lookup helper, so force it down when its time budget is
|
|
215
|
+
// exhausted rather than letting it wedge the whole CLI.
|
|
216
|
+
killSignal: "SIGKILL",
|
|
217
|
+
// The identifier is untrusted input; never hand it to a shell. This also
|
|
218
|
+
// means a Windows npm shim (`el-identity.cmd`) will NOT be found — see
|
|
219
|
+
// the Windows note in docs/configuration.md. Turning `shell: true` on to
|
|
220
|
+
// fix that would hand the untrusted identifier to cmd.exe, which is a
|
|
221
|
+
// far worse trade.
|
|
222
|
+
shell: false,
|
|
223
|
+
// stdin closed: a resolver that decides to prompt must not hang the CLI.
|
|
224
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
225
|
+
env: resolverEnv(env),
|
|
226
|
+
});
|
|
227
|
+
// BOTH conditions are load-bearing; do not "simplify" this to one.
|
|
228
|
+
// - a plain timeout → error ETIMEDOUT, status null
|
|
229
|
+
// - a maxBuffer overflow → error ENOBUFS, status null
|
|
230
|
+
// - a child that leaves a grandchild holding the stdout pipe
|
|
231
|
+
// → error ETIMEDOUT, status **0**
|
|
232
|
+
// Checking only `status` would let that last case through as a success and
|
|
233
|
+
// parse whatever partial output happened to be buffered.
|
|
234
|
+
if (result.error) {
|
|
235
|
+
debugMiss(`${command}: ${result.error.message}`, env);
|
|
236
|
+
return null;
|
|
237
|
+
}
|
|
238
|
+
if (result.status !== 0) {
|
|
239
|
+
const stderr = (result.stderr ?? "").trim().split("\n")[0] ?? "";
|
|
240
|
+
debugMiss(`${command} exited ${result.status}${stderr ? `: ${stderr}` : ""}`, env);
|
|
241
|
+
return null;
|
|
242
|
+
}
|
|
243
|
+
const parsed = parseResolverOutput(result.stdout ?? "");
|
|
244
|
+
if (!parsed) {
|
|
245
|
+
debugMiss(`${command} printed no usable Linear UUID for "${identifier}"`, env);
|
|
246
|
+
}
|
|
247
|
+
return parsed;
|
|
248
|
+
}
|
|
249
|
+
catch (err) {
|
|
250
|
+
debugMiss(err instanceof Error ? err.message : String(err), env);
|
|
251
|
+
return null;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
@@ -14,14 +14,29 @@ export declare function resolveMember(input: string): string;
|
|
|
14
14
|
*/
|
|
15
15
|
export declare function resolveAssignee(input: string, rootOpts: Record<string, unknown>): Promise<string>;
|
|
16
16
|
/**
|
|
17
|
-
* Resolve a member identifier (alias / handle / name) to a UUID
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
* `--delegate` (DEV-4871 / DEV-4872).
|
|
17
|
+
* Resolve a member identifier (alias / handle / name) to a UUID. The shared
|
|
18
|
+
* async resolution path behind `--assignee` and `--delegate` (DEV-4871 /
|
|
19
|
+
* DEV-4872 / DEV-5628).
|
|
21
20
|
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
21
|
+
* Order, most authoritative first:
|
|
22
|
+
*
|
|
23
|
+
* 1. **The identity-resolver hook** (`identity.resolver`, DEV-5628) — an
|
|
24
|
+
* operator-supplied command. This is the one to reach for: it can answer
|
|
25
|
+
* for aliases and cross-system handles Linear has never heard of, and it
|
|
26
|
+
* owns its own credentials, so el-linear carries no auth scheme.
|
|
27
|
+
* 2. **The HTTP registry** (`EL_IDENTITY_URL`, DEV-4871) — the older,
|
|
28
|
+
* env-gated path. Superseded by (1), which needs no URL or CF-Access
|
|
29
|
+
* credentials in the environment. Kept because it ships and someone may
|
|
30
|
+
* rely on it.
|
|
31
|
+
* 3. **The bundled config** (`resolveMember`) — a local lookup table.
|
|
32
|
+
*
|
|
33
|
+
* All three are optional. With none of them, `resolveMember` hands the raw input
|
|
34
|
+
* back and Linear's own user lookup resolves it (`LinearService.resolveUserId`
|
|
35
|
+
* matches email → displayName → name), which is why an install with no config at
|
|
36
|
+
* all still works.
|
|
37
|
+
*
|
|
38
|
+
* Fail-open throughout: a miss or a failure at any layer falls through to the
|
|
39
|
+
* next. Never throws.
|
|
25
40
|
*/
|
|
26
41
|
export declare function resolveMemberWithRegistry(input: string): Promise<string>;
|
|
27
42
|
/**
|
package/dist/config/resolver.js
CHANGED
|
@@ -2,6 +2,7 @@ import { createGraphQLService } from "../utils/graphql-service.js";
|
|
|
2
2
|
import { outputWarning } from "../utils/output.js";
|
|
3
3
|
import { isUuid, isUuidPrefix } from "../utils/uuid.js";
|
|
4
4
|
import { loadConfig } from "./config.js";
|
|
5
|
+
import { resolveViaCommand } from "./identity-resolver.js";
|
|
5
6
|
import { isRegistryConfigured, resolveViaRegistry, } from "./registry-resolve.js";
|
|
6
7
|
/**
|
|
7
8
|
* Resolve a team key/name/alias to its UUID.
|
|
@@ -102,16 +103,39 @@ export async function resolveAssignee(input, rootOpts) {
|
|
|
102
103
|
return resolveMemberWithRegistry(input);
|
|
103
104
|
}
|
|
104
105
|
/**
|
|
105
|
-
* Resolve a member identifier (alias / handle / name) to a UUID
|
|
106
|
-
*
|
|
107
|
-
*
|
|
108
|
-
* `--delegate` (DEV-4871 / DEV-4872).
|
|
106
|
+
* Resolve a member identifier (alias / handle / name) to a UUID. The shared
|
|
107
|
+
* async resolution path behind `--assignee` and `--delegate` (DEV-4871 /
|
|
108
|
+
* DEV-4872 / DEV-5628).
|
|
109
109
|
*
|
|
110
|
-
*
|
|
111
|
-
*
|
|
112
|
-
*
|
|
110
|
+
* Order, most authoritative first:
|
|
111
|
+
*
|
|
112
|
+
* 1. **The identity-resolver hook** (`identity.resolver`, DEV-5628) — an
|
|
113
|
+
* operator-supplied command. This is the one to reach for: it can answer
|
|
114
|
+
* for aliases and cross-system handles Linear has never heard of, and it
|
|
115
|
+
* owns its own credentials, so el-linear carries no auth scheme.
|
|
116
|
+
* 2. **The HTTP registry** (`EL_IDENTITY_URL`, DEV-4871) — the older,
|
|
117
|
+
* env-gated path. Superseded by (1), which needs no URL or CF-Access
|
|
118
|
+
* credentials in the environment. Kept because it ships and someone may
|
|
119
|
+
* rely on it.
|
|
120
|
+
* 3. **The bundled config** (`resolveMember`) — a local lookup table.
|
|
121
|
+
*
|
|
122
|
+
* All three are optional. With none of them, `resolveMember` hands the raw input
|
|
123
|
+
* back and Linear's own user lookup resolves it (`LinearService.resolveUserId`
|
|
124
|
+
* matches email → displayName → name), which is why an install with no config at
|
|
125
|
+
* all still works.
|
|
126
|
+
*
|
|
127
|
+
* Fail-open throughout: a miss or a failure at any layer falls through to the
|
|
128
|
+
* next. Never throws.
|
|
113
129
|
*/
|
|
114
130
|
export async function resolveMemberWithRegistry(input) {
|
|
131
|
+
// UUIDs are already canonical — don't spend a subprocess on them.
|
|
132
|
+
if (isUuid(input)) {
|
|
133
|
+
return input;
|
|
134
|
+
}
|
|
135
|
+
const viaCommand = resolveViaCommand(input, loadConfig());
|
|
136
|
+
if (viaCommand) {
|
|
137
|
+
return viaCommand;
|
|
138
|
+
}
|
|
115
139
|
if (isRegistryConfigured()) {
|
|
116
140
|
const viaRegistry = await resolveViaRegistry(input);
|
|
117
141
|
if (viaRegistry) {
|
|
@@ -147,10 +147,20 @@ export interface BatchGetIssuesResponse {
|
|
|
147
147
|
nodes: IssueWithCommentsNode[];
|
|
148
148
|
};
|
|
149
149
|
}
|
|
150
|
+
/**
|
|
151
|
+
* Cursor-pagination page metadata (DEV-6312). Present on the connection
|
|
152
|
+
* responses the list/search enumeration paths chunk through so a large
|
|
153
|
+
* `--limit` / `--all` never blows Linear's GraphQL complexity ceiling.
|
|
154
|
+
*/
|
|
155
|
+
export interface IssuePageInfo {
|
|
156
|
+
hasNextPage: boolean;
|
|
157
|
+
endCursor: string | null;
|
|
158
|
+
}
|
|
150
159
|
/** Response shape for `GET_ISSUES_QUERY`. */
|
|
151
160
|
export interface GetIssuesResponse {
|
|
152
161
|
issues: {
|
|
153
162
|
nodes: IssueNode[];
|
|
163
|
+
pageInfo?: IssuePageInfo;
|
|
154
164
|
};
|
|
155
165
|
}
|
|
156
166
|
/**
|
|
@@ -161,6 +171,7 @@ export interface TeamScopedFilteredIssuesResponse {
|
|
|
161
171
|
team: {
|
|
162
172
|
issues: {
|
|
163
173
|
nodes: IssueNode[];
|
|
174
|
+
pageInfo?: IssuePageInfo;
|
|
164
175
|
};
|
|
165
176
|
} | null;
|
|
166
177
|
}
|
package/dist/queries/issues.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
export declare const GET_ISSUES_QUERY = "\n query GetIssues($first: Int!, $orderBy: PaginationOrderBy) {\n issues(\n first: $first\n orderBy: $orderBy\n filter: {\n state: { type: { neq: \"completed\" } }\n }\n ) {\n nodes {\n \n \n id\n identifier\n title\n description\n summary { content generationStatus }\n branchName\n priority\n estimate\n dueDate\n url\n createdAt\n updatedAt\n completedAt\n\n \n state {\n id\n name\n type\n }\n\n \n assignee {\n id\n name\n url\n }\n\n \n delegate {\n id\n name\n url\n }\n\n \n team {\n id\n key\n name\n }\n\n \n project {\n id\n name\n }\n\n \n labels {\n nodes {\n id\n name\n }\n }\n\n \n cycle {\n id\n name\n number\n }\n\n \n projectMilestone {\n id\n name\n targetDate\n }\n\n \n parent {\n id\n identifier\n title\n }\n\n \n children {\n nodes {\n id\n identifier\n title\n }\n }\n\n\n }\n }\n }\n";
|
|
1
|
+
export declare const GET_ISSUES_QUERY = "\n query GetIssues($first: Int!, $after: String, $orderBy: PaginationOrderBy) {\n issues(\n first: $first\n after: $after\n orderBy: $orderBy\n filter: {\n state: { type: { neq: \"completed\" } }\n }\n ) {\n nodes {\n \n \n id\n identifier\n title\n description\n summary { content generationStatus }\n branchName\n priority\n estimate\n dueDate\n url\n createdAt\n updatedAt\n completedAt\n\n \n state {\n id\n name\n type\n }\n\n \n assignee {\n id\n name\n url\n }\n\n \n delegate {\n id\n name\n url\n }\n\n \n team {\n id\n key\n name\n }\n\n \n project {\n id\n name\n }\n\n \n labels {\n nodes {\n id\n name\n }\n }\n\n \n cycle {\n id\n name\n number\n }\n\n \n projectMilestone {\n id\n name\n targetDate\n }\n\n \n parent {\n id\n identifier\n title\n }\n\n \n children {\n nodes {\n id\n identifier\n title\n }\n }\n\n\n }\n pageInfo {\n hasNextPage\n endCursor\n }\n }\n }\n";
|
|
2
2
|
export declare const SEARCH_ISSUES_QUERY = "\n query SearchIssues($term: String!, $first: Int!) {\n searchIssues(term: $term, first: $first, includeArchived: false) {\n nodes {\n \n \n id\n identifier\n title\n description\n summary { content generationStatus }\n branchName\n priority\n estimate\n dueDate\n url\n createdAt\n updatedAt\n completedAt\n\n \n state {\n id\n name\n type\n }\n\n \n assignee {\n id\n name\n url\n }\n\n \n delegate {\n id\n name\n url\n }\n\n \n team {\n id\n key\n name\n }\n\n \n project {\n id\n name\n }\n\n \n labels {\n nodes {\n id\n name\n }\n }\n\n \n cycle {\n id\n name\n number\n }\n\n \n projectMilestone {\n id\n name\n targetDate\n }\n\n \n parent {\n id\n identifier\n title\n }\n\n \n children {\n nodes {\n id\n identifier\n title\n }\n }\n\n\n }\n }\n }\n";
|
|
3
|
-
export declare const FILTERED_SEARCH_ISSUES_QUERY = "\n query FilteredSearchIssues(\n $first: Int!\n $filter: IssueFilter\n $orderBy: PaginationOrderBy\n ) {\n issues(\n first: $first\n filter: $filter\n orderBy: $orderBy\n includeArchived: false\n ) {\n nodes {\n \n \n id\n identifier\n title\n description\n summary { content generationStatus }\n branchName\n priority\n estimate\n dueDate\n url\n createdAt\n updatedAt\n completedAt\n\n \n state {\n id\n name\n type\n }\n\n \n assignee {\n id\n name\n url\n }\n\n \n delegate {\n id\n name\n url\n }\n\n \n team {\n id\n key\n name\n }\n\n \n project {\n id\n name\n }\n\n \n labels {\n nodes {\n id\n name\n }\n }\n\n \n cycle {\n id\n name\n number\n }\n\n \n projectMilestone {\n id\n name\n targetDate\n }\n\n \n parent {\n id\n identifier\n title\n }\n\n \n children {\n nodes {\n id\n identifier\n title\n }\n }\n\n\n }\n }\n }\n";
|
|
3
|
+
export declare const FILTERED_SEARCH_ISSUES_QUERY = "\n query FilteredSearchIssues(\n $first: Int!\n $after: String\n $filter: IssueFilter\n $orderBy: PaginationOrderBy\n ) {\n issues(\n first: $first\n after: $after\n filter: $filter\n orderBy: $orderBy\n includeArchived: false\n ) {\n nodes {\n \n \n id\n identifier\n title\n description\n summary { content generationStatus }\n branchName\n priority\n estimate\n dueDate\n url\n createdAt\n updatedAt\n completedAt\n\n \n state {\n id\n name\n type\n }\n\n \n assignee {\n id\n name\n url\n }\n\n \n delegate {\n id\n name\n url\n }\n\n \n team {\n id\n key\n name\n }\n\n \n project {\n id\n name\n }\n\n \n labels {\n nodes {\n id\n name\n }\n }\n\n \n cycle {\n id\n name\n number\n }\n\n \n projectMilestone {\n id\n name\n targetDate\n }\n\n \n parent {\n id\n identifier\n title\n }\n\n \n children {\n nodes {\n id\n identifier\n title\n }\n }\n\n\n }\n pageInfo {\n hasNextPage\n endCursor\n }\n }\n }\n";
|
|
4
4
|
/**
|
|
5
5
|
* Team-scoped variant of `FILTERED_SEARCH_ISSUES_QUERY` (DEV-5578).
|
|
6
6
|
*
|
|
@@ -19,7 +19,7 @@ export declare const FILTERED_SEARCH_ISSUES_QUERY = "\n query FilteredSearchIss
|
|
|
19
19
|
* (state / labels / assignee / priority / project) is applied on top of the
|
|
20
20
|
* already-team-scoped connection.
|
|
21
21
|
*/
|
|
22
|
-
export declare const TEAM_SCOPED_FILTERED_ISSUES_QUERY = "\n query TeamScopedFilteredIssues(\n $teamId: String!\n $first: Int!\n $filter: IssueFilter\n $orderBy: PaginationOrderBy\n ) {\n team(id: $teamId) {\n issues(\n first: $first\n filter: $filter\n orderBy: $orderBy\n includeArchived: false\n ) {\n nodes {\n \n \n id\n identifier\n title\n description\n summary { content generationStatus }\n branchName\n priority\n estimate\n dueDate\n url\n createdAt\n updatedAt\n completedAt\n\n \n state {\n id\n name\n type\n }\n\n \n assignee {\n id\n name\n url\n }\n\n \n delegate {\n id\n name\n url\n }\n\n \n team {\n id\n key\n name\n }\n\n \n project {\n id\n name\n }\n\n \n labels {\n nodes {\n id\n name\n }\n }\n\n \n cycle {\n id\n name\n number\n }\n\n \n projectMilestone {\n id\n name\n targetDate\n }\n\n \n parent {\n id\n identifier\n title\n }\n\n \n children {\n nodes {\n id\n identifier\n title\n }\n }\n\n\n }\n }\n }\n }\n";
|
|
22
|
+
export declare const TEAM_SCOPED_FILTERED_ISSUES_QUERY = "\n query TeamScopedFilteredIssues(\n $teamId: String!\n $first: Int!\n $after: String\n $filter: IssueFilter\n $orderBy: PaginationOrderBy\n ) {\n team(id: $teamId) {\n issues(\n first: $first\n after: $after\n filter: $filter\n orderBy: $orderBy\n includeArchived: false\n ) {\n nodes {\n \n \n id\n identifier\n title\n description\n summary { content generationStatus }\n branchName\n priority\n estimate\n dueDate\n url\n createdAt\n updatedAt\n completedAt\n\n \n state {\n id\n name\n type\n }\n\n \n assignee {\n id\n name\n url\n }\n\n \n delegate {\n id\n name\n url\n }\n\n \n team {\n id\n key\n name\n }\n\n \n project {\n id\n name\n }\n\n \n labels {\n nodes {\n id\n name\n }\n }\n\n \n cycle {\n id\n name\n number\n }\n\n \n projectMilestone {\n id\n name\n targetDate\n }\n\n \n parent {\n id\n identifier\n title\n }\n\n \n children {\n nodes {\n id\n identifier\n title\n }\n }\n\n\n }\n pageInfo {\n hasNextPage\n endCursor\n }\n }\n }\n }\n";
|
|
23
23
|
/**
|
|
24
24
|
* Batch-resolves a search's team/project/assignee/delegate filter inputs.
|
|
25
25
|
*
|
package/dist/queries/issues.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { COMPLETE_ISSUE_FRAGMENT, COMPLETE_ISSUE_WITH_COMMENTS_FRAGMENT, } from "./common.js";
|
|
2
2
|
export const GET_ISSUES_QUERY = `
|
|
3
|
-
query GetIssues($first: Int!, $orderBy: PaginationOrderBy) {
|
|
3
|
+
query GetIssues($first: Int!, $after: String, $orderBy: PaginationOrderBy) {
|
|
4
4
|
issues(
|
|
5
5
|
first: $first
|
|
6
|
+
after: $after
|
|
6
7
|
orderBy: $orderBy
|
|
7
8
|
filter: {
|
|
8
9
|
state: { type: { neq: "completed" } }
|
|
@@ -11,6 +12,10 @@ export const GET_ISSUES_QUERY = `
|
|
|
11
12
|
nodes {
|
|
12
13
|
${COMPLETE_ISSUE_FRAGMENT}
|
|
13
14
|
}
|
|
15
|
+
pageInfo {
|
|
16
|
+
hasNextPage
|
|
17
|
+
endCursor
|
|
18
|
+
}
|
|
14
19
|
}
|
|
15
20
|
}
|
|
16
21
|
`;
|
|
@@ -26,11 +31,13 @@ export const SEARCH_ISSUES_QUERY = `
|
|
|
26
31
|
export const FILTERED_SEARCH_ISSUES_QUERY = `
|
|
27
32
|
query FilteredSearchIssues(
|
|
28
33
|
$first: Int!
|
|
34
|
+
$after: String
|
|
29
35
|
$filter: IssueFilter
|
|
30
36
|
$orderBy: PaginationOrderBy
|
|
31
37
|
) {
|
|
32
38
|
issues(
|
|
33
39
|
first: $first
|
|
40
|
+
after: $after
|
|
34
41
|
filter: $filter
|
|
35
42
|
orderBy: $orderBy
|
|
36
43
|
includeArchived: false
|
|
@@ -38,6 +45,10 @@ export const FILTERED_SEARCH_ISSUES_QUERY = `
|
|
|
38
45
|
nodes {
|
|
39
46
|
${COMPLETE_ISSUE_FRAGMENT}
|
|
40
47
|
}
|
|
48
|
+
pageInfo {
|
|
49
|
+
hasNextPage
|
|
50
|
+
endCursor
|
|
51
|
+
}
|
|
41
52
|
}
|
|
42
53
|
}
|
|
43
54
|
`;
|
|
@@ -63,12 +74,14 @@ export const TEAM_SCOPED_FILTERED_ISSUES_QUERY = `
|
|
|
63
74
|
query TeamScopedFilteredIssues(
|
|
64
75
|
$teamId: String!
|
|
65
76
|
$first: Int!
|
|
77
|
+
$after: String
|
|
66
78
|
$filter: IssueFilter
|
|
67
79
|
$orderBy: PaginationOrderBy
|
|
68
80
|
) {
|
|
69
81
|
team(id: $teamId) {
|
|
70
82
|
issues(
|
|
71
83
|
first: $first
|
|
84
|
+
after: $after
|
|
72
85
|
filter: $filter
|
|
73
86
|
orderBy: $orderBy
|
|
74
87
|
includeArchived: false
|
|
@@ -76,6 +89,10 @@ export const TEAM_SCOPED_FILTERED_ISSUES_QUERY = `
|
|
|
76
89
|
nodes {
|
|
77
90
|
${COMPLETE_ISSUE_FRAGMENT}
|
|
78
91
|
}
|
|
92
|
+
pageInfo {
|
|
93
|
+
hasNextPage
|
|
94
|
+
endCursor
|
|
95
|
+
}
|
|
79
96
|
}
|
|
80
97
|
}
|
|
81
98
|
}
|
|
@@ -183,6 +183,21 @@ export declare class GraphQLIssuesService {
|
|
|
183
183
|
private readonly graphQLService;
|
|
184
184
|
private readonly linearService;
|
|
185
185
|
constructor(graphQLService: GraphQLService, linearService: LinearService);
|
|
186
|
+
/**
|
|
187
|
+
* Chunked cursor pagination shared by every issue-enumeration path
|
|
188
|
+
* (DEV-6312). Fetches successive pages of at most `SAFE_ISSUE_PAGE_SIZE`
|
|
189
|
+
* via `fetchPage(first, after)` until the target is met or the connection
|
|
190
|
+
* is exhausted, so a large `--limit` (or `--all`, passed as `target === 0`)
|
|
191
|
+
* never issues one oversized request that Linear rejects as "Query too
|
|
192
|
+
* complex".
|
|
193
|
+
*
|
|
194
|
+
* `target === 0` means unlimited (fetch the whole connection). A positive
|
|
195
|
+
* target caps the total and trims any final-page overshoot. `fetchPage`
|
|
196
|
+
* returns `null` when the connection root is absent (e.g. an unresolved
|
|
197
|
+
* team) — treated as an empty result. A page that claims `hasNextPage` but
|
|
198
|
+
* returns zero nodes terminates the loop rather than spinning forever.
|
|
199
|
+
*/
|
|
200
|
+
private paginateIssueNodes;
|
|
186
201
|
getIssues(limit?: number): Promise<LinearIssue[]>;
|
|
187
202
|
getIssueById(issueId: string): Promise<LinearIssue>;
|
|
188
203
|
/**
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { loadConfig } from "../config/config.js";
|
|
2
|
+
import { resolveViaCommand } from "../config/identity-resolver.js";
|
|
1
3
|
import { isRegistryConfigured, resolveViaRegistry, } from "../config/registry-resolve.js";
|
|
2
4
|
import { resolveUserDisplayName } from "../config/resolver.js";
|
|
3
5
|
import { ARCHIVE_ISSUE_MUTATION, BATCH_GET_ISSUES_QUERY, BATCH_RESOLVE_FOR_CREATE_QUERY, BATCH_RESOLVE_FOR_SEARCH_QUERY, BATCH_RESOLVE_FOR_UPDATE_QUERY, buildResolveLabelsByNameQuery, CREATE_ISSUE_MUTATION, DELETE_ISSUE_MUTATION, FILTERED_SEARCH_ISSUES_QUERY, GET_ISSUE_BY_ID_QUERY, GET_ISSUE_BY_IDENTIFIER_QUERY, GET_ISSUE_CLAIM_CONTEXT_QUERY, GET_ISSUE_START_CONTEXT_QUERY, GET_ISSUE_TEAM_QUERY, GET_ISSUES_QUERY, SEARCH_ISSUES_QUERY, TEAM_SCOPED_FILTERED_ISSUES_QUERY, TEAM_STARTED_STATUSES_QUERY, UPDATE_ISSUE_MUTATION, } from "../queries/issues.js";
|
|
@@ -10,6 +12,18 @@ import { parseIssueIdentifier, tryParseIssueIdentifier, } from "./identifier-par
|
|
|
10
12
|
import { logger } from "./logger.js";
|
|
11
13
|
import { isUuid } from "./uuid.js";
|
|
12
14
|
const TEAM_KEY_REGEX = /^[A-Z0-9]+$/i;
|
|
15
|
+
/**
|
|
16
|
+
* Per-page cap for the chunked issue-enumeration paths (DEV-6312).
|
|
17
|
+
*
|
|
18
|
+
* The list/search queries embed the rich `COMPLETE_ISSUE_FRAGMENT`, so a single
|
|
19
|
+
* page's GraphQL complexity scales with `first`. Linear rejects any query above
|
|
20
|
+
* complexity 10000; empirically `first: 500` measured ~10900, i.e. ~21.8 per
|
|
21
|
+
* issue. 200 keeps a page near ~4400 — comfortably under the ceiling with
|
|
22
|
+
* headroom for the fragment growing — while still being few enough round-trips
|
|
23
|
+
* for large teams. A big `--limit` (or `--all`) is fetched as successive pages
|
|
24
|
+
* of this size rather than one oversized request that would be rejected.
|
|
25
|
+
*/
|
|
26
|
+
const SAFE_ISSUE_PAGE_SIZE = 200;
|
|
13
27
|
function extractSummaryText(summary) {
|
|
14
28
|
if (summary.generationStatus !== "completed" || !summary.content) {
|
|
15
29
|
return undefined;
|
|
@@ -60,12 +74,55 @@ export class GraphQLIssuesService {
|
|
|
60
74
|
this.graphQLService = graphQLService;
|
|
61
75
|
this.linearService = linearService;
|
|
62
76
|
}
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
77
|
+
/**
|
|
78
|
+
* Chunked cursor pagination shared by every issue-enumeration path
|
|
79
|
+
* (DEV-6312). Fetches successive pages of at most `SAFE_ISSUE_PAGE_SIZE`
|
|
80
|
+
* via `fetchPage(first, after)` until the target is met or the connection
|
|
81
|
+
* is exhausted, so a large `--limit` (or `--all`, passed as `target === 0`)
|
|
82
|
+
* never issues one oversized request that Linear rejects as "Query too
|
|
83
|
+
* complex".
|
|
84
|
+
*
|
|
85
|
+
* `target === 0` means unlimited (fetch the whole connection). A positive
|
|
86
|
+
* target caps the total and trims any final-page overshoot. `fetchPage`
|
|
87
|
+
* returns `null` when the connection root is absent (e.g. an unresolved
|
|
88
|
+
* team) — treated as an empty result. A page that claims `hasNextPage` but
|
|
89
|
+
* returns zero nodes terminates the loop rather than spinning forever.
|
|
90
|
+
*/
|
|
91
|
+
async paginateIssueNodes(target, fetchPage) {
|
|
92
|
+
const unlimited = target === 0;
|
|
93
|
+
const collected = [];
|
|
94
|
+
let after = null;
|
|
95
|
+
while (true) {
|
|
96
|
+
const remaining = unlimited
|
|
97
|
+
? SAFE_ISSUE_PAGE_SIZE
|
|
98
|
+
: Math.min(target - collected.length, SAFE_ISSUE_PAGE_SIZE);
|
|
99
|
+
if (!unlimited && remaining <= 0) {
|
|
100
|
+
break;
|
|
101
|
+
}
|
|
102
|
+
const page = await fetchPage(remaining, after);
|
|
103
|
+
const nodes = page?.nodes ?? [];
|
|
104
|
+
collected.push(...nodes);
|
|
105
|
+
if (!unlimited && collected.length >= target) {
|
|
106
|
+
break;
|
|
107
|
+
}
|
|
108
|
+
const pageInfo = page?.pageInfo;
|
|
109
|
+
if (!pageInfo?.hasNextPage || !pageInfo.endCursor) {
|
|
110
|
+
break;
|
|
111
|
+
}
|
|
112
|
+
// Defensive: an empty page that still claims hasNextPage would loop
|
|
113
|
+
// forever on the same cursor. Stop instead.
|
|
114
|
+
if (nodes.length === 0) {
|
|
115
|
+
break;
|
|
116
|
+
}
|
|
117
|
+
after = pageInfo.endCursor;
|
|
68
118
|
}
|
|
119
|
+
return unlimited ? collected : collected.slice(0, target);
|
|
120
|
+
}
|
|
121
|
+
async getIssues(limit = 25) {
|
|
122
|
+
const nodes = await this.paginateIssueNodes(limit, async (first, after) => {
|
|
123
|
+
const result = await this.graphQLService.rawRequest(GET_ISSUES_QUERY, { first, after, orderBy: "updatedAt" });
|
|
124
|
+
return result.issues ?? null;
|
|
125
|
+
});
|
|
69
126
|
return nodes.map((issue) => this.transformIssueData(issue));
|
|
70
127
|
}
|
|
71
128
|
async getIssueById(issueId) {
|
|
@@ -577,9 +634,17 @@ export class GraphQLIssuesService {
|
|
|
577
634
|
: undefined;
|
|
578
635
|
const limit = args.limit ?? 10;
|
|
579
636
|
if (args.query) {
|
|
637
|
+
// Full-text search is relevance-ranked and then filtered client-side,
|
|
638
|
+
// so it cannot promise exhaustive `--all` semantics. Keep it to one
|
|
639
|
+
// bounded candidate page: the query embeds COMPLETE_ISSUE_FRAGMENT and
|
|
640
|
+
// an explicit large --limit would otherwise recreate the GraphQL
|
|
641
|
+
// complexity failure that chunking prevents on enumeration paths.
|
|
642
|
+
const fullTextLimit = limit === 0
|
|
643
|
+
? SAFE_ISSUE_PAGE_SIZE
|
|
644
|
+
: Math.min(limit, SAFE_ISSUE_PAGE_SIZE);
|
|
580
645
|
const searchResult = await this.graphQLService.rawRequest(SEARCH_ISSUES_QUERY, {
|
|
581
646
|
term: args.query,
|
|
582
|
-
first:
|
|
647
|
+
first: fullTextLimit,
|
|
583
648
|
});
|
|
584
649
|
const nodes = searchResult.searchIssues?.nodes;
|
|
585
650
|
if (!nodes?.length) {
|
|
@@ -616,28 +681,28 @@ export class GraphQLIssuesService {
|
|
|
616
681
|
const filterArg = Object.keys(filter).length > 0 ? filter : undefined;
|
|
617
682
|
const orderBy = args.orderBy ?? "updatedAt";
|
|
618
683
|
if (finalTeamId) {
|
|
619
|
-
const
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
684
|
+
const nodes = await this.paginateIssueNodes(limit, async (first, after) => {
|
|
685
|
+
const teamScoped = await this.graphQLService.rawRequest(TEAM_SCOPED_FILTERED_ISSUES_QUERY, {
|
|
686
|
+
teamId: finalTeamId,
|
|
687
|
+
first,
|
|
688
|
+
after,
|
|
689
|
+
filter: filterArg,
|
|
690
|
+
orderBy,
|
|
691
|
+
});
|
|
692
|
+
return teamScoped.team?.issues ?? null;
|
|
624
693
|
});
|
|
625
|
-
const nodes = teamScoped.team?.issues?.nodes;
|
|
626
|
-
if (!nodes?.length) {
|
|
627
|
-
return [];
|
|
628
|
-
}
|
|
629
694
|
return nodes.map((issue) => this.transformIssueData(issue));
|
|
630
695
|
}
|
|
631
|
-
const
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
696
|
+
const nodes = await this.paginateIssueNodes(limit, async (first, after) => {
|
|
697
|
+
const searchResult = await this.graphQLService.rawRequest(FILTERED_SEARCH_ISSUES_QUERY, {
|
|
698
|
+
first,
|
|
699
|
+
after,
|
|
700
|
+
filter: filterArg,
|
|
701
|
+
orderBy,
|
|
702
|
+
});
|
|
703
|
+
return searchResult.issues ?? null;
|
|
635
704
|
});
|
|
636
|
-
|
|
637
|
-
if (!filteredIssues?.nodes) {
|
|
638
|
-
return [];
|
|
639
|
-
}
|
|
640
|
-
return filteredIssues.nodes.map((issue) => this.transformIssueData(issue));
|
|
705
|
+
return nodes.map((issue) => this.transformIssueData(issue));
|
|
641
706
|
}
|
|
642
707
|
// --- Private helper methods for field resolution ---
|
|
643
708
|
async resolveTeamId(teamId, resolveResult) {
|
|
@@ -1116,9 +1181,15 @@ export class GraphQLIssuesService {
|
|
|
1116
1181
|
if (!assigneeId || isUuid(assigneeId)) {
|
|
1117
1182
|
return assigneeId;
|
|
1118
1183
|
}
|
|
1119
|
-
// Opt-in
|
|
1120
|
-
//
|
|
1121
|
-
//
|
|
1184
|
+
// Opt-in identity resolution, falling back to the Linear-API user lookup
|
|
1185
|
+
// below on a miss. Both layers are optional and fail-open — an install
|
|
1186
|
+
// with neither configured never reaches the network here.
|
|
1187
|
+
// 1. The resolver-command hook (DEV-5628) — brings its own credentials.
|
|
1188
|
+
// 2. The older env-gated HTTP registry (DEV-4872).
|
|
1189
|
+
const viaCommand = resolveViaCommand(assigneeId, loadConfig());
|
|
1190
|
+
if (viaCommand) {
|
|
1191
|
+
return viaCommand;
|
|
1192
|
+
}
|
|
1122
1193
|
if (isRegistryConfigured()) {
|
|
1123
1194
|
const viaRegistry = await resolveViaRegistry(assigneeId);
|
|
1124
1195
|
if (viaRegistry) {
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Guard against overwriting an issue body with the issue's own JSON envelope.
|
|
3
|
+
*
|
|
4
|
+
* `issues get/read --format json` emits `JSON.stringify(transformIssueData(...))`
|
|
5
|
+
* — an object shaped `{ id, identifier, url, title, description, branchName,
|
|
6
|
+
* state, assignee, ... }`. Feeding that straight back into
|
|
7
|
+
* `issues update <ID> --description "$(... get <ID> --format json)"` (or the
|
|
8
|
+
* equivalent create) silently replaces the real markdown with the stringified
|
|
9
|
+
* envelope. Re-running on an already-corrupted issue re-wraps it, producing
|
|
10
|
+
* envelope-inside-envelope — so the damage deepens and recurs.
|
|
11
|
+
*
|
|
12
|
+
* This detector spots the envelope signature so the create/update path can
|
|
13
|
+
* block it with an actionable error. It is deliberately conservative: a plain
|
|
14
|
+
* markdown body never parses to an object carrying `identifier` plus an
|
|
15
|
+
* issue-only sibling key, so real descriptions pass untouched.
|
|
16
|
+
*
|
|
17
|
+
* See DEV-6315 (and the two victims it recovered, DEV-6092 / DEV-6042).
|
|
18
|
+
*/
|
|
19
|
+
export interface IssueEnvelopeMatch {
|
|
20
|
+
/** The `identifier` field carried by the detected envelope (e.g. "DEV-123"). */
|
|
21
|
+
identifier: string;
|
|
22
|
+
/** UUID carried by the envelope, when present. Used for self-match messages. */
|
|
23
|
+
id?: string;
|
|
24
|
+
/** Linear URL carried by the envelope, when present. */
|
|
25
|
+
url?: string;
|
|
26
|
+
/** True when the envelope's `description` is itself a nested envelope — the
|
|
27
|
+
* double-nesting signature of a re-corrupted body. */
|
|
28
|
+
nested: boolean;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Return a match when `text` parses as an issue-envelope JSON object, else null.
|
|
32
|
+
*
|
|
33
|
+
* An envelope is an object with a string `identifier` AND at least one of
|
|
34
|
+
* {@link ENVELOPE_SIBLING_KEYS}. Non-JSON text, JSON that isn't an object, and
|
|
35
|
+
* JSON objects without the signature all return null.
|
|
36
|
+
*/
|
|
37
|
+
export declare function detectIssueEnvelope(text: string): IssueEnvelopeMatch | null;
|
|
38
|
+
/**
|
|
39
|
+
* Throw an actionable error when `text` looks like an issue's own JSON
|
|
40
|
+
* envelope being used as a description body. No-op otherwise, or when the
|
|
41
|
+
* caller passed the audited `--allow-json-description` override.
|
|
42
|
+
*
|
|
43
|
+
* `context` carries the audited override and, when known, the raw update target
|
|
44
|
+
* (identifier, UUID, or URL) so the message can flag the exact self-overwrite
|
|
45
|
+
* case without making a network request.
|
|
46
|
+
*/
|
|
47
|
+
export declare function assertNotIssueEnvelope(text: string | undefined, context?: {
|
|
48
|
+
allow?: boolean;
|
|
49
|
+
targetIssueRef?: string;
|
|
50
|
+
}): void;
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Guard against overwriting an issue body with the issue's own JSON envelope.
|
|
3
|
+
*
|
|
4
|
+
* `issues get/read --format json` emits `JSON.stringify(transformIssueData(...))`
|
|
5
|
+
* — an object shaped `{ id, identifier, url, title, description, branchName,
|
|
6
|
+
* state, assignee, ... }`. Feeding that straight back into
|
|
7
|
+
* `issues update <ID> --description "$(... get <ID> --format json)"` (or the
|
|
8
|
+
* equivalent create) silently replaces the real markdown with the stringified
|
|
9
|
+
* envelope. Re-running on an already-corrupted issue re-wraps it, producing
|
|
10
|
+
* envelope-inside-envelope — so the damage deepens and recurs.
|
|
11
|
+
*
|
|
12
|
+
* This detector spots the envelope signature so the create/update path can
|
|
13
|
+
* block it with an actionable error. It is deliberately conservative: a plain
|
|
14
|
+
* markdown body never parses to an object carrying `identifier` plus an
|
|
15
|
+
* issue-only sibling key, so real descriptions pass untouched.
|
|
16
|
+
*
|
|
17
|
+
* See DEV-6315 (and the two victims it recovered, DEV-6092 / DEV-6042).
|
|
18
|
+
*/
|
|
19
|
+
/** Strong issue-envelope keys that, alongside `identifier`, distinguish the
|
|
20
|
+
* CLI output from unrelated ticket JSON. `description` is deliberately absent:
|
|
21
|
+
* `{ identifier, description }` is common enough outside Linear to avoid a
|
|
22
|
+
* false positive, while real `issues get` output always carries `url` and
|
|
23
|
+
* normally also `state` / `branchName`. */
|
|
24
|
+
const ENVELOPE_SIBLING_KEYS = ["branchName", "state", "url"];
|
|
25
|
+
/**
|
|
26
|
+
* Return a match when `text` parses as an issue-envelope JSON object, else null.
|
|
27
|
+
*
|
|
28
|
+
* An envelope is an object with a string `identifier` AND at least one of
|
|
29
|
+
* {@link ENVELOPE_SIBLING_KEYS}. Non-JSON text, JSON that isn't an object, and
|
|
30
|
+
* JSON objects without the signature all return null.
|
|
31
|
+
*/
|
|
32
|
+
export function detectIssueEnvelope(text) {
|
|
33
|
+
const trimmed = text.trim();
|
|
34
|
+
// Cheap prefilter: an envelope is a JSON object literal. Skips the parse for
|
|
35
|
+
// the overwhelmingly common markdown case.
|
|
36
|
+
if (!trimmed.startsWith("{")) {
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
let parsed;
|
|
40
|
+
try {
|
|
41
|
+
parsed = JSON.parse(trimmed);
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
const obj = parsed;
|
|
50
|
+
if (typeof obj.identifier !== "string" || obj.identifier.length === 0) {
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
const hasSibling = ENVELOPE_SIBLING_KEYS.some((key) => key in obj);
|
|
54
|
+
if (!hasSibling) {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
const nested = typeof obj.description === "string" &&
|
|
58
|
+
detectIssueEnvelope(obj.description) !== null;
|
|
59
|
+
return {
|
|
60
|
+
identifier: obj.identifier,
|
|
61
|
+
...(typeof obj.id === "string" ? { id: obj.id } : {}),
|
|
62
|
+
...(typeof obj.url === "string" ? { url: obj.url } : {}),
|
|
63
|
+
nested,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
/** Whether a raw CLI target (identifier, UUID, or Linear URL) names `match`. */
|
|
67
|
+
function isSameIssueTarget(match, targetIssueRef) {
|
|
68
|
+
if (!targetIssueRef) {
|
|
69
|
+
return false;
|
|
70
|
+
}
|
|
71
|
+
const target = targetIssueRef.toLowerCase();
|
|
72
|
+
if (target === match.identifier.toLowerCase() ||
|
|
73
|
+
(match.id !== undefined && target === match.id.toLowerCase()) ||
|
|
74
|
+
(match.url !== undefined && target === match.url.toLowerCase())) {
|
|
75
|
+
return true;
|
|
76
|
+
}
|
|
77
|
+
// Linear issue URLs carry the canonical identifier as a path segment.
|
|
78
|
+
return target.split(/[/?#]/).includes(match.identifier.toLowerCase());
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Throw an actionable error when `text` looks like an issue's own JSON
|
|
82
|
+
* envelope being used as a description body. No-op otherwise, or when the
|
|
83
|
+
* caller passed the audited `--allow-json-description` override.
|
|
84
|
+
*
|
|
85
|
+
* `context` carries the audited override and, when known, the raw update target
|
|
86
|
+
* (identifier, UUID, or URL) so the message can flag the exact self-overwrite
|
|
87
|
+
* case without making a network request.
|
|
88
|
+
*/
|
|
89
|
+
export function assertNotIssueEnvelope(text, context = {}) {
|
|
90
|
+
if (!text || context.allow) {
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
const match = detectIssueEnvelope(text);
|
|
94
|
+
if (!match) {
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
const isSelf = isSameIssueTarget(match, context.targetIssueRef);
|
|
98
|
+
const selfNote = isSelf
|
|
99
|
+
? ` This is ${match.identifier}'s own envelope — the update would overwrite its body with itself.`
|
|
100
|
+
: "";
|
|
101
|
+
const nestedNote = match.nested
|
|
102
|
+
? " (It is already a doubly-nested envelope — a sign this body was corrupted by an earlier run.)"
|
|
103
|
+
: "";
|
|
104
|
+
throw new Error(`Refusing to write a description that looks like an issue's JSON envelope ` +
|
|
105
|
+
`(a "${match.identifier}" object with an issue-only field such as branchName/state/url).${selfNote}${nestedNote}\n` +
|
|
106
|
+
`This is almost always an "issues get --format json" output accidentally piped into --description — ` +
|
|
107
|
+
`which silently destroys the real body (DEV-6315). ` +
|
|
108
|
+
`Pass a markdown body via --description-file <path>, or, if you genuinely mean to store this JSON, ` +
|
|
109
|
+
`re-run with --allow-json-description.`);
|
|
110
|
+
}
|
package/dist/utils/output.js
CHANGED
|
@@ -348,6 +348,12 @@ export function outputSuccess(data) {
|
|
|
348
348
|
* current limit) so the agent doesn't have to derive it.
|
|
349
349
|
*/
|
|
350
350
|
export function warnIfTruncated(count, limit) {
|
|
351
|
+
// limit <= 0 means unlimited (--all / --limit 0, DEV-6312): a fully
|
|
352
|
+
// paginated result set can't be truncated, so never warn — and never
|
|
353
|
+
// emit the nonsensical "--limit 0" hint on an empty unlimited fetch.
|
|
354
|
+
if (limit <= 0) {
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
351
357
|
if (count !== limit) {
|
|
352
358
|
return;
|
|
353
359
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@enrichlayer/el-linear",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.43.0",
|
|
4
4
|
"description": "A pragmatic CLI for Linear.app — deterministic team/label/member resolution, structured issue validation, configurable term enforcement, and a GraphQL escape hatch.",
|
|
5
5
|
"main": "dist/main.js",
|
|
6
6
|
"types": "dist/main.d.ts",
|