@enrichlayer/el-linear 1.28.0 → 1.29.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.
@@ -1,4 +1,4 @@
1
- import { resolveAssignee, resolveLabels, resolveMember, resolveTeam, } from "../config/resolver.js";
1
+ import { resolveAssignee, resolveLabels, resolveMemberWithRegistry, resolveTeam, } from "../config/resolver.js";
2
2
  import { createIssuesService } from "../utils/issues-service-bootstrap.js";
3
3
  import { logger } from "../utils/logger.js";
4
4
  import { handleAsyncCommand, outputSuccess, outputWarning, } from "../utils/output.js";
@@ -45,7 +45,7 @@ async function resolveTargetIssues(options, rootOpts) {
45
45
  ? await resolveAssignee(filters.assignee, rootOpts)
46
46
  : undefined,
47
47
  delegateId: filters.delegate
48
- ? resolveMember(filters.delegate)
48
+ ? await resolveMemberWithRegistry(filters.delegate)
49
49
  : undefined,
50
50
  project: filters.project
51
51
  ? { kind: "id", id: filters.project }
@@ -18,6 +18,7 @@
18
18
  * connection has no top-level state filter).
19
19
  */
20
20
  import { buildIssueTreeQuery, DEFAULT_TREE_DEPTH, MAX_TREE_DEPTH, } from "../../queries/issue-tree.js";
21
+ import { TERMINAL_STATE_TYPES } from "../../types/linear.js";
21
22
  import { formatTree } from "../../utils/format-tree.js";
22
23
  import { createIssuesService } from "../../utils/issues-service-bootstrap.js";
23
24
  import { handleAsyncCommand, outputSuccess } from "../../utils/output.js";
@@ -74,10 +75,11 @@ function parseDepth(raw) {
74
75
  return n;
75
76
  }
76
77
  /**
77
- * Walk the tree depth-first and drop any node whose `state.type` is
78
- * `completed` or `canceled`. Pure function — does not mutate the input.
79
- * A pruned child takes its entire subtree with it (consistent with how
80
- * `issues list --no-include-closed` excludes closed work entirely).
78
+ * Walk the tree depth-first and drop any node whose `state.type` is terminal
79
+ * (`completed` / `canceled` / `duplicate` — `TERMINAL_STATE_TYPES`, DEV-4879).
80
+ * Pure function — does not mutate the input. A pruned child takes its entire
81
+ * subtree with it (consistent with how `issues list --no-include-closed`
82
+ * excludes closed work entirely — same shared constant).
81
83
  *
82
84
  * Assumes Linear's parent → children graph stays single-parent (a tree,
83
85
  * not a DAG). If Linear ever ships multi-parent issues, this recursion
@@ -97,6 +99,5 @@ function pruneTerminalStates(root) {
97
99
  };
98
100
  }
99
101
  function isTerminalState(node) {
100
- const t = node.state?.type;
101
- return t === "completed" || t === "canceled";
102
+ return TERMINAL_STATE_TYPES.includes(node.state?.type ?? "");
102
103
  }
@@ -2,7 +2,7 @@ import { execFileSync } from "node:child_process";
2
2
  import { loadConfig } from "../config/config.js";
3
3
  import { enrichProjectResolverError, enrichValidationErrors, } from "../config/error-enrichment.js";
4
4
  import { enforceValidation, validateIssueCreation, } from "../config/issue-validation.js";
5
- import { resolveAssignee, resolveLabels, resolveMember, resolveTeam, } from "../config/resolver.js";
5
+ import { resolveAssignee, resolveLabels, resolveMemberWithRegistry, resolveTeam, } from "../config/resolver.js";
6
6
  import { resolveDefaultStatus } from "../config/status-defaults.js";
7
7
  import { enforceTerms } from "../config/term-enforcer.js";
8
8
  import { GET_ISSUE_RELATIONS_QUERY, GET_ISSUE_STATE_HISTORY_QUERY, } from "../queries/issues.js";
@@ -210,7 +210,7 @@ async function handleListIssues(options, command) {
210
210
  ? await resolveAssignee(options.assignee, rootOpts)
211
211
  : undefined,
212
212
  delegateId: options.delegate
213
- ? resolveMember(options.delegate)
213
+ ? await resolveMemberWithRegistry(options.delegate)
214
214
  : undefined,
215
215
  project: resolveProjectFlag(options.project),
216
216
  labelNames: options.labels ? splitList(options.labels) : undefined,
@@ -256,7 +256,7 @@ async function handleSearchIssues(query, options, command) {
256
256
  ? await resolveAssignee(options.assignee, rootOpts)
257
257
  : undefined,
258
258
  delegateId: options.delegate
259
- ? resolveMember(options.delegate)
259
+ ? await resolveMemberWithRegistry(options.delegate)
260
260
  : undefined,
261
261
  project: resolveProjectFlag(options.project),
262
262
  status: explicitStatus,
@@ -384,7 +384,7 @@ async function resolveCreateInputs(title, options, rootOpts) {
384
384
  ? await resolveAssignee(effectiveAssignee, rootOpts)
385
385
  : undefined;
386
386
  const delegateId = options.delegate
387
- ? resolveMember(options.delegate)
387
+ ? await resolveMemberWithRegistry(options.delegate)
388
388
  : undefined;
389
389
  let labelIds = [];
390
390
  if (options.labels) {
@@ -407,7 +407,10 @@ async function resolveCreateInputs(title, options, rootOpts) {
407
407
  });
408
408
  let subscriberIds;
409
409
  if (options.subscriber) {
410
- subscriberIds = splitList(options.subscriber).map((s) => resolveMember(s));
410
+ // --subscriber resolves through the opt-in registry too (DEV-4880),
411
+ // matching --assignee / --delegate. Each entry falls back to config on a
412
+ // miss; resolveMemberWithRegistry is fail-open and dormant when unconfigured.
413
+ subscriberIds = await Promise.all(splitList(options.subscriber).map((s) => resolveMemberWithRegistry(s)));
411
414
  }
412
415
  const priority = effectivePriorityInput
413
416
  ? validatePriority(effectivePriorityInput)
@@ -843,7 +846,7 @@ async function handleUpdateIssue(issueId, options, command) {
843
846
  const delegateId = options.clearDelegate
844
847
  ? null
845
848
  : options.delegate
846
- ? resolveMember(options.delegate)
849
+ ? await resolveMemberWithRegistry(options.delegate)
847
850
  : undefined;
848
851
  const updateArgs = buildUpdateArgs(issueId, options, assigneeId, delegateId);
849
852
  const result = await withProjectResolverEnrichment(() => issuesService.updateIssue(updateArgs, options.labelBy || "adding"), {
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Optional, opt-in resolution against the company-wide identity registry
3
+ * (DEV-4827 / DEV-4871). EL-only: this activates **solely** when
4
+ * `EL_IDENTITY_URL` is set. el-linear is MIT/open-source — a non-EL install
5
+ * leaves this dormant (no network, no config, nothing written), and the package
6
+ * never takes a dependency on EL-internal infrastructure. It mirrors the
7
+ * env-gated CF-Access pattern of the tools `@enrichlayer/el-identity` client but
8
+ * is kept self-contained so the OSS package carries no private dependency.
9
+ *
10
+ * The registry is an *enhancement* for EL users: callers try it first, then fall
11
+ * back to the bundled config (`resolveMember`). It must never throw or break a
12
+ * command because the registry is unreachable.
13
+ */
14
+ /** True only when the registry is explicitly configured (opt-in). */
15
+ export declare function isRegistryConfigured(env?: NodeJS.ProcessEnv): boolean;
16
+ /**
17
+ * Resolve any identifier (alias, handle, email, name, Linear UUID) to a Linear
18
+ * UUID via the registry's `GET /api/people/resolve`. Returns `null` when the
19
+ * registry is not configured, when nothing matches, or on **any** failure
20
+ * (unreachable / timeout / CF-Access challenge / malformed response) — the
21
+ * caller falls back to the config-based resolver. Never throws.
22
+ */
23
+ export declare function resolveViaRegistry(identifier: string, env?: NodeJS.ProcessEnv, fetchImpl?: typeof fetch): Promise<string | null>;
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Optional, opt-in resolution against the company-wide identity registry
3
+ * (DEV-4827 / DEV-4871). EL-only: this activates **solely** when
4
+ * `EL_IDENTITY_URL` is set. el-linear is MIT/open-source — a non-EL install
5
+ * leaves this dormant (no network, no config, nothing written), and the package
6
+ * never takes a dependency on EL-internal infrastructure. It mirrors the
7
+ * env-gated CF-Access pattern of the tools `@enrichlayer/el-identity` client but
8
+ * is kept self-contained so the OSS package carries no private dependency.
9
+ *
10
+ * The registry is an *enhancement* for EL users: callers try it first, then fall
11
+ * back to the bundled config (`resolveMember`). It must never throw or break a
12
+ * command because the registry is unreachable.
13
+ */
14
+ const URL_ENV = "EL_IDENTITY_URL";
15
+ const CF_ID_ENV = "EL_IDENTITY_CF_ACCESS_CLIENT_ID";
16
+ const CF_SECRET_ENV = "EL_IDENTITY_CF_ACCESS_CLIENT_SECRET";
17
+ const TIMEOUT_MS = 8000;
18
+ /** True only when the registry is explicitly configured (opt-in). */
19
+ export function isRegistryConfigured(env = process.env) {
20
+ return Boolean(env[URL_ENV]?.trim());
21
+ }
22
+ /**
23
+ * Resolve any identifier (alias, handle, email, name, Linear UUID) to a Linear
24
+ * UUID via the registry's `GET /api/people/resolve`. Returns `null` when the
25
+ * registry is not configured, when nothing matches, or on **any** failure
26
+ * (unreachable / timeout / CF-Access challenge / malformed response) — the
27
+ * caller falls back to the config-based resolver. Never throws.
28
+ */
29
+ export async function resolveViaRegistry(identifier, env = process.env, fetchImpl = fetch) {
30
+ const base = env[URL_ENV]?.trim();
31
+ if (!base) {
32
+ return null;
33
+ }
34
+ const headers = { Accept: "application/json" };
35
+ const cfId = env[CF_ID_ENV];
36
+ const cfSecret = env[CF_SECRET_ENV];
37
+ if (cfId && cfSecret) {
38
+ headers["CF-Access-Client-Id"] = cfId;
39
+ headers["CF-Access-Client-Secret"] = cfSecret;
40
+ }
41
+ const url = `${base.replace(/\/+$/, "")}/api/people/resolve?identifier=${encodeURIComponent(identifier)}`;
42
+ try {
43
+ const res = await fetchImpl(url, {
44
+ headers,
45
+ // A 3xx is a Cloudflare Access SSO bounce, not a result — `redirect:
46
+ // "manual"` surfaces it as a non-ok status we treat as a miss.
47
+ redirect: "manual",
48
+ signal: AbortSignal.timeout(TIMEOUT_MS),
49
+ });
50
+ if (!res.ok) {
51
+ return null;
52
+ }
53
+ const record = (await res.json());
54
+ return record?.linearId ?? null;
55
+ }
56
+ catch {
57
+ return null;
58
+ }
59
+ }
@@ -13,6 +13,17 @@ export declare function resolveMember(input: string): string;
13
13
  * Falls back to resolveMember for all other inputs.
14
14
  */
15
15
  export declare function resolveAssignee(input: string, rootOpts: Record<string, unknown>): Promise<string>;
16
+ /**
17
+ * Resolve a member identifier (alias / handle / name) to a UUID, consulting the
18
+ * opt-in identity registry first when configured, then the bundled config
19
+ * (`resolveMember`). The shared async resolution path behind `--assignee` and
20
+ * `--delegate` (DEV-4871 / DEV-4872).
21
+ *
22
+ * EL-only and env-gated on `EL_IDENTITY_URL`; fail-open — when unconfigured, on
23
+ * a miss, or on any failure it falls back to config, so a non-EL install and an
24
+ * unreachable registry both behave exactly as before. Never throws.
25
+ */
26
+ export declare function resolveMemberWithRegistry(input: string): Promise<string>;
16
27
  /**
17
28
  * Resolve a user's display name from their UUID via config fullNames map.
18
29
  * Returns the full name if found, otherwise returns the original name.
@@ -2,6 +2,7 @@ import { createGraphQLService } from "../utils/graphql-service.js";
2
2
  import { outputWarning } from "../utils/output.js";
3
3
  import { isUuid, isUuidPrefix } from "../utils/uuid.js";
4
4
  import { loadConfig } from "./config.js";
5
+ import { isRegistryConfigured, resolveViaRegistry, } from "./registry-resolve.js";
5
6
  /**
6
7
  * Resolve a team key/name/alias to its UUID.
7
8
  * Case-insensitive: "fe" → FE UUID, "frontend" → FE UUID via alias.
@@ -98,6 +99,25 @@ export async function resolveAssignee(input, rootOpts) {
98
99
  }
99
100
  return result.viewer.id;
100
101
  }
102
+ return resolveMemberWithRegistry(input);
103
+ }
104
+ /**
105
+ * Resolve a member identifier (alias / handle / name) to a UUID, consulting the
106
+ * opt-in identity registry first when configured, then the bundled config
107
+ * (`resolveMember`). The shared async resolution path behind `--assignee` and
108
+ * `--delegate` (DEV-4871 / DEV-4872).
109
+ *
110
+ * EL-only and env-gated on `EL_IDENTITY_URL`; fail-open — when unconfigured, on
111
+ * a miss, or on any failure it falls back to config, so a non-EL install and an
112
+ * unreachable registry both behave exactly as before. Never throws.
113
+ */
114
+ export async function resolveMemberWithRegistry(input) {
115
+ if (isRegistryConfigured()) {
116
+ const viaRegistry = await resolveViaRegistry(input);
117
+ if (viaRegistry) {
118
+ return viaRegistry;
119
+ }
120
+ }
101
121
  return resolveMember(input);
102
122
  }
103
123
  /**
@@ -35,6 +35,17 @@ interface StateRef {
35
35
  */
36
36
  type?: string;
37
37
  }
38
+ /**
39
+ * Workflow-state types treated as terminal / closed for default filtering.
40
+ * `issues list` / `issues search` exclude these unless `--include-closed` (or
41
+ * an explicit `--status`) is passed (DEV-4478).
42
+ *
43
+ * `duplicate` is included (DEV-4879) because this workspace parks resolved
44
+ * duplicates in a `duplicate`-typed state — they carry a `duplicate-of`
45
+ * relation and are semantically closed, so they must not leak into the default
46
+ * open-issues view the way `completed` / `canceled` don't.
47
+ */
48
+ export declare const TERMINAL_STATE_TYPES: readonly string[];
38
49
  interface ProjectRef {
39
50
  id: string;
40
51
  name: string;
@@ -3,4 +3,18 @@
3
3
  * These represent the public API surface — GraphQL responses are
4
4
  * transformed into these shapes at the service boundary.
5
5
  */
6
- export {};
6
+ /**
7
+ * Workflow-state types treated as terminal / closed for default filtering.
8
+ * `issues list` / `issues search` exclude these unless `--include-closed` (or
9
+ * an explicit `--status`) is passed (DEV-4478).
10
+ *
11
+ * `duplicate` is included (DEV-4879) because this workspace parks resolved
12
+ * duplicates in a `duplicate`-typed state — they carry a `duplicate-of`
13
+ * relation and are semantically closed, so they must not leak into the default
14
+ * open-issues view the way `completed` / `canceled` don't.
15
+ */
16
+ export const TERMINAL_STATE_TYPES = [
17
+ "completed",
18
+ "canceled",
19
+ "duplicate",
20
+ ];
@@ -10,6 +10,7 @@
10
10
  * Pure rendering — does not fetch or filter. The depth bound and the
11
11
  * terminal-state exclusion are enforced by the caller before this runs.
12
12
  */
13
+ import { TERMINAL_STATE_TYPES } from "../types/linear.js";
13
14
  const BRANCH_TEE = "├── ";
14
15
  const BRANCH_END = "└── ";
15
16
  const VERTICAL = "│ ";
@@ -40,7 +41,7 @@ function formatStateSuffix(state) {
40
41
  // Only annotate terminal-typed states; the renderer is otherwise
41
42
  // state-name-agnostic so workspace-custom workflow states (e.g. "In
42
43
  // Review") don't get a noisy suffix.
43
- if (state.type === "completed" || state.type === "canceled") {
44
+ if (TERMINAL_STATE_TYPES.includes(state.type)) {
44
45
  return ` [${state.name}]`;
45
46
  }
46
47
  return "";
@@ -42,7 +42,8 @@ export interface SearchIssueArgs {
42
42
  /** Issue states to include (e.g. `["Todo", "In Progress"]`). */
43
43
  status?: string[];
44
44
  /**
45
- * Exclude terminal states (workflow type `completed` or `canceled`).
45
+ * Exclude terminal states (workflow type `completed`, `canceled`, or
46
+ * `duplicate` — see `TERMINAL_STATE_TYPES`).
46
47
  * Default `false` for back-compat at the service layer; the CLI's
47
48
  * `issues list` / `issues search` flip this to `true` by default
48
49
  * (DEV-4478) and accept `--include-closed` to opt back in. When
@@ -1,6 +1,8 @@
1
+ import { isRegistryConfigured, resolveViaRegistry, } from "../config/registry-resolve.js";
1
2
  import { resolveUserDisplayName } from "../config/resolver.js";
2
3
  import { ARCHIVE_ISSUE_MUTATION, BATCH_GET_ISSUES_QUERY, BATCH_RESOLVE_FOR_CREATE_QUERY, BATCH_RESOLVE_FOR_SEARCH_QUERY, BATCH_RESOLVE_FOR_UPDATE_QUERY, buildResolveLabelsByNameQuery, CREATE_ISSUE_MUTATION, DELETE_ISSUE_MUTATION, FILTERED_SEARCH_ISSUES_QUERY, GET_ISSUE_BY_ID_QUERY, GET_ISSUE_BY_IDENTIFIER_QUERY, GET_ISSUE_CLAIM_CONTEXT_QUERY, GET_ISSUE_START_CONTEXT_QUERY, GET_ISSUE_TEAM_QUERY, GET_ISSUES_QUERY, SEARCH_ISSUES_QUERY, TEAM_STARTED_STATUSES_QUERY, UPDATE_ISSUE_MUTATION, } from "../queries/issues.js";
3
4
  import { CREATE_LABEL_MUTATION } from "../queries/labels.js";
5
+ import { TERMINAL_STATE_TYPES } from "../types/linear.js";
4
6
  import { toISOStringOrNow } from "./date-format.js";
5
7
  import { extractEmbeds } from "./embed-parser.js";
6
8
  import { multipleMatchesError, notFoundError } from "./error-messages.js";
@@ -980,7 +982,7 @@ export class GraphQLIssuesService {
980
982
  else if (filters.excludeTerminalStates) {
981
983
  // Implicit terminal-state exclusion (DEV-4478). Explicit `status`
982
984
  // always wins — the user already named the states they want.
983
- filtered = filtered.filter((issue) => issue.state?.type !== "completed" && issue.state?.type !== "canceled");
985
+ filtered = filtered.filter((issue) => !TERMINAL_STATE_TYPES.includes(issue.state?.type ?? ""));
984
986
  }
985
987
  if (filters.labelNames && filters.labelNames.length > 0) {
986
988
  const lowerNames = filters.labelNames.map((n) => n.toLowerCase());
@@ -1023,7 +1025,7 @@ export class GraphQLIssuesService {
1023
1025
  else if (filters.excludeTerminalStates) {
1024
1026
  // Implicit terminal-state exclusion (DEV-4478). Explicit `status`
1025
1027
  // always wins — the user already named the states they want.
1026
- filter.state = { type: { nin: ["completed", "canceled"] } };
1028
+ filter.state = { type: { nin: [...TERMINAL_STATE_TYPES] } };
1027
1029
  }
1028
1030
  if (filters.labelNames && filters.labelNames.length > 0) {
1029
1031
  if (filters.labelNames.length === 1) {
@@ -1048,6 +1050,15 @@ export class GraphQLIssuesService {
1048
1050
  if (!assigneeId || isUuid(assigneeId)) {
1049
1051
  return assigneeId;
1050
1052
  }
1053
+ // Opt-in registry resolution (DEV-4872): try the identity registry first,
1054
+ // falling back to the Linear-API user lookup below on a miss. EL-only,
1055
+ // env-gated, fail-open — a non-EL install never reaches the network here.
1056
+ if (isRegistryConfigured()) {
1057
+ const viaRegistry = await resolveViaRegistry(assigneeId);
1058
+ if (viaRegistry) {
1059
+ return viaRegistry;
1060
+ }
1061
+ }
1051
1062
  // A plain name (no `@`) resolves via the user lookup — same as
1052
1063
  // `resolveDelegateId`. Without this, a non-config full name like
1053
1064
  // "Yury Tsukerman" fell through unchanged and was sent to the
@@ -1068,6 +1079,14 @@ export class GraphQLIssuesService {
1068
1079
  if (!delegateId || isUuid(delegateId)) {
1069
1080
  return delegateId;
1070
1081
  }
1082
+ // Opt-in registry resolution (DEV-4872): registry-first, Linear-API
1083
+ // fallback. EL-only, env-gated, fail-open.
1084
+ if (isRegistryConfigured()) {
1085
+ const viaRegistry = await resolveViaRegistry(delegateId);
1086
+ if (viaRegistry) {
1087
+ return viaRegistry;
1088
+ }
1089
+ }
1071
1090
  if (!delegateId.includes("@")) {
1072
1091
  return this.linearService.resolveUserId(delegateId);
1073
1092
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@enrichlayer/el-linear",
3
- "version": "1.28.0",
3
+ "version": "1.29.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",