@enrichlayer/el-linear 1.37.0 → 1.37.1
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 +12 -0
- package/claude-skills/linear-operations/SKILL.md +3 -4
- package/dist/commands/issues.js +22 -5
- package/dist/commands/profile.js +20 -1
- package/dist/commands/projects.js +8 -5
- package/dist/config/paths.d.ts +9 -0
- package/dist/config/paths.js +9 -1
- package/dist/main.js +3 -1
- package/dist/queries/issues-types.d.ts +11 -0
- package/dist/queries/issues.d.ts +19 -0
- package/dist/queries/issues.js +39 -0
- package/dist/utils/graphql-issues-service.js +29 -7
- package/dist/utils/output.js +41 -1
- package/dist/utils/root-opts.d.ts +13 -0
- package/dist/utils/root-opts.js +23 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -160,6 +160,18 @@ The active profile is selected by, in priority:
|
|
|
160
160
|
The legacy fallback means **existing single-profile users see no
|
|
161
161
|
behavior change** — profiles are purely opt-in.
|
|
162
162
|
|
|
163
|
+
**Automation and concurrent sessions: prefer `--profile`/`$EL_LINEAR_PROFILE`
|
|
164
|
+
over `profile use`.** `~/.config/el-linear/active-profile` is a single,
|
|
165
|
+
machine-global marker file — `profile use <name>` mutates it for *every*
|
|
166
|
+
el-linear invocation on the machine, including other terminals, scripts, and
|
|
167
|
+
agent sessions that don't set an explicit override. Two processes wanting
|
|
168
|
+
different workspaces at the same time will silently clobber each other. The
|
|
169
|
+
flag and env var are per-invocation and never touch the marker file, so they
|
|
170
|
+
compose safely with any number of concurrent sessions each pinned to their
|
|
171
|
+
own workspace. Reserve `profile use` for a human's own interactive default
|
|
172
|
+
switch; `profile use`/`profile add` print a loud warning when they change the
|
|
173
|
+
global marker so the blast radius is visible instead of silent.
|
|
174
|
+
|
|
163
175
|
## Migrating from v1.0–1.3
|
|
164
176
|
|
|
165
177
|
Versions 1.0–1.3 stored everything in the single-file layout
|
|
@@ -241,11 +241,10 @@ el-linear issues search "keywords from proposed title" --include-closed 2>&1
|
|
|
241
241
|
|
|
242
242
|
The dup-check above guards against duplicating an *issue*. This guards against duplicating *reality*: before filing an issue to **add** a flag / guard / command / subcommand, confirm it doesn't **already exist**.
|
|
243
243
|
|
|
244
|
-
- `<cli> <subcommand> --help` (
|
|
245
|
-
- `el-
|
|
246
|
-
- For a hook/guard, grep the source — e.g. `cli/el-hook/src/checks`.
|
|
244
|
+
- **`el-catalog commands --search "<intent>"`** / `el-catalog clis --search` — the **authoritative** check for a command/subcommand; it deterministically indexes every `<cli> <subcommand>`. Confirm with `<cli> <subcommand> --help` (and `<cli> --help` — a flag may be a global option absent from the subcommand's help).
|
|
245
|
+
- For a hook/guard, grep the source (e.g. `cli/el-hook/src/checks`) — but **empty grep output is inconclusive, not proof of absence**. A mis-quoted glob or bad flag makes grep exit silently with zero matches (`grep --include=*.ts …` errors under zsh, returning nothing for a symbol that exists). Never read a raw grep's silence as "doesn't exist" for the command check — that is exactly what `el-catalog` is for.
|
|
247
246
|
|
|
248
|
-
Skipping it cost
|
|
247
|
+
Skipping it cost real rework across sessions: a hook guard and `--jq`/`--fields` flags filed as "features" though both already existed and worked, and a near-duplicate `el-git` subcommand built after a raw grep silently misfired (the `el-catalog` search would have found the existing one) — every premise caught only at implementation.
|
|
249
248
|
|
|
250
249
|
When `el-linear issues search` (or the cross-resource `search`) returns rows
|
|
251
250
|
carrying issue identifiers, the JSON envelope embeds a `_warnings` line
|
package/dist/commands/issues.js
CHANGED
|
@@ -18,7 +18,7 @@ import { createLinearService, } from "../utils/linear-service.js";
|
|
|
18
18
|
import { logger } from "../utils/logger.js";
|
|
19
19
|
import { handleAsyncCommand, outputSuccess, outputWarning, warnIfTruncated, } from "../utils/output.js";
|
|
20
20
|
import { buildRelationCandidatePrompt } from "../utils/relation-candidate-prompt.js";
|
|
21
|
-
import { getRootOpts } from "../utils/root-opts.js";
|
|
21
|
+
import { effectiveOption, getRootOpts } from "../utils/root-opts.js";
|
|
22
22
|
import { formatCsv, formatMarkdown, formatTable, } from "../utils/table-formatter.js";
|
|
23
23
|
import { parsePositiveInt, parsePriorityFilter, splitList, validatePriority, } from "../utils/validators.js";
|
|
24
24
|
import { currentGitBranch, extractIssueIdentifierFromBranch, getBranchLinearIssue, gitCheckoutBranch, setBranchLinearIssue, toBranchName, } from "./issues/branch.js";
|
|
@@ -121,7 +121,17 @@ function buildUpdateArgs(issueId, options, assigneeId, delegateId) {
|
|
|
121
121
|
dueDate: options.dueDate || (options.clearDueDate ? null : undefined),
|
|
122
122
|
};
|
|
123
123
|
}
|
|
124
|
-
|
|
124
|
+
/**
|
|
125
|
+
* `--format` / `--fields` are registered on both the root program and the
|
|
126
|
+
* list/search subcommands, and commander 15 assigns the CLI token to the
|
|
127
|
+
* root registration — so the values must be resolved via
|
|
128
|
+
* {@link effectiveOption} rather than read off the subcommand's own
|
|
129
|
+
* options (DEV-5376: `--format table --fields …` silently fell through to
|
|
130
|
+
* the JSON envelope because `options.format` stayed at its default).
|
|
131
|
+
*/
|
|
132
|
+
function outputIssues(issues, command, meta) {
|
|
133
|
+
const format = effectiveOption(command, "format");
|
|
134
|
+
const fields = effectiveOption(command, "fields");
|
|
125
135
|
const fieldList = fields ? splitList(fields) : undefined;
|
|
126
136
|
if (format === "table") {
|
|
127
137
|
logger.info(formatTable(issues, fieldList));
|
|
@@ -134,6 +144,13 @@ function outputIssues(issues, format, fields, meta) {
|
|
|
134
144
|
logger.info(formatCsv(issues, fieldList));
|
|
135
145
|
}
|
|
136
146
|
else {
|
|
147
|
+
// JSON path: the `--fields` projection is applied by the global
|
|
148
|
+
// `fieldsFilter` inside `outputSuccess` (set in main.ts's preAction),
|
|
149
|
+
// NOT by `fieldList` here — `fieldList` only feeds the tabular
|
|
150
|
+
// formatters above. Keeping the projection in one place is what
|
|
151
|
+
// prevents double-application; a future refactor that routes
|
|
152
|
+
// table/csv/md through `outputSuccess` would need to drop the global
|
|
153
|
+
// filter or it would project twice.
|
|
137
154
|
outputSuccess({ data: issues, meta: { count: issues.length, ...meta } });
|
|
138
155
|
}
|
|
139
156
|
}
|
|
@@ -228,14 +245,14 @@ async function handleListIssues(options, command) {
|
|
|
228
245
|
outputWarning("excluded terminal states (Done / Canceled) by default; pass --include-closed to include them");
|
|
229
246
|
}
|
|
230
247
|
warnIfTruncated(result.length, limit);
|
|
231
|
-
outputIssues(result,
|
|
248
|
+
outputIssues(result, command, {
|
|
232
249
|
team: options.team,
|
|
233
250
|
});
|
|
234
251
|
}
|
|
235
252
|
else {
|
|
236
253
|
const result = sortIssues(await issuesService.getIssues(limit), options.sort);
|
|
237
254
|
warnIfTruncated(result.length, limit);
|
|
238
|
-
outputIssues(result,
|
|
255
|
+
outputIssues(result, command, {});
|
|
239
256
|
}
|
|
240
257
|
}
|
|
241
258
|
async function handleSearchIssues(query, options, command) {
|
|
@@ -282,7 +299,7 @@ async function handleSearchIssues(query, options, command) {
|
|
|
282
299
|
if (relationPrompt) {
|
|
283
300
|
outputWarning(relationPrompt);
|
|
284
301
|
}
|
|
285
|
-
outputIssues(result,
|
|
302
|
+
outputIssues(result, command, { query });
|
|
286
303
|
}
|
|
287
304
|
/**
|
|
288
305
|
* Run an `issuesService.createIssue` / `.updateIssue` call and, if it
|
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, isSafeProfileName as isSafeName, 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, readActiveProfileMarker, 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";
|
|
@@ -120,8 +120,27 @@ export async function runProfileUse(name) {
|
|
|
120
120
|
if (!isSafeName(trimmed)) {
|
|
121
121
|
throw new Error(`Profile name "${trimmed}" must contain only [a-z0-9_-]. Pick a different name.`);
|
|
122
122
|
}
|
|
123
|
+
// DEV-5610: `active-profile` is a single, machine-global marker — every
|
|
124
|
+
// el-linear invocation anywhere on this machine that doesn't set
|
|
125
|
+
// --profile/$EL_LINEAR_PROFILE resolves through it. Warn loudly on an
|
|
126
|
+
// actual switch so the blast radius is visible instead of silently
|
|
127
|
+
// redirecting some other concurrent session/tool to a different
|
|
128
|
+
// workspace. `--profile`/$EL_LINEAR_PROFILE remain the correct,
|
|
129
|
+
// non-mutating choice for automation and concurrent sessions.
|
|
130
|
+
//
|
|
131
|
+
// Read the raw marker (readActiveProfileMarker), not
|
|
132
|
+
// resolveActiveProfile().name — the latter resolves through the
|
|
133
|
+
// --profile/$EL_LINEAR_PROFILE precedence layers too, so a concurrent
|
|
134
|
+
// override in place while `profile use` runs would make it report the
|
|
135
|
+
// wrong "previous" value (a false-positive warning when the marker
|
|
136
|
+
// didn't actually change, or a false-negative when it did) — cycle-1
|
|
137
|
+
// review finding on PR #229.
|
|
138
|
+
const previous = readActiveProfileMarker();
|
|
123
139
|
await fsp.mkdir(CONFIG_DIR, { recursive: true, mode: 0o700 });
|
|
124
140
|
await fsp.writeFile(ACTIVE_PROFILE_FILE, `${trimmed}\n`, { mode: 0o644 });
|
|
141
|
+
if (previous !== trimmed) {
|
|
142
|
+
outputWarning(`Switched the GLOBAL active profile to "${trimmed}" (${ACTIVE_PROFILE_FILE}). This affects every other el-linear invocation on this machine that doesn't set --profile/$EL_LINEAR_PROFILE explicitly — concurrent sessions/tools relying on a different profile will silently start hitting the wrong workspace.`);
|
|
143
|
+
}
|
|
125
144
|
}
|
|
126
145
|
export async function runProfileAdd(name) {
|
|
127
146
|
const trimmed = name.trim();
|
|
@@ -6,7 +6,7 @@ import { createGraphQLService } from "../utils/graphql-service.js";
|
|
|
6
6
|
import { createLinearService } from "../utils/linear-service.js";
|
|
7
7
|
import { logger } from "../utils/logger.js";
|
|
8
8
|
import { handleAsyncCommand, outputSuccess, outputWarning, } from "../utils/output.js";
|
|
9
|
-
import { getRootOpts } from "../utils/root-opts.js";
|
|
9
|
+
import { effectiveOption, getRootOpts } from "../utils/root-opts.js";
|
|
10
10
|
import { renderCsv, renderFixedWidthTable, renderMarkdownTable, } from "../utils/table-formatter.js";
|
|
11
11
|
import { isUuid } from "../utils/uuid.js";
|
|
12
12
|
import { parsePositiveInt, splitList } from "../utils/validators.js";
|
|
@@ -516,7 +516,11 @@ export function setupProjectsCommands(program) {
|
|
|
516
516
|
// ahead of completed/canceled in every format, so the active
|
|
517
517
|
// set always fits within `--limit`. DEV-4175.
|
|
518
518
|
const sorted = sortActiveFirst(result);
|
|
519
|
-
|
|
519
|
+
// Resolved via effectiveOption because commander 15 hands the
|
|
520
|
+
// CLI token to the root program's same-named --format/--fields
|
|
521
|
+
// registration, leaving the subcommand's option at its default
|
|
522
|
+
// (DEV-5376).
|
|
523
|
+
const format = effectiveOption(command, "format") ?? "json";
|
|
520
524
|
const isTabular = format === "table" ||
|
|
521
525
|
format === "md" ||
|
|
522
526
|
format === "markdown" ||
|
|
@@ -530,9 +534,8 @@ export function setupProjectsCommands(program) {
|
|
|
530
534
|
warnProjectsTruncated(sorted.length, limit, format);
|
|
531
535
|
}
|
|
532
536
|
if (isTabular) {
|
|
533
|
-
const
|
|
534
|
-
|
|
535
|
-
: undefined;
|
|
537
|
+
const fields = effectiveOption(command, "fields");
|
|
538
|
+
const fieldList = fields ? splitList(fields) : undefined;
|
|
536
539
|
formatProjectsOutput(sorted, format, fieldList);
|
|
537
540
|
if (format === "table") {
|
|
538
541
|
logger.info(`\n${sorted.length} projects`);
|
package/dist/config/paths.d.ts
CHANGED
|
@@ -51,3 +51,12 @@ export declare function getSessionProfileOverride(): string | null;
|
|
|
51
51
|
export declare function resolveActiveProfile(env?: NodeJS.ProcessEnv, fsImpl?: ProfileFsOps): ProfilePaths;
|
|
52
52
|
/** Build profile-relative paths for a named profile. Pure. */
|
|
53
53
|
export declare function profilePaths(name: string): ProfilePaths;
|
|
54
|
+
/**
|
|
55
|
+
* Read the raw on-disk marker content, bypassing the `--profile`/
|
|
56
|
+
* `EL_LINEAR_PROFILE` precedence layers `resolveActiveProfile` applies.
|
|
57
|
+
* Exported (DEV-5610) so `profile use` can detect whether *the file itself*
|
|
58
|
+
* is about to change, independent of any per-invocation override that might
|
|
59
|
+
* otherwise make `resolveActiveProfile().name` report a different "previous"
|
|
60
|
+
* value than what's actually on disk.
|
|
61
|
+
*/
|
|
62
|
+
export declare function readActiveProfileMarker(fsImpl?: ProfileFsOps): string | null;
|
package/dist/config/paths.js
CHANGED
|
@@ -127,7 +127,15 @@ export function profilePaths(name) {
|
|
|
127
127
|
tokenPath: path.join(dir, "token"),
|
|
128
128
|
};
|
|
129
129
|
}
|
|
130
|
-
|
|
130
|
+
/**
|
|
131
|
+
* Read the raw on-disk marker content, bypassing the `--profile`/
|
|
132
|
+
* `EL_LINEAR_PROFILE` precedence layers `resolveActiveProfile` applies.
|
|
133
|
+
* Exported (DEV-5610) so `profile use` can detect whether *the file itself*
|
|
134
|
+
* is about to change, independent of any per-invocation override that might
|
|
135
|
+
* otherwise make `resolveActiveProfile().name` report a different "previous"
|
|
136
|
+
* value than what's actually on disk.
|
|
137
|
+
*/
|
|
138
|
+
export function readActiveProfileMarker(fsImpl = DEFAULT_FS_OPS) {
|
|
131
139
|
if (!fsImpl.existsSync(ACTIVE_PROFILE_FILE))
|
|
132
140
|
return null;
|
|
133
141
|
try {
|
package/dist/main.js
CHANGED
|
@@ -70,7 +70,9 @@ program
|
|
|
70
70
|
"Warnings (e.g. --fields fields_unresolved) can't ride on a bare array, so " +
|
|
71
71
|
"they are written to stderr prefixed `_warnings: `, keeping stdout a pure JSON array")
|
|
72
72
|
.option("--jq <filter>", "apply a jq filter to the JSON output")
|
|
73
|
-
.option("--fields <fields>", "filter output to specific fields (comma-separated).
|
|
73
|
+
.option("--fields <fields>", "filter output to specific fields (comma-separated). Dot-paths resolve nested " +
|
|
74
|
+
"values; the column aliases status (state.name) and updated (updatedAt) " +
|
|
75
|
+
"resolve too. Unresolved fields are " +
|
|
74
76
|
"emitted as null plus a `fields_unresolved:` warning — in the JSON " +
|
|
75
77
|
"envelope's `_warnings`, or on stderr when output is a bare array (with --raw)")
|
|
76
78
|
.option("--no-cache", "bypass the on-disk cache for `teams list` / `labels list` / `projects list`");
|
|
@@ -153,6 +153,17 @@ export interface GetIssuesResponse {
|
|
|
153
153
|
nodes: IssueNode[];
|
|
154
154
|
};
|
|
155
155
|
}
|
|
156
|
+
/**
|
|
157
|
+
* Response shape for `TEAM_SCOPED_FILTERED_ISSUES_QUERY` (DEV-5578).
|
|
158
|
+
* `team` is null when the resolved team UUID does not exist.
|
|
159
|
+
*/
|
|
160
|
+
export interface TeamScopedFilteredIssuesResponse {
|
|
161
|
+
team: {
|
|
162
|
+
issues: {
|
|
163
|
+
nodes: IssueNode[];
|
|
164
|
+
};
|
|
165
|
+
} | null;
|
|
166
|
+
}
|
|
156
167
|
/** Response shape for `SEARCH_ISSUES_QUERY` (full-text). */
|
|
157
168
|
export interface SearchIssuesResponse {
|
|
158
169
|
searchIssues: {
|
package/dist/queries/issues.d.ts
CHANGED
|
@@ -1,6 +1,25 @@
|
|
|
1
1
|
export declare const GET_ISSUES_QUERY = "\n query GetIssues($first: Int!, $orderBy: PaginationOrderBy) {\n issues(\n first: $first\n orderBy: $orderBy\n filter: {\n state: { type: { neq: \"completed\" } }\n }\n ) {\n nodes {\n \n \n id\n identifier\n title\n description\n summary { content generationStatus }\n branchName\n priority\n estimate\n dueDate\n url\n createdAt\n updatedAt\n completedAt\n\n \n state {\n id\n name\n type\n }\n\n \n assignee {\n id\n name\n url\n }\n\n \n delegate {\n id\n name\n url\n }\n\n \n team {\n id\n key\n name\n }\n\n \n project {\n id\n name\n }\n\n \n labels {\n nodes {\n id\n name\n }\n }\n\n \n cycle {\n id\n name\n number\n }\n\n \n projectMilestone {\n id\n name\n targetDate\n }\n\n \n parent {\n id\n identifier\n title\n }\n\n \n children {\n nodes {\n id\n identifier\n title\n }\n }\n\n\n }\n }\n }\n";
|
|
2
2
|
export declare const SEARCH_ISSUES_QUERY = "\n query SearchIssues($term: String!, $first: Int!) {\n searchIssues(term: $term, first: $first, includeArchived: false) {\n nodes {\n \n \n id\n identifier\n title\n description\n summary { content generationStatus }\n branchName\n priority\n estimate\n dueDate\n url\n createdAt\n updatedAt\n completedAt\n\n \n state {\n id\n name\n type\n }\n\n \n assignee {\n id\n name\n url\n }\n\n \n delegate {\n id\n name\n url\n }\n\n \n team {\n id\n key\n name\n }\n\n \n project {\n id\n name\n }\n\n \n labels {\n nodes {\n id\n name\n }\n }\n\n \n cycle {\n id\n name\n number\n }\n\n \n projectMilestone {\n id\n name\n targetDate\n }\n\n \n parent {\n id\n identifier\n title\n }\n\n \n children {\n nodes {\n id\n identifier\n title\n }\n }\n\n\n }\n }\n }\n";
|
|
3
3
|
export declare const FILTERED_SEARCH_ISSUES_QUERY = "\n query FilteredSearchIssues(\n $first: Int!\n $filter: IssueFilter\n $orderBy: PaginationOrderBy\n ) {\n issues(\n first: $first\n filter: $filter\n orderBy: $orderBy\n includeArchived: false\n ) {\n nodes {\n \n \n id\n identifier\n title\n description\n summary { content generationStatus }\n branchName\n priority\n estimate\n dueDate\n url\n createdAt\n updatedAt\n completedAt\n\n \n state {\n id\n name\n type\n }\n\n \n assignee {\n id\n name\n url\n }\n\n \n delegate {\n id\n name\n url\n }\n\n \n team {\n id\n key\n name\n }\n\n \n project {\n id\n name\n }\n\n \n labels {\n nodes {\n id\n name\n }\n }\n\n \n cycle {\n id\n name\n number\n }\n\n \n projectMilestone {\n id\n name\n targetDate\n }\n\n \n parent {\n id\n identifier\n title\n }\n\n \n children {\n nodes {\n id\n identifier\n title\n }\n }\n\n\n }\n }\n }\n";
|
|
4
|
+
/**
|
|
5
|
+
* Team-scoped variant of `FILTERED_SEARCH_ISSUES_QUERY` (DEV-5578).
|
|
6
|
+
*
|
|
7
|
+
* When a `--team` filter is present, the team boundary is applied
|
|
8
|
+
* *structurally* — the `issues` connection is rooted at the `Team` node
|
|
9
|
+
* (`team(id: $teamId).issues(...)`) rather than passed as a top-level
|
|
10
|
+
* `issues(filter: { team: { id: { eq } } })` relation filter.
|
|
11
|
+
*
|
|
12
|
+
* The top-level relation-filter form is unreliable at scale: Linear's API
|
|
13
|
+
* silently leaks issues from *other* teams once `$first` grows past a small
|
|
14
|
+
* page (~20), so `issues list --team DEV --limit 100` returned issues from
|
|
15
|
+
* EMW/INF/FE too. This is the same class of bug DEV-5325 fixed for
|
|
16
|
+
* `projects list --team` (`ProjectFilter` had no working `teams` relation, so
|
|
17
|
+
* project scoping moved to `Team.projects`). Rooting at the team node keeps
|
|
18
|
+
* the team boundary server-side and exact. The remaining `$filter`
|
|
19
|
+
* (state / labels / assignee / priority / project) is applied on top of the
|
|
20
|
+
* already-team-scoped connection.
|
|
21
|
+
*/
|
|
22
|
+
export declare const TEAM_SCOPED_FILTERED_ISSUES_QUERY = "\n query TeamScopedFilteredIssues(\n $teamId: String!\n $first: Int!\n $filter: IssueFilter\n $orderBy: PaginationOrderBy\n ) {\n team(id: $teamId) {\n issues(\n first: $first\n filter: $filter\n orderBy: $orderBy\n includeArchived: false\n ) {\n nodes {\n \n \n id\n identifier\n title\n description\n summary { content generationStatus }\n branchName\n priority\n estimate\n dueDate\n url\n createdAt\n updatedAt\n completedAt\n\n \n state {\n id\n name\n type\n }\n\n \n assignee {\n id\n name\n url\n }\n\n \n delegate {\n id\n name\n url\n }\n\n \n team {\n id\n key\n name\n }\n\n \n project {\n id\n name\n }\n\n \n labels {\n nodes {\n id\n name\n }\n }\n\n \n cycle {\n id\n name\n number\n }\n\n \n projectMilestone {\n id\n name\n targetDate\n }\n\n \n parent {\n id\n identifier\n title\n }\n\n \n children {\n nodes {\n id\n identifier\n title\n }\n }\n\n\n }\n }\n }\n }\n";
|
|
4
23
|
/**
|
|
5
24
|
* Batch-resolves a search's team/project/assignee/delegate filter inputs.
|
|
6
25
|
*
|
package/dist/queries/issues.js
CHANGED
|
@@ -41,6 +41,45 @@ export const FILTERED_SEARCH_ISSUES_QUERY = `
|
|
|
41
41
|
}
|
|
42
42
|
}
|
|
43
43
|
`;
|
|
44
|
+
/**
|
|
45
|
+
* Team-scoped variant of `FILTERED_SEARCH_ISSUES_QUERY` (DEV-5578).
|
|
46
|
+
*
|
|
47
|
+
* When a `--team` filter is present, the team boundary is applied
|
|
48
|
+
* *structurally* — the `issues` connection is rooted at the `Team` node
|
|
49
|
+
* (`team(id: $teamId).issues(...)`) rather than passed as a top-level
|
|
50
|
+
* `issues(filter: { team: { id: { eq } } })` relation filter.
|
|
51
|
+
*
|
|
52
|
+
* The top-level relation-filter form is unreliable at scale: Linear's API
|
|
53
|
+
* silently leaks issues from *other* teams once `$first` grows past a small
|
|
54
|
+
* page (~20), so `issues list --team DEV --limit 100` returned issues from
|
|
55
|
+
* EMW/INF/FE too. This is the same class of bug DEV-5325 fixed for
|
|
56
|
+
* `projects list --team` (`ProjectFilter` had no working `teams` relation, so
|
|
57
|
+
* project scoping moved to `Team.projects`). Rooting at the team node keeps
|
|
58
|
+
* the team boundary server-side and exact. The remaining `$filter`
|
|
59
|
+
* (state / labels / assignee / priority / project) is applied on top of the
|
|
60
|
+
* already-team-scoped connection.
|
|
61
|
+
*/
|
|
62
|
+
export const TEAM_SCOPED_FILTERED_ISSUES_QUERY = `
|
|
63
|
+
query TeamScopedFilteredIssues(
|
|
64
|
+
$teamId: String!
|
|
65
|
+
$first: Int!
|
|
66
|
+
$filter: IssueFilter
|
|
67
|
+
$orderBy: PaginationOrderBy
|
|
68
|
+
) {
|
|
69
|
+
team(id: $teamId) {
|
|
70
|
+
issues(
|
|
71
|
+
first: $first
|
|
72
|
+
filter: $filter
|
|
73
|
+
orderBy: $orderBy
|
|
74
|
+
includeArchived: false
|
|
75
|
+
) {
|
|
76
|
+
nodes {
|
|
77
|
+
${COMPLETE_ISSUE_FRAGMENT}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
`;
|
|
44
83
|
/**
|
|
45
84
|
* Batch-resolves a search's team/project/assignee/delegate filter inputs.
|
|
46
85
|
*
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { isRegistryConfigured, resolveViaRegistry, } from "../config/registry-resolve.js";
|
|
2
2
|
import { resolveUserDisplayName } from "../config/resolver.js";
|
|
3
|
-
import { ARCHIVE_ISSUE_MUTATION, BATCH_GET_ISSUES_QUERY, BATCH_RESOLVE_FOR_CREATE_QUERY, BATCH_RESOLVE_FOR_SEARCH_QUERY, BATCH_RESOLVE_FOR_UPDATE_QUERY, buildResolveLabelsByNameQuery, CREATE_ISSUE_MUTATION, DELETE_ISSUE_MUTATION, FILTERED_SEARCH_ISSUES_QUERY, GET_ISSUE_BY_ID_QUERY, GET_ISSUE_BY_IDENTIFIER_QUERY, GET_ISSUE_CLAIM_CONTEXT_QUERY, GET_ISSUE_START_CONTEXT_QUERY, GET_ISSUE_TEAM_QUERY, GET_ISSUES_QUERY, SEARCH_ISSUES_QUERY, TEAM_STARTED_STATUSES_QUERY, UPDATE_ISSUE_MUTATION, } from "../queries/issues.js";
|
|
3
|
+
import { ARCHIVE_ISSUE_MUTATION, BATCH_GET_ISSUES_QUERY, BATCH_RESOLVE_FOR_CREATE_QUERY, BATCH_RESOLVE_FOR_SEARCH_QUERY, BATCH_RESOLVE_FOR_UPDATE_QUERY, buildResolveLabelsByNameQuery, CREATE_ISSUE_MUTATION, DELETE_ISSUE_MUTATION, FILTERED_SEARCH_ISSUES_QUERY, GET_ISSUE_BY_ID_QUERY, GET_ISSUE_BY_IDENTIFIER_QUERY, GET_ISSUE_CLAIM_CONTEXT_QUERY, GET_ISSUE_START_CONTEXT_QUERY, GET_ISSUE_TEAM_QUERY, GET_ISSUES_QUERY, SEARCH_ISSUES_QUERY, TEAM_SCOPED_FILTERED_ISSUES_QUERY, TEAM_STARTED_STATUSES_QUERY, UPDATE_ISSUE_MUTATION, } from "../queries/issues.js";
|
|
4
4
|
import { CREATE_LABEL_MUTATION } from "../queries/labels.js";
|
|
5
5
|
import { TERMINAL_STATE_TYPES } from "../types/linear.js";
|
|
6
6
|
import { toISOStringOrNow, toISOStringOrUndefined } from "./date-format.js";
|
|
@@ -556,8 +556,14 @@ export class GraphQLIssuesService {
|
|
|
556
556
|
priority: args.priority,
|
|
557
557
|
});
|
|
558
558
|
}
|
|
559
|
+
// DEV-5578: the team boundary is NOT put in the IssueFilter. Linear's
|
|
560
|
+
// top-level `issues(filter: { team: { id: { eq } } })` relation filter
|
|
561
|
+
// leaks other teams' issues once `first` grows past a small page (~20),
|
|
562
|
+
// so `--team` was silently contaminated on paginated (`--limit > ~20`)
|
|
563
|
+
// results. When a team is present we scope structurally through the
|
|
564
|
+
// `Team.issues` connection below (same class of fix as DEV-5325's
|
|
565
|
+
// `Team.projects`); the rest of the filter rides on top of it.
|
|
559
566
|
const filter = this.buildSearchFilter({
|
|
560
|
-
teamId: finalTeamId,
|
|
561
567
|
assigneeId: finalAssigneeId,
|
|
562
568
|
delegateId: finalDelegateId,
|
|
563
569
|
project: projectFilter,
|
|
@@ -566,10 +572,25 @@ export class GraphQLIssuesService {
|
|
|
566
572
|
labelNames: args.labelNames,
|
|
567
573
|
priority: args.priority,
|
|
568
574
|
});
|
|
575
|
+
const filterArg = Object.keys(filter).length > 0 ? filter : undefined;
|
|
576
|
+
const orderBy = args.orderBy ?? "updatedAt";
|
|
577
|
+
if (finalTeamId) {
|
|
578
|
+
const teamScoped = await this.graphQLService.rawRequest(TEAM_SCOPED_FILTERED_ISSUES_QUERY, {
|
|
579
|
+
teamId: finalTeamId,
|
|
580
|
+
first: limit,
|
|
581
|
+
filter: filterArg,
|
|
582
|
+
orderBy,
|
|
583
|
+
});
|
|
584
|
+
const nodes = teamScoped.team?.issues?.nodes;
|
|
585
|
+
if (!nodes?.length) {
|
|
586
|
+
return [];
|
|
587
|
+
}
|
|
588
|
+
return nodes.map((issue) => this.transformIssueData(issue));
|
|
589
|
+
}
|
|
569
590
|
const searchResult = await this.graphQLService.rawRequest(FILTERED_SEARCH_ISSUES_QUERY, {
|
|
570
591
|
first: limit,
|
|
571
|
-
filter:
|
|
572
|
-
orderBy
|
|
592
|
+
filter: filterArg,
|
|
593
|
+
orderBy,
|
|
573
594
|
});
|
|
574
595
|
const filteredIssues = searchResult.issues;
|
|
575
596
|
if (!filteredIssues?.nodes) {
|
|
@@ -999,11 +1020,12 @@ export class GraphQLIssuesService {
|
|
|
999
1020
|
}
|
|
1000
1021
|
return filtered;
|
|
1001
1022
|
}
|
|
1023
|
+
// DEV-5578: `teamId` is intentionally NOT a member — the team boundary is
|
|
1024
|
+
// applied structurally via `TEAM_SCOPED_FILTERED_ISSUES_QUERY`, never as a
|
|
1025
|
+
// top-level `team` relation filter (which Linear leaks past ~20 rows). Do
|
|
1026
|
+
// not reintroduce a `filter.team = …` branch here.
|
|
1002
1027
|
buildSearchFilter(filters) {
|
|
1003
1028
|
const filter = {};
|
|
1004
|
-
if (filters.teamId) {
|
|
1005
|
-
filter.team = { id: { eq: filters.teamId } };
|
|
1006
|
-
}
|
|
1007
1029
|
if (filters.assigneeId) {
|
|
1008
1030
|
filter.assignee = { id: { eq: filters.assigneeId } };
|
|
1009
1031
|
}
|
package/dist/utils/output.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { resolveActiveProfile } from "../config/paths.js";
|
|
2
3
|
import { dispatch as dispatchSummary, drainSummaryFieldWarnings, formatLine, inferKindFromPayload, } from "./formatters/summary.js";
|
|
3
4
|
import { logger } from "./logger.js";
|
|
4
5
|
import { sanitizeForLog } from "./sanitize-for-log.js";
|
|
@@ -57,6 +58,19 @@ function getNestedPath(obj, path) {
|
|
|
57
58
|
}
|
|
58
59
|
return cur;
|
|
59
60
|
}
|
|
61
|
+
/**
|
|
62
|
+
* Aliases for the table/csv column names that don't exist as literal keys
|
|
63
|
+
* on the JSON payload (DEV-5376). Tried only after the literal key and the
|
|
64
|
+
* dot-path both miss, and only kept when the alias target actually
|
|
65
|
+
* resolves — so a resource that genuinely lacks `state`/`updatedAt` still
|
|
66
|
+
* gets the explicit-null + `fields_unresolved` treatment. The requested
|
|
67
|
+
* name stays the output key (`{"status": "Todo"}`), matching the
|
|
68
|
+
* "consumers read exactly what they asked for" contract.
|
|
69
|
+
*/
|
|
70
|
+
const FIELD_ALIASES = {
|
|
71
|
+
status: "state.name",
|
|
72
|
+
updated: "updatedAt",
|
|
73
|
+
};
|
|
60
74
|
/**
|
|
61
75
|
* Project `obj` down to the requested fields.
|
|
62
76
|
*
|
|
@@ -64,6 +78,8 @@ function getNestedPath(obj, path) {
|
|
|
64
78
|
* - Dot-separated paths (DEV-5323) resolve nested values; the requested path
|
|
65
79
|
* string becomes a flat output key (`{"pipeline.status": "success"}`), so
|
|
66
80
|
* consumers read exactly what they asked for.
|
|
81
|
+
* - Documented column aliases resolve when the literal key is absent
|
|
82
|
+
* (`status` → `state.name`, `updated` → `updatedAt`; DEV-5376).
|
|
67
83
|
* - A field that resolves nowhere is emitted as an explicit `null` AND
|
|
68
84
|
* reported via `unresolved` — never silently omitted. Silent omission made
|
|
69
85
|
* a typo'd field indistinguishable from an empty value (DEV-5323).
|
|
@@ -114,6 +130,12 @@ function filterFields(obj, fields, unresolved) {
|
|
|
114
130
|
result[field] = nested;
|
|
115
131
|
continue;
|
|
116
132
|
}
|
|
133
|
+
const alias = FIELD_ALIASES[field];
|
|
134
|
+
const aliased = alias !== undefined ? getNestedPath(source, alias) : undefined;
|
|
135
|
+
if (aliased !== undefined) {
|
|
136
|
+
result[field] = aliased;
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
117
139
|
result[field] = null;
|
|
118
140
|
unresolved?.add(field);
|
|
119
141
|
}
|
|
@@ -354,7 +376,25 @@ function outputError(error) {
|
|
|
354
376
|
// `lin_oauth_…` / `Bearer <payload>` in error text can't leak a token
|
|
355
377
|
// into stdout, shell history, or CI logs. The wizard already sanitizes
|
|
356
378
|
// its own log paths; this is the central error path on every command.
|
|
357
|
-
|
|
379
|
+
//
|
|
380
|
+
// `activeProfile` is included so a "not found" error is distinguishable
|
|
381
|
+
// from "wrong workspace" without manually inspecting
|
|
382
|
+
// ~/.config/el-linear/active-profile (DEV-5610) — the active profile can
|
|
383
|
+
// change between commands (an explicit `profile use`, a different
|
|
384
|
+
// $EL_LINEAR_PROFILE, or another process on the machine switching the
|
|
385
|
+
// shared marker file), and a bare "X not found" reads as data loss or an
|
|
386
|
+
// API outage when the real cause is often just the wrong workspace.
|
|
387
|
+
// Guarded: resolveActiveProfile() throws on an invalid $EL_LINEAR_PROFILE
|
|
388
|
+
// value (a real, separate user error) — outputError is the last-resort
|
|
389
|
+
// error path, so this addition must not itself throw uncaught.
|
|
390
|
+
let activeProfile = "<unknown>";
|
|
391
|
+
try {
|
|
392
|
+
activeProfile = resolveActiveProfile().name ?? "<legacy default>";
|
|
393
|
+
}
|
|
394
|
+
catch {
|
|
395
|
+
// leave activeProfile as "<unknown>"
|
|
396
|
+
}
|
|
397
|
+
const payload = JSON.stringify({ error: sanitizeForLog(error.message), activeProfile }, null, 2);
|
|
358
398
|
// Write to stdout (same channel as success) so machine callers always
|
|
359
399
|
// receive exactly one parseable JSON object regardless of stream capture.
|
|
360
400
|
logger.info(payload);
|
|
@@ -9,3 +9,16 @@ import type { Command, OptionValues } from "commander";
|
|
|
9
9
|
* assertions live here so call sites can stay clean and typed.
|
|
10
10
|
*/
|
|
11
11
|
export declare function getRootOpts(command: Command): OptionValues;
|
|
12
|
+
/**
|
|
13
|
+
* Resolve an option that is registered on BOTH the root program and a
|
|
14
|
+
* subcommand (`--format` / `--fields` on `issues list|search`,
|
|
15
|
+
* `projects list`).
|
|
16
|
+
*
|
|
17
|
+
* Commander 15 hands a same-named option to the OUTERMOST registration
|
|
18
|
+
* even when it appears after the subcommand: `issues list --format table`
|
|
19
|
+
* sets the program's global `--format` and leaves the subcommand's local
|
|
20
|
+
* option at its default, so handlers reading `options.format` silently
|
|
21
|
+
* saw `"json"` (DEV-5376). Precedence here: local CLI-set value, then the
|
|
22
|
+
* nearest ancestor's CLI-set value, then the local default.
|
|
23
|
+
*/
|
|
24
|
+
export declare function effectiveOption(command: Command, key: string): string | undefined;
|
package/dist/utils/root-opts.js
CHANGED
|
@@ -11,3 +11,26 @@ export function getRootOpts(command) {
|
|
|
11
11
|
// biome-ignore lint/style/noNonNullAssertion: command.parent / .parent.parent are guaranteed by commander inside subcommand actions; see docstring
|
|
12
12
|
return (command.parent?.parent ?? command.parent).opts();
|
|
13
13
|
}
|
|
14
|
+
/**
|
|
15
|
+
* Resolve an option that is registered on BOTH the root program and a
|
|
16
|
+
* subcommand (`--format` / `--fields` on `issues list|search`,
|
|
17
|
+
* `projects list`).
|
|
18
|
+
*
|
|
19
|
+
* Commander 15 hands a same-named option to the OUTERMOST registration
|
|
20
|
+
* even when it appears after the subcommand: `issues list --format table`
|
|
21
|
+
* sets the program's global `--format` and leaves the subcommand's local
|
|
22
|
+
* option at its default, so handlers reading `options.format` silently
|
|
23
|
+
* saw `"json"` (DEV-5376). Precedence here: local CLI-set value, then the
|
|
24
|
+
* nearest ancestor's CLI-set value, then the local default.
|
|
25
|
+
*/
|
|
26
|
+
export function effectiveOption(command, key) {
|
|
27
|
+
if (command.getOptionValueSource(key) === "cli") {
|
|
28
|
+
return command.getOptionValue(key);
|
|
29
|
+
}
|
|
30
|
+
for (let c = command.parent; c; c = c.parent) {
|
|
31
|
+
if (c.getOptionValueSource(key) === "cli") {
|
|
32
|
+
return c.getOptionValue(key);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return command.getOptionValue(key);
|
|
36
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@enrichlayer/el-linear",
|
|
3
|
-
"version": "1.37.
|
|
3
|
+
"version": "1.37.1",
|
|
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",
|