@enrichlayer/el-linear 1.33.0 → 1.34.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/commands/issues.js
CHANGED
|
@@ -1042,8 +1042,30 @@ export function setupIssuesCommands(program) {
|
|
|
1042
1042
|
const issues = program
|
|
1043
1043
|
.command("issues")
|
|
1044
1044
|
.alias("issue")
|
|
1045
|
-
.description("Issue operations")
|
|
1046
|
-
|
|
1045
|
+
.description("Issue operations")
|
|
1046
|
+
.argument("[issueId...]", "issue ID(s) to read when no subcommand is given — shorthand for `issues read`")
|
|
1047
|
+
.option("--body", "Print the issue's full description as raw markdown text — no JSON " +
|
|
1048
|
+
"envelope, single-issue only. Exits non-zero if the issue has no " +
|
|
1049
|
+
"description. The whole-body sibling of --field; mutually exclusive " +
|
|
1050
|
+
"with --field / --sections / --with.")
|
|
1051
|
+
.option("--field <name>", 'Extract a single named section from the issue description (e.g. "Done when"). ' +
|
|
1052
|
+
"Matches H2/H3 headers and bold pseudo-headers case-insensitively. " +
|
|
1053
|
+
"Outputs the section text only — no JSON envelope. Single-issue only.")
|
|
1054
|
+
.option("--sections <names>", 'Extract multiple named description sections in one call (comma-separated, e.g. "Done when,Out of scope"). ' +
|
|
1055
|
+
"Single-issue only. Returns a JSON envelope { identifier, sections: { name -> text|null } }; missing sections appear as null + a _warnings entry. " +
|
|
1056
|
+
"Sibling of --field (singular). Named --sections rather than --fields because the program already has a global --fields for output-key filtering.")
|
|
1057
|
+
.option("--with <names>", "Comma-separated opt-in includes. Each value fetches an extra " +
|
|
1058
|
+
'block of data and adds it to the JSON envelope. Currently supported: "relations" ' +
|
|
1059
|
+
"(adds an array of cross-issue relations under a top-level `relations` key).")
|
|
1060
|
+
.addHelpText("after", "\nNo-subcommand shorthand: `el-linear issue DEV-123` behaves like `issues read DEV-123`, " +
|
|
1061
|
+
"including its options (--body, --field, --sections, --with).");
|
|
1062
|
+
issues.action(handleAsyncCommand((issueIds = [], options, command) => {
|
|
1063
|
+
if (issueIds.length === 0) {
|
|
1064
|
+
issues.help();
|
|
1065
|
+
return Promise.resolve();
|
|
1066
|
+
}
|
|
1067
|
+
return readIssues(issueIds, options, command);
|
|
1068
|
+
}));
|
|
1047
1069
|
// DEV-4480: `issues tree <ID>` lives in its own file because the
|
|
1048
1070
|
// recursive query builder + ASCII formatter belong together and are
|
|
1049
1071
|
// substantial enough to warrant the split.
|
|
@@ -42,6 +42,15 @@ export declare function formatLabelList(labels: unknown[]): string;
|
|
|
42
42
|
* the row falls back to the stored direction.
|
|
43
43
|
*/
|
|
44
44
|
export declare function formatRelationList(relations: unknown[], sourceRef?: string): string;
|
|
45
|
+
/**
|
|
46
|
+
* Table render for `issues related <id>` — the `{ data: RelatedIssueEntry[] }`
|
|
47
|
+
* envelope where each entry is `{ type, direction, issue }`. Distinct from
|
|
48
|
+
* `formatRelationList` (the `issues relate` mutation echo, `{ type, issue,
|
|
49
|
+
* relatedIssue }` shape): this is the read-path listing of ALL of an issue's
|
|
50
|
+
* relations, so direction, state, and assignee are surfaced per row instead
|
|
51
|
+
* of collapsing to a single source-oriented type/target/title line.
|
|
52
|
+
*/
|
|
53
|
+
export declare function formatIssueRelationList(relations: unknown[]): string;
|
|
45
54
|
export declare function formatUserSummary(user: Record<string, unknown>): string;
|
|
46
55
|
export declare function formatUserList(users: unknown[]): string;
|
|
47
56
|
export declare function formatDocumentSummary(doc: Record<string, unknown>): string;
|
|
@@ -59,7 +68,7 @@ export declare function formatSearchResultList(results: unknown[]): string;
|
|
|
59
68
|
* the full payload. Lists fall back to a simple bulleted list.
|
|
60
69
|
*/
|
|
61
70
|
export declare function formatGenericSummary(value: unknown): string;
|
|
62
|
-
export type ResourceKind = "issue" | "issue-list" | "project" | "project-list" | "comment" | "comment-list" | "cycle" | "cycle-list" | "milestone" | "milestone-list" | "team-list" | "label-list" | "user" | "user-list" | "document" | "document-list" | "template" | "template-list" | "attachment-list" | "release" | "release-list" | "search-result-list" | "relation-list" | "empty-list" | "generic";
|
|
71
|
+
export type ResourceKind = "issue" | "issue-list" | "project" | "project-list" | "comment" | "comment-list" | "cycle" | "cycle-list" | "milestone" | "milestone-list" | "team-list" | "label-list" | "user" | "user-list" | "document" | "document-list" | "template" | "template-list" | "attachment-list" | "release" | "release-list" | "search-result-list" | "relation-list" | "issue-relation-list" | "empty-list" | "generic";
|
|
63
72
|
/**
|
|
64
73
|
* Heuristic — used by the central `outputSuccess` path which doesn't know
|
|
65
74
|
* which command produced the payload. Looks at the shape of the data to
|
|
@@ -749,6 +749,42 @@ export function formatRelationList(relations, sourceRef = "—") {
|
|
|
749
749
|
},
|
|
750
750
|
], { emptyText: "(no relations)", itemNoun: "relation" });
|
|
751
751
|
}
|
|
752
|
+
/**
|
|
753
|
+
* Table render for `issues related <id>` — the `{ data: RelatedIssueEntry[] }`
|
|
754
|
+
* envelope where each entry is `{ type, direction, issue }`. Distinct from
|
|
755
|
+
* `formatRelationList` (the `issues relate` mutation echo, `{ type, issue,
|
|
756
|
+
* relatedIssue }` shape): this is the read-path listing of ALL of an issue's
|
|
757
|
+
* relations, so direction, state, and assignee are surfaced per row instead
|
|
758
|
+
* of collapsing to a single source-oriented type/target/title line.
|
|
759
|
+
*/
|
|
760
|
+
export function formatIssueRelationList(relations) {
|
|
761
|
+
const rows = relations.map((raw) => asObj(raw) ?? {});
|
|
762
|
+
return renderTable(rows, [
|
|
763
|
+
{ header: "RELATION", minWidth: 8, extract: (r) => s(r.type) },
|
|
764
|
+
{ header: "DIRECTION", minWidth: 9, extract: (r) => s(r.direction) },
|
|
765
|
+
{
|
|
766
|
+
header: "ID",
|
|
767
|
+
minWidth: 2,
|
|
768
|
+
extract: (r) => s(asObj(r.issue)?.identifier),
|
|
769
|
+
},
|
|
770
|
+
{
|
|
771
|
+
header: "STATE",
|
|
772
|
+
minWidth: 5,
|
|
773
|
+
extract: (r) => getName(asObj(r.issue)?.state),
|
|
774
|
+
},
|
|
775
|
+
{
|
|
776
|
+
header: "ASSIGNEE",
|
|
777
|
+
minWidth: 8,
|
|
778
|
+
extract: (r) => getName(asObj(r.issue)?.assignee),
|
|
779
|
+
},
|
|
780
|
+
{
|
|
781
|
+
header: "TITLE",
|
|
782
|
+
minWidth: 5,
|
|
783
|
+
maxWidth: TITLE_TRUNC,
|
|
784
|
+
extract: (r) => s(asObj(r.issue)?.title),
|
|
785
|
+
},
|
|
786
|
+
], { emptyText: "(no issue relations)", itemNoun: "relation" });
|
|
787
|
+
}
|
|
752
788
|
// ── users ──────────────────────────────────────────────────────
|
|
753
789
|
export function formatUserSummary(user) {
|
|
754
790
|
const name = s(user.name);
|
|
@@ -1032,6 +1068,10 @@ function inferListKind(items) {
|
|
|
1032
1068
|
// identifier/title, so this must precede the issue-list check below.
|
|
1033
1069
|
if ("relatedIssue" in sample && "issue" in sample && "type" in sample)
|
|
1034
1070
|
return "relation-list";
|
|
1071
|
+
// `issues related <id>` rows ({ type, direction, issue }) — the read-path
|
|
1072
|
+
// listing, distinct from the `relatedIssue`-shaped mutation echo above.
|
|
1073
|
+
if ("direction" in sample && "issue" in sample && "type" in sample)
|
|
1074
|
+
return "issue-relation-list";
|
|
1035
1075
|
if ("identifier" in sample && "title" in sample) {
|
|
1036
1076
|
// could be issue or search result
|
|
1037
1077
|
if ("type" in sample && !("templateData" in sample)) {
|
|
@@ -1154,6 +1194,8 @@ export function dispatch(kind, payload, fields) {
|
|
|
1154
1194
|
return formatSearchResultList(list ?? []);
|
|
1155
1195
|
case "relation-list":
|
|
1156
1196
|
return formatRelationList(list ?? [], s(asObj(obj?.meta)?.source));
|
|
1197
|
+
case "issue-relation-list":
|
|
1198
|
+
return formatIssueRelationList(list ?? []);
|
|
1157
1199
|
case "empty-list":
|
|
1158
1200
|
return "(no results)";
|
|
1159
1201
|
case "generic":
|
|
@@ -131,20 +131,32 @@ export class LinearService {
|
|
|
131
131
|
else if (options.excludeStates && options.excludeStates.length > 0) {
|
|
132
132
|
filter.state = { nin: options.excludeStates };
|
|
133
133
|
}
|
|
134
|
-
if (options.teamId) {
|
|
135
|
-
filter.teams = { some: { id: { eq: options.teamId } } };
|
|
136
|
-
}
|
|
137
134
|
// `limit === 0` means "no limit" — paginate the full result set.
|
|
138
135
|
// Otherwise a single page of `limit` projects. DEV-4175: `--all` /
|
|
139
136
|
// `--limit 0` must return every project so callers never make a false
|
|
140
137
|
// "does not exist" determination off a silently truncated page.
|
|
141
138
|
const unlimited = limit === 0;
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
139
|
+
// DEV-5325: ProjectFilter has no `teams` relation filter — the
|
|
140
|
+
// `teams: { some: … }` shape this used since DEV-4165 errors against
|
|
141
|
+
// the live GraphQL schema ('Field "teams" is not defined by type
|
|
142
|
+
// "ProjectFilter"'). Team scoping queries from the team side instead:
|
|
143
|
+
// `Team.projects` accepts the same name/state ProjectFilter and
|
|
144
|
+
// paginates identically, keeping the filter server-side. (Args stay
|
|
145
|
+
// inline in each branch so `sdkOrderBy`'s generic keeps its contextual
|
|
146
|
+
// type from the SDK signature.)
|
|
147
|
+
let page = options.teamId
|
|
148
|
+
? await (await this.client.team(options.teamId)).projects({
|
|
149
|
+
filter: nonEmptyFilter(filter),
|
|
150
|
+
first: unlimited ? 250 : limit,
|
|
151
|
+
orderBy: sdkOrderBy("updatedAt"),
|
|
152
|
+
includeArchived: false,
|
|
153
|
+
})
|
|
154
|
+
: await this.client.projects({
|
|
155
|
+
filter: nonEmptyFilter(filter),
|
|
156
|
+
first: unlimited ? 250 : limit,
|
|
157
|
+
orderBy: sdkOrderBy("updatedAt"),
|
|
158
|
+
includeArchived: false,
|
|
159
|
+
});
|
|
148
160
|
const projectNodes = [...page.nodes];
|
|
149
161
|
if (unlimited) {
|
|
150
162
|
while (page.pageInfo.hasNextPage) {
|
|
@@ -516,25 +528,21 @@ export class LinearService {
|
|
|
516
528
|
throw notFoundError("Project", projectInput);
|
|
517
529
|
}
|
|
518
530
|
// DEV-4103: scope by team when provided so a name shared across teams
|
|
519
|
-
// doesn't resolve to a different team's project.
|
|
520
|
-
// `
|
|
531
|
+
// doesn't resolve to a different team's project. DEV-5325: the scoping
|
|
532
|
+
// goes through `Team.projects` (same as `getProjects`) — ProjectFilter
|
|
533
|
+
// rejects a `teams` relation filter against the live schema.
|
|
521
534
|
const teamId = teamInput ? await this.resolveTeamId(teamInput) : undefined;
|
|
522
535
|
const filter = {
|
|
523
536
|
name: { eqIgnoreCase: projectInput },
|
|
524
537
|
};
|
|
525
|
-
if (teamId) {
|
|
526
|
-
// Matches the filter shape `getProjects` uses for `--team`.
|
|
527
|
-
filter.teams = { some: { id: { eq: teamId } } };
|
|
528
|
-
}
|
|
529
538
|
// `first: 5` is wide enough to detect ambiguity (same name across
|
|
530
539
|
// multiple teams) without paying for a deeper page. Linear's UI caps
|
|
531
540
|
// effective project-name collisions at a handful in practice; if a
|
|
532
541
|
// workspace ever exceeds this we'll see it as a still-ambiguous error
|
|
533
542
|
// listing the first 5 teams — better than a silent wrong pick.
|
|
534
|
-
const projectsConnection =
|
|
535
|
-
filter,
|
|
536
|
-
first: 5
|
|
537
|
-
});
|
|
543
|
+
const projectsConnection = teamId
|
|
544
|
+
? await (await this.client.team(teamId)).projects({ filter, first: 5 })
|
|
545
|
+
: await this.client.projects({ filter, first: 5 });
|
|
538
546
|
if (projectsConnection.nodes.length === 0) {
|
|
539
547
|
const context = teamInput ? `on team "${teamInput}"` : undefined;
|
|
540
548
|
throw notFoundError("Project", projectInput, context);
|
package/dist/utils/output.js
CHANGED
|
@@ -40,9 +40,58 @@ export function setOutputFormat(format) {
|
|
|
40
40
|
export function getOutputFormat() {
|
|
41
41
|
return outputFormat;
|
|
42
42
|
}
|
|
43
|
-
|
|
43
|
+
/**
|
|
44
|
+
* Resolve a dot-separated path against a value (`a.b.c`; array indices are
|
|
45
|
+
* numeric segments, `items.0.id`). Returns `undefined` when any segment is
|
|
46
|
+
* missing so callers can distinguish "missing" from a real `null` value.
|
|
47
|
+
* Same path grammar as el-git's `--field` selector — keeping the two flags'
|
|
48
|
+
* semantics aligned is the point (DEV-5323).
|
|
49
|
+
*/
|
|
50
|
+
function getNestedPath(obj, path) {
|
|
51
|
+
let cur = obj;
|
|
52
|
+
for (const seg of path.split(".")) {
|
|
53
|
+
if (cur === null || cur === undefined || typeof cur !== "object") {
|
|
54
|
+
return undefined;
|
|
55
|
+
}
|
|
56
|
+
cur = cur[seg];
|
|
57
|
+
}
|
|
58
|
+
return cur;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Project `obj` down to the requested fields.
|
|
62
|
+
*
|
|
63
|
+
* - Top-level keys copy through as before.
|
|
64
|
+
* - Dot-separated paths (DEV-5323) resolve nested values; the requested path
|
|
65
|
+
* string becomes a flat output key (`{"pipeline.status": "success"}`), so
|
|
66
|
+
* consumers read exactly what they asked for.
|
|
67
|
+
* - A field that resolves nowhere is emitted as an explicit `null` AND
|
|
68
|
+
* reported via `unresolved` — never silently omitted. Silent omission made
|
|
69
|
+
* a typo'd field indistinguishable from an empty value (DEV-5323).
|
|
70
|
+
* - For arrays, each item is projected; a field counts as unresolved only
|
|
71
|
+
* when it resolves on NO item (heterogeneous lists legitimately have
|
|
72
|
+
* per-item gaps).
|
|
73
|
+
*/
|
|
74
|
+
function filterFields(obj, fields, unresolved) {
|
|
44
75
|
if (Array.isArray(obj)) {
|
|
45
|
-
|
|
76
|
+
const resolvedSomewhere = new Set();
|
|
77
|
+
const items = obj.map((item) => {
|
|
78
|
+
const perItem = new Set();
|
|
79
|
+
const projected = filterFields(item, fields, perItem);
|
|
80
|
+
for (const field of fields) {
|
|
81
|
+
if (!perItem.has(field)) {
|
|
82
|
+
resolvedSomewhere.add(field);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return projected;
|
|
86
|
+
});
|
|
87
|
+
if (unresolved) {
|
|
88
|
+
for (const field of fields) {
|
|
89
|
+
if (!resolvedSomewhere.has(field)) {
|
|
90
|
+
unresolved.add(field);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return items;
|
|
46
95
|
}
|
|
47
96
|
if (obj !== null && typeof obj === "object") {
|
|
48
97
|
const source = obj;
|
|
@@ -50,7 +99,17 @@ function filterFields(obj, fields) {
|
|
|
50
99
|
for (const field of fields) {
|
|
51
100
|
if (field in source) {
|
|
52
101
|
result[field] = source[field];
|
|
102
|
+
continue;
|
|
53
103
|
}
|
|
104
|
+
const nested = field.includes(".")
|
|
105
|
+
? getNestedPath(source, field)
|
|
106
|
+
: undefined;
|
|
107
|
+
if (nested !== undefined) {
|
|
108
|
+
result[field] = nested;
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
result[field] = null;
|
|
112
|
+
unresolved?.add(field);
|
|
54
113
|
}
|
|
55
114
|
return result;
|
|
56
115
|
}
|
|
@@ -143,18 +202,41 @@ export function outputSuccess(data) {
|
|
|
143
202
|
// (project.name, teams[].key) the formatter needs to render. Skip the
|
|
144
203
|
// JSON filter when summary is active and let the formatter do the work.
|
|
145
204
|
if (fieldsFilter && outputFormat !== "summary") {
|
|
205
|
+
const unresolved = new Set();
|
|
146
206
|
if (Array.isArray(output)) {
|
|
147
|
-
output = filterFields(output, fieldsFilter);
|
|
207
|
+
output = filterFields(output, fieldsFilter, unresolved);
|
|
148
208
|
}
|
|
149
209
|
else if (output !== null && typeof output === "object") {
|
|
150
210
|
const obj = output;
|
|
151
211
|
if (Array.isArray(obj.data)) {
|
|
152
|
-
output = {
|
|
212
|
+
output = {
|
|
213
|
+
...obj,
|
|
214
|
+
data: filterFields(obj.data, fieldsFilter, unresolved),
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
else if (obj.data !== null &&
|
|
218
|
+
obj.data !== undefined &&
|
|
219
|
+
typeof obj.data === "object") {
|
|
220
|
+
// Envelope with an OBJECT data payload (e.g. el-git context):
|
|
221
|
+
// project inside `data`, same as the array branch. Before
|
|
222
|
+
// DEV-5323 this fell through to root filtering, so
|
|
223
|
+
// `--fields branch,issueId` on an envelope returned `{}`.
|
|
224
|
+
// Paths are relative to `data` for every envelope shape.
|
|
225
|
+
output = {
|
|
226
|
+
...obj,
|
|
227
|
+
data: filterFields(obj.data, fieldsFilter, unresolved),
|
|
228
|
+
};
|
|
153
229
|
}
|
|
154
230
|
else {
|
|
155
|
-
output = filterFields(output, fieldsFilter);
|
|
231
|
+
output = filterFields(output, fieldsFilter, unresolved);
|
|
156
232
|
}
|
|
157
233
|
}
|
|
234
|
+
if (unresolved.size > 0) {
|
|
235
|
+
// Fail-visible: the projected keys carry explicit nulls and this
|
|
236
|
+
// warning names them, so a typo'd/missing field is never mistaken
|
|
237
|
+
// for an empty value (DEV-5323).
|
|
238
|
+
outputWarning(`fields_unresolved: ${[...unresolved].join(", ")} did not resolve on this payload (emitted as null). Paths are dot-separated and relative to the envelope's data when one is present.`);
|
|
239
|
+
}
|
|
158
240
|
}
|
|
159
241
|
// summary format takes the post-raw value and renders it as a
|
|
160
242
|
// human-readable block. We bypass the jq path because jq is a
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@enrichlayer/el-linear",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.34.1",
|
|
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",
|