@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.
- package/README.md +159 -0
- package/claude-skills/linear-operations/SKILL.md +17 -0
- package/dist/auth/oauth-storage.d.ts +5 -3
- package/dist/auth/oauth-token.d.ts +7 -0
- package/dist/auth/oauth-token.js +13 -0
- package/dist/auth/token-resolver.d.ts +2 -0
- package/dist/auth/token-resolver.js +25 -10
- package/dist/commands/init/index.js +17 -1
- package/dist/commands/init/oauth.d.ts +9 -1
- package/dist/commands/init/oauth.js +62 -3
- package/dist/commands/issues.js +244 -1
- package/dist/config/config.d.ts +30 -0
- package/dist/config/config.js +3 -0
- package/dist/config/consent-receipt.d.ts +76 -0
- package/dist/config/consent-receipt.js +219 -0
- package/dist/config/label-advisor.d.ts +53 -0
- package/dist/config/label-advisor.js +245 -0
- package/dist/queries/catalog-types.d.ts +129 -0
- package/dist/queries/catalog-types.js +1 -0
- package/dist/queries/catalog.d.ts +20 -0
- package/dist/queries/catalog.js +140 -0
- package/dist/utils/disk-cache.js +48 -14
- package/dist/utils/graphql-service.d.ts +42 -1
- package/dist/utils/graphql-service.js +267 -14
- package/dist/utils/linear-graphql-error.d.ts +100 -0
- package/dist/utils/linear-graphql-error.js +182 -0
- package/dist/utils/linear-service.d.ts +19 -2
- package/dist/utils/linear-service.js +194 -214
- package/dist/utils/output.d.ts +39 -0
- package/dist/utils/output.js +152 -1
- package/dist/utils/rate-limit-admission.d.ts +33 -0
- package/dist/utils/rate-limit-admission.js +240 -0
- package/package.json +79 -79
|
@@ -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
|
+
`;
|
package/dist/utils/disk-cache.js
CHANGED
|
@@ -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
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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
|
|
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
|
-
//
|
|
140
|
-
//
|
|
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]
|
|
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
|