@enrichlayer/el-linear 1.10.0 → 1.15.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 +126 -10
- package/claude-skills/linear-operations/SKILL.md +41 -1
- package/dist/auth/linear-credential.d.ts +27 -0
- package/dist/auth/linear-credential.js +1 -0
- package/dist/auth/oauth-app-config.d.ts +4 -3
- package/dist/auth/oauth-app-config.js +13 -2
- package/dist/auth/oauth-callback.d.ts +2 -3
- package/dist/auth/oauth-callback.js +2 -2
- package/dist/auth/oauth-client.d.ts +8 -2
- package/dist/auth/oauth-client.js +26 -0
- package/dist/auth/oauth-fs.d.ts +2 -1
- package/dist/auth/oauth-headless.d.ts +2 -1
- package/dist/auth/oauth-storage.d.ts +5 -1
- package/dist/auth/oauth-storage.js +1 -1
- package/dist/auth/oauth-token.d.ts +4 -3
- package/dist/auth/oauth-token.js +16 -4
- package/dist/auth/token-resolver.d.ts +14 -5
- package/dist/auth/token-resolver.js +6 -1
- package/dist/commands/batch.js +18 -21
- package/dist/commands/comments.js +5 -5
- package/dist/commands/config.js +178 -5
- package/dist/commands/init/aliases.js +1 -1
- package/dist/commands/init/defaults.d.ts +2 -1
- package/dist/commands/init/index.js +45 -35
- package/dist/commands/init/oauth.d.ts +4 -1
- package/dist/commands/init/oauth.js +22 -4
- package/dist/commands/init/shared.d.ts +24 -2
- package/dist/commands/init/shared.js +35 -4
- package/dist/commands/init/token.d.ts +3 -3
- package/dist/commands/init/token.js +5 -24
- package/dist/commands/init/workspace.d.ts +2 -1
- package/dist/commands/init/workspace.js +1 -1
- package/dist/commands/introspect.d.ts +27 -0
- package/dist/commands/introspect.js +178 -0
- package/dist/commands/issues/branch.js +9 -1
- package/dist/commands/issues/relations.d.ts +3 -14
- package/dist/commands/issues/relations.js +3 -3
- package/dist/commands/issues.js +222 -43
- package/dist/commands/labels.js +2 -1
- package/dist/commands/profile.js +1 -0
- package/dist/commands/projects.d.ts +2 -0
- package/dist/commands/projects.js +91 -7
- package/dist/commands/read-shortcut.d.ts +1 -1
- package/dist/commands/read-shortcut.js +28 -8
- package/dist/commands/refs.js +67 -8
- package/dist/commands/search.js +30 -5
- package/dist/commands/users.js +4 -2
- package/dist/config/config.d.ts +99 -1
- package/dist/config/config.js +264 -52
- package/dist/config/error-enrichment.d.ts +62 -0
- package/dist/config/error-enrichment.js +417 -0
- package/dist/config/issue-validation.d.ts +37 -0
- package/dist/config/issue-validation.js +63 -1
- package/dist/config/paths.d.ts +2 -8
- package/dist/config/paths.js +4 -2
- package/dist/config/resolver.d.ts +8 -1
- package/dist/config/resolver.js +9 -2
- package/dist/main.js +13 -1
- package/dist/queries/comments-types.d.ts +15 -9
- package/dist/queries/common.d.ts +2 -2
- package/dist/queries/common.js +8 -0
- package/dist/queries/documents-types.d.ts +4 -3
- package/dist/queries/introspect-types.d.ts +8 -7
- package/dist/queries/issues-types.d.ts +92 -27
- package/dist/queries/issues.d.ts +49 -10
- package/dist/queries/issues.js +125 -5
- package/dist/queries/labels-types.d.ts +7 -6
- package/dist/queries/project-milestones-types.d.ts +5 -4
- package/dist/queries/project-milestones.d.ts +1 -1
- package/dist/queries/projects-types.d.ts +8 -7
- package/dist/queries/releases-types.d.ts +5 -4
- package/dist/queries/search-types.d.ts +28 -12
- package/dist/queries/templates-types.d.ts +3 -2
- package/dist/types/linear.d.ts +13 -1
- package/dist/utils/auto-link-references.d.ts +3 -3
- package/dist/utils/auto-link-references.js +1 -10
- package/dist/utils/extract-field.d.ts +19 -0
- package/dist/utils/extract-field.js +99 -0
- package/dist/utils/file-service.d.ts +6 -13
- package/dist/utils/file-service.js +0 -2
- package/dist/utils/formatters/summary.js +6 -1
- package/dist/utils/graphql-issues-service.d.ts +101 -45
- package/dist/utils/graphql-issues-service.js +252 -39
- package/dist/utils/graphql-service.d.ts +10 -12
- package/dist/utils/graphql-service.js +0 -3
- package/dist/utils/issue-reference-extractor.d.ts +7 -0
- package/dist/utils/issue-reference-extractor.js +5 -3
- package/dist/utils/issues-service-bootstrap.d.ts +28 -0
- package/dist/utils/issues-service-bootstrap.js +27 -0
- package/dist/utils/linear-service.d.ts +21 -14
- package/dist/utils/linear-service.js +73 -11
- package/dist/utils/markdown-prosemirror.js +12 -12
- package/dist/utils/mention-resolver.js +1 -1
- package/dist/utils/output.d.ts +81 -3
- package/dist/utils/output.js +61 -6
- package/dist/utils/project-slug.d.ts +21 -0
- package/dist/utils/project-slug.js +45 -0
- package/dist/utils/protected-ranges.d.ts +14 -0
- package/dist/utils/protected-ranges.js +88 -2
- package/dist/utils/sanitize-for-log.d.ts +24 -0
- package/dist/utils/sanitize-for-log.js +38 -0
- package/dist/utils/table-formatter.js +24 -0
- package/dist/utils/validators.d.ts +7 -2
- package/dist/utils/validators.js +6 -0
- package/dist/utils/workspace-url.js +20 -4
- package/package.json +2 -2
|
@@ -4,6 +4,7 @@ import { resolveUserDisplayName } from "../config/resolver.js";
|
|
|
4
4
|
import { toISOStringOrNow, toISOStringOrUndefined } from "./date-format.js";
|
|
5
5
|
import { multipleMatchesError, notFoundError } from "./error-messages.js";
|
|
6
6
|
import { parseIssueIdentifier } from "./identifier-parser.js";
|
|
7
|
+
import { parseProjectSlugId } from "./project-slug.js";
|
|
7
8
|
import { isUuid } from "./uuid.js";
|
|
8
9
|
const DEFAULT_CYCLE_PAGINATION_LIMIT = 250;
|
|
9
10
|
// The Linear SDK types don't accept string orderBy values, but the API does.
|
|
@@ -22,9 +23,6 @@ function nonEmptyFilter(filter) {
|
|
|
22
23
|
return Object.keys(filter).length > 0 ? filter : undefined;
|
|
23
24
|
}
|
|
24
25
|
function buildLinearClient(auth) {
|
|
25
|
-
if (typeof auth === "string") {
|
|
26
|
-
return new LinearClient({ apiKey: auth });
|
|
27
|
-
}
|
|
28
26
|
if ("oauthToken" in auth) {
|
|
29
27
|
// Linear's SDK natively supports OAuth via the `accessToken` option,
|
|
30
28
|
// which causes the underlying transport to send
|
|
@@ -133,13 +131,28 @@ export class LinearService {
|
|
|
133
131
|
else if (options.excludeStates && options.excludeStates.length > 0) {
|
|
134
132
|
filter.state = { nin: options.excludeStates };
|
|
135
133
|
}
|
|
136
|
-
|
|
134
|
+
if (options.teamId) {
|
|
135
|
+
filter.teams = { some: { id: { eq: options.teamId } } };
|
|
136
|
+
}
|
|
137
|
+
// `limit === 0` means "no limit" — paginate the full result set.
|
|
138
|
+
// Otherwise a single page of `limit` projects. DEV-4175: `--all` /
|
|
139
|
+
// `--limit 0` must return every project so callers never make a false
|
|
140
|
+
// "does not exist" determination off a silently truncated page.
|
|
141
|
+
const unlimited = limit === 0;
|
|
142
|
+
let page = await this.client.projects({
|
|
137
143
|
filter: nonEmptyFilter(filter),
|
|
138
|
-
first: limit,
|
|
144
|
+
first: unlimited ? 250 : limit,
|
|
139
145
|
orderBy: sdkOrderBy("updatedAt"),
|
|
140
146
|
includeArchived: false,
|
|
141
147
|
});
|
|
142
|
-
const
|
|
148
|
+
const projectNodes = [...page.nodes];
|
|
149
|
+
if (unlimited) {
|
|
150
|
+
while (page.pageInfo.hasNextPage) {
|
|
151
|
+
page = await page.fetchNext();
|
|
152
|
+
projectNodes.push(...page.nodes);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
const projectsWithData = await Promise.all(projectNodes.map(async (project) => {
|
|
143
156
|
const [teams, lead] = await Promise.all([
|
|
144
157
|
project.teams(),
|
|
145
158
|
project.lead,
|
|
@@ -373,6 +386,9 @@ export class LinearService {
|
|
|
373
386
|
url: issue.url,
|
|
374
387
|
title: issue.title,
|
|
375
388
|
description: issue.description || undefined,
|
|
389
|
+
// Linear SDK types `priority` as `number`; the GraphQL schema only
|
|
390
|
+
// emits 0-4, so the cast is safe and the runtime range is
|
|
391
|
+
// guaranteed by Linear's server-side schema.
|
|
376
392
|
priority: issue.priority,
|
|
377
393
|
estimate: issue.estimate || undefined,
|
|
378
394
|
state: state ? { id: state.id, name: state.name } : undefined,
|
|
@@ -455,20 +471,66 @@ export class LinearService {
|
|
|
455
471
|
}
|
|
456
472
|
return chosen.id;
|
|
457
473
|
}
|
|
458
|
-
async resolveProjectId(
|
|
459
|
-
if (isUuid(
|
|
460
|
-
return
|
|
474
|
+
async resolveProjectId(projectInput) {
|
|
475
|
+
if (isUuid(projectInput)) {
|
|
476
|
+
return projectInput;
|
|
477
|
+
}
|
|
478
|
+
const slugId = parseProjectSlugId(projectInput);
|
|
479
|
+
if (slugId) {
|
|
480
|
+
// `as Record<string, unknown>` — `@linear/sdk`'s typed ProjectFilter
|
|
481
|
+
// lags Linear's schema and doesn't yet expose `slugId`. The field is
|
|
482
|
+
// present on the server; the cast is here until the SDK ships the
|
|
483
|
+
// typing. Don't "clean up" without verifying the typed shape exists.
|
|
484
|
+
//
|
|
485
|
+
// `includeArchived: true` — URLs in the wild often point at archived
|
|
486
|
+
// projects (someone digs up an old link); silently 404-ing them is
|
|
487
|
+
// worse than resolving to an archived project, which the caller can
|
|
488
|
+
// inspect via the returned UUID. Name resolution stays active-only
|
|
489
|
+
// because names collide more readily than slug-ids do.
|
|
490
|
+
const bySlug = await this.client.projects({
|
|
491
|
+
filter: { slugId: { eq: slugId } },
|
|
492
|
+
first: 1,
|
|
493
|
+
includeArchived: true,
|
|
494
|
+
});
|
|
495
|
+
if (bySlug.nodes.length > 0) {
|
|
496
|
+
return bySlug.nodes[0].id;
|
|
497
|
+
}
|
|
498
|
+
// Slug-id form was syntactically valid but didn't match a project
|
|
499
|
+
// — fall through to name resolution would be misleading (the user
|
|
500
|
+
// clearly pasted a URL/slug, not a name). Throw the same shape of
|
|
501
|
+
// not-found error as the name path.
|
|
502
|
+
throw notFoundError("Project", projectInput);
|
|
461
503
|
}
|
|
462
|
-
const filter = { name: { eqIgnoreCase:
|
|
504
|
+
const filter = { name: { eqIgnoreCase: projectInput } };
|
|
463
505
|
const projectsConnection = await this.client.projects({
|
|
464
506
|
filter,
|
|
465
507
|
first: 1,
|
|
466
508
|
});
|
|
467
509
|
if (projectsConnection.nodes.length === 0) {
|
|
468
|
-
throw notFoundError("Project",
|
|
510
|
+
throw notFoundError("Project", projectInput);
|
|
469
511
|
}
|
|
470
512
|
return projectsConnection.nodes[0].id;
|
|
471
513
|
}
|
|
514
|
+
/**
|
|
515
|
+
* Normalize a user-supplied project input to a UUID when the input is
|
|
516
|
+
* a URL or slug-id form. Pass-through for UUIDs and plain names — the
|
|
517
|
+
* latter stays a name so downstream batch-resolve queries can fold the
|
|
518
|
+
* lookup into their single round-trip.
|
|
519
|
+
*
|
|
520
|
+
* Used by callers that route project resolution through a separate
|
|
521
|
+
* batch-resolve step (e.g. `GraphqlIssuesService.createIssue`) — they
|
|
522
|
+
* can pre-normalize URL/slug inputs to UUIDs so the batch query's
|
|
523
|
+
* `name eqIgnoreCase` filter doesn't have to learn the URL/slug shape.
|
|
524
|
+
*/
|
|
525
|
+
async normalizeProjectInput(projectInput) {
|
|
526
|
+
if (isUuid(projectInput)) {
|
|
527
|
+
return projectInput;
|
|
528
|
+
}
|
|
529
|
+
if (parseProjectSlugId(projectInput)) {
|
|
530
|
+
return this.resolveProjectId(projectInput);
|
|
531
|
+
}
|
|
532
|
+
return projectInput;
|
|
533
|
+
}
|
|
472
534
|
}
|
|
473
535
|
export async function createLinearService(options) {
|
|
474
536
|
const auth = await getActiveAuth(options);
|
|
@@ -88,7 +88,7 @@ function parseFencedCodeBlock(state, line) {
|
|
|
88
88
|
attrs.language = language;
|
|
89
89
|
}
|
|
90
90
|
state.content.push({
|
|
91
|
-
type: "
|
|
91
|
+
type: "code_block",
|
|
92
92
|
...(Object.keys(attrs).length > 0 ? { attrs } : {}),
|
|
93
93
|
content: codeLines.length > 0
|
|
94
94
|
? [{ type: "text", text: codeLines.join("\n") }]
|
|
@@ -100,7 +100,7 @@ function parseHorizontalRule(state, line) {
|
|
|
100
100
|
if (!HR_RE.test(line)) {
|
|
101
101
|
return false;
|
|
102
102
|
}
|
|
103
|
-
state.content.push({ type: "
|
|
103
|
+
state.content.push({ type: "horizontal_rule" });
|
|
104
104
|
state.i++;
|
|
105
105
|
return true;
|
|
106
106
|
}
|
|
@@ -128,12 +128,12 @@ function parseBulletList(state, line) {
|
|
|
128
128
|
break;
|
|
129
129
|
}
|
|
130
130
|
items.push({
|
|
131
|
-
type: "
|
|
131
|
+
type: "list_item",
|
|
132
132
|
content: [{ type: "paragraph", content: parseInline(m[1]) }],
|
|
133
133
|
});
|
|
134
134
|
state.i++;
|
|
135
135
|
}
|
|
136
|
-
state.content.push({ type: "
|
|
136
|
+
state.content.push({ type: "bullet_list", content: items });
|
|
137
137
|
return true;
|
|
138
138
|
}
|
|
139
139
|
function parseOrderedList(state, line) {
|
|
@@ -147,12 +147,12 @@ function parseOrderedList(state, line) {
|
|
|
147
147
|
break;
|
|
148
148
|
}
|
|
149
149
|
items.push({
|
|
150
|
-
type: "
|
|
150
|
+
type: "list_item",
|
|
151
151
|
content: [{ type: "paragraph", content: parseInline(m[1]) }],
|
|
152
152
|
});
|
|
153
153
|
state.i++;
|
|
154
154
|
}
|
|
155
|
-
state.content.push({ type: "
|
|
155
|
+
state.content.push({ type: "ordered_list", content: items });
|
|
156
156
|
return true;
|
|
157
157
|
}
|
|
158
158
|
function parseBlockquote(state) {
|
|
@@ -217,9 +217,9 @@ function parseTable(state, line) {
|
|
|
217
217
|
const headerCells = splitTableRow(rowMatch[1]);
|
|
218
218
|
const cols = headerCells.length;
|
|
219
219
|
rows.push({
|
|
220
|
-
type: "
|
|
220
|
+
type: "table_row",
|
|
221
221
|
content: headerCells.map((cell) => ({
|
|
222
|
-
type: "
|
|
222
|
+
type: "table_header",
|
|
223
223
|
content: [{ type: "paragraph", content: cellContent(cell) }],
|
|
224
224
|
})),
|
|
225
225
|
});
|
|
@@ -238,9 +238,9 @@ function parseTable(state, line) {
|
|
|
238
238
|
}
|
|
239
239
|
cells.length = cols;
|
|
240
240
|
rows.push({
|
|
241
|
-
type: "
|
|
241
|
+
type: "table_row",
|
|
242
242
|
content: cells.map((cell) => ({
|
|
243
|
-
type: "
|
|
243
|
+
type: "table_cell",
|
|
244
244
|
content: [{ type: "paragraph", content: cellContent(cell) }],
|
|
245
245
|
})),
|
|
246
246
|
});
|
|
@@ -327,7 +327,7 @@ function findEarliestInlineMatch(text) {
|
|
|
327
327
|
index: boldMatch.index ?? 0,
|
|
328
328
|
length: boldMatch[0].length,
|
|
329
329
|
innerText: boldMatch[1] ?? boldMatch[2],
|
|
330
|
-
marks: [{ type: "
|
|
330
|
+
marks: [{ type: "strong" }],
|
|
331
331
|
});
|
|
332
332
|
}
|
|
333
333
|
const italicMatch = text.match(INLINE_ITALIC_RE);
|
|
@@ -336,7 +336,7 @@ function findEarliestInlineMatch(text) {
|
|
|
336
336
|
index: italicMatch.index ?? 0,
|
|
337
337
|
length: italicMatch[0].length,
|
|
338
338
|
innerText: italicMatch[1] ?? italicMatch[2],
|
|
339
|
-
marks: [{ type: "
|
|
339
|
+
marks: [{ type: "em" }],
|
|
340
340
|
});
|
|
341
341
|
}
|
|
342
342
|
if (candidates.length === 0) {
|
|
@@ -146,7 +146,7 @@ function injectMentions(doc, explicit, bare) {
|
|
|
146
146
|
return {
|
|
147
147
|
...doc,
|
|
148
148
|
content: doc.content.map((node) => {
|
|
149
|
-
if (node.content && node.type !== "
|
|
149
|
+
if (node.content && node.type !== "code_block") {
|
|
150
150
|
const processed = injectMentions(node, explicit, bare);
|
|
151
151
|
if (node.type === "paragraph" || node.type === "heading") {
|
|
152
152
|
return {
|
package/dist/utils/output.d.ts
CHANGED
|
@@ -1,13 +1,91 @@
|
|
|
1
|
-
|
|
1
|
+
type OutputFormat = "json" | "summary";
|
|
2
2
|
export declare function setRawMode(enabled: boolean): void;
|
|
3
3
|
export declare function setJqFilter(filter: string | null): void;
|
|
4
4
|
export declare function setFieldsFilter(fields: string[] | null): void;
|
|
5
5
|
export declare function setOutputFormat(format: OutputFormat): void;
|
|
6
6
|
/** @internal Test seam — consumers should not depend on the format state. */
|
|
7
7
|
export declare function getOutputFormat(): OutputFormat;
|
|
8
|
+
/**
|
|
9
|
+
* Resource-specific extra metadata that may be added to a list response
|
|
10
|
+
* alongside the canonical `count` (e.g. `query` on search, `team` on
|
|
11
|
+
* filtered list). Excludes `count` at the type level — callers can't
|
|
12
|
+
* accidentally pass a string `count` and have it silently overridden;
|
|
13
|
+
* the only way to set `count` is via `data.length` inside `outputList`.
|
|
14
|
+
*
|
|
15
|
+
* Implementation note: `count?: never` together with `Record<string, unknown>`
|
|
16
|
+
* lets TypeScript accept any other key while disallowing the literal
|
|
17
|
+
* `count` key. The `never`-typed property is impossible to assign, which
|
|
18
|
+
* is what we want for the "no count here" contract.
|
|
19
|
+
*/
|
|
20
|
+
export type ListExtraMeta = Record<string, unknown> & {
|
|
21
|
+
count?: never;
|
|
22
|
+
};
|
|
23
|
+
/**
|
|
24
|
+
* Metadata envelope for list responses. Always includes `count`; resources
|
|
25
|
+
* may add their own keys (e.g. `query` on search, `team` on filtered list).
|
|
26
|
+
*
|
|
27
|
+
* `meta.count` is the cardinality of `data[]` — downstream `jq` pipelines
|
|
28
|
+
* read it both for emptiness checks (`.meta.count == 0`) and for the
|
|
29
|
+
* actual magnitude (e.g. logging "found N issues"). A boolean isEmpty
|
|
30
|
+
* would lose the magnitude signal, so `count: number` stays.
|
|
31
|
+
*/
|
|
32
|
+
export interface ListMeta extends Record<string, unknown> {
|
|
33
|
+
count: number;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Canonical JSON envelope for a list response. Pre-DEV-4068 T6, every
|
|
37
|
+
* call site built this object literal inline and `outputSuccess(data:
|
|
38
|
+
* unknown)` accepted it without type-checking. Use `outputList<T>` to
|
|
39
|
+
* route a list through the same emit path with a real per-element type
|
|
40
|
+
* (so e.g. `data: T[]` and `--fields` consumers stay in lock-step).
|
|
41
|
+
*/
|
|
42
|
+
export interface CliListEnvelope<T> {
|
|
43
|
+
data: T[];
|
|
44
|
+
meta: ListMeta;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Typed wrapper around `outputSuccess` for list responses — builds the
|
|
48
|
+
* `{ data, meta: { count, ...extraMeta } }` envelope and emits it. The
|
|
49
|
+
* 81 existing inline `outputSuccess({ data, meta: { count, ... } })`
|
|
50
|
+
* call sites can migrate to this incrementally; both go through the
|
|
51
|
+
* same `outputSuccess` emit path so JSON / summary / --raw / --fields /
|
|
52
|
+
* --jq behavior is identical.
|
|
53
|
+
*
|
|
54
|
+
* @param data The array payload.
|
|
55
|
+
* @param extraMeta Optional resource-specific meta keys (e.g. `query`,
|
|
56
|
+
* `team`). The type excludes `count` — `count` is
|
|
57
|
+
* always computed from `data.length` to preserve the
|
|
58
|
+
* wire-contract invariant.
|
|
59
|
+
*/
|
|
60
|
+
export declare function outputList<T>(data: T[], extraMeta?: ListExtraMeta): void;
|
|
61
|
+
/**
|
|
62
|
+
* Typed wrapper around `outputSuccess` for a single-resource response.
|
|
63
|
+
* Pure passthrough — the envelope contract for single resources is just
|
|
64
|
+
* the resource object itself (no `data`/`meta` wrapping). Same emit
|
|
65
|
+
* path as `outputSuccess` and `outputList`; this overload exists so
|
|
66
|
+
* call sites can document their intent at the type level.
|
|
67
|
+
*
|
|
68
|
+
* The conditional return type rejects array inputs at the type level —
|
|
69
|
+
* a caller that meant `outputList` and passed an array gets a compile
|
|
70
|
+
* error pointing them at the right helper. Runtime behavior on an array
|
|
71
|
+
* is unchanged (it'd still emit JSON), but the type catches the foot-gun.
|
|
72
|
+
*/
|
|
73
|
+
export declare function outputSingle<T>(data: T extends readonly unknown[] ? "outputSingle does not accept arrays — use outputList(data) instead" : T): void;
|
|
8
74
|
export declare function outputSuccess(data: unknown): void;
|
|
75
|
+
/**
|
|
76
|
+
* Emit a `results_truncated` warning when a list command's result count
|
|
77
|
+
* equals the requested `--limit`, signaling to the caller (typically an
|
|
78
|
+
* AI agent) that more results may exist beyond the page. Heuristic, not
|
|
79
|
+
* exact — `length === limit` has a false-positive when the workspace
|
|
80
|
+
* happens to hold exactly `limit` matching items. Accurate `hasNextPage`
|
|
81
|
+
* would require widening every list-service return type; deferred until
|
|
82
|
+
* a caller cares about the false-positive rate.
|
|
83
|
+
*
|
|
84
|
+
* Message includes a concrete next-step (suggested `--limit` is 2× the
|
|
85
|
+
* current limit) so the agent doesn't have to derive it.
|
|
86
|
+
*/
|
|
87
|
+
export declare function warnIfTruncated(count: number, limit: number): void;
|
|
9
88
|
export declare function outputWarning(message: string | string[]): void;
|
|
10
89
|
export declare function resetWarnings(): void;
|
|
11
|
-
/** @internal Test seam — call between tests that toggle `setOutputFormat`. */
|
|
12
|
-
export declare function resetOutputFormat(): void;
|
|
13
90
|
export declare function handleAsyncCommand<TArgs extends unknown[]>(asyncFn: (...args: TArgs) => Promise<void>): (...args: TArgs) => Promise<void>;
|
|
91
|
+
export {};
|
package/dist/utils/output.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { execFileSync } from "node:child_process";
|
|
2
2
|
import { dispatch as dispatchSummary, inferKindFromPayload, } from "./formatters/summary.js";
|
|
3
3
|
import { logger } from "./logger.js";
|
|
4
|
+
import { sanitizeForLog } from "./sanitize-for-log.js";
|
|
4
5
|
const warningBuffer = [];
|
|
5
6
|
let rawMode = false;
|
|
6
7
|
let jqFilter = null;
|
|
@@ -50,6 +51,40 @@ function filterFields(obj, fields) {
|
|
|
50
51
|
function emitSummary(payload, kind) {
|
|
51
52
|
logger.info(dispatchSummary(kind, payload));
|
|
52
53
|
}
|
|
54
|
+
/**
|
|
55
|
+
* Typed wrapper around `outputSuccess` for list responses — builds the
|
|
56
|
+
* `{ data, meta: { count, ...extraMeta } }` envelope and emits it. The
|
|
57
|
+
* 81 existing inline `outputSuccess({ data, meta: { count, ... } })`
|
|
58
|
+
* call sites can migrate to this incrementally; both go through the
|
|
59
|
+
* same `outputSuccess` emit path so JSON / summary / --raw / --fields /
|
|
60
|
+
* --jq behavior is identical.
|
|
61
|
+
*
|
|
62
|
+
* @param data The array payload.
|
|
63
|
+
* @param extraMeta Optional resource-specific meta keys (e.g. `query`,
|
|
64
|
+
* `team`). The type excludes `count` — `count` is
|
|
65
|
+
* always computed from `data.length` to preserve the
|
|
66
|
+
* wire-contract invariant.
|
|
67
|
+
*/
|
|
68
|
+
export function outputList(data, extraMeta) {
|
|
69
|
+
const meta = { ...extraMeta, count: data.length };
|
|
70
|
+
const envelope = { data, meta };
|
|
71
|
+
outputSuccess(envelope);
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Typed wrapper around `outputSuccess` for a single-resource response.
|
|
75
|
+
* Pure passthrough — the envelope contract for single resources is just
|
|
76
|
+
* the resource object itself (no `data`/`meta` wrapping). Same emit
|
|
77
|
+
* path as `outputSuccess` and `outputList`; this overload exists so
|
|
78
|
+
* call sites can document their intent at the type level.
|
|
79
|
+
*
|
|
80
|
+
* The conditional return type rejects array inputs at the type level —
|
|
81
|
+
* a caller that meant `outputList` and passed an array gets a compile
|
|
82
|
+
* error pointing them at the right helper. Runtime behavior on an array
|
|
83
|
+
* is unchanged (it'd still emit JSON), but the type catches the foot-gun.
|
|
84
|
+
*/
|
|
85
|
+
export function outputSingle(data) {
|
|
86
|
+
outputSuccess(data);
|
|
87
|
+
}
|
|
53
88
|
export function outputSuccess(data) {
|
|
54
89
|
const warnings = drainWarnings();
|
|
55
90
|
let output;
|
|
@@ -120,6 +155,25 @@ export function outputSuccess(data) {
|
|
|
120
155
|
logger.info(JSON.stringify(output, null, 2));
|
|
121
156
|
}
|
|
122
157
|
}
|
|
158
|
+
/**
|
|
159
|
+
* Emit a `results_truncated` warning when a list command's result count
|
|
160
|
+
* equals the requested `--limit`, signaling to the caller (typically an
|
|
161
|
+
* AI agent) that more results may exist beyond the page. Heuristic, not
|
|
162
|
+
* exact — `length === limit` has a false-positive when the workspace
|
|
163
|
+
* happens to hold exactly `limit` matching items. Accurate `hasNextPage`
|
|
164
|
+
* would require widening every list-service return type; deferred until
|
|
165
|
+
* a caller cares about the false-positive rate.
|
|
166
|
+
*
|
|
167
|
+
* Message includes a concrete next-step (suggested `--limit` is 2× the
|
|
168
|
+
* current limit) so the agent doesn't have to derive it.
|
|
169
|
+
*/
|
|
170
|
+
export function warnIfTruncated(count, limit) {
|
|
171
|
+
if (count !== limit) {
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
outputWarning(`results_truncated: returned ${count} matching --limit ${limit}; more results may exist. ` +
|
|
175
|
+
`Re-run with --limit ${limit * 2} (or narrow via filters like --name, --state, --team) to verify.`);
|
|
176
|
+
}
|
|
123
177
|
export function outputWarning(message) {
|
|
124
178
|
const messages = Array.isArray(message) ? message : [message];
|
|
125
179
|
for (const msg of messages) {
|
|
@@ -136,17 +190,18 @@ function drainWarnings() {
|
|
|
136
190
|
export function resetWarnings() {
|
|
137
191
|
warningBuffer.length = 0;
|
|
138
192
|
}
|
|
139
|
-
/** @internal Test seam — call between tests that toggle `setOutputFormat`. */
|
|
140
|
-
export function resetOutputFormat() {
|
|
141
|
-
outputFormat = "json";
|
|
142
|
-
}
|
|
143
193
|
function outputError(error) {
|
|
144
|
-
|
|
194
|
+
// Run the message and stack through sanitizeForLog so a future SDK
|
|
195
|
+
// upgrade (or proxy/MITM error body) that embeds `lin_api_…` /
|
|
196
|
+
// `lin_oauth_…` / `Bearer <payload>` in error text can't leak a token
|
|
197
|
+
// into stdout, shell history, or CI logs. The wizard already sanitizes
|
|
198
|
+
// its own log paths; this is the central error path on every command.
|
|
199
|
+
const payload = JSON.stringify({ error: sanitizeForLog(error.message) }, null, 2);
|
|
145
200
|
// Write to stdout (same channel as success) so machine callers always
|
|
146
201
|
// receive exactly one parseable JSON object regardless of stream capture.
|
|
147
202
|
logger.info(payload);
|
|
148
203
|
if (process.env.EL_LINEAR_DEBUG ?? process.env.LINCTL_DEBUG) {
|
|
149
|
-
logger.error(error.stack ?? "");
|
|
204
|
+
logger.error(sanitizeForLog(error.stack ?? ""));
|
|
150
205
|
}
|
|
151
206
|
process.exit(1);
|
|
152
207
|
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parse a Linear project URL or bare slug-id into the `slugId` form that
|
|
3
|
+
* Linear's GraphQL `ProjectFilter.slugId` accepts.
|
|
4
|
+
*
|
|
5
|
+
* Linear project URLs look like:
|
|
6
|
+
* https://linear.app/<workspace>/project/<slug>-<12-hex>/<view>
|
|
7
|
+
*
|
|
8
|
+
* The slug-id is the `<slug>-<12-hex>` segment (kebab-case name followed
|
|
9
|
+
* by a 12-character hex suffix). Linear uses this exact string as the
|
|
10
|
+
* unique `slugId` field on the Project type.
|
|
11
|
+
*
|
|
12
|
+
* Accepts:
|
|
13
|
+
* - Full URL form: extracts the slug-id from the path
|
|
14
|
+
* - Bare slug-id: `tools-and-standardization-40815d9beb16`
|
|
15
|
+
* - Trailing path / query string is tolerated (`/overview`, `?foo=bar`)
|
|
16
|
+
*
|
|
17
|
+
* Returns `null` when the input is neither — caller can then fall through
|
|
18
|
+
* to name-based or UUID-based resolution. Canonical UUIDs are rejected
|
|
19
|
+
* here so that `isUuid()` callers stay the authoritative UUID path.
|
|
20
|
+
*/
|
|
21
|
+
export declare function parseProjectSlugId(input: string): string | null;
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { isUuid } from "./uuid.js";
|
|
2
|
+
/**
|
|
3
|
+
* Parse a Linear project URL or bare slug-id into the `slugId` form that
|
|
4
|
+
* Linear's GraphQL `ProjectFilter.slugId` accepts.
|
|
5
|
+
*
|
|
6
|
+
* Linear project URLs look like:
|
|
7
|
+
* https://linear.app/<workspace>/project/<slug>-<12-hex>/<view>
|
|
8
|
+
*
|
|
9
|
+
* The slug-id is the `<slug>-<12-hex>` segment (kebab-case name followed
|
|
10
|
+
* by a 12-character hex suffix). Linear uses this exact string as the
|
|
11
|
+
* unique `slugId` field on the Project type.
|
|
12
|
+
*
|
|
13
|
+
* Accepts:
|
|
14
|
+
* - Full URL form: extracts the slug-id from the path
|
|
15
|
+
* - Bare slug-id: `tools-and-standardization-40815d9beb16`
|
|
16
|
+
* - Trailing path / query string is tolerated (`/overview`, `?foo=bar`)
|
|
17
|
+
*
|
|
18
|
+
* Returns `null` when the input is neither — caller can then fall through
|
|
19
|
+
* to name-based or UUID-based resolution. Canonical UUIDs are rejected
|
|
20
|
+
* here so that `isUuid()` callers stay the authoritative UUID path.
|
|
21
|
+
*/
|
|
22
|
+
export function parseProjectSlugId(input) {
|
|
23
|
+
const trimmed = input.trim();
|
|
24
|
+
if (!trimmed || isUuid(trimmed)) {
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
const urlMatch = trimmed.match(/\blinear\.app\/[^/\s]+\/project\/([^/?#\s]+)/i);
|
|
28
|
+
if (urlMatch) {
|
|
29
|
+
const candidate = urlMatch[1];
|
|
30
|
+
return looksLikeSlugId(candidate) ? candidate : null;
|
|
31
|
+
}
|
|
32
|
+
if (looksLikeSlugId(trimmed)) {
|
|
33
|
+
return trimmed;
|
|
34
|
+
}
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* A Linear project slug-id is a kebab-case name segment followed by a
|
|
39
|
+
* 12-character hex suffix. The minimum form is just the 12 hex chars
|
|
40
|
+
* (when the project name slugifies to empty), but in practice there's
|
|
41
|
+
* always at least one name segment.
|
|
42
|
+
*/
|
|
43
|
+
function looksLikeSlugId(value) {
|
|
44
|
+
return /^[a-z0-9]+(?:-[a-z0-9]+)*-[a-f0-9]{12}$/i.test(value);
|
|
45
|
+
}
|
|
@@ -17,6 +17,20 @@
|
|
|
17
17
|
*/
|
|
18
18
|
/** Linear identifier shape: ABC-123, EMW-1, DEV-3592. */
|
|
19
19
|
export declare const IDENTIFIER_REGEX: RegExp;
|
|
20
|
+
/**
|
|
21
|
+
* Find the first position in `s` where a close-bracket character
|
|
22
|
+
* appears without a matching opener earlier in the string. Returns
|
|
23
|
+
* `s.length` if all close-brackets are balanced. Used to truncate
|
|
24
|
+
* a bare-URL match at the first unbalanced `)`, `]`, or `}` —
|
|
25
|
+
* exactly the position CommonMark treats as the URL terminator.
|
|
26
|
+
*
|
|
27
|
+
* Exported for direct unit testing. Depth counters per bracket type
|
|
28
|
+
* are independent — pathological interleavings like `[(a]b)` don't
|
|
29
|
+
* trigger an unbalance, which errs toward keeping a URL whole rather
|
|
30
|
+
* than over-truncating (no real-world URL nests bracket types this
|
|
31
|
+
* way).
|
|
32
|
+
*/
|
|
33
|
+
export declare function firstUnbalancedClose(s: string): number;
|
|
20
34
|
export interface ProtectedRange {
|
|
21
35
|
end: number;
|
|
22
36
|
start: number;
|
|
@@ -33,7 +33,87 @@ const ANGLE_AUTOLINK_REGEX = /<[^>\s]+>/g;
|
|
|
33
33
|
// Bare URLs in prose. We protect these so identifiers inside path
|
|
34
34
|
// components (e.g. "https://github.com/foo/DEV-100") don't get
|
|
35
35
|
// processed.
|
|
36
|
-
|
|
36
|
+
//
|
|
37
|
+
// Match strategy follows CommonMark's "extended autolink" rule:
|
|
38
|
+
//
|
|
39
|
+
// 1. Greedy match up to whitespace or angle/quote terminators
|
|
40
|
+
// (`<`, `>`, `"`). Parens and brackets stay IN the match — they
|
|
41
|
+
// appear inside legitimate URLs (Wikipedia article paths, Next.js
|
|
42
|
+
// route groups like `/docs/app/(group)/page`, CDN signing-key
|
|
43
|
+
// query strings, etc.).
|
|
44
|
+
// 2. Post-process via `trimBareUrlTrailingPunct` to strip UNBALANCED
|
|
45
|
+
// trailing brackets and stand-alone punctuation. So
|
|
46
|
+
// `https://example.com/foo)DEV-100` correctly terminates at
|
|
47
|
+
// `…foo` (the closing paren is unbalanced — no opener inside the
|
|
48
|
+
// match), while `https://en.wikipedia.org/wiki/Foo_(bar)` keeps
|
|
49
|
+
// its balanced parens intact.
|
|
50
|
+
//
|
|
51
|
+
// Pre-fix `https?:\/\/\S+` greedily consumed everything to the next
|
|
52
|
+
// whitespace and silently hid identifiers in position-dependent
|
|
53
|
+
// prose. A char-class exclusion of `)`, `]`, `}` over-corrected and
|
|
54
|
+
// broke Wikipedia / route-group URLs (cycle 2 finding on PR #75).
|
|
55
|
+
// The balanced-paren trim is the CommonMark-faithful middle ground.
|
|
56
|
+
const BARE_URL_REGEX = /https?:\/\/[^\s<>"]+/g;
|
|
57
|
+
/**
|
|
58
|
+
* Find the first position in `s` where a close-bracket character
|
|
59
|
+
* appears without a matching opener earlier in the string. Returns
|
|
60
|
+
* `s.length` if all close-brackets are balanced. Used to truncate
|
|
61
|
+
* a bare-URL match at the first unbalanced `)`, `]`, or `}` —
|
|
62
|
+
* exactly the position CommonMark treats as the URL terminator.
|
|
63
|
+
*
|
|
64
|
+
* Exported for direct unit testing. Depth counters per bracket type
|
|
65
|
+
* are independent — pathological interleavings like `[(a]b)` don't
|
|
66
|
+
* trigger an unbalance, which errs toward keeping a URL whole rather
|
|
67
|
+
* than over-truncating (no real-world URL nests bracket types this
|
|
68
|
+
* way).
|
|
69
|
+
*/
|
|
70
|
+
export function firstUnbalancedClose(s) {
|
|
71
|
+
let parenDepth = 0;
|
|
72
|
+
let brackDepth = 0;
|
|
73
|
+
let braceDepth = 0;
|
|
74
|
+
for (let i = 0; i < s.length; i++) {
|
|
75
|
+
const ch = s[i];
|
|
76
|
+
if (ch === "(")
|
|
77
|
+
parenDepth++;
|
|
78
|
+
else if (ch === ")") {
|
|
79
|
+
if (parenDepth === 0)
|
|
80
|
+
return i;
|
|
81
|
+
parenDepth--;
|
|
82
|
+
}
|
|
83
|
+
else if (ch === "[")
|
|
84
|
+
brackDepth++;
|
|
85
|
+
else if (ch === "]") {
|
|
86
|
+
if (brackDepth === 0)
|
|
87
|
+
return i;
|
|
88
|
+
brackDepth--;
|
|
89
|
+
}
|
|
90
|
+
else if (ch === "{")
|
|
91
|
+
braceDepth++;
|
|
92
|
+
else if (ch === "}") {
|
|
93
|
+
if (braceDepth === 0)
|
|
94
|
+
return i;
|
|
95
|
+
braceDepth--;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return s.length;
|
|
99
|
+
}
|
|
100
|
+
function trimBareUrlTrailingPunct(url) {
|
|
101
|
+
// First: truncate at the first unbalanced bracket. So
|
|
102
|
+
// `https://example.com/foo)DEV-100` (no `(` opener inside) terminates
|
|
103
|
+
// at the `)` regardless of what follows it; `Foo_(bar)/DEV` keeps
|
|
104
|
+
// the balanced `(bar)` intact.
|
|
105
|
+
let trimmed = url.slice(0, firstUnbalancedClose(url));
|
|
106
|
+
// Then: strip standard trailing sentence-terminators (`.`, `,`, `;`,
|
|
107
|
+
// `:`, `!`, `?`). These never appear in legitimate URL paths at the
|
|
108
|
+
// very end, but they're commonly adjacent to URLs in prose. The
|
|
109
|
+
// char class deliberately excludes `)`, `]`, `}` — those are
|
|
110
|
+
// already handled by firstUnbalancedClose above, where the
|
|
111
|
+
// balanced/unbalanced check lives.
|
|
112
|
+
while (trimmed.length > 0 && /[.,;:!?]$/.test(trimmed)) {
|
|
113
|
+
trimmed = trimmed.slice(0, -1);
|
|
114
|
+
}
|
|
115
|
+
return trimmed;
|
|
116
|
+
}
|
|
37
117
|
/**
|
|
38
118
|
* Find ranges of `text` that should NOT have identifiers processed.
|
|
39
119
|
* Covered: fenced code, inline backticks, existing markdown links,
|
|
@@ -54,13 +134,19 @@ export function findProtectedRanges(text) {
|
|
|
54
134
|
// clarity here.
|
|
55
135
|
SLACK_LINK_REGEX,
|
|
56
136
|
ANGLE_AUTOLINK_REGEX,
|
|
57
|
-
BARE_URL_REGEX,
|
|
58
137
|
]) {
|
|
59
138
|
for (const m of text.matchAll(re)) {
|
|
60
139
|
const start = m.index ?? 0;
|
|
61
140
|
ranges.push({ start, end: start + m[0].length });
|
|
62
141
|
}
|
|
63
142
|
}
|
|
143
|
+
// Bare URLs need the trailing-punctuation trim that's hard to express
|
|
144
|
+
// in pure regex (depends on bracket-balance inside the match).
|
|
145
|
+
for (const m of text.matchAll(BARE_URL_REGEX)) {
|
|
146
|
+
const start = m.index ?? 0;
|
|
147
|
+
const trimmed = trimBareUrlTrailingPunct(m[0]);
|
|
148
|
+
ranges.push({ start, end: start + trimmed.length });
|
|
149
|
+
}
|
|
64
150
|
return ranges;
|
|
65
151
|
}
|
|
66
152
|
export function isProtected(pos, ranges) {
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Strip anything that looks like a Linear API/OAuth token from a string.
|
|
3
|
+
*
|
|
4
|
+
* Defense in depth: today the @linear/sdk error message embeds `{ query,
|
|
5
|
+
* variables }` but not the Authorization header. A future SDK upgrade that
|
|
6
|
+
* includes headers (which upstream graphql-request has done historically)
|
|
7
|
+
* would otherwise silently write `Bearer lin_api_…` into stdout, shell
|
|
8
|
+
* history, or CI logs. The regex also catches token shapes that may show
|
|
9
|
+
* up in custom error wrappers — e.g. a network proxy that echoes the
|
|
10
|
+
* Bearer header in its 502 body.
|
|
11
|
+
*
|
|
12
|
+
* Originally lived in `commands/init/token.ts` for the wizard's error
|
|
13
|
+
* formatting. Hoisted to `utils/` so the central error path
|
|
14
|
+
* (`output.ts`'s `outputError`) can use it too — that path runs on
|
|
15
|
+
* every non-wizard CLI invocation. `init/token.ts` re-exports the
|
|
16
|
+
* symbol so existing imports under `init/` keep working.
|
|
17
|
+
*
|
|
18
|
+
* The OAuth token-exchange / refresh / revoke paths also call this
|
|
19
|
+
* function at source (`auth/oauth-token.ts`'s `postForm` + `revokeToken`,
|
|
20
|
+
* `auth/token-resolver.ts`'s refresh-failure rewrap) so a future caller
|
|
21
|
+
* that catches+rethrows or logs mid-chain can't leak a token before the
|
|
22
|
+
* error reaches `outputError`. Defense in depth (DEV-4065).
|
|
23
|
+
*/
|
|
24
|
+
export declare function sanitizeForLog(text: string): string;
|