@enrichlayer/el-linear 1.43.0 → 1.44.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 +45 -3
- package/claude-skills/linear-operations/SKILL.md +8 -0
- package/dist/commands/comments.js +43 -1
- package/dist/commands/issues.js +14 -0
- package/dist/commands/profile.d.ts +20 -0
- package/dist/commands/profile.js +150 -2
- package/dist/commands/projects.js +22 -3
- package/dist/config/intake-decision-validation.d.ts +22 -0
- package/dist/config/intake-decision-validation.js +174 -49
- package/dist/config/pin-hint.d.ts +36 -0
- package/dist/config/pin-hint.js +64 -0
- package/dist/config/repo-profile.d.ts +100 -0
- package/dist/config/repo-profile.js +274 -0
- package/dist/main.js +24 -2
- package/dist/queries/projects-types.d.ts +6 -0
- package/dist/queries/projects.d.ts +1 -1
- package/dist/queries/projects.js +6 -0
- package/dist/utils/formatters/summary.js +7 -0
- package/dist/utils/graphql-issues-service.js +1 -1
- package/dist/utils/graphql-service.d.ts +20 -2
- package/dist/utils/graphql-service.js +40 -4
- package/dist/utils/linear-service.d.ts +17 -0
- package/dist/utils/linear-service.js +28 -0
- package/dist/utils/protected-ranges.d.ts +2 -2
- package/dist/utils/protected-ranges.js +9 -2
- package/package.json +4 -4
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Repo-aware Linear-profile resolution (DEV-7277).
|
|
3
|
+
*
|
|
4
|
+
* el-linear supports multiple named profiles (one Linear workspace each). When
|
|
5
|
+
* a user works across several workspaces the machine-global `active-profile`
|
|
6
|
+
* marker is a foot-gun: whichever workspace was last `profile use`d becomes the
|
|
7
|
+
* default for EVERY repo, so a write lands in the wrong workspace.
|
|
8
|
+
*
|
|
9
|
+
* This module lets a repo PIN the profile it belongs to, read from two on-disk
|
|
10
|
+
* sources in precedence order:
|
|
11
|
+
* 1. Repo-root `.el-git.json`: `{ "linearProfile": "<name>" }`.
|
|
12
|
+
* 2. `${XDG_CONFIG_HOME:-~/.config}/el-git/linear-profiles.json`:
|
|
13
|
+
* `{ "profiles": { "owner/repo": "<name>", "owner/*": "<name>" } }` — keys
|
|
14
|
+
* are the origin remote's `owner/repo`; an exact key wins over the
|
|
15
|
+
* `owner/*` wildcard.
|
|
16
|
+
*
|
|
17
|
+
* ── SHARED CROSS-TOOL CONTRACT — DO NOT DRIFT ────────────────────────────────
|
|
18
|
+
* The file names, shapes, and precedence here are the SAME contract el-git
|
|
19
|
+
* (DEV-6465) and el-session (DEV-7131) already read/write via the internal
|
|
20
|
+
* `@enrichlayer/cli-package-info` module (`resolveRepoLinearProfile`). el-linear
|
|
21
|
+
* is a standalone MIT package published to public npm; cli-package-info is
|
|
22
|
+
* published only to an internal GitLab registry, so depending on it would break
|
|
23
|
+
* public installs. Instead this file re-implements the EXACT same formats and
|
|
24
|
+
* precedence. The filename stays `.el-git.json` (NOT a new `.el-linear` file) on
|
|
25
|
+
* purpose — it is the established shared location el-session already reads. If
|
|
26
|
+
* you change anything here, change the canonical impl at
|
|
27
|
+
* `tools/cli/cli-package-info/src/index.cjs` in lock-step or the tools silently
|
|
28
|
+
* disagree about which workspace a repo belongs to.
|
|
29
|
+
* ─────────────────────────────────────────────────────────────────────────────
|
|
30
|
+
*
|
|
31
|
+
* Trust note (inherited from the shared contract): the repo-local file is read
|
|
32
|
+
* from whatever repo you `cd` into, including an untrusted clone. Blast radius
|
|
33
|
+
* is bounded to selecting a profile NAME — worst case is a wrong-workspace
|
|
34
|
+
* lookup or a rejected name, never code execution. Callers still validate the
|
|
35
|
+
* resolved name with `isSafeProfileName` before it reaches any path join.
|
|
36
|
+
*/
|
|
37
|
+
import { execFileSync } from "node:child_process";
|
|
38
|
+
import fs from "node:fs";
|
|
39
|
+
import os from "node:os";
|
|
40
|
+
import path from "node:path";
|
|
41
|
+
/** Repo-local pin file, at the git repo root. Shared with el-git/el-session. */
|
|
42
|
+
export const LINEAR_PROFILE_REPO_FILE = ".el-git.json";
|
|
43
|
+
/** Global pin table filename, under `<config>/el-git/`. */
|
|
44
|
+
export const LINEAR_PROFILE_USER_CONFIG = "linear-profiles.json";
|
|
45
|
+
/** `${XDG_CONFIG_HOME:-~/.config}/el-git`. Env-injectable for tests. */
|
|
46
|
+
export function linearProfileConfigDir(env = process.env) {
|
|
47
|
+
const xdg = (env.XDG_CONFIG_HOME ?? "").trim();
|
|
48
|
+
return xdg
|
|
49
|
+
? path.join(xdg, "el-git")
|
|
50
|
+
: path.join(os.homedir(), ".config", "el-git");
|
|
51
|
+
}
|
|
52
|
+
export function globalPinTablePath(env = process.env) {
|
|
53
|
+
return path.join(linearProfileConfigDir(env), LINEAR_PROFILE_USER_CONFIG);
|
|
54
|
+
}
|
|
55
|
+
// `git@host:owner/repo.git` and `https://host/owner/repo(.git)` → `owner/repo`.
|
|
56
|
+
// Byte-for-byte mirror of cli-package-info's `parseGitRemoteFullpath`.
|
|
57
|
+
const GIT_SCP_REMOTE_RE = /^[^@/]+@[^:]+:(.+)$/;
|
|
58
|
+
const GIT_REMOTE_LEADING_SLASHES_RE = /^\/+/;
|
|
59
|
+
const GIT_REMOTE_DOT_GIT_RE = /\.git$/;
|
|
60
|
+
const GIT_REMOTE_TRAILING_SLASHES_RE = /\/+$/;
|
|
61
|
+
export function parseGitRemoteFullpath(url) {
|
|
62
|
+
if (typeof url !== "string" || !url.trim()) {
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
const trimmed = url.trim();
|
|
66
|
+
const scp = trimmed.match(GIT_SCP_REMOTE_RE);
|
|
67
|
+
let rawPath;
|
|
68
|
+
if (scp) {
|
|
69
|
+
rawPath = scp[1] ?? null;
|
|
70
|
+
}
|
|
71
|
+
else {
|
|
72
|
+
try {
|
|
73
|
+
rawPath = new URL(trimmed).pathname;
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
rawPath = null;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
if (!rawPath) {
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
const cleaned = rawPath
|
|
83
|
+
.replace(GIT_REMOTE_LEADING_SLASHES_RE, "")
|
|
84
|
+
.replace(GIT_REMOTE_DOT_GIT_RE, "")
|
|
85
|
+
.replace(GIT_REMOTE_TRAILING_SLASHES_RE, "");
|
|
86
|
+
return cleaned || null;
|
|
87
|
+
}
|
|
88
|
+
/** Real git/fs implementations. Overridden per-field in tests. */
|
|
89
|
+
export function defaultRepoProfileOps() {
|
|
90
|
+
return {
|
|
91
|
+
existsSync: (p) => fs.existsSync(p),
|
|
92
|
+
readFileSync: (p) => fs.readFileSync(p, "utf8"),
|
|
93
|
+
repoRoot: (cwd) => {
|
|
94
|
+
try {
|
|
95
|
+
return execFileSync("git", ["rev-parse", "--show-toplevel"], {
|
|
96
|
+
cwd,
|
|
97
|
+
encoding: "utf8",
|
|
98
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
99
|
+
}).trim();
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
},
|
|
105
|
+
originFullpath: (cwd) => {
|
|
106
|
+
try {
|
|
107
|
+
const url = execFileSync("git", ["remote", "get-url", "origin"], {
|
|
108
|
+
cwd,
|
|
109
|
+
encoding: "utf8",
|
|
110
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
111
|
+
}).trim();
|
|
112
|
+
return parseGitRemoteFullpath(url);
|
|
113
|
+
}
|
|
114
|
+
catch {
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
},
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
function readJsonObject(filePath, ops, onWarn) {
|
|
121
|
+
if (!ops.existsSync(filePath)) {
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
try {
|
|
125
|
+
const parsed = JSON.parse(ops.readFileSync(filePath));
|
|
126
|
+
if (typeof parsed !== "object" ||
|
|
127
|
+
parsed === null ||
|
|
128
|
+
Array.isArray(parsed)) {
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
return parsed;
|
|
132
|
+
}
|
|
133
|
+
catch {
|
|
134
|
+
// Fail open — a malformed config must never take down unrelated
|
|
135
|
+
// commands — but surface it so a fat-fingered mapping doesn't hide as a
|
|
136
|
+
// confusing lookup miss two steps downstream.
|
|
137
|
+
onWarn?.(filePath);
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* Resolve the profile a repo is pinned to, or null when it has no pin.
|
|
143
|
+
* Repo-local `.el-git.json` wins over the global `owner/repo` table; within the
|
|
144
|
+
* table an exact key wins over the `owner/*` wildcard.
|
|
145
|
+
*/
|
|
146
|
+
export function resolveRepoLinearProfile(cwd = process.cwd(), opts = {}) {
|
|
147
|
+
const ops = {
|
|
148
|
+
...defaultRepoProfileOps(),
|
|
149
|
+
...(opts.ops ?? {}),
|
|
150
|
+
};
|
|
151
|
+
const env = opts.env ?? process.env;
|
|
152
|
+
const onWarn = opts.onMalformedConfig;
|
|
153
|
+
const root = ops.repoRoot(cwd);
|
|
154
|
+
if (root) {
|
|
155
|
+
const repoConfig = readJsonObject(path.join(root, LINEAR_PROFILE_REPO_FILE), ops, onWarn);
|
|
156
|
+
const fromRepo = repoConfig?.linearProfile;
|
|
157
|
+
if (typeof fromRepo === "string" && fromRepo.trim()) {
|
|
158
|
+
return { profile: fromRepo.trim(), source: "repo-file" };
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
const userConfig = readJsonObject(globalPinTablePath(env), ops, onWarn);
|
|
162
|
+
const table = userConfig?.profiles;
|
|
163
|
+
if (typeof table !== "object" || table === null) {
|
|
164
|
+
return null;
|
|
165
|
+
}
|
|
166
|
+
const fullpath = ops.originFullpath(cwd);
|
|
167
|
+
if (!fullpath) {
|
|
168
|
+
return null;
|
|
169
|
+
}
|
|
170
|
+
const lookup = table;
|
|
171
|
+
const exact = lookup[fullpath];
|
|
172
|
+
if (typeof exact === "string" && exact.trim()) {
|
|
173
|
+
return { profile: exact.trim(), source: "user-config" };
|
|
174
|
+
}
|
|
175
|
+
const wildcard = lookup[`${fullpath.split("/")[0]}/*`];
|
|
176
|
+
if (typeof wildcard === "string" && wildcard.trim()) {
|
|
177
|
+
return { profile: wildcard.trim(), source: "user-config" };
|
|
178
|
+
}
|
|
179
|
+
return null;
|
|
180
|
+
}
|
|
181
|
+
// ---- Write helpers (pure) ----------------------------------------------------
|
|
182
|
+
//
|
|
183
|
+
// The pin/unpin commands do the actual fs I/O; these pure string→string helpers
|
|
184
|
+
// hold the merge logic so it is unit-testable and can't clobber sibling keys.
|
|
185
|
+
function parseMutableJsonObject(existing, fileName) {
|
|
186
|
+
if (existing === null) {
|
|
187
|
+
return {};
|
|
188
|
+
}
|
|
189
|
+
try {
|
|
190
|
+
const parsed = JSON.parse(existing);
|
|
191
|
+
if (typeof parsed !== "object" ||
|
|
192
|
+
parsed === null ||
|
|
193
|
+
Array.isArray(parsed)) {
|
|
194
|
+
throw new Error("expected a JSON object");
|
|
195
|
+
}
|
|
196
|
+
return parsed;
|
|
197
|
+
}
|
|
198
|
+
catch {
|
|
199
|
+
throw new Error(`Refusing to edit malformed JSON in ${fileName}. Fix or remove it, then retry.`);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
/**
|
|
203
|
+
* Merge `{ linearProfile: name }` into an existing `.el-git.json` body,
|
|
204
|
+
* preserving every other key. `existing` is the current file text (or null when
|
|
205
|
+
* the file is absent). Returns the new file text (2-space, trailing newline).
|
|
206
|
+
*/
|
|
207
|
+
export function applyRepoLocalPin(existing, name) {
|
|
208
|
+
const obj = parseMutableJsonObject(existing, LINEAR_PROFILE_REPO_FILE);
|
|
209
|
+
obj.linearProfile = name;
|
|
210
|
+
return `${JSON.stringify(obj, null, 2)}\n`;
|
|
211
|
+
}
|
|
212
|
+
/**
|
|
213
|
+
* Remove the `linearProfile` key from an existing `.el-git.json` body,
|
|
214
|
+
* preserving other keys. Returns the new file text, or null when the file
|
|
215
|
+
* should be deleted (it held only the pin / was absent / becomes empty).
|
|
216
|
+
*/
|
|
217
|
+
export function removeRepoLocalPin(existing) {
|
|
218
|
+
if (existing === null) {
|
|
219
|
+
return null;
|
|
220
|
+
}
|
|
221
|
+
const obj = parseMutableJsonObject(existing, LINEAR_PROFILE_REPO_FILE);
|
|
222
|
+
if (!("linearProfile" in obj)) {
|
|
223
|
+
// Nothing to remove; leave the file exactly as-is.
|
|
224
|
+
return existing;
|
|
225
|
+
}
|
|
226
|
+
delete obj.linearProfile;
|
|
227
|
+
if (Object.keys(obj).length === 0) {
|
|
228
|
+
return null;
|
|
229
|
+
}
|
|
230
|
+
return `${JSON.stringify(obj, null, 2)}\n`;
|
|
231
|
+
}
|
|
232
|
+
/**
|
|
233
|
+
* Merge `profiles[fullpath] = name` into the global pin table body, preserving
|
|
234
|
+
* every other entry. Returns the new file text (2-space, trailing newline).
|
|
235
|
+
*/
|
|
236
|
+
export function applyGlobalPin(existing, fullpath, name) {
|
|
237
|
+
const obj = parseMutableJsonObject(existing, LINEAR_PROFILE_USER_CONFIG);
|
|
238
|
+
const table = typeof obj.profiles === "object" &&
|
|
239
|
+
obj.profiles !== null &&
|
|
240
|
+
!Array.isArray(obj.profiles)
|
|
241
|
+
? obj.profiles
|
|
242
|
+
: {};
|
|
243
|
+
table[fullpath] = name;
|
|
244
|
+
obj.profiles = table;
|
|
245
|
+
return `${JSON.stringify(obj, null, 2)}\n`;
|
|
246
|
+
}
|
|
247
|
+
/**
|
|
248
|
+
* Remove an exact `owner/repo` entry from the global pin table. Preserves
|
|
249
|
+
* wildcard entries and unrelated top-level keys. Returns null only when the
|
|
250
|
+
* file is absent or becomes empty after removal.
|
|
251
|
+
*/
|
|
252
|
+
export function removeGlobalPin(existing, fullpath) {
|
|
253
|
+
if (existing === null) {
|
|
254
|
+
return null;
|
|
255
|
+
}
|
|
256
|
+
const obj = parseMutableJsonObject(existing, LINEAR_PROFILE_USER_CONFIG);
|
|
257
|
+
if (typeof obj.profiles !== "object" ||
|
|
258
|
+
obj.profiles === null ||
|
|
259
|
+
Array.isArray(obj.profiles)) {
|
|
260
|
+
return existing;
|
|
261
|
+
}
|
|
262
|
+
const table = obj.profiles;
|
|
263
|
+
if (!(fullpath in table)) {
|
|
264
|
+
return existing;
|
|
265
|
+
}
|
|
266
|
+
delete table[fullpath];
|
|
267
|
+
if (Object.keys(table).length === 0) {
|
|
268
|
+
delete obj.profiles;
|
|
269
|
+
}
|
|
270
|
+
if (Object.keys(obj).length === 0) {
|
|
271
|
+
return null;
|
|
272
|
+
}
|
|
273
|
+
return `${JSON.stringify(obj, null, 2)}\n`;
|
|
274
|
+
}
|
package/dist/main.js
CHANGED
|
@@ -29,7 +29,8 @@ import { setupSearchCommands } from "./commands/search.js";
|
|
|
29
29
|
import { setupTeamsCommands } from "./commands/teams.js";
|
|
30
30
|
import { setupTemplatesCommands } from "./commands/templates.js";
|
|
31
31
|
import { setupUsersCommands } from "./commands/users.js";
|
|
32
|
-
import { setActiveProfileForSession } from "./config/paths.js";
|
|
32
|
+
import { isSafeProfileName, setActiveProfileForSession, } from "./config/paths.js";
|
|
33
|
+
import { resolveRepoLinearProfile } from "./config/repo-profile.js";
|
|
33
34
|
import { initCliSentry } from "./sentry.js";
|
|
34
35
|
import { logger } from "./utils/logger.js";
|
|
35
36
|
import { applyIpv4Preference } from "./utils/network-preference.js";
|
|
@@ -63,7 +64,7 @@ program
|
|
|
63
64
|
.description("A pragmatic CLI for Linear.app — deterministic resolution, structured validation, GraphQL escape hatch.")
|
|
64
65
|
.version(packageJson.version)
|
|
65
66
|
.option("--api-token <token>", "Linear API token")
|
|
66
|
-
.option("--profile <name>", "named profile (under ~/.config/el-linear/profiles/<name>/) for this invocation. Overrides EL_LINEAR_PROFILE
|
|
67
|
+
.option("--profile <name>", "named profile (under ~/.config/el-linear/profiles/<name>/) for this invocation. Overrides EL_LINEAR_PROFILE, repo pins, and the on-disk active-profile marker.")
|
|
67
68
|
.option("--json", "output as JSON (default, accepted for compatibility)")
|
|
68
69
|
.option("--format <kind>", "output format: json (default, structured envelope) or summary (human-readable)", "json")
|
|
69
70
|
.option("--raw", "strip { data, meta } wrapper from list output — emit the array directly. " +
|
|
@@ -134,6 +135,27 @@ program.hook("preAction", (_thisCommand, actionCommand) => {
|
|
|
134
135
|
if (rootOpts.profile) {
|
|
135
136
|
setActiveProfileForSession(rootOpts.profile);
|
|
136
137
|
}
|
|
138
|
+
else if (!process.env.EL_LINEAR_PROFILE?.trim()) {
|
|
139
|
+
// Repo pin (DEV-7277) slots BELOW --profile / $EL_LINEAR_PROFILE and
|
|
140
|
+
// ABOVE the machine-global active-profile marker. It is applied here,
|
|
141
|
+
// not inside the pure resolveActiveProfile, because reading it shells out
|
|
142
|
+
// to git. Setting the session override when a repo pin exists makes the
|
|
143
|
+
// rest of the run — token + config load — target the repo's workspace
|
|
144
|
+
// instead of whatever the global marker last selected.
|
|
145
|
+
//
|
|
146
|
+
// Fail-open: an unsafe/foreign name in an untrusted repo's `.el-git.json`
|
|
147
|
+
// is ignored rather than thrown, so a bad pin can never brick every
|
|
148
|
+
// command run inside that repo. Any resolution error is swallowed too.
|
|
149
|
+
try {
|
|
150
|
+
const pin = resolveRepoLinearProfile();
|
|
151
|
+
if (pin && isSafeProfileName(pin.profile)) {
|
|
152
|
+
setActiveProfileForSession(pin.profile);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
catch {
|
|
156
|
+
// Repo-pin resolution must never break a command.
|
|
157
|
+
}
|
|
158
|
+
}
|
|
137
159
|
});
|
|
138
160
|
program.action(() => {
|
|
139
161
|
program.help();
|
|
@@ -80,6 +80,12 @@ export interface UpdateProjectResponse {
|
|
|
80
80
|
interface ProjectUpdateFieldsNode extends ProjectBaseNode {
|
|
81
81
|
description: string | null;
|
|
82
82
|
content: string | null;
|
|
83
|
+
status?: {
|
|
84
|
+
id: string;
|
|
85
|
+
name: string;
|
|
86
|
+
};
|
|
87
|
+
progress: number;
|
|
88
|
+
url: string;
|
|
83
89
|
}
|
|
84
90
|
export interface UpdateProjectFieldsResponse {
|
|
85
91
|
projectUpdate: {
|
|
@@ -14,6 +14,6 @@ export declare const GET_PROJECT_TEAM_ISSUES_QUERY = "\n query GetProjectTeamIs
|
|
|
14
14
|
export declare const SEARCH_PROJECTS_BY_NAME_QUERY = "\n query SearchProjectsByName($name: String!) {\n projects(filter: { name: { containsIgnoreCase: $name } }, first: 10) {\n nodes {\n id\n name\n state\n teams {\n nodes { id key name }\n }\n }\n }\n }\n";
|
|
15
15
|
export declare const CREATE_PROJECT_MUTATION = "\n mutation CreateProject($input: ProjectCreateInput!) {\n projectCreate(input: $input) {\n success\n project {\n id\n name\n state\n teams {\n nodes { id key name }\n }\n }\n }\n }\n";
|
|
16
16
|
export declare const UPDATE_PROJECT_MUTATION = "\n mutation UpdateProject($id: String!, $input: ProjectUpdateInput!) {\n projectUpdate(id: $id, input: $input) {\n success\n project {\n id\n name\n teams {\n nodes {\n id\n key\n name\n }\n }\n }\n }\n }\n";
|
|
17
|
-
export declare const UPDATE_PROJECT_FIELDS_MUTATION = "\n mutation UpdateProjectFields($id: String!, $input: ProjectUpdateInput!) {\n projectUpdate(id: $id, input: $input) {\n success\n project {\n id\n name\n description\n content\n teams {\n nodes {\n id\n key\n name\n }\n }\n }\n }\n }\n";
|
|
17
|
+
export declare const UPDATE_PROJECT_FIELDS_MUTATION = "\n mutation UpdateProjectFields($id: String!, $input: ProjectUpdateInput!) {\n projectUpdate(id: $id, input: $input) {\n success\n project {\n id\n name\n description\n content\n status {\n id\n name\n }\n progress\n url\n teams {\n nodes {\n id\n key\n name\n }\n }\n }\n }\n }\n";
|
|
18
18
|
export declare const ARCHIVE_PROJECT_MUTATION = "\n mutation ArchiveProject($id: String!) {\n projectArchive(id: $id) {\n success\n lastSyncId\n entity {\n id\n }\n }\n }\n";
|
|
19
19
|
export declare const DELETE_PROJECT_MUTATION = "\n mutation DeleteProject($id: String!) {\n projectDelete(id: $id) {\n success\n lastSyncId\n entity {\n id\n }\n }\n }\n";
|
package/dist/queries/projects.js
CHANGED
|
@@ -1278,6 +1278,13 @@ export function formatLine(payload) {
|
|
|
1278
1278
|
if (kind === "comment" && obj) {
|
|
1279
1279
|
return `comment ${s(obj.id)}`;
|
|
1280
1280
|
}
|
|
1281
|
+
if (kind === "project" && obj) {
|
|
1282
|
+
// `projects update` (DEV-7021) is the current producer of a "project"
|
|
1283
|
+
// quiet line; it carries the resolved `status` name. Other project
|
|
1284
|
+
// payloads (read/list) carry the deprecated `state` scalar instead —
|
|
1285
|
+
// fall back to that so this line degrades gracefully if reused.
|
|
1286
|
+
return `${s(obj.name)} ${s(obj.status ?? obj.state)} ${s(obj.url)}`;
|
|
1287
|
+
}
|
|
1281
1288
|
if (kind === "project-update" && obj) {
|
|
1282
1289
|
// create doesn't carry an identifier/title — health + url are the
|
|
1283
1290
|
// stable handles a caller needs (matches the summary header style).
|
|
@@ -436,7 +436,7 @@ export class GraphQLIssuesService {
|
|
|
436
436
|
updateResult = await this.graphQLService.rawRequest(UPDATE_ISSUE_MUTATION, {
|
|
437
437
|
id: resolvedIssueId,
|
|
438
438
|
input: updateInput,
|
|
439
|
-
});
|
|
439
|
+
}, { retrySafeMutation: true });
|
|
440
440
|
}
|
|
441
441
|
catch (error) {
|
|
442
442
|
const msg = error instanceof Error ? error.message : String(error);
|
|
@@ -1,6 +1,21 @@
|
|
|
1
1
|
import type { LinearCredential } from "../auth/linear-credential.js";
|
|
2
2
|
import type { GraphQLResponseData, GraphQLVariables } from "../types/linear.js";
|
|
3
3
|
import type { AuthOptions } from "./auth.js";
|
|
4
|
+
export interface GraphQLRequestOptions {
|
|
5
|
+
/**
|
|
6
|
+
* Retry only an operation whose mutation is safe to repeat. Callers must
|
|
7
|
+
* opt in; creates and comment writes intentionally retain one-shot
|
|
8
|
+
* semantics because a transport failure can leave their server-side result
|
|
9
|
+
* unknown.
|
|
10
|
+
*/
|
|
11
|
+
retrySafeMutation?: boolean;
|
|
12
|
+
}
|
|
13
|
+
interface GraphQLServiceRuntimeOptions {
|
|
14
|
+
retryDelaysMs?: readonly number[];
|
|
15
|
+
sleep?: (ms: number) => Promise<void>;
|
|
16
|
+
}
|
|
17
|
+
/** True only for failures where retrying a known-idempotent request is useful. */
|
|
18
|
+
export declare function isTransientGraphQLError(error: unknown): boolean;
|
|
4
19
|
/**
|
|
5
20
|
* Constructor arg for `GraphQLService`. Re-exported alias of the shared
|
|
6
21
|
* `LinearCredential` union (`{ apiKey } | { oauthToken }`). Kept as a
|
|
@@ -15,7 +30,10 @@ import type { AuthOptions } from "./auth.js";
|
|
|
15
30
|
export type GraphQLServiceAuth = LinearCredential;
|
|
16
31
|
export declare class GraphQLService {
|
|
17
32
|
private readonly graphQLClient;
|
|
18
|
-
|
|
19
|
-
|
|
33
|
+
private readonly retryDelaysMs;
|
|
34
|
+
private readonly sleep;
|
|
35
|
+
constructor(auth: GraphQLServiceAuth, options?: GraphQLServiceRuntimeOptions);
|
|
36
|
+
rawRequest<T = GraphQLResponseData>(query: string, variables?: GraphQLVariables, options?: GraphQLRequestOptions): Promise<T>;
|
|
20
37
|
}
|
|
21
38
|
export declare function createGraphQLService(options: AuthOptions): Promise<GraphQLService>;
|
|
39
|
+
export {};
|
|
@@ -1,5 +1,20 @@
|
|
|
1
1
|
import { LinearClient } from "@linear/sdk";
|
|
2
2
|
import { getActiveAuth } from "../auth/token-resolver.js";
|
|
3
|
+
const DEFAULT_SAFE_MUTATION_RETRY_DELAYS_MS = [150, 400];
|
|
4
|
+
function errorMessage(error) {
|
|
5
|
+
return error instanceof Error ? error.message : String(error);
|
|
6
|
+
}
|
|
7
|
+
/** True only for failures where retrying a known-idempotent request is useful. */
|
|
8
|
+
export function isTransientGraphQLError(error) {
|
|
9
|
+
const detail = error;
|
|
10
|
+
const status = detail.response?.status ?? detail.response?.statusCode;
|
|
11
|
+
if (status === 408 ||
|
|
12
|
+
status === 429 ||
|
|
13
|
+
(status !== undefined && status >= 500)) {
|
|
14
|
+
return true;
|
|
15
|
+
}
|
|
16
|
+
return /\b(?:408|429|500|502|503|504)\b|(?:ECONNRESET|ECONNREFUSED|ETIMEDOUT|fetch failed|network error|connection termination)/i.test(errorMessage(error));
|
|
17
|
+
}
|
|
3
18
|
function buildLinearClient(auth) {
|
|
4
19
|
const baseHeaders = { "public-file-urls-expire-in": "3600" };
|
|
5
20
|
if ("oauthToken" in auth) {
|
|
@@ -16,17 +31,38 @@ function buildLinearClient(auth) {
|
|
|
16
31
|
}
|
|
17
32
|
export class GraphQLService {
|
|
18
33
|
graphQLClient;
|
|
19
|
-
|
|
34
|
+
retryDelaysMs;
|
|
35
|
+
sleep;
|
|
36
|
+
constructor(auth, options = {}) {
|
|
20
37
|
const client = buildLinearClient(auth);
|
|
21
38
|
// LinearClient stores a private graphql-request client — access via escape hatch
|
|
22
39
|
this.graphQLClient = client.client;
|
|
40
|
+
this.retryDelaysMs =
|
|
41
|
+
options.retryDelaysMs ?? DEFAULT_SAFE_MUTATION_RETRY_DELAYS_MS;
|
|
42
|
+
this.sleep =
|
|
43
|
+
options.sleep ??
|
|
44
|
+
((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
|
|
23
45
|
}
|
|
24
46
|
// Default type allows property access on raw GraphQL responses.
|
|
25
47
|
// Callers can narrow with explicit type parameter: rawRequest<{ issues: { nodes: T[] } }>(...)
|
|
26
|
-
async rawRequest(query, variables) {
|
|
48
|
+
async rawRequest(query, variables, options = {}) {
|
|
49
|
+
let attempt = 0;
|
|
27
50
|
try {
|
|
28
|
-
|
|
29
|
-
|
|
51
|
+
while (true) {
|
|
52
|
+
try {
|
|
53
|
+
const response = await this.graphQLClient.rawRequest(query, variables);
|
|
54
|
+
return response.data;
|
|
55
|
+
}
|
|
56
|
+
catch (error) {
|
|
57
|
+
if (!options.retrySafeMutation ||
|
|
58
|
+
attempt >= this.retryDelaysMs.length ||
|
|
59
|
+
!isTransientGraphQLError(error)) {
|
|
60
|
+
throw error;
|
|
61
|
+
}
|
|
62
|
+
await this.sleep(this.retryDelaysMs[attempt]);
|
|
63
|
+
attempt += 1;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
30
66
|
}
|
|
31
67
|
catch (error) {
|
|
32
68
|
const err = error;
|
|
@@ -58,6 +58,23 @@ export declare class LinearService {
|
|
|
58
58
|
* have to double-resolve.
|
|
59
59
|
*/
|
|
60
60
|
resolveProjectId(projectInput: string, teamInput?: string): Promise<string>;
|
|
61
|
+
/**
|
|
62
|
+
* Resolve a `--status` value on `projects update` to a `ProjectStatus` id
|
|
63
|
+
* (DEV-7021). Project statuses are a workspace-configurable set (Linear
|
|
64
|
+
* Settings → Workflow → Projects) — there is no fixed enum to validate
|
|
65
|
+
* against client-side, unlike `VALID_PROJECT_STATES` in `commands/projects.ts`
|
|
66
|
+
* (which enumerates the *type* group, not the individual named statuses).
|
|
67
|
+
* `client.projectStatuses()` has no server-side name filter, so we fetch the
|
|
68
|
+
* workspace's full set (small in practice — a handful of statuses) and match
|
|
69
|
+
* case-insensitively. On a miss, list the workspace's actual status names
|
|
70
|
+
* rather than surfacing Linear's raw `statusId` mutation error, which names
|
|
71
|
+
* the *type* (e.g. "paused") instead of a status a caller could have typed.
|
|
72
|
+
*
|
|
73
|
+
* Mirrors `resolveTeamId`'s not-found shape (`Available teams: ...`) rather
|
|
74
|
+
* than `resolveStatusId`'s (which omits the list) — this is the "workspace-
|
|
75
|
+
* configurable set with candidates on a miss" precedent to follow.
|
|
76
|
+
*/
|
|
77
|
+
resolveProjectStatusId(statusName: string): Promise<string>;
|
|
61
78
|
/**
|
|
62
79
|
* Normalize a user-supplied project input to a UUID when the input is
|
|
63
80
|
* a URL or slug-id form. Pass-through for UUIDs and plain names — the
|
|
@@ -582,6 +582,34 @@ export class LinearService {
|
|
|
582
582
|
const descriptions = candidates.map((c) => `${c.id} (teams: ${c.teamKeys.join(", ") || "none"})`);
|
|
583
583
|
throw multipleMatchesError("project", projectInput, descriptions, "scope with --team, or pass the project URL/slug-id");
|
|
584
584
|
}
|
|
585
|
+
/**
|
|
586
|
+
* Resolve a `--status` value on `projects update` to a `ProjectStatus` id
|
|
587
|
+
* (DEV-7021). Project statuses are a workspace-configurable set (Linear
|
|
588
|
+
* Settings → Workflow → Projects) — there is no fixed enum to validate
|
|
589
|
+
* against client-side, unlike `VALID_PROJECT_STATES` in `commands/projects.ts`
|
|
590
|
+
* (which enumerates the *type* group, not the individual named statuses).
|
|
591
|
+
* `client.projectStatuses()` has no server-side name filter, so we fetch the
|
|
592
|
+
* workspace's full set (small in practice — a handful of statuses) and match
|
|
593
|
+
* case-insensitively. On a miss, list the workspace's actual status names
|
|
594
|
+
* rather than surfacing Linear's raw `statusId` mutation error, which names
|
|
595
|
+
* the *type* (e.g. "paused") instead of a status a caller could have typed.
|
|
596
|
+
*
|
|
597
|
+
* Mirrors `resolveTeamId`'s not-found shape (`Available teams: ...`) rather
|
|
598
|
+
* than `resolveStatusId`'s (which omits the list) — this is the "workspace-
|
|
599
|
+
* configurable set with candidates on a miss" precedent to follow.
|
|
600
|
+
*/
|
|
601
|
+
async resolveProjectStatusId(statusName) {
|
|
602
|
+
if (isUuid(statusName)) {
|
|
603
|
+
return statusName;
|
|
604
|
+
}
|
|
605
|
+
const statuses = await this.client.projectStatuses({ first: 250 });
|
|
606
|
+
const match = statuses.nodes.find((s) => s.name.toLowerCase() === statusName.toLowerCase());
|
|
607
|
+
if (!match) {
|
|
608
|
+
const available = statuses.nodes.map((s) => s.name);
|
|
609
|
+
throw notFoundError("Project status", statusName, undefined, `\n Available statuses: ${available.join(", ")}`);
|
|
610
|
+
}
|
|
611
|
+
return match.id;
|
|
612
|
+
}
|
|
585
613
|
/**
|
|
586
614
|
* Normalize a user-supplied project input to a UUID when the input is
|
|
587
615
|
* a URL or slug-id form. Pass-through for UUIDs and plain names — the
|
|
@@ -37,8 +37,8 @@ export interface ProtectedRange {
|
|
|
37
37
|
}
|
|
38
38
|
/**
|
|
39
39
|
* Find ranges of `text` that should NOT have identifiers processed.
|
|
40
|
-
* Covered: fenced code, inline backticks, existing markdown
|
|
41
|
-
* Slack links, angle-bracket autolinks, bare URLs.
|
|
40
|
+
* Covered: fenced code, inline backticks, HTML comments, existing markdown
|
|
41
|
+
* links, Slack links, angle-bracket autolinks, bare URLs.
|
|
42
42
|
*
|
|
43
43
|
* The returned ranges may overlap; callers only test "is this position
|
|
44
44
|
* inside any protected range" so overlap is harmless.
|
|
@@ -22,6 +22,12 @@ const FENCED_CODE_BLOCK_REGEX = /(?:^|\n)([ \t]*)(?:```|~~~)[^\n]*\n[\s\S]*?\n\1
|
|
|
22
22
|
// for spans containing backticks; we keep it simple and match
|
|
23
23
|
// single-backtick spans, which covers the common case.
|
|
24
24
|
const INLINE_CODE_REGEX = /`[^`\n]+?`/g;
|
|
25
|
+
// HTML comments carry machine-readable metadata in Markdown documents (for
|
|
26
|
+
// example, el-git's signed review and nit-disposition markers). They are not
|
|
27
|
+
// rendered prose, so identifiers inside them must remain byte-for-byte stable.
|
|
28
|
+
// Treat an unterminated comment as extending to EOF: preserving a malformed
|
|
29
|
+
// marker is safer than inserting Markdown into its payload.
|
|
30
|
+
const HTML_COMMENT_REGEX = /<!--[\s\S]*?(?:-->|$)/g;
|
|
25
31
|
// Existing markdown links: [text](url). Both halves protected so
|
|
26
32
|
// identifiers inside the URL or the text aren't reprocessed.
|
|
27
33
|
const MARKDOWN_LINK_REGEX = /\[([^\]]*)\]\(([^)]*)\)/g;
|
|
@@ -116,8 +122,8 @@ function trimBareUrlTrailingPunct(url) {
|
|
|
116
122
|
}
|
|
117
123
|
/**
|
|
118
124
|
* Find ranges of `text` that should NOT have identifiers processed.
|
|
119
|
-
* Covered: fenced code, inline backticks, existing markdown
|
|
120
|
-
* Slack links, angle-bracket autolinks, bare URLs.
|
|
125
|
+
* Covered: fenced code, inline backticks, HTML comments, existing markdown
|
|
126
|
+
* links, Slack links, angle-bracket autolinks, bare URLs.
|
|
121
127
|
*
|
|
122
128
|
* The returned ranges may overlap; callers only test "is this position
|
|
123
129
|
* inside any protected range" so overlap is harmless.
|
|
@@ -127,6 +133,7 @@ export function findProtectedRanges(text) {
|
|
|
127
133
|
for (const re of [
|
|
128
134
|
FENCED_CODE_BLOCK_REGEX,
|
|
129
135
|
INLINE_CODE_REGEX,
|
|
136
|
+
HTML_COMMENT_REGEX,
|
|
130
137
|
MARKDOWN_LINK_REGEX,
|
|
131
138
|
// Slack links must be considered before generic angle-bracket
|
|
132
139
|
// autolinks — the autolink regex doesn't know about the `|label`
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@enrichlayer/el-linear",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.44.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",
|
|
@@ -52,14 +52,14 @@
|
|
|
52
52
|
},
|
|
53
53
|
"dependencies": {
|
|
54
54
|
"@inquirer/prompts": "^8.4.2",
|
|
55
|
-
"@linear/sdk": "^
|
|
55
|
+
"@linear/sdk": "^87.0.0",
|
|
56
56
|
"commander": "^15.0.0",
|
|
57
57
|
"picocolors": "^1.1.1"
|
|
58
58
|
},
|
|
59
59
|
"devDependencies": {
|
|
60
60
|
"@biomejs/biome": "^2.4.14",
|
|
61
|
-
"@types/node": "^
|
|
62
|
-
"graphql": "^
|
|
61
|
+
"@types/node": "^26.0.0",
|
|
62
|
+
"graphql": "^17.0.1",
|
|
63
63
|
"tsx": "^4.21.0",
|
|
64
64
|
"typescript": "^6.0.3",
|
|
65
65
|
"vitest": "^4.0.18"
|