@enrichlayer/el-linear 1.28.0 → 1.29.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.
@@ -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 }
@@ -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
  /**
@@ -1,3 +1,4 @@
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";
@@ -1048,6 +1049,15 @@ export class GraphQLIssuesService {
1048
1049
  if (!assigneeId || isUuid(assigneeId)) {
1049
1050
  return assigneeId;
1050
1051
  }
1052
+ // Opt-in registry resolution (DEV-4872): try the identity registry first,
1053
+ // falling back to the Linear-API user lookup below on a miss. EL-only,
1054
+ // env-gated, fail-open — a non-EL install never reaches the network here.
1055
+ if (isRegistryConfigured()) {
1056
+ const viaRegistry = await resolveViaRegistry(assigneeId);
1057
+ if (viaRegistry) {
1058
+ return viaRegistry;
1059
+ }
1060
+ }
1051
1061
  // A plain name (no `@`) resolves via the user lookup — same as
1052
1062
  // `resolveDelegateId`. Without this, a non-config full name like
1053
1063
  // "Yury Tsukerman" fell through unchanged and was sent to the
@@ -1068,6 +1078,14 @@ export class GraphQLIssuesService {
1068
1078
  if (!delegateId || isUuid(delegateId)) {
1069
1079
  return delegateId;
1070
1080
  }
1081
+ // Opt-in registry resolution (DEV-4872): registry-first, Linear-API
1082
+ // fallback. EL-only, env-gated, fail-open.
1083
+ if (isRegistryConfigured()) {
1084
+ const viaRegistry = await resolveViaRegistry(delegateId);
1085
+ if (viaRegistry) {
1086
+ return viaRegistry;
1087
+ }
1088
+ }
1071
1089
  if (!delegateId.includes("@")) {
1072
1090
  return this.linearService.resolveUserId(delegateId);
1073
1091
  }
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.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",