@enrichlayer/el-linear 1.6.0 → 1.9.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 +101 -5
- package/claude-skills/linear-operations/SKILL.md +55 -0
- package/dist/auth/oauth-app-config.d.ts +21 -0
- package/dist/auth/oauth-app-config.js +82 -0
- package/dist/auth/oauth-callback.js +14 -7
- package/dist/auth/oauth-fs.d.ts +22 -0
- package/dist/auth/oauth-fs.js +77 -6
- package/dist/auth/oauth-headless.d.ts +13 -8
- package/dist/auth/oauth-headless.js +18 -11
- package/dist/auth/oauth-storage.d.ts +11 -3
- package/dist/auth/oauth-storage.js +15 -8
- package/dist/auth/token-resolver.d.ts +9 -0
- package/dist/auth/token-resolver.js +57 -37
- package/dist/commands/init/defaults.d.ts +18 -1
- package/dist/commands/init/defaults.js +83 -2
- package/dist/commands/init/index.js +9 -1
- package/dist/commands/init/oauth.d.ts +10 -4
- package/dist/commands/init/oauth.js +32 -8
- package/dist/commands/init/token.d.ts +0 -8
- package/dist/commands/init/token.js +13 -1
- package/dist/commands/issues/branch.d.ts +17 -0
- package/dist/commands/issues/branch.js +40 -0
- package/dist/commands/issues/description.d.ts +89 -0
- package/dist/commands/issues/description.js +187 -0
- package/dist/commands/issues.js +76 -222
- package/dist/commands/labels.js +17 -2
- package/dist/commands/profile/migrate-legacy.js +10 -33
- package/dist/commands/profile.js +1 -10
- package/dist/commands/projects.d.ts +5 -1
- package/dist/commands/projects.js +144 -64
- package/dist/commands/read-shortcut.d.ts +6 -0
- package/dist/commands/read-shortcut.js +6 -1
- package/dist/commands/refs.js +2 -1
- package/dist/commands/teams.js +12 -2
- package/dist/commands/templates.js +127 -1
- package/dist/commands/users.js +2 -1
- package/dist/config/config.d.ts +19 -0
- package/dist/config/config.js +31 -9
- package/dist/config/issue-validation.js +1 -1
- package/dist/config/paths.d.ts +12 -0
- package/dist/config/paths.js +45 -6
- package/dist/config/term-enforcer.js +1 -1
- package/dist/main.js +39 -3
- package/dist/queries/templates.d.ts +3 -0
- package/dist/queries/templates.js +43 -0
- package/dist/utils/auth.js +7 -9
- package/dist/utils/auto-link-references.js +15 -1
- package/dist/utils/disk-cache.d.ts +51 -0
- package/dist/utils/disk-cache.js +179 -0
- package/dist/utils/formatters/summary.d.ts +62 -0
- package/dist/utils/formatters/summary.js +755 -0
- package/dist/utils/graphql-issues-service.d.ts +106 -3
- package/dist/utils/graphql-issues-service.js +51 -37
- package/dist/utils/issue-reference-extractor.d.ts +9 -1
- package/dist/utils/issue-reference-extractor.js +16 -9
- package/dist/utils/issue-reference-wrapper.js +1 -54
- package/dist/utils/linear-service.d.ts +7 -3
- package/dist/utils/linear-service.js +27 -5
- package/dist/utils/markdown-prosemirror.js +17 -1
- package/dist/utils/mention-resolver.js +17 -5
- package/dist/utils/output.d.ts +5 -1
- package/dist/utils/output.js +28 -1
- package/dist/utils/protected-ranges.d.ts +33 -0
- package/dist/utils/protected-ranges.js +73 -0
- package/dist/utils/table-formatter.d.ts +36 -0
- package/dist/utils/table-formatter.js +46 -24
- package/dist/utils/validators.d.ts +9 -1
- package/dist/utils/validators.js +10 -0
- package/package.json +1 -1
|
@@ -38,7 +38,8 @@
|
|
|
38
38
|
import { promises as fsp } from "node:fs";
|
|
39
39
|
import path from "node:path";
|
|
40
40
|
import { confirm, input, password } from "@inquirer/prompts";
|
|
41
|
-
import {
|
|
41
|
+
import { atomicWrite } from "../../auth/oauth-fs.js";
|
|
42
|
+
import { ACTIVE_PROFILE_FILE, CONFIG_DIR, CONFIG_PATH, isSafeProfileName as isSafeName, PROFILES_DIR, profilePaths, TOKEN_PATH, } from "../../config/paths.js";
|
|
42
43
|
import { sanitizeForLog, validateToken } from "../init/token.js";
|
|
43
44
|
const DEFAULT_PROMPTS = { input, password, confirm };
|
|
44
45
|
/** Register `el-linear profile migrate-legacy` under the parent `profile` command. */
|
|
@@ -165,10 +166,7 @@ async function resolveAndValidateToken(options, prompts, validate) {
|
|
|
165
166
|
async function copyConfigIntoProfile(destConfigPath, options, prompts) {
|
|
166
167
|
const sourceContent = await fsp.readFile(CONFIG_PATH, "utf8");
|
|
167
168
|
if (!(await pathExists(destConfigPath))) {
|
|
168
|
-
await
|
|
169
|
-
mode: 0o644,
|
|
170
|
-
encoding: "utf8",
|
|
171
|
-
});
|
|
169
|
+
await atomicWrite(destConfigPath, sourceContent, 0o644);
|
|
172
170
|
return;
|
|
173
171
|
}
|
|
174
172
|
const destContent = await fsp.readFile(destConfigPath, "utf8");
|
|
@@ -192,21 +190,15 @@ async function copyConfigIntoProfile(destConfigPath, options, prompts) {
|
|
|
192
190
|
throw new Error("Aborted by user.");
|
|
193
191
|
}
|
|
194
192
|
}
|
|
195
|
-
await
|
|
196
|
-
mode: 0o644,
|
|
197
|
-
encoding: "utf8",
|
|
198
|
-
});
|
|
193
|
+
await atomicWrite(destConfigPath, sourceContent, 0o644);
|
|
199
194
|
}
|
|
200
195
|
async function writeProfileToken(destTokenPath, token, options, prompts) {
|
|
201
196
|
const newline = `${token}\n`;
|
|
202
197
|
if (!(await pathExists(destTokenPath))) {
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
// fs.writeFile mode is only honored on file creation; chmod defensively
|
|
208
|
-
// in case a parent process pre-created the file with permissive perms.
|
|
209
|
-
await fsp.chmod(destTokenPath, 0o600);
|
|
198
|
+
// atomicWrite creates the tmp file with the requested mode and
|
|
199
|
+
// renames into place, so there is no window where the token is
|
|
200
|
+
// readable at a looser mode than 0o600.
|
|
201
|
+
await atomicWrite(destTokenPath, newline, 0o600);
|
|
210
202
|
return;
|
|
211
203
|
}
|
|
212
204
|
const existing = (await fsp.readFile(destTokenPath, "utf8")).trim();
|
|
@@ -230,11 +222,7 @@ async function writeProfileToken(destTokenPath, token, options, prompts) {
|
|
|
230
222
|
throw new Error("Aborted by user.");
|
|
231
223
|
}
|
|
232
224
|
}
|
|
233
|
-
await
|
|
234
|
-
mode: 0o600,
|
|
235
|
-
encoding: "utf8",
|
|
236
|
-
});
|
|
237
|
-
await fsp.chmod(destTokenPath, 0o600);
|
|
225
|
+
await atomicWrite(destTokenPath, newline, 0o600);
|
|
238
226
|
}
|
|
239
227
|
async function ensureActiveProfile(name) {
|
|
240
228
|
await fsp.mkdir(CONFIG_DIR, { recursive: true, mode: 0o700 });
|
|
@@ -244,10 +232,7 @@ async function ensureActiveProfile(name) {
|
|
|
244
232
|
}
|
|
245
233
|
if (current === name)
|
|
246
234
|
return;
|
|
247
|
-
await
|
|
248
|
-
mode: 0o644,
|
|
249
|
-
encoding: "utf8",
|
|
250
|
-
});
|
|
235
|
+
await atomicWrite(ACTIVE_PROFILE_FILE, `${name}\n`, 0o644);
|
|
251
236
|
}
|
|
252
237
|
async function pathExists(p) {
|
|
253
238
|
try {
|
|
@@ -258,14 +243,6 @@ async function pathExists(p) {
|
|
|
258
243
|
return false;
|
|
259
244
|
}
|
|
260
245
|
}
|
|
261
|
-
/**
|
|
262
|
-
* Profile names land in filesystem paths and the active-profile marker.
|
|
263
|
-
* Same conservative charset as `profile add` to avoid `..` traversal,
|
|
264
|
-
* shell metacharacters, and Unicode lookalikes.
|
|
265
|
-
*/
|
|
266
|
-
function isSafeName(name) {
|
|
267
|
-
return /^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$/.test(name);
|
|
268
|
-
}
|
|
269
246
|
// Re-export internals so the integration test (and follow-up commands)
|
|
270
247
|
// can compose them without re-implementing the idempotency rules.
|
|
271
248
|
export { CONFIG_PATH, PROFILES_DIR, TOKEN_PATH };
|
package/dist/commands/profile.js
CHANGED
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
import { promises as fsp } from "node:fs";
|
|
24
24
|
import path from "node:path";
|
|
25
25
|
import { confirm } from "@inquirer/prompts";
|
|
26
|
-
import { ACTIVE_PROFILE_FILE, CONFIG_DIR, CONFIG_PATH, PROFILES_DIR, profilePaths, resolveActiveProfile, setActiveProfileForSession, TOKEN_PATH, } from "../config/paths.js";
|
|
26
|
+
import { ACTIVE_PROFILE_FILE, CONFIG_DIR, CONFIG_PATH, isSafeProfileName as isSafeName, PROFILES_DIR, profilePaths, resolveActiveProfile, setActiveProfileForSession, TOKEN_PATH, } from "../config/paths.js";
|
|
27
27
|
import { outputSuccess, outputWarning } from "../utils/output.js";
|
|
28
28
|
import { runFullWizard } from "./init/index.js";
|
|
29
29
|
import { registerMigrateLegacy } from "./profile/migrate-legacy.js";
|
|
@@ -180,12 +180,3 @@ async function pathExists(p) {
|
|
|
180
180
|
return false;
|
|
181
181
|
}
|
|
182
182
|
}
|
|
183
|
-
/**
|
|
184
|
-
* Profile names land in filesystem paths AND get written to a
|
|
185
|
-
* single-line marker. Restrict to a conservative charset so we can't
|
|
186
|
-
* accidentally pick up `..` traversal, command-line metacharacters, or
|
|
187
|
-
* Unicode lookalikes that would confuse `profile use`.
|
|
188
|
-
*/
|
|
189
|
-
function isSafeName(name) {
|
|
190
|
-
return /^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$/.test(name);
|
|
191
|
-
}
|
|
@@ -1,2 +1,6 @@
|
|
|
1
|
-
import type { Command } from "commander";
|
|
1
|
+
import type { Command, OptionValues } from "commander";
|
|
2
|
+
export declare function resolveProjectStateFilter(options: OptionValues): {
|
|
3
|
+
states?: string[];
|
|
4
|
+
excludeStates?: string[];
|
|
5
|
+
};
|
|
2
6
|
export declare function setupProjectsCommands(program: Command): void;
|
|
@@ -1,78 +1,135 @@
|
|
|
1
|
+
import { loadConfig } from "../config/config.js";
|
|
1
2
|
import { resolveTeam } from "../config/resolver.js";
|
|
2
3
|
import { CREATE_PROJECT_MUTATION, GET_PROJECT_QUERY, GET_PROJECT_TEAM_ISSUES_QUERY, PROJECT_BY_ID_QUERY, SEARCH_PROJECTS_BY_NAME_QUERY, UPDATE_PROJECT_MUTATION, } from "../queries/projects.js";
|
|
4
|
+
import { cached, resolveCacheTTL } from "../utils/disk-cache.js";
|
|
3
5
|
import { createGraphQLService } from "../utils/graphql-service.js";
|
|
4
6
|
import { createLinearService } from "../utils/linear-service.js";
|
|
5
7
|
import { logger } from "../utils/logger.js";
|
|
6
8
|
import { handleAsyncCommand, outputSuccess } from "../utils/output.js";
|
|
7
9
|
import { getRootOpts } from "../utils/root-opts.js";
|
|
10
|
+
import { renderCsv, renderFixedWidthTable, renderMarkdownTable, } from "../utils/table-formatter.js";
|
|
8
11
|
import { isUuid } from "../utils/uuid.js";
|
|
9
12
|
import { splitList } from "../utils/validators.js";
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
const
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
case "name":
|
|
24
|
-
row[f] = p.name;
|
|
25
|
-
break;
|
|
26
|
-
case "state":
|
|
27
|
-
row[f] = p.state ?? "";
|
|
28
|
-
break;
|
|
29
|
-
case "progress":
|
|
30
|
-
row[f] =
|
|
31
|
-
p.progress !== undefined ? `${Math.round(p.progress * 100)}%` : "";
|
|
32
|
-
break;
|
|
33
|
-
case "teams":
|
|
34
|
-
row[f] = p.teams?.map((t) => t.key).join(", ") ?? "";
|
|
35
|
-
break;
|
|
36
|
-
case "lead":
|
|
37
|
-
row[f] = p.lead?.name ?? "";
|
|
38
|
-
break;
|
|
39
|
-
case "targetDate":
|
|
40
|
-
row[f] = p.targetDate ?? "";
|
|
41
|
-
break;
|
|
42
|
-
case "id":
|
|
43
|
-
row[f] = p.id;
|
|
44
|
-
break;
|
|
45
|
-
default:
|
|
46
|
-
row[f] = String(p[f] ?? "");
|
|
47
|
-
}
|
|
13
|
+
const VALID_PROJECT_STATES = new Set([
|
|
14
|
+
"backlog",
|
|
15
|
+
"planned",
|
|
16
|
+
"started",
|
|
17
|
+
"paused",
|
|
18
|
+
"completed",
|
|
19
|
+
"canceled",
|
|
20
|
+
]);
|
|
21
|
+
function parseStateList(value, flagName) {
|
|
22
|
+
const states = splitList(value).map((s) => s.toLowerCase());
|
|
23
|
+
for (const s of states) {
|
|
24
|
+
if (!VALID_PROJECT_STATES.has(s)) {
|
|
25
|
+
throw new Error(`Invalid state "${s}" for ${flagName}. Valid: ${[...VALID_PROJECT_STATES].join(", ")}.`);
|
|
48
26
|
}
|
|
49
|
-
|
|
50
|
-
|
|
27
|
+
}
|
|
28
|
+
return states;
|
|
29
|
+
}
|
|
30
|
+
export function resolveProjectStateFilter(options) {
|
|
31
|
+
const flags = [options.state, options.excludeState, options.active].filter(Boolean).length;
|
|
32
|
+
if (flags > 1) {
|
|
33
|
+
throw new Error("--state, --exclude-state, and --active are mutually exclusive.");
|
|
34
|
+
}
|
|
35
|
+
if (options.active) {
|
|
36
|
+
return { excludeStates: ["completed", "canceled"] };
|
|
37
|
+
}
|
|
38
|
+
if (options.state) {
|
|
39
|
+
return { states: parseStateList(options.state, "--state") };
|
|
40
|
+
}
|
|
41
|
+
if (options.excludeState) {
|
|
42
|
+
return {
|
|
43
|
+
excludeStates: parseStateList(options.excludeState, "--exclude-state"),
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
return {};
|
|
47
|
+
}
|
|
48
|
+
// ── Project column definitions ────────────────────────────────
|
|
49
|
+
//
|
|
50
|
+
// Pre-fix this file inlined an 80-line `formatProjectsOutput`
|
|
51
|
+
// function that hand-rolled column width math, table padding, CSV
|
|
52
|
+
// quoting, and markdown pipe syntax — all duplicating
|
|
53
|
+
// `utils/table-formatter.ts`. Now we declare `ColumnDef<LinearProject>`
|
|
54
|
+
// arrays and let the shared renderers do the work. ALL-938 cleanup.
|
|
55
|
+
const PROJECT_COLUMNS = {
|
|
56
|
+
name: { key: "name", header: "Name", width: 32, extract: (p) => p.name },
|
|
57
|
+
state: {
|
|
58
|
+
key: "state",
|
|
59
|
+
header: "State",
|
|
60
|
+
width: 10,
|
|
61
|
+
extract: (p) => p.state ?? "",
|
|
62
|
+
},
|
|
63
|
+
progress: {
|
|
64
|
+
key: "progress",
|
|
65
|
+
header: "Progress",
|
|
66
|
+
width: 10,
|
|
67
|
+
extract: (p) => p.progress !== undefined ? `${Math.round(p.progress * 100)}%` : "",
|
|
68
|
+
},
|
|
69
|
+
teams: {
|
|
70
|
+
key: "teams",
|
|
71
|
+
header: "Teams",
|
|
72
|
+
width: 18,
|
|
73
|
+
extract: (p) => p.teams?.map((t) => t.key).join(", ") ?? "",
|
|
74
|
+
},
|
|
75
|
+
lead: {
|
|
76
|
+
key: "lead",
|
|
77
|
+
header: "Lead",
|
|
78
|
+
width: 18,
|
|
79
|
+
extract: (p) => p.lead?.name ?? "",
|
|
80
|
+
},
|
|
81
|
+
targetDate: {
|
|
82
|
+
key: "targetDate",
|
|
83
|
+
header: "Target",
|
|
84
|
+
width: 12,
|
|
85
|
+
extract: (p) => p.targetDate ?? "",
|
|
86
|
+
},
|
|
87
|
+
id: { key: "id", header: "ID", width: 38, extract: (p) => p.id },
|
|
88
|
+
};
|
|
89
|
+
const PROJECT_MD_COLUMNS = {
|
|
90
|
+
name: { header: "Name", extract: (p) => p.name },
|
|
91
|
+
state: { header: "State", extract: (p) => p.state ?? "" },
|
|
92
|
+
progress: {
|
|
93
|
+
header: "Progress",
|
|
94
|
+
extract: (p) => p.progress !== undefined ? `${Math.round(p.progress * 100)}%` : "",
|
|
95
|
+
},
|
|
96
|
+
teams: {
|
|
97
|
+
header: "Teams",
|
|
98
|
+
extract: (p) => p.teams?.map((t) => t.key).join(", ") ?? "",
|
|
99
|
+
},
|
|
100
|
+
lead: { header: "Lead", extract: (p) => p.lead?.name ?? "" },
|
|
101
|
+
targetDate: { header: "Target", extract: (p) => p.targetDate ?? "" },
|
|
102
|
+
id: { header: "ID", extract: (p) => p.id },
|
|
103
|
+
};
|
|
104
|
+
const DEFAULT_PROJECT_COLUMNS = [
|
|
105
|
+
"name",
|
|
106
|
+
"state",
|
|
107
|
+
"progress",
|
|
108
|
+
"teams",
|
|
109
|
+
"lead",
|
|
110
|
+
"targetDate",
|
|
111
|
+
];
|
|
112
|
+
function formatProjectsOutput(projects, format, fieldNames) {
|
|
113
|
+
const keys = fieldNames ?? DEFAULT_PROJECT_COLUMNS;
|
|
51
114
|
if (format === "csv") {
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
115
|
+
const columns = keys
|
|
116
|
+
.map((k) => PROJECT_COLUMNS[k])
|
|
117
|
+
.filter((c) => c !== undefined);
|
|
118
|
+
logger.info(renderCsv(projects, columns));
|
|
56
119
|
return;
|
|
57
120
|
}
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
? `| ${fields.map((f, i) => f.padEnd(widths[i])).join(" | ")} |`
|
|
65
|
-
: fields.map((f, i) => ` ${f.padEnd(widths[i])} `).join("|");
|
|
66
|
-
logger.info(header);
|
|
67
|
-
logger.info(separator);
|
|
68
|
-
for (const row of rows) {
|
|
69
|
-
const line = format === "md"
|
|
70
|
-
? `| ${fields.map((f, i) => (row[f] ?? "").padEnd(widths[i])).join(" | ")} |`
|
|
71
|
-
: fields
|
|
72
|
-
.map((f, i) => ` ${(row[f] ?? "").padEnd(widths[i])} `)
|
|
73
|
-
.join("|");
|
|
74
|
-
logger.info(line);
|
|
121
|
+
if (format === "md") {
|
|
122
|
+
const columns = keys
|
|
123
|
+
.map((k) => PROJECT_MD_COLUMNS[k])
|
|
124
|
+
.filter((c) => c !== undefined);
|
|
125
|
+
logger.info(renderMarkdownTable(projects, columns));
|
|
126
|
+
return;
|
|
75
127
|
}
|
|
128
|
+
// Default: fixed-width text table.
|
|
129
|
+
const columns = keys
|
|
130
|
+
.map((k) => PROJECT_COLUMNS[k])
|
|
131
|
+
.filter((c) => c !== undefined);
|
|
132
|
+
logger.info(renderFixedWidthTable(projects, columns));
|
|
76
133
|
}
|
|
77
134
|
async function resolveProjectWithTeams(graphQLService, projectNameOrId) {
|
|
78
135
|
if (isUuid(projectNameOrId)) {
|
|
@@ -301,12 +358,35 @@ export function setupProjectsCommands(program) {
|
|
|
301
358
|
.command("list")
|
|
302
359
|
.description("List projects")
|
|
303
360
|
.option("-l, --limit <number>", "limit results", "100")
|
|
304
|
-
.option("--format <format>", "output format (json, table, md, csv)", "json")
|
|
361
|
+
.option("--format <format>", "output format (json, summary, table, md, csv)", "json")
|
|
305
362
|
.option("--fields <fields>", "columns for table/csv (comma-separated: name,state,progress,teams,lead,targetDate)")
|
|
363
|
+
.option("--name <substring>", "filter by case-insensitive substring on project name")
|
|
364
|
+
.option("--state <names>", "include only projects in these states (comma-separated: backlog, planned, started, paused, completed, canceled)")
|
|
365
|
+
.option("--exclude-state <names>", "exclude projects in these states (comma-separated). Mutually exclusive with --state.")
|
|
366
|
+
.option("--active", "shorthand for --exclude-state completed,canceled (mutually exclusive with --state / --exclude-state)")
|
|
306
367
|
.action(handleAsyncCommand(async (options, command) => {
|
|
307
368
|
const rootOpts = getRootOpts(command);
|
|
308
|
-
const
|
|
309
|
-
const
|
|
369
|
+
const limit = Number.parseInt(options.limit, 10);
|
|
370
|
+
const nameFilter = options.name;
|
|
371
|
+
const stateFilter = resolveProjectStateFilter(options);
|
|
372
|
+
const ttl = resolveCacheTTL({
|
|
373
|
+
configTTL: loadConfig().cacheTTLSeconds,
|
|
374
|
+
noCacheFlag: rootOpts.cache === false,
|
|
375
|
+
});
|
|
376
|
+
// Cache key includes filter inputs so different filter combinations
|
|
377
|
+
// don't collide.
|
|
378
|
+
const cacheKey = `projects-list-limit:${limit}` +
|
|
379
|
+
`-name:${nameFilter ?? "_all"}` +
|
|
380
|
+
`-states:${stateFilter.states?.join(",") ?? "_any"}` +
|
|
381
|
+
`-excl:${stateFilter.excludeStates?.join(",") ?? "_none"}`;
|
|
382
|
+
const result = await cached(cacheKey, ttl, async () => {
|
|
383
|
+
const service = await createLinearService(rootOpts);
|
|
384
|
+
return service.getProjects(limit, {
|
|
385
|
+
nameFilter,
|
|
386
|
+
states: stateFilter.states,
|
|
387
|
+
excludeStates: stateFilter.excludeStates,
|
|
388
|
+
});
|
|
389
|
+
});
|
|
310
390
|
const format = options.format;
|
|
311
391
|
if (format === "table" ||
|
|
312
392
|
format === "md" ||
|
|
@@ -4,3 +4,9 @@ import type { Command } from "commander";
|
|
|
4
4
|
* issue identifiers so `el-linear ADM-652` works without the `issues read` prefix.
|
|
5
5
|
*/
|
|
6
6
|
export declare function setupReadShortcut(program: Command): void;
|
|
7
|
+
/**
|
|
8
|
+
* Shared `issues read` body — exported so the top-level shortcut and
|
|
9
|
+
* `issues read` (in `commands/issues.ts`) both call one implementation
|
|
10
|
+
* instead of carrying byte-equivalent duplicates. ALL-938 cleanup.
|
|
11
|
+
*/
|
|
12
|
+
export declare function readIssues(issueIds: string[], _options: Record<string, unknown>, command: Command): Promise<void>;
|
|
@@ -49,7 +49,12 @@ export function setupReadShortcut(program) {
|
|
|
49
49
|
return originalParse(argv, parseOptions);
|
|
50
50
|
};
|
|
51
51
|
}
|
|
52
|
-
|
|
52
|
+
/**
|
|
53
|
+
* Shared `issues read` body — exported so the top-level shortcut and
|
|
54
|
+
* `issues read` (in `commands/issues.ts`) both call one implementation
|
|
55
|
+
* instead of carrying byte-equivalent duplicates. ALL-938 cleanup.
|
|
56
|
+
*/
|
|
57
|
+
export async function readIssues(issueIds, _options, command) {
|
|
53
58
|
const rootOpts = getRootOpts(command);
|
|
54
59
|
const graphQLService = await createGraphQLService(rootOpts);
|
|
55
60
|
const linearService = await createLinearService(rootOpts);
|
package/dist/commands/refs.js
CHANGED
|
@@ -3,6 +3,7 @@ import { createGraphQLService } from "../utils/graphql-service.js";
|
|
|
3
3
|
import { extractIssueReferences } from "../utils/issue-reference-extractor.js";
|
|
4
4
|
import { wrapIssueReferencesAsLinks, } from "../utils/issue-reference-wrapper.js";
|
|
5
5
|
import { createLinearService } from "../utils/linear-service.js";
|
|
6
|
+
import { logger } from "../utils/logger.js";
|
|
6
7
|
import { handleAsyncCommand } from "../utils/output.js";
|
|
7
8
|
import { getRootOpts } from "../utils/root-opts.js";
|
|
8
9
|
import { validateReferences } from "../utils/validate-references.js";
|
|
@@ -73,7 +74,7 @@ async function handleWrap(options, command) {
|
|
|
73
74
|
};
|
|
74
75
|
if (!validate) {
|
|
75
76
|
// stderr advisory only — keeps stdout a clean text stream for piping.
|
|
76
|
-
|
|
77
|
+
logger.error("el-linear refs wrap: --no-validate set; emitting links for every regex match without checking the workspace.");
|
|
77
78
|
}
|
|
78
79
|
const wrapped = await wrapRefsCore({ text, target, validate }, deps);
|
|
79
80
|
process.stdout.write(wrapped);
|
package/dist/commands/teams.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { loadConfig } from "../config/config.js";
|
|
2
|
+
import { cached, resolveCacheTTL } from "../utils/disk-cache.js";
|
|
1
3
|
import { createLinearService } from "../utils/linear-service.js";
|
|
2
4
|
import { handleAsyncCommand, outputSuccess } from "../utils/output.js";
|
|
3
5
|
import { getRootOpts } from "../utils/root-opts.js";
|
|
@@ -13,8 +15,16 @@ export function setupTeamsCommands(program) {
|
|
|
13
15
|
.option("-l, --limit <number>", "limit results", "100")
|
|
14
16
|
.action(handleAsyncCommand(async (options, command) => {
|
|
15
17
|
const rootOpts = getRootOpts(command);
|
|
16
|
-
const
|
|
17
|
-
const
|
|
18
|
+
const limit = Number.parseInt(options.limit, 10);
|
|
19
|
+
const ttl = resolveCacheTTL({
|
|
20
|
+
configTTL: loadConfig().cacheTTLSeconds,
|
|
21
|
+
// commander's `--no-cache` produces `cache: false` on the root opts.
|
|
22
|
+
noCacheFlag: rootOpts.cache === false,
|
|
23
|
+
});
|
|
24
|
+
const result = await cached(`teams-list-limit:${limit}`, ttl, async () => {
|
|
25
|
+
const service = await createLinearService(rootOpts);
|
|
26
|
+
return service.getTeams(limit);
|
|
27
|
+
});
|
|
18
28
|
outputSuccess({ data: result, meta: { count: result.length } });
|
|
19
29
|
}));
|
|
20
30
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { TEMPLATE_BY_ID_QUERY, TEMPLATE_CREATE_MUTATION, TEMPLATE_DELETE_MUTATION, TEMPLATE_UPDATE_MUTATION, TEMPLATES_LIST_QUERY, } from "../queries/templates.js";
|
|
2
3
|
import { createGraphQLService } from "../utils/graphql-service.js";
|
|
3
4
|
import { handleAsyncCommand, outputSuccess } from "../utils/output.js";
|
|
4
5
|
import { getRootOpts } from "../utils/root-opts.js";
|
|
@@ -56,4 +57,129 @@ export function setupTemplatesCommands(program) {
|
|
|
56
57
|
}
|
|
57
58
|
outputSuccess(template);
|
|
58
59
|
}));
|
|
60
|
+
templates
|
|
61
|
+
.command("create")
|
|
62
|
+
.description("Create a new issue / document / project template")
|
|
63
|
+
.requiredOption("--type <type>", "template kind (issue, document, project, recurringIssue)")
|
|
64
|
+
.requiredOption("--name <name>", "template display name")
|
|
65
|
+
.requiredOption("--team-id <id>", "team id (UUID) the template belongs to. See `el-linear teams list`")
|
|
66
|
+
.option("--description <text>", "description shown in the template picker")
|
|
67
|
+
.option("--data <json>", "templateData as a JSON string (typically { title, description, labelIds, priority })")
|
|
68
|
+
.option("--data-file <path>", "templateData read from a JSON file (relative path). Mutually exclusive with --data")
|
|
69
|
+
.option("--allow-absolute", "allow --data-file to read absolute or `..`-traversing paths (file contents are sent to Linear's API)")
|
|
70
|
+
.option("--icon <name>", "Lucide icon name shown in the picker")
|
|
71
|
+
.option("--color <hex>", "color (hex) for the template badge")
|
|
72
|
+
.action(handleAsyncCommand(async (options, command) => {
|
|
73
|
+
const rootOpts = getRootOpts(command);
|
|
74
|
+
const graphQLService = await createGraphQLService(rootOpts);
|
|
75
|
+
const templateData = parseTemplateData(options);
|
|
76
|
+
const input = {
|
|
77
|
+
type: options.type,
|
|
78
|
+
name: options.name,
|
|
79
|
+
teamId: options.teamId,
|
|
80
|
+
};
|
|
81
|
+
if (options.description)
|
|
82
|
+
input.description = options.description;
|
|
83
|
+
if (templateData !== undefined)
|
|
84
|
+
input.templateData = templateData;
|
|
85
|
+
if (options.icon)
|
|
86
|
+
input.icon = options.icon;
|
|
87
|
+
if (options.color)
|
|
88
|
+
input.color = options.color;
|
|
89
|
+
const result = await graphQLService.rawRequest(TEMPLATE_CREATE_MUTATION, { input });
|
|
90
|
+
const payload = result.templateCreate;
|
|
91
|
+
if (!payload?.success) {
|
|
92
|
+
throw new Error("templateCreate returned success=false");
|
|
93
|
+
}
|
|
94
|
+
outputSuccess(payload.template);
|
|
95
|
+
}));
|
|
96
|
+
templates
|
|
97
|
+
.command("update <templateId>")
|
|
98
|
+
.description("Update a template's name, description, templateData, icon, or color")
|
|
99
|
+
.option("--name <name>", "new display name")
|
|
100
|
+
.option("--description <text>", "new description")
|
|
101
|
+
.option("--data <json>", "new templateData as a JSON string")
|
|
102
|
+
.option("--data-file <path>", "new templateData read from a JSON file (relative path)")
|
|
103
|
+
.option("--allow-absolute", "allow --data-file to read absolute or `..`-traversing paths")
|
|
104
|
+
.option("--icon <name>", "Lucide icon name")
|
|
105
|
+
.option("--color <hex>", "color (hex)")
|
|
106
|
+
.option("--team-id <id>", "move the template to a different team")
|
|
107
|
+
.action(handleAsyncCommand(async (templateId, options, command) => {
|
|
108
|
+
const rootOpts = getRootOpts(command);
|
|
109
|
+
const graphQLService = await createGraphQLService(rootOpts);
|
|
110
|
+
const input = {};
|
|
111
|
+
if (options.name)
|
|
112
|
+
input.name = options.name;
|
|
113
|
+
if (options.description !== undefined) {
|
|
114
|
+
input.description = options.description;
|
|
115
|
+
}
|
|
116
|
+
const templateData = parseTemplateData(options);
|
|
117
|
+
if (templateData !== undefined)
|
|
118
|
+
input.templateData = templateData;
|
|
119
|
+
if (options.icon)
|
|
120
|
+
input.icon = options.icon;
|
|
121
|
+
if (options.color)
|
|
122
|
+
input.color = options.color;
|
|
123
|
+
if (options.teamId)
|
|
124
|
+
input.teamId = options.teamId;
|
|
125
|
+
if (Object.keys(input).length === 0) {
|
|
126
|
+
throw new Error("templates update: at least one of --name, --description, --data, --data-file, --icon, --color, --team-id is required");
|
|
127
|
+
}
|
|
128
|
+
const result = await graphQLService.rawRequest(TEMPLATE_UPDATE_MUTATION, { id: templateId, input });
|
|
129
|
+
const payload = result.templateUpdate;
|
|
130
|
+
if (!payload?.success) {
|
|
131
|
+
throw new Error("templateUpdate returned success=false");
|
|
132
|
+
}
|
|
133
|
+
outputSuccess(payload.template);
|
|
134
|
+
}));
|
|
135
|
+
templates
|
|
136
|
+
.command("delete <templateId>")
|
|
137
|
+
.description("Delete a template (cannot be undone)")
|
|
138
|
+
.action(handleAsyncCommand(async (templateId, _options, command) => {
|
|
139
|
+
const rootOpts = getRootOpts(command);
|
|
140
|
+
const graphQLService = await createGraphQLService(rootOpts);
|
|
141
|
+
const result = await graphQLService.rawRequest(TEMPLATE_DELETE_MUTATION, { id: templateId });
|
|
142
|
+
const payload = result.templateDelete;
|
|
143
|
+
if (!payload?.success) {
|
|
144
|
+
throw new Error("templateDelete returned success=false");
|
|
145
|
+
}
|
|
146
|
+
outputSuccess({ id: templateId, deleted: true });
|
|
147
|
+
}));
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* Resolve `--data <json>` or `--data-file <path>` into the parsed
|
|
151
|
+
* templateData object. Returns undefined when neither flag was set
|
|
152
|
+
* so the caller can omit the field from the input object.
|
|
153
|
+
*/
|
|
154
|
+
function parseTemplateData(options) {
|
|
155
|
+
if (options.data && options.dataFile) {
|
|
156
|
+
throw new Error("Pass either --data or --data-file, not both");
|
|
157
|
+
}
|
|
158
|
+
if (options.data) {
|
|
159
|
+
try {
|
|
160
|
+
return JSON.parse(options.data);
|
|
161
|
+
}
|
|
162
|
+
catch (err) {
|
|
163
|
+
throw new Error(`--data is not valid JSON: ${err instanceof Error ? err.message : String(err)}`);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
if (options.dataFile) {
|
|
167
|
+
const dataFile = String(options.dataFile);
|
|
168
|
+
// Reject absolute paths and `..` traversal unless explicitly
|
|
169
|
+
// allowed. CI invocations or scripted callers can pass an
|
|
170
|
+
// attacker-controlled path otherwise (e.g. `~/.aws/credentials.json`,
|
|
171
|
+
// `/etc/passwd`) and have its contents flow into Linear's API
|
|
172
|
+
// via the `templateData` field.
|
|
173
|
+
if (!options.allowAbsolute && /^(\/|\.\.\/|\.\.\\)/.test(dataFile)) {
|
|
174
|
+
throw new Error(`--data-file ${dataFile} resolves outside the current directory. Pass --allow-absolute to opt in (sends file contents to Linear's API).`);
|
|
175
|
+
}
|
|
176
|
+
const raw = readFileSync(dataFile, "utf8");
|
|
177
|
+
try {
|
|
178
|
+
return JSON.parse(raw);
|
|
179
|
+
}
|
|
180
|
+
catch (err) {
|
|
181
|
+
throw new Error(`--data-file ${dataFile} is not valid JSON: ${err instanceof Error ? err.message : String(err)}`);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
return undefined;
|
|
59
185
|
}
|
package/dist/commands/users.js
CHANGED
|
@@ -8,11 +8,12 @@ export function setupUsersCommands(program) {
|
|
|
8
8
|
.command("list")
|
|
9
9
|
.description("List all users")
|
|
10
10
|
.option("--active", "Only show active users")
|
|
11
|
+
.option("--name <substring>", "filter by case-insensitive substring on user name")
|
|
11
12
|
.option("-l, --limit <number>", "limit results", "100")
|
|
12
13
|
.action(handleAsyncCommand(async (options, command) => {
|
|
13
14
|
const rootOpts = getRootOpts(command);
|
|
14
15
|
const service = await createLinearService(rootOpts);
|
|
15
|
-
const result = await service.getUsers(options.active, Number.parseInt(options.limit, 10));
|
|
16
|
+
const result = await service.getUsers(options.active, Number.parseInt(options.limit, 10), options.name);
|
|
16
17
|
outputSuccess({ data: result, meta: { count: result.length } });
|
|
17
18
|
}));
|
|
18
19
|
}
|
package/dist/config/config.d.ts
CHANGED
|
@@ -57,6 +57,25 @@ export interface ElLinearConfig {
|
|
|
57
57
|
* }
|
|
58
58
|
*/
|
|
59
59
|
descriptionTemplates?: Record<string, string>;
|
|
60
|
+
/**
|
|
61
|
+
* Default assignee identifier (alias / display name / email / UUID — same
|
|
62
|
+
* shapes resolveAssignee accepts) for `issues create`. Applied when
|
|
63
|
+
* `--assignee` is not passed. Pass `--no-assignee` to override at one site.
|
|
64
|
+
*/
|
|
65
|
+
defaultAssignee?: string;
|
|
66
|
+
/**
|
|
67
|
+
* Default priority for `issues create` / `issues update`. Accepts the same
|
|
68
|
+
* keywords as the --priority flag: `none|urgent|high|medium|normal|low`
|
|
69
|
+
* or `0`–`4`. Applied when `--priority` is not passed.
|
|
70
|
+
*/
|
|
71
|
+
defaultPriority?: string;
|
|
72
|
+
/**
|
|
73
|
+
* TTL (seconds) for the on-disk cache used by `teams list`, `labels list`,
|
|
74
|
+
* and `projects list`. Defaults to 3600 (1 hour) when omitted. A value of
|
|
75
|
+
* `0` disables the cache entirely. Override per-invocation with
|
|
76
|
+
* `--no-cache`.
|
|
77
|
+
*/
|
|
78
|
+
cacheTTLSeconds?: number;
|
|
60
79
|
}
|
|
61
80
|
/** Test seam — resets the cache between test cases. */
|
|
62
81
|
export declare function _resetConfigCacheForTests(): void;
|