@enrichlayer/el-linear 1.34.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.
@@ -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
- let page = await this.client.projects({
143
- filter: nonEmptyFilter(filter),
144
- first: unlimited ? 250 : limit,
145
- orderBy: sdkOrderBy("updatedAt"),
146
- includeArchived: false,
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. Same SDK filter shape
520
- // `getProjects` already uses for `--team` filtering.
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 = await this.client.projects({
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);
@@ -40,9 +40,58 @@ export function setOutputFormat(format) {
40
40
  export function getOutputFormat() {
41
41
  return outputFormat;
42
42
  }
43
- function filterFields(obj, fields) {
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
- return obj.map((item) => filterFields(item, fields));
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 = { ...obj, data: filterFields(obj.data, fieldsFilter) };
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.34.0",
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",