@enrichlayer/el-linear 1.34.0 → 1.35.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 CHANGED
@@ -445,6 +445,7 @@ el-linear <command> --help # detailed help for one command
445
445
  | Comments | `comments {list, read, create, update, delete}` |
446
446
  | Labels | `labels {list, create, retire, restore}` |
447
447
  | Projects | `projects {list, add-team, remove-team}` |
448
+ | Project updates | `project-updates {create, list, read}` (post a status update to a project's Updates feed) |
448
449
  | Cycles | `cycles {list, read}` |
449
450
  | Documents | `documents {list, read, create, update, delete}` |
450
451
  | Releases | `releases {list, read, create, pipelines}` |
@@ -578,7 +579,7 @@ el-linear projects list --format summary --fields name,state,progress,lead,teams
578
579
  # ...
579
580
  ```
580
581
 
581
- Unrecognized field names are reported as a `_warnings:` line appended after the summary block (`fields_unprojectable: --format summary on issues list does not project foo, bar; ...`) — same signal scripts get on the JSON path. Resources whose summary formatter doesn't yet wire `--fields` (cycles, milestones, comments, teams, labels, users, documents, templates, attachments, releases, search results) emit the same warning and render their default summary.
582
+ Unrecognized field names are reported as a `_warnings:` line appended after the summary block (`fields_unprojectable: --format summary on issues list does not project foo, bar; ...`) — same signal scripts get on the JSON path. Resources whose summary formatter doesn't yet wire `--fields` (cycles, milestones, project updates, comments, teams, labels, users, documents, templates, attachments, releases, search results) emit the same warning and render their default summary.
582
583
 
583
584
  ### Windowed metadata (`WindowedMeta`)
584
585
 
@@ -22,11 +22,12 @@ import { handleAsyncCommand, outputSuccess } from "../utils/output.js";
22
22
  // elsewhere in the workspace. Any addition here is the single point of
23
23
  // truth that all skills rely on.
24
24
  //
25
- // Accepted prefixes (DEV-4777 adds bug/spike; DEV-4660 added codex; both
26
- // mirror tools-repo DEV-4417):
27
- // feature | fix | chore | refactor | dev — SOP-canonical + Linear-CLI direct
25
+ // Accepted prefixes (DEV-4777 adds bug/spike; DEV-4660 added codex; DEV-5342
26
+ // adds the feat short-form alias; all mirror tools-repo DEV-4417/DEV-5334):
27
+ // feature | feat | fix | chore | refactor | dev — SOP-canonical + Linear-CLI direct
28
28
  // bug | spike — sanctioned Linear type labels
29
29
  // codex — Codex-authored branches (codex/<TEAM>-<N>-slug)
30
+ // feat — short-form alias for feature (DEV-5342)
30
31
  // New authoring surfaces (Codex, future agent prefixes) and sanctioned Linear
31
32
  // type labels (bug, spike) get first-class issue detection so commit guards,
32
33
  // MR descriptions, and session handoff don't go dark on a branch a human
@@ -34,7 +35,7 @@ import { handleAsyncCommand, outputSuccess } from "../utils/output.js";
34
35
  //
35
36
  // Mirror of cli/el-git/src/commands/context.ts:BRANCH_RE in the
36
37
  // vertical-int/tools repo. If you change one, change the other.
37
- const BRANCH_RE = /^(?:feature|fix|chore|refactor|bug|spike|dev|codex)[-/]([A-Z]{2,4})-(\d+)(?:[-/](.*))?$/i;
38
+ const BRANCH_RE = /^(?:feature|feat|fix|chore|refactor|bug|spike|dev|codex)[-/]([A-Z]{2,4})-(\d+)(?:[-/](.*))?$/i;
38
39
  export function parseBranchName(branch) {
39
40
  const m = branch.match(BRANCH_RE);
40
41
  if (!m) {
@@ -0,0 +1,2 @@
1
+ import type { Command } from "commander";
2
+ export declare function setupProjectUpdatesCommands(program: Command): void;
@@ -0,0 +1,116 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { CREATE_PROJECT_UPDATE_MUTATION, GET_PROJECT_UPDATE_BY_ID_QUERY, LIST_PROJECT_UPDATES_QUERY, } from "../queries/project-updates.js";
3
+ import { notFoundError } from "../utils/error-messages.js";
4
+ import { createGraphQLService } from "../utils/graphql-service.js";
5
+ import { createLinearService } from "../utils/linear-service.js";
6
+ import { handleAsyncCommand, outputSuccess } from "../utils/output.js";
7
+ import { getRootOpts } from "../utils/root-opts.js";
8
+ import { parsePositiveInt } from "../utils/validators.js";
9
+ const VALID_HEALTH = [
10
+ "onTrack",
11
+ "atRisk",
12
+ "offTrack",
13
+ ];
14
+ /**
15
+ * Resolve the update body from `--body` / `--body-file` (mutually exclusive,
16
+ * exactly one required) — mirrors the comments-command contract so file-sourced
17
+ * bodies sidestep shell-quoting traps for markdown/tables/backticks.
18
+ */
19
+ function resolveBody(options) {
20
+ if (options.body && options.bodyFile) {
21
+ throw new Error("--body and --body-file are mutually exclusive — pass one or the other");
22
+ }
23
+ if (options.bodyFile) {
24
+ return readFileSync(options.bodyFile, "utf-8");
25
+ }
26
+ if (options.body) {
27
+ return options.body;
28
+ }
29
+ throw new Error("Either --body or --body-file is required");
30
+ }
31
+ /**
32
+ * Validate an optional `--health` value against Linear's
33
+ * `ProjectUpdateHealthType` enum. Returns undefined when the flag is omitted
34
+ * (Linear then defaults the health), throws on an unknown value.
35
+ */
36
+ function resolveHealth(value) {
37
+ if (value === undefined) {
38
+ return undefined;
39
+ }
40
+ if (!VALID_HEALTH.includes(value)) {
41
+ throw new Error(`Invalid --health "${value}" — expected one of: ${VALID_HEALTH.join(", ")}`);
42
+ }
43
+ return value;
44
+ }
45
+ async function handleCreateProjectUpdate(options, command) {
46
+ // Validate local input first so bad --body/--health fails with zero network.
47
+ const body = resolveBody(options);
48
+ const health = resolveHealth(options.health);
49
+ const rootOpts = getRootOpts(command);
50
+ const graphQLService = await createGraphQLService(rootOpts);
51
+ const linearService = await createLinearService(rootOpts);
52
+ const projectId = await linearService.resolveProjectId(options.project);
53
+ const input = { projectId, body };
54
+ if (health !== undefined) {
55
+ input.health = health;
56
+ }
57
+ if (options.diffHidden) {
58
+ input.isDiffHidden = true;
59
+ }
60
+ const result = await graphQLService.rawRequest(CREATE_PROJECT_UPDATE_MUTATION, { input });
61
+ if (!result.projectUpdateCreate.success ||
62
+ !result.projectUpdateCreate.projectUpdate) {
63
+ throw new Error(`Failed to create project update on project "${options.project}"`);
64
+ }
65
+ outputSuccess(result.projectUpdateCreate.projectUpdate);
66
+ }
67
+ async function handleListProjectUpdates(options, command) {
68
+ const rootOpts = getRootOpts(command);
69
+ const graphQLService = await createGraphQLService(rootOpts);
70
+ const linearService = await createLinearService(rootOpts);
71
+ const projectId = await linearService.resolveProjectId(options.project);
72
+ const result = await graphQLService.rawRequest(LIST_PROJECT_UPDATES_QUERY, {
73
+ projectId,
74
+ first: parsePositiveInt(options.limit, "--limit"),
75
+ });
76
+ if (!result.project) {
77
+ throw notFoundError("Project", options.project);
78
+ }
79
+ const nodes = result.project.projectUpdates.nodes;
80
+ outputSuccess({ data: nodes, meta: { count: nodes.length } });
81
+ }
82
+ async function handleReadProjectUpdate(updateId, _options, command) {
83
+ const rootOpts = getRootOpts(command);
84
+ const graphQLService = await createGraphQLService(rootOpts);
85
+ const result = await graphQLService.rawRequest(GET_PROJECT_UPDATE_BY_ID_QUERY, { id: updateId });
86
+ if (!result.projectUpdate) {
87
+ throw notFoundError("Project update", updateId);
88
+ }
89
+ outputSuccess(result.projectUpdate);
90
+ }
91
+ export function setupProjectUpdatesCommands(program) {
92
+ const projectUpdates = program
93
+ .command("project-updates")
94
+ .description("Project update (status post) operations");
95
+ projectUpdates.action(() => projectUpdates.help());
96
+ projectUpdates
97
+ .command("create")
98
+ .description("Post a status update to a project (appears in the project's Updates feed).")
99
+ .requiredOption("--project <project>", "project name or ID")
100
+ .option("--body <body>", "update body markdown (inline)")
101
+ .option("--body-file <path>", "read update body from file")
102
+ .option("--health <health>", "project health: onTrack | atRisk | offTrack (omit to leave unset)")
103
+ .option("--diff-hidden", "hide the progress diff on the update")
104
+ .option("-q, --quiet", "print one confirmation line (health url) instead of the full JSON")
105
+ .action(handleAsyncCommand(handleCreateProjectUpdate));
106
+ projectUpdates
107
+ .command("list")
108
+ .description("List status updates posted to a project (newest first).")
109
+ .requiredOption("--project <project>", "project name or ID")
110
+ .option("-l, --limit <number>", "limit results", "50")
111
+ .action(handleAsyncCommand(handleListProjectUpdates));
112
+ projectUpdates
113
+ .command("read <updateId>")
114
+ .description("Get a single project update by its ID.")
115
+ .action(handleAsyncCommand(handleReadProjectUpdate));
116
+ }
package/dist/main.js CHANGED
@@ -20,6 +20,7 @@ import { setupIssuesCommands } from "./commands/issues.js";
20
20
  import { setupLabelsCommands } from "./commands/labels.js";
21
21
  import { setupProfileCommands } from "./commands/profile.js";
22
22
  import { setupProjectMilestonesCommands } from "./commands/project-milestones.js";
23
+ import { setupProjectUpdatesCommands } from "./commands/project-updates.js";
23
24
  import { setupProjectsCommands } from "./commands/projects.js";
24
25
  import { setupReadShortcut } from "./commands/read-shortcut.js";
25
26
  import { setupRefsCommands } from "./commands/refs.js";
@@ -65,9 +66,13 @@ program
65
66
  .option("--profile <name>", "named profile (under ~/.config/el-linear/profiles/<name>/) for this invocation. Overrides EL_LINEAR_PROFILE env + the on-disk active-profile marker.")
66
67
  .option("--json", "output as JSON (default, accepted for compatibility)")
67
68
  .option("--format <kind>", "output format: json (default, structured envelope) or summary (human-readable)", "json")
68
- .option("--raw", "strip { data, meta } wrapper from list output — emit the array directly")
69
+ .option("--raw", "strip { data, meta } wrapper from list output — emit the array directly. " +
70
+ "Warnings (e.g. --fields fields_unresolved) can't ride on a bare array, so " +
71
+ "they are written to stderr prefixed `_warnings: `, keeping stdout a pure JSON array")
69
72
  .option("--jq <filter>", "apply a jq filter to the JSON output")
70
- .option("--fields <fields>", "filter output to specific fields (comma-separated)")
73
+ .option("--fields <fields>", "filter output to specific fields (comma-separated). Unresolved fields are " +
74
+ "emitted as null plus a `fields_unresolved:` warning — in the JSON " +
75
+ "envelope's `_warnings`, or on stderr when output is a bare array (with --raw)")
71
76
  .option("--no-cache", "bypass the on-disk cache for `teams list` / `labels list` / `projects list`");
72
77
  program.hook("preAction", (_thisCommand, actionCommand) => {
73
78
  const rootOpts = actionCommand.optsWithGlobals();
@@ -143,6 +148,7 @@ setupReleasesCommands(program);
143
148
  setupProjectsCommands(program);
144
149
  setupCyclesCommands(program);
145
150
  setupProjectMilestonesCommands(program);
151
+ setupProjectUpdatesCommands(program);
146
152
  setupEmbedsCommands(program);
147
153
  setupTeamsCommands(program);
148
154
  setupTemplatesCommands(program);
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Typed response shapes for the queries in `./project-updates.ts`.
3
+ * See `./issues-types.ts` for the rationale (ALL-937).
4
+ *
5
+ * All three queries (create / list / read) select the same
6
+ * `PROJECT_UPDATE_FRAGMENT`, so one node shape covers every consumer.
7
+ */
8
+ export type ProjectUpdateHealth = "onTrack" | "atRisk" | "offTrack";
9
+ interface ProjectUpdateProjectRef {
10
+ id: string;
11
+ name: string;
12
+ }
13
+ interface ProjectUpdateUserRef {
14
+ id: string;
15
+ name: string;
16
+ displayName: string | null;
17
+ }
18
+ /**
19
+ * Mirrors `PROJECT_UPDATE_FRAGMENT` — the full selection set shared by the
20
+ * create mutation, the project-scoped list, and the by-id read.
21
+ */
22
+ export interface ProjectUpdateNode {
23
+ id: string;
24
+ body: string | null;
25
+ health: ProjectUpdateHealth | null;
26
+ url: string | null;
27
+ slugId: string | null;
28
+ createdAt: string;
29
+ updatedAt: string;
30
+ editedAt: string | null;
31
+ user: ProjectUpdateUserRef | null;
32
+ project: ProjectUpdateProjectRef | null;
33
+ }
34
+ export interface CreateProjectUpdateResponse {
35
+ projectUpdateCreate: {
36
+ success: boolean;
37
+ projectUpdate: ProjectUpdateNode | null;
38
+ };
39
+ }
40
+ export interface ListProjectUpdatesResponse {
41
+ project: {
42
+ id: string;
43
+ name: string;
44
+ projectUpdates: {
45
+ nodes: ProjectUpdateNode[];
46
+ };
47
+ } | null;
48
+ }
49
+ export interface GetProjectUpdateByIdResponse {
50
+ projectUpdate: ProjectUpdateNode | null;
51
+ }
52
+ export {};
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Typed response shapes for the queries in `./project-updates.ts`.
3
+ * See `./issues-types.ts` for the rationale (ALL-937).
4
+ *
5
+ * All three queries (create / list / read) select the same
6
+ * `PROJECT_UPDATE_FRAGMENT`, so one node shape covers every consumer.
7
+ */
8
+ export {};
@@ -0,0 +1,3 @@
1
+ export declare const CREATE_PROJECT_UPDATE_MUTATION = "\n mutation ProjectUpdateCreate($input: ProjectUpdateCreateInput!) {\n projectUpdateCreate(input: $input) {\n success\n projectUpdate {\n \n id\n body\n health\n url\n slugId\n createdAt\n updatedAt\n editedAt\n user {\n id\n name\n displayName\n }\n project {\n id\n name\n }\n\n }\n }\n }\n";
2
+ export declare const LIST_PROJECT_UPDATES_QUERY = "\n query ListProjectUpdates($projectId: String!, $first: Int!) {\n project(id: $projectId) {\n id\n name\n projectUpdates(first: $first, orderBy: createdAt) {\n nodes {\n \n id\n body\n health\n url\n slugId\n createdAt\n updatedAt\n editedAt\n user {\n id\n name\n displayName\n }\n project {\n id\n name\n }\n\n }\n }\n }\n }\n";
3
+ export declare const GET_PROJECT_UPDATE_BY_ID_QUERY = "\n query GetProjectUpdate($id: String!) {\n projectUpdate(id: $id) {\n \n id\n body\n health\n url\n slugId\n createdAt\n updatedAt\n editedAt\n user {\n id\n name\n displayName\n }\n project {\n id\n name\n }\n\n }\n }\n";
@@ -0,0 +1,49 @@
1
+ const PROJECT_UPDATE_FRAGMENT = `
2
+ id
3
+ body
4
+ health
5
+ url
6
+ slugId
7
+ createdAt
8
+ updatedAt
9
+ editedAt
10
+ user {
11
+ id
12
+ name
13
+ displayName
14
+ }
15
+ project {
16
+ id
17
+ name
18
+ }
19
+ `;
20
+ export const CREATE_PROJECT_UPDATE_MUTATION = `
21
+ mutation ProjectUpdateCreate($input: ProjectUpdateCreateInput!) {
22
+ projectUpdateCreate(input: $input) {
23
+ success
24
+ projectUpdate {
25
+ ${PROJECT_UPDATE_FRAGMENT}
26
+ }
27
+ }
28
+ }
29
+ `;
30
+ export const LIST_PROJECT_UPDATES_QUERY = `
31
+ query ListProjectUpdates($projectId: String!, $first: Int!) {
32
+ project(id: $projectId) {
33
+ id
34
+ name
35
+ projectUpdates(first: $first, orderBy: createdAt) {
36
+ nodes {
37
+ ${PROJECT_UPDATE_FRAGMENT}
38
+ }
39
+ }
40
+ }
41
+ }
42
+ `;
43
+ export const GET_PROJECT_UPDATE_BY_ID_QUERY = `
44
+ query GetProjectUpdate($id: String!) {
45
+ projectUpdate(id: $id) {
46
+ ${PROJECT_UPDATE_FRAGMENT}
47
+ }
48
+ }
49
+ `;
@@ -30,6 +30,8 @@ export declare function formatCycleSummary(cycle: Record<string, unknown>): stri
30
30
  export declare function formatCycleList(cycles: unknown[]): string;
31
31
  export declare function formatMilestoneSummary(milestone: Record<string, unknown>): string;
32
32
  export declare function formatMilestoneList(milestones: unknown[]): string;
33
+ export declare function formatProjectUpdateSummary(update: Record<string, unknown>): string;
34
+ export declare function formatProjectUpdateList(updates: unknown[]): string;
33
35
  export declare function formatTeamList(teams: unknown[]): string;
34
36
  export declare function formatLabelList(labels: unknown[]): string;
35
37
  /**
@@ -68,7 +70,7 @@ export declare function formatSearchResultList(results: unknown[]): string;
68
70
  * the full payload. Lists fall back to a simple bulleted list.
69
71
  */
70
72
  export declare function formatGenericSummary(value: unknown): string;
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";
73
+ export type ResourceKind = "issue" | "issue-list" | "project" | "project-list" | "comment" | "comment-list" | "cycle" | "cycle-list" | "milestone" | "milestone-list" | "project-update" | "project-update-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";
72
74
  /**
73
75
  * Heuristic — used by the central `outputSuccess` path which doesn't know
74
76
  * which command produced the payload. Looks at the shape of the data to
@@ -655,6 +655,35 @@ export function formatMilestoneList(milestones) {
655
655
  { header: "PROJECT", minWidth: 7, extract: (m) => getName(m.project) },
656
656
  ], { emptyText: "(no milestones)", itemNoun: "milestone" });
657
657
  }
658
+ // ── project updates ────────────────────────────────────────────
659
+ export function formatProjectUpdateSummary(update) {
660
+ const headerLine = `project-update ${s(update.id)}`;
661
+ const fields = [
662
+ { label: "Health", value: s(update.health) },
663
+ { label: "Project", value: getName(update.project) },
664
+ { label: "Author", value: getName(update.user) },
665
+ { label: "Created", value: s(update.createdAt) },
666
+ { label: "URL", value: s(update.url) },
667
+ ];
668
+ const header = renderHeader(fields);
669
+ const body = clipDescription(update.body);
670
+ const parts = [headerLine, header];
671
+ if (body)
672
+ parts.push("", body);
673
+ return parts.filter((p) => p !== "").join("\n");
674
+ }
675
+ export function formatProjectUpdateList(updates) {
676
+ return renderTable(updates.map((raw) => asObj(raw) ?? {}), [
677
+ { header: "HEALTH", minWidth: 6, extract: (u) => s(u.health) },
678
+ { header: "AUTHOR", minWidth: 6, extract: (u) => getName(u.user) },
679
+ {
680
+ header: "CREATED",
681
+ minWidth: 7,
682
+ extract: (u) => s(u.createdAt).slice(0, 10),
683
+ },
684
+ { header: "URL", minWidth: 3, maxWidth: 60, extract: (u) => s(u.url) },
685
+ ], { emptyText: "(no project updates)", itemNoun: "project update" });
686
+ }
658
687
  // ── teams ──────────────────────────────────────────────────────
659
688
  export function formatTeamList(teams) {
660
689
  return renderTable(teams.map((raw) => asObj(raw) ?? {}), [
@@ -1036,6 +1065,10 @@ export function inferKindFromPayload(value) {
1036
1065
  "progress" in obj &&
1037
1066
  ("state" in obj || "lead" in obj || "teams" in obj))
1038
1067
  return "project";
1068
+ // Project updates carry body + createdAt + user like comments; `health` is
1069
+ // the distinguishing field, so this must precede the comment check.
1070
+ if ("body" in obj && "health" in obj)
1071
+ return "project-update";
1039
1072
  if ("body" in obj && "createdAt" in obj && "user" in obj)
1040
1073
  return "comment";
1041
1074
  if (("number" in obj || "isActive" in obj) &&
@@ -1079,6 +1112,10 @@ function inferListKind(items) {
1079
1112
  }
1080
1113
  return "issue-list";
1081
1114
  }
1115
+ // Project-update rows carry body + createdAt like comments; `health` is the
1116
+ // distinguishing field, so this must precede the comment-list check.
1117
+ if ("body" in sample && "health" in sample)
1118
+ return "project-update-list";
1082
1119
  if ("body" in sample && "createdAt" in sample)
1083
1120
  return "comment-list";
1084
1121
  if ("progress" in sample && "name" in sample) {
@@ -1168,6 +1205,10 @@ export function dispatch(kind, payload, fields) {
1168
1205
  return formatMilestoneSummary((obj ?? {}));
1169
1206
  case "milestone-list":
1170
1207
  return formatMilestoneList(list ?? []);
1208
+ case "project-update":
1209
+ return formatProjectUpdateSummary((obj ?? {}));
1210
+ case "project-update-list":
1211
+ return formatProjectUpdateList(list ?? []);
1171
1212
  case "team-list":
1172
1213
  return formatTeamList(list ?? []);
1173
1214
  case "label-list":
@@ -1230,6 +1271,11 @@ export function formatLine(payload) {
1230
1271
  if (kind === "comment" && obj) {
1231
1272
  return `comment ${s(obj.id)}`;
1232
1273
  }
1274
+ if (kind === "project-update" && obj) {
1275
+ // create doesn't carry an identifier/title — health + url are the
1276
+ // stable handles a caller needs (matches the summary header style).
1277
+ return `${s(obj.health)} ${s(obj.url)}`;
1278
+ }
1233
1279
  if (kind === "relation-list") {
1234
1280
  return formatRelationLine(payload);
1235
1281
  }
@@ -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,64 @@ 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
+ // A primitive item short-circuits: filterFields returns it
82
+ // unchanged and records NOTHING in perItem, so every field counts
83
+ // as resolved here — a mixed array holding one primitive suppresses
84
+ // the unresolved warning for all fields. Acceptable: a primitive
85
+ // item can't meaningfully be projected, and heterogeneous lists
86
+ // legitimately have per-item gaps.
87
+ if (!perItem.has(field)) {
88
+ resolvedSomewhere.add(field);
89
+ }
90
+ }
91
+ return projected;
92
+ });
93
+ if (unresolved) {
94
+ for (const field of fields) {
95
+ if (!resolvedSomewhere.has(field)) {
96
+ unresolved.add(field);
97
+ }
98
+ }
99
+ }
100
+ return items;
46
101
  }
47
102
  if (obj !== null && typeof obj === "object") {
48
103
  const source = obj;
@@ -50,7 +105,17 @@ function filterFields(obj, fields) {
50
105
  for (const field of fields) {
51
106
  if (field in source) {
52
107
  result[field] = source[field];
108
+ continue;
109
+ }
110
+ const nested = field.includes(".")
111
+ ? getNestedPath(source, field)
112
+ : undefined;
113
+ if (nested !== undefined) {
114
+ result[field] = nested;
115
+ continue;
53
116
  }
117
+ result[field] = null;
118
+ unresolved?.add(field);
54
119
  }
55
120
  return result;
56
121
  }
@@ -143,18 +208,40 @@ export function outputSuccess(data) {
143
208
  // (project.name, teams[].key) the formatter needs to render. Skip the
144
209
  // JSON filter when summary is active and let the formatter do the work.
145
210
  if (fieldsFilter && outputFormat !== "summary") {
211
+ const unresolved = new Set();
146
212
  if (Array.isArray(output)) {
147
- output = filterFields(output, fieldsFilter);
213
+ output = filterFields(output, fieldsFilter, unresolved);
148
214
  }
149
215
  else if (output !== null && typeof output === "object") {
150
216
  const obj = output;
151
- if (Array.isArray(obj.data)) {
152
- output = { ...obj, data: filterFields(obj.data, fieldsFilter) };
217
+ if (obj.data !== null &&
218
+ obj.data !== undefined &&
219
+ typeof obj.data === "object") {
220
+ // Envelope with a `data` payload — project inside `data`,
221
+ // whether it's an array (list envelope) or an object (e.g.
222
+ // el-git context). Arrays ARE objects, so this single
223
+ // `typeof === "object"` check subsumes the former separate
224
+ // `Array.isArray(obj.data)` arm (DEV-5339 collapsed the two
225
+ // byte-identical branches); `filterFields` dispatches on
226
+ // array-vs-object internally. Paths are relative to `data` for
227
+ // every envelope shape. Before DEV-5323 the object case fell
228
+ // through to root filtering, so `--fields branch,issueId` on an
229
+ // envelope returned `{}`.
230
+ output = {
231
+ ...obj,
232
+ data: filterFields(obj.data, fieldsFilter, unresolved),
233
+ };
153
234
  }
154
235
  else {
155
- output = filterFields(output, fieldsFilter);
236
+ output = filterFields(output, fieldsFilter, unresolved);
156
237
  }
157
238
  }
239
+ if (unresolved.size > 0) {
240
+ // Fail-visible: the projected keys carry explicit nulls and this
241
+ // warning names them, so a typo'd/missing field is never mistaken
242
+ // for an empty value (DEV-5323).
243
+ 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.`);
244
+ }
158
245
  }
159
246
  // summary format takes the post-raw value and renders it as a
160
247
  // human-readable block. We bypass the jq path because jq is a
@@ -179,11 +266,31 @@ export function outputSuccess(data) {
179
266
  // fields_unprojectable surfaced from a prior dispatch) and embed
180
267
  // them as `_warnings` on the envelope.
181
268
  const warnings = [...drainWarnings(), ...drainSummaryFieldWarnings()];
182
- if (warnings.length > 0 &&
183
- output !== null &&
184
- typeof output === "object" &&
185
- !Array.isArray(output)) {
186
- output = { ...output, _warnings: warnings };
269
+ if (warnings.length > 0) {
270
+ if (output !== null &&
271
+ typeof output === "object" &&
272
+ !Array.isArray(output)) {
273
+ output = { ...output, _warnings: warnings };
274
+ }
275
+ else {
276
+ // Bare-array (or primitive / null) output has no envelope object to
277
+ // carry `_warnings`. This is the DEV-5339 fix: previously the buffer
278
+ // was drained above but only re-embedded for object output, so a
279
+ // warning was silently dropped on a top-level array payload AND on
280
+ // `--raw` (which unwraps { data: [...] } to a bare array *before*
281
+ // this point) — losing exactly the DEV-5323 `fields_unresolved:`
282
+ // fail-visible signal on the `--raw` form our own CLAUDE.md
283
+ // recommends to agents. Route each warning to STDERR prefixed
284
+ // `_warnings: ` so the signal always reaches the consumer while
285
+ // stdout stays a pure JSON array (safe to pipe to `jq`). Mirrors the
286
+ // summary path's `_warnings:` line convention — that path uses
287
+ // logger.info because its stdout is already human text; here stdout
288
+ // must remain machine-parseable JSON, so we use logger.error
289
+ // (stderr).
290
+ for (const w of warnings) {
291
+ logger.error(`_warnings: ${w}`);
292
+ }
293
+ }
187
294
  }
188
295
  if (jqFilter) {
189
296
  const json = JSON.stringify(output);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@enrichlayer/el-linear",
3
- "version": "1.34.0",
3
+ "version": "1.35.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",