@enrichlayer/el-linear 1.44.2 → 1.46.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.
@@ -0,0 +1,53 @@
1
+ import type { ElLinearConfig } from "./config.js";
2
+ export interface LabelAdvisorInput {
3
+ team: string | null;
4
+ project: string | null;
5
+ title: string | null;
6
+ description: string | null;
7
+ labels: string[];
8
+ state: string | null;
9
+ }
10
+ /**
11
+ * Receipt policy fields an advisor may return with a consent label
12
+ * (DEV-10455). el-linear never guesses these: `repo` is the repository the
13
+ * organization's intake policy maps this issue to, and `reason` is why that
14
+ * policy consents to unattended work. el-linear adds only what it knows
15
+ * itself (the new issue's identifier and the acting Linear user) and writes
16
+ * the receipt after the issue exists. See `consent-receipt.ts`.
17
+ */
18
+ export interface LabelAdvisorReceipt {
19
+ repo: string;
20
+ reason: string;
21
+ }
22
+ export type LabelAdvisorResult = {
23
+ ok: true;
24
+ labels: string[];
25
+ reason: string | null;
26
+ receipt: LabelAdvisorReceipt | null;
27
+ } | {
28
+ ok: false;
29
+ error: string;
30
+ };
31
+ /**
32
+ * The configured advisor argv, or `null` when the hook is off. Env wins over
33
+ * config so one invocation can point elsewhere or disable it without editing
34
+ * files.
35
+ */
36
+ export declare function labelAdvisorCommand(config: Pick<ElLinearConfig, "labelAdvisor">, env?: NodeJS.ProcessEnv): string[] | null;
37
+ /**
38
+ * Parse what the advisor printed. Returns an error string for anything that is
39
+ * not exactly a well-formed answer — a malformed answer is a failure, and a
40
+ * failure adds no labels. Partial acceptance (keep the valid elements, drop the
41
+ * rest) is deliberately not offered: an advisor emitting garbage is broken, and
42
+ * trusting half of its output is how a wrong label slips in.
43
+ */
44
+ export declare function parseLabelAdvisorOutput(stdout: string): LabelAdvisorResult;
45
+ /**
46
+ * Run the configured advisor. Returns `null` when no advisor is configured,
47
+ * otherwise the parsed advice or a failure. Never throws.
48
+ *
49
+ * Synchronous for the same reason as the identity resolver: it sits on the
50
+ * critical path of a short-lived CLI, and a single failure site is easier to
51
+ * reason about than a promise race.
52
+ */
53
+ export declare function runLabelAdvisor(input: LabelAdvisorInput, config: Pick<ElLinearConfig, "labelAdvisor">, env?: NodeJS.ProcessEnv): LabelAdvisorResult | null;
@@ -0,0 +1,245 @@
1
+ import { spawnSync } from "node:child_process";
2
+ /**
3
+ * Optional **label advisor hook** (DEV-10372).
4
+ *
5
+ * An organization may have a rubric that knows which labels a new issue should
6
+ * carry — for example "this Tools issue is small and well specified, so it
7
+ * belongs in the unattended-automation lane". el-linear can consult it on
8
+ * `issues create`, but it must never learn *what* the rubric is or how to run
9
+ * it. So the hook is a **command**, modelled on the identity resolver
10
+ * (DEV-5628, `identity-resolver.ts`):
11
+ *
12
+ * "labelAdvisor": { "command": ["my-rubric", "--advise"] }
13
+ *
14
+ * Contract:
15
+ *
16
+ * - stdin: one JSON object describing the proposed issue —
17
+ * `{team, project, title, description, labels, state}`.
18
+ * - stdout: JSON. Either a bare array of label names (`["bot"]`) or an
19
+ * object `{"labels": ["bot"], "reason": "…"}`; one level of `{data: …}`
20
+ * envelope is unwrapped (the el-* CLI shape). `{"labels": []}` means
21
+ * "nothing to add". With a consent label (see `consent-receipt.ts`) the
22
+ * object may also carry `"receipt": {"repo": "…", "reason": "…"}`; then
23
+ * el-linear applies the consent label after create together with an
24
+ * `el-intake-decision:v1` receipt naming the new issue.
25
+ * - exit 0 on success.
26
+ *
27
+ * **Fail-closed on labels.** A label may carry meaning — in some workspaces a
28
+ * label is consent for unattended work — so a broken advisor must never add
29
+ * one. Non-zero exit, timeout, missing binary, unparseable or malformed output
30
+ * all return a failure; the caller warns and creates the issue with exactly
31
+ * the labels the author asked for. The hook never throws.
32
+ *
33
+ * The issue text is untrusted input (agents create issues from arbitrary
34
+ * prose), so it travels on stdin, never in argv, and the command runs with
35
+ * `shell: false`.
36
+ */
37
+ /** Env override — a whitespace-separated command; `""` is an explicit OFF. */
38
+ const ADVISOR_ENV = "EL_LINEAR_LABEL_ADVISOR";
39
+ /** An advisor that hasn't answered in this long is not going to. */
40
+ const DEFAULT_TIMEOUT_MS = 5000;
41
+ /** Advice for more labels than this is a confused advisor, not a rubric. */
42
+ const MAX_LABELS = 10;
43
+ const MAX_LABEL_LENGTH = 80;
44
+ const MAX_REASON_LENGTH = 300;
45
+ const MAX_RECEIPT_REPO_LENGTH = 200;
46
+ const MAX_RECEIPT_REASON_LENGTH = 500;
47
+ /** True when `text` has no ASCII control character (U+0000–U+001F, U+007F). */
48
+ function isSafeText(text) {
49
+ for (let i = 0; i < text.length; i++) {
50
+ const code = text.charCodeAt(i);
51
+ if (code < 0x20 || code === 0x7f)
52
+ return false;
53
+ }
54
+ return text.length > 0;
55
+ }
56
+ /**
57
+ * The configured advisor argv, or `null` when the hook is off. Env wins over
58
+ * config so one invocation can point elsewhere or disable it without editing
59
+ * files.
60
+ */
61
+ export function labelAdvisorCommand(config, env = process.env) {
62
+ const fromEnv = env[ADVISOR_ENV];
63
+ if (fromEnv !== undefined) {
64
+ const argv = fromEnv.trim().split(/\s+/).filter(Boolean);
65
+ return argv.length > 0 ? argv : null;
66
+ }
67
+ const configured = config.labelAdvisor?.command;
68
+ if (!Array.isArray(configured) || configured.length === 0) {
69
+ return null;
70
+ }
71
+ return configured;
72
+ }
73
+ /**
74
+ * Resolve the effective timeout. Node treats `timeout <= 0` as *no timeout*,
75
+ * which would turn a hung advisor into a hung CLI — so non-positive values
76
+ * fall back to the default, exactly like the identity resolver.
77
+ */
78
+ function resolveTimeoutMs(config) {
79
+ const configured = config.labelAdvisor?.timeoutMs;
80
+ return typeof configured === "number" && configured > 0
81
+ ? configured
82
+ : DEFAULT_TIMEOUT_MS;
83
+ }
84
+ /** The advisor classifies issue text; it never needs Linear's token. */
85
+ function advisorEnv(env) {
86
+ const { LINEAR_API_TOKEN: _dropped, ...rest } = env;
87
+ return rest;
88
+ }
89
+ function isRecord(value) {
90
+ return value !== null && typeof value === "object" && !Array.isArray(value);
91
+ }
92
+ /**
93
+ * Parse what the advisor printed. Returns an error string for anything that is
94
+ * not exactly a well-formed answer — a malformed answer is a failure, and a
95
+ * failure adds no labels. Partial acceptance (keep the valid elements, drop the
96
+ * rest) is deliberately not offered: an advisor emitting garbage is broken, and
97
+ * trusting half of its output is how a wrong label slips in.
98
+ */
99
+ export function parseLabelAdvisorOutput(stdout) {
100
+ const trimmed = stdout.trim();
101
+ if (!trimmed) {
102
+ return { ok: false, error: "advisor printed nothing (expected JSON)" };
103
+ }
104
+ let parsed;
105
+ try {
106
+ parsed = JSON.parse(trimmed);
107
+ }
108
+ catch {
109
+ return { ok: false, error: "advisor output is not valid JSON" };
110
+ }
111
+ if (isRecord(parsed) && isRecord(parsed.data)) {
112
+ parsed = parsed.data;
113
+ }
114
+ let rawLabels;
115
+ let rawReason = null;
116
+ let rawReceipt = null;
117
+ if (Array.isArray(parsed)) {
118
+ rawLabels = parsed;
119
+ }
120
+ else if (isRecord(parsed)) {
121
+ rawLabels = parsed.labels;
122
+ rawReason = parsed.reason ?? null;
123
+ rawReceipt = parsed.receipt ?? null;
124
+ }
125
+ else {
126
+ return {
127
+ ok: false,
128
+ error: "advisor output must be a label array or an object",
129
+ };
130
+ }
131
+ if (!Array.isArray(rawLabels)) {
132
+ return { ok: false, error: 'advisor output has no "labels" array' };
133
+ }
134
+ if (rawLabels.length > MAX_LABELS) {
135
+ return {
136
+ ok: false,
137
+ error: `advisor returned ${rawLabels.length} labels (limit ${MAX_LABELS})`,
138
+ };
139
+ }
140
+ const labels = [];
141
+ for (const label of rawLabels) {
142
+ if (typeof label !== "string" ||
143
+ label.trim().length === 0 ||
144
+ label.length > MAX_LABEL_LENGTH ||
145
+ !isSafeText(label) ||
146
+ label.includes(",")) {
147
+ return {
148
+ ok: false,
149
+ error: "advisor returned a label that is not a plain label name",
150
+ };
151
+ }
152
+ const name = label.trim();
153
+ if (!labels.some((seen) => seen.toLowerCase() === name.toLowerCase())) {
154
+ labels.push(name);
155
+ }
156
+ }
157
+ if (rawReason !== null && typeof rawReason !== "string") {
158
+ return { ok: false, error: "advisor reason must be a string" };
159
+ }
160
+ const reason = typeof rawReason === "string" && rawReason.trim()
161
+ ? rawReason.replace(/\s+/g, " ").trim().slice(0, MAX_REASON_LENGTH)
162
+ : null;
163
+ const receipt = parseReceiptFields(rawReceipt);
164
+ if (typeof receipt === "string") {
165
+ return { ok: false, error: receipt };
166
+ }
167
+ return { ok: true, labels, reason, receipt };
168
+ }
169
+ /**
170
+ * Validate the optional `receipt` object. A malformed one fails the whole
171
+ * answer (returned as an error string): a receipt is consent, so half of one
172
+ * is never used.
173
+ */
174
+ function parseReceiptFields(raw) {
175
+ if (raw === null) {
176
+ return null;
177
+ }
178
+ if (!isRecord(raw)) {
179
+ return "advisor receipt must be an object";
180
+ }
181
+ const { repo, reason } = raw;
182
+ if (typeof repo !== "string" ||
183
+ repo.trim().length === 0 ||
184
+ repo.length > MAX_RECEIPT_REPO_LENGTH ||
185
+ !isSafeText(repo)) {
186
+ return "advisor receipt.repo must be a non-empty plain string";
187
+ }
188
+ if (typeof reason !== "string" ||
189
+ reason.trim().length === 0 ||
190
+ reason.length > MAX_RECEIPT_REASON_LENGTH ||
191
+ !isSafeText(reason)) {
192
+ return `advisor receipt.reason must be a non-empty plain string of at most ${MAX_RECEIPT_REASON_LENGTH} characters`;
193
+ }
194
+ return { repo: repo.trim(), reason: reason.trim() };
195
+ }
196
+ /**
197
+ * Run the configured advisor. Returns `null` when no advisor is configured,
198
+ * otherwise the parsed advice or a failure. Never throws.
199
+ *
200
+ * Synchronous for the same reason as the identity resolver: it sits on the
201
+ * critical path of a short-lived CLI, and a single failure site is easier to
202
+ * reason about than a promise race.
203
+ */
204
+ export function runLabelAdvisor(input, config, env = process.env) {
205
+ const argv = labelAdvisorCommand(config, env);
206
+ if (!argv) {
207
+ return null;
208
+ }
209
+ const [command, ...args] = argv;
210
+ if (!command) {
211
+ return null;
212
+ }
213
+ try {
214
+ const result = spawnSync(command, args, {
215
+ encoding: "utf8",
216
+ input: JSON.stringify(input),
217
+ timeout: resolveTimeoutMs(config),
218
+ // A trapped SIGTERM would let a broken advisor wedge the CLI past
219
+ // its time budget; see the identical note in identity-resolver.ts.
220
+ killSignal: "SIGKILL",
221
+ shell: false,
222
+ stdio: ["pipe", "pipe", "pipe"],
223
+ env: advisorEnv(env),
224
+ });
225
+ // Check `error` before `status`: a timeout that leaves a grandchild
226
+ // holding the pipe reports ETIMEDOUT with status 0 (identity-resolver.ts).
227
+ if (result.error) {
228
+ return { ok: false, error: `${command}: ${result.error.message}` };
229
+ }
230
+ if (result.status !== 0) {
231
+ const stderr = (result.stderr ?? "").trim().split("\n")[0] ?? "";
232
+ return {
233
+ ok: false,
234
+ error: `${command} exited ${result.status ?? `on ${result.signal}`}${stderr ? `: ${stderr}` : ""}`,
235
+ };
236
+ }
237
+ return parseLabelAdvisorOutput(result.stdout ?? "");
238
+ }
239
+ catch (err) {
240
+ return {
241
+ ok: false,
242
+ error: err instanceof Error ? err.message : String(err),
243
+ };
244
+ }
245
+ }
@@ -0,0 +1,129 @@
1
+ export interface CatalogTeamRef {
2
+ id: string;
3
+ key: string;
4
+ name: string;
5
+ }
6
+ export interface CatalogProjectNode {
7
+ id: string;
8
+ name: string;
9
+ description: string | null;
10
+ state: string;
11
+ progress: number;
12
+ targetDate: string | null;
13
+ createdAt: string;
14
+ updatedAt: string;
15
+ lead: {
16
+ id: string;
17
+ name: string;
18
+ } | null;
19
+ teams: {
20
+ nodes: CatalogTeamRef[];
21
+ };
22
+ }
23
+ export interface CatalogPageInfo {
24
+ hasNextPage: boolean;
25
+ endCursor: string | null;
26
+ }
27
+ export interface ProjectCatalogConnection {
28
+ nodes: CatalogProjectNode[];
29
+ pageInfo: CatalogPageInfo;
30
+ }
31
+ export interface GetProjectsCatalogResponse {
32
+ projects: ProjectCatalogConnection | null;
33
+ team: {
34
+ projects: ProjectCatalogConnection;
35
+ } | null;
36
+ }
37
+ export interface CatalogLabelNode {
38
+ id: string;
39
+ name: string;
40
+ color: string;
41
+ isGroup: boolean;
42
+ parent: {
43
+ id: string;
44
+ name: string;
45
+ } | null;
46
+ team: CatalogTeamRef | null;
47
+ }
48
+ export interface GetLabelsCatalogResponse {
49
+ issueLabels: {
50
+ nodes: CatalogLabelNode[];
51
+ };
52
+ }
53
+ export interface CatalogCycleNode {
54
+ id: string;
55
+ name: string | null;
56
+ number: number;
57
+ startsAt: string | null;
58
+ endsAt: string | null;
59
+ isActive: boolean;
60
+ isPrevious: boolean;
61
+ isNext: boolean;
62
+ progress: number;
63
+ issueCountHistory: number[] | null;
64
+ team: CatalogTeamRef | null;
65
+ }
66
+ export interface GetCyclesCatalogResponse {
67
+ cycles: {
68
+ nodes: CatalogCycleNode[];
69
+ };
70
+ }
71
+ export interface CatalogIssueNode {
72
+ id: string;
73
+ identifier: string;
74
+ url: string;
75
+ title: string;
76
+ description: string | null;
77
+ priority: number;
78
+ estimate: number | null;
79
+ createdAt: string;
80
+ updatedAt: string;
81
+ state: {
82
+ id: string;
83
+ name: string;
84
+ } | null;
85
+ assignee: {
86
+ id: string;
87
+ name: string;
88
+ } | null;
89
+ team: CatalogTeamRef | null;
90
+ project: {
91
+ id: string;
92
+ name: string;
93
+ } | null;
94
+ labels: {
95
+ nodes: Array<{
96
+ id: string;
97
+ name: string;
98
+ }>;
99
+ };
100
+ }
101
+ export interface GetCycleDetailResponse {
102
+ cycle: (CatalogCycleNode & {
103
+ issues: {
104
+ nodes: CatalogIssueNode[];
105
+ };
106
+ }) | null;
107
+ }
108
+ export interface ResolveProjectCatalogResponse {
109
+ projects: {
110
+ nodes: Array<{
111
+ id: string;
112
+ name: string;
113
+ teams: {
114
+ nodes: CatalogTeamRef[];
115
+ };
116
+ }>;
117
+ } | null;
118
+ team: {
119
+ projects: {
120
+ nodes: Array<{
121
+ id: string;
122
+ name: string;
123
+ teams: {
124
+ nodes: CatalogTeamRef[];
125
+ };
126
+ }>;
127
+ };
128
+ } | null;
129
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Fetch project summaries, embedded teams, and lead in one paginated request.
3
+ * The conditional team branch replaces SDK relation follow-ups and keeps both
4
+ * request count and GraphQL complexity visible at one catalog boundary.
5
+ */
6
+ export declare const GET_PROJECTS_CATALOG_QUERY = "\n query GetProjectsCatalog(\n $filter: ProjectFilter\n\t\t$teamId: String!\n\t\t$teamScoped: Boolean!\n $first: Int!\n $after: String\n ) {\n projects(filter: $filter, first: $first, after: $after, orderBy: updatedAt, includeArchived: false) @skip(if: $teamScoped) {\n nodes { \n id\n name\n description\n state\n progress\n targetDate\n createdAt\n updatedAt\n lead { id name }\n teams { nodes { id key name } }\n }\n pageInfo { hasNextPage endCursor }\n }\n team(id: $teamId) @include(if: $teamScoped) {\n projects(filter: $filter, first: $first, after: $after, orderBy: updatedAt, includeArchived: false) {\n nodes { \n id\n name\n description\n state\n progress\n targetDate\n createdAt\n updatedAt\n lead { id name }\n teams { nodes { id key name } }\n }\n pageInfo { hasNextPage endCursor }\n }\n }\n }\n";
7
+ /** Batch label metadata and parent/team relations into one bounded catalog read. */
8
+ export declare const GET_LABELS_CATALOG_QUERY = "\n query GetLabelsCatalog($filter: IssueLabelFilter, $first: Int!) {\n issueLabels(filter: $filter, first: $first) {\n nodes {\n id\n name\n color\n isGroup\n parent { id name }\n team { id key name }\n }\n }\n }\n";
9
+ /** Batch cycle summaries and their team relation instead of resolving each edge. */
10
+ export declare const GET_CYCLES_CATALOG_QUERY = "\n query GetCyclesCatalog($filter: CycleFilter, $first: Int!) {\n cycles(filter: $filter, first: $first, orderBy: createdAt) {\n nodes {\n id\n name\n number\n startsAt\n endsAt\n isActive\n isPrevious\n isNext\n progress\n issueCountHistory\n team { id key name }\n }\n }\n }\n";
11
+ /**
12
+ * Read one cycle and its bounded issue catalog in a single query, avoiding the
13
+ * SDK's per-issue relation fan-out while keeping the issue page size explicit.
14
+ */
15
+ export declare const GET_CYCLE_DETAIL_QUERY = "\n query GetCycleDetail($id: String!, $issuesFirst: Int!) {\n cycle(id: $id) {\n id\n name\n number\n startsAt\n endsAt\n isActive\n isPrevious\n isNext\n progress\n issueCountHistory\n team { id key name }\n issues(first: $issuesFirst) {\n nodes {\n id\n identifier\n url\n title\n description\n priority\n estimate\n createdAt\n updatedAt\n state { id name }\n assignee { id name }\n team { id key name }\n project { id name }\n labels { nodes { id name } }\n }\n }\n }\n }\n";
16
+ /**
17
+ * Resolve a project name/slug with one bounded query. The conditional team
18
+ * branch performs scoping server-side without a separate project catalog read.
19
+ */
20
+ export declare const RESOLVE_PROJECT_QUERY = "\n query ResolveProjectCatalog(\n $filter: ProjectFilter!\n\t\t$teamId: String!\n\t\t$teamScoped: Boolean!\n $includeArchived: Boolean!\n ) {\n projects(filter: $filter, first: 5, includeArchived: $includeArchived) @skip(if: $teamScoped) {\n nodes {\n id\n name\n teams { nodes { id key name } }\n }\n }\n team(id: $teamId) @include(if: $teamScoped) {\n projects(filter: $filter, first: 5, includeArchived: $includeArchived) {\n nodes {\n id\n name\n teams { nodes { id key name } }\n }\n }\n }\n }\n";
@@ -0,0 +1,140 @@
1
+ const PROJECT_FIELDS = `
2
+ id
3
+ name
4
+ description
5
+ state
6
+ progress
7
+ targetDate
8
+ createdAt
9
+ updatedAt
10
+ lead { id name }
11
+ teams { nodes { id key name } }
12
+ `;
13
+ /**
14
+ * Fetch project summaries, embedded teams, and lead in one paginated request.
15
+ * The conditional team branch replaces SDK relation follow-ups and keeps both
16
+ * request count and GraphQL complexity visible at one catalog boundary.
17
+ */
18
+ export const GET_PROJECTS_CATALOG_QUERY = `
19
+ query GetProjectsCatalog(
20
+ $filter: ProjectFilter
21
+ $teamId: String!
22
+ $teamScoped: Boolean!
23
+ $first: Int!
24
+ $after: String
25
+ ) {
26
+ projects(filter: $filter, first: $first, after: $after, orderBy: updatedAt, includeArchived: false) @skip(if: $teamScoped) {
27
+ nodes { ${PROJECT_FIELDS} }
28
+ pageInfo { hasNextPage endCursor }
29
+ }
30
+ team(id: $teamId) @include(if: $teamScoped) {
31
+ projects(filter: $filter, first: $first, after: $after, orderBy: updatedAt, includeArchived: false) {
32
+ nodes { ${PROJECT_FIELDS} }
33
+ pageInfo { hasNextPage endCursor }
34
+ }
35
+ }
36
+ }
37
+ `;
38
+ /** Batch label metadata and parent/team relations into one bounded catalog read. */
39
+ export const GET_LABELS_CATALOG_QUERY = `
40
+ query GetLabelsCatalog($filter: IssueLabelFilter, $first: Int!) {
41
+ issueLabels(filter: $filter, first: $first) {
42
+ nodes {
43
+ id
44
+ name
45
+ color
46
+ isGroup
47
+ parent { id name }
48
+ team { id key name }
49
+ }
50
+ }
51
+ }
52
+ `;
53
+ /** Batch cycle summaries and their team relation instead of resolving each edge. */
54
+ export const GET_CYCLES_CATALOG_QUERY = `
55
+ query GetCyclesCatalog($filter: CycleFilter, $first: Int!) {
56
+ cycles(filter: $filter, first: $first, orderBy: createdAt) {
57
+ nodes {
58
+ id
59
+ name
60
+ number
61
+ startsAt
62
+ endsAt
63
+ isActive
64
+ isPrevious
65
+ isNext
66
+ progress
67
+ issueCountHistory
68
+ team { id key name }
69
+ }
70
+ }
71
+ }
72
+ `;
73
+ /**
74
+ * Read one cycle and its bounded issue catalog in a single query, avoiding the
75
+ * SDK's per-issue relation fan-out while keeping the issue page size explicit.
76
+ */
77
+ export const GET_CYCLE_DETAIL_QUERY = `
78
+ query GetCycleDetail($id: String!, $issuesFirst: Int!) {
79
+ cycle(id: $id) {
80
+ id
81
+ name
82
+ number
83
+ startsAt
84
+ endsAt
85
+ isActive
86
+ isPrevious
87
+ isNext
88
+ progress
89
+ issueCountHistory
90
+ team { id key name }
91
+ issues(first: $issuesFirst) {
92
+ nodes {
93
+ id
94
+ identifier
95
+ url
96
+ title
97
+ description
98
+ priority
99
+ estimate
100
+ createdAt
101
+ updatedAt
102
+ state { id name }
103
+ assignee { id name }
104
+ team { id key name }
105
+ project { id name }
106
+ labels { nodes { id name } }
107
+ }
108
+ }
109
+ }
110
+ }
111
+ `;
112
+ /**
113
+ * Resolve a project name/slug with one bounded query. The conditional team
114
+ * branch performs scoping server-side without a separate project catalog read.
115
+ */
116
+ export const RESOLVE_PROJECT_QUERY = `
117
+ query ResolveProjectCatalog(
118
+ $filter: ProjectFilter!
119
+ $teamId: String!
120
+ $teamScoped: Boolean!
121
+ $includeArchived: Boolean!
122
+ ) {
123
+ projects(filter: $filter, first: 5, includeArchived: $includeArchived) @skip(if: $teamScoped) {
124
+ nodes {
125
+ id
126
+ name
127
+ teams { nodes { id key name } }
128
+ }
129
+ }
130
+ team(id: $teamId) @include(if: $teamScoped) {
131
+ projects(filter: $filter, first: 5, includeArchived: $includeArchived) {
132
+ nodes {
133
+ id
134
+ name
135
+ teams { nodes { id key name } }
136
+ }
137
+ }
138
+ }
139
+ }
140
+ `;
@@ -24,6 +24,7 @@
24
24
  import { randomBytes } from "node:crypto";
25
25
  import fs from "node:fs/promises";
26
26
  import path from "node:path";
27
+ import { withFileLock } from "../auth/oauth-fs.js";
27
28
  import { resolveActiveProfile } from "../config/paths.js";
28
29
  import { logger } from "./logger.js";
29
30
  const CACHE_VERSION = 1;
@@ -117,31 +118,64 @@ export async function cached(key, ttlSeconds, fetcher, options) {
117
118
  if (ttlSeconds <= 0) {
118
119
  return fetcher();
119
120
  }
120
- const now = Date.now();
121
121
  if (!options?.bypass) {
122
122
  const envelope = await readEnvelope(key);
123
- if (envelope && envelope.expiresAt > now) {
123
+ if (envelope && envelope.expiresAt > Date.now()) {
124
124
  return envelope.data;
125
125
  }
126
126
  }
127
- const data = await fetcher();
128
- const envelope = {
129
- v: CACHE_VERSION,
130
- key,
131
- fetchedAt: now,
132
- expiresAt: now + ttlSeconds * 1000,
133
- data,
127
+ const fetchAndStore = async () => {
128
+ const data = await fetcher();
129
+ const now = Date.now();
130
+ const envelope = {
131
+ v: CACHE_VERSION,
132
+ key,
133
+ fetchedAt: now,
134
+ expiresAt: now + ttlSeconds * 1000,
135
+ data,
136
+ };
137
+ try {
138
+ await writeEnvelope(key, envelope);
139
+ }
140
+ catch (err) {
141
+ // Cache writes are best-effort — log to stderr and return the data
142
+ // anyway so a flaky disk doesn't break the user's command.
143
+ const msg = err instanceof Error ? err.message : String(err);
144
+ logger.error(`[disk-cache] write failed for "${key}": ${msg}`);
145
+ }
146
+ return data;
134
147
  };
148
+ // `--no-cache` is an explicit request to bypass both the cached value and
149
+ // cache coordination. Preserve that contract instead of making two forced
150
+ // refreshes wait on one another.
151
+ if (options?.bypass)
152
+ return fetchAndStore();
153
+ // Cold-cache single flight across processes. Re-read only after acquiring
154
+ // the sidecar lock: another el-linear invocation may have populated the
155
+ // envelope while this process was waiting. Without this second read, a
156
+ // burst of identical commands still sends one Linear request per process.
157
+ let enteredCriticalSection = false;
135
158
  try {
136
- await writeEnvelope(key, envelope);
159
+ await fs.mkdir(cacheDir(), { recursive: true, mode: CACHE_DIR_MODE });
160
+ return await withFileLock(cachePath(key), async () => {
161
+ enteredCriticalSection = true;
162
+ const winner = await readEnvelope(key);
163
+ if (winner && winner.expiresAt > Date.now())
164
+ return winner.data;
165
+ return fetchAndStore();
166
+ });
137
167
  }
138
168
  catch (err) {
139
- // Cache writes are best-effort — log to stderr and return the data
140
- // anyway so a flaky disk doesn't break the user's command.
169
+ // Never retry a failed fetcher: a write may have reached Linear before
170
+ // throwing, so replaying it would be unsafe. Only coordination failures
171
+ // that happen before entering the critical section degrade to the old
172
+ // uncoordinated read-through behavior.
173
+ if (enteredCriticalSection)
174
+ throw err;
141
175
  const msg = err instanceof Error ? err.message : String(err);
142
- logger.error(`[disk-cache] write failed for "${key}": ${msg}`);
176
+ logger.error(`[disk-cache] single-flight unavailable for "${key}": ${msg}`);
177
+ return fetchAndStore();
143
178
  }
144
- return data;
145
179
  }
146
180
  /**
147
181
  * Clear cached entries. With no `prefix`, removes the entire cache