@enrichlayer/el-linear 1.37.0 → 1.37.2
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 +23 -1
- package/claude-skills/linear-operations/SKILL.md +3 -4
- package/dist/commands/init/aliases.d.ts +29 -0
- package/dist/commands/init/aliases.js +86 -32
- package/dist/commands/issues.js +59 -18
- package/dist/commands/profile/members.d.ts +50 -0
- package/dist/commands/profile/members.js +132 -0
- package/dist/commands/profile.js +24 -1
- package/dist/commands/projects.js +8 -5
- package/dist/commands/users.js +9 -0
- package/dist/config/config.d.ts +11 -0
- 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/duplicate-detection.d.ts +46 -2
- package/dist/utils/duplicate-detection.js +54 -4
- package/dist/utils/gate-telemetry.d.ts +8 -2
- package/dist/utils/graphql-issues-service.js +29 -7
- package/dist/utils/linear-service.d.ts +8 -0
- package/dist/utils/linear-service.js +18 -0
- 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/dist/commands/profile.js
CHANGED
|
@@ -23,9 +23,10 @@
|
|
|
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
|
+
import { registerMembersCommands } from "./profile/members.js";
|
|
29
30
|
import { registerMigrateLegacy } from "./profile/migrate-legacy.js";
|
|
30
31
|
export function setupProfileCommands(program) {
|
|
31
32
|
const profile = program
|
|
@@ -79,6 +80,9 @@ export function setupProfileCommands(program) {
|
|
|
79
80
|
// self-contained and unit-testable without dragging in the full
|
|
80
81
|
// profile-management surface.
|
|
81
82
|
registerMigrateLegacy(profile);
|
|
83
|
+
// `el-linear profile members {list,clear,set}` — DEV-5612: direct,
|
|
84
|
+
// non-interactive alias/handle edits without hand-editing config.json.
|
|
85
|
+
registerMembersCommands(profile);
|
|
82
86
|
}
|
|
83
87
|
export async function runProfileList() {
|
|
84
88
|
const active = resolveActiveProfile();
|
|
@@ -120,8 +124,27 @@ export async function runProfileUse(name) {
|
|
|
120
124
|
if (!isSafeName(trimmed)) {
|
|
121
125
|
throw new Error(`Profile name "${trimmed}" must contain only [a-z0-9_-]. Pick a different name.`);
|
|
122
126
|
}
|
|
127
|
+
// DEV-5610: `active-profile` is a single, machine-global marker — every
|
|
128
|
+
// el-linear invocation anywhere on this machine that doesn't set
|
|
129
|
+
// --profile/$EL_LINEAR_PROFILE resolves through it. Warn loudly on an
|
|
130
|
+
// actual switch so the blast radius is visible instead of silently
|
|
131
|
+
// redirecting some other concurrent session/tool to a different
|
|
132
|
+
// workspace. `--profile`/$EL_LINEAR_PROFILE remain the correct,
|
|
133
|
+
// non-mutating choice for automation and concurrent sessions.
|
|
134
|
+
//
|
|
135
|
+
// Read the raw marker (readActiveProfileMarker), not
|
|
136
|
+
// resolveActiveProfile().name — the latter resolves through the
|
|
137
|
+
// --profile/$EL_LINEAR_PROFILE precedence layers too, so a concurrent
|
|
138
|
+
// override in place while `profile use` runs would make it report the
|
|
139
|
+
// wrong "previous" value (a false-positive warning when the marker
|
|
140
|
+
// didn't actually change, or a false-negative when it did) — cycle-1
|
|
141
|
+
// review finding on PR #229.
|
|
142
|
+
const previous = readActiveProfileMarker();
|
|
123
143
|
await fsp.mkdir(CONFIG_DIR, { recursive: true, mode: 0o700 });
|
|
124
144
|
await fsp.writeFile(ACTIVE_PROFILE_FILE, `${trimmed}\n`, { mode: 0o644 });
|
|
145
|
+
if (previous !== trimmed) {
|
|
146
|
+
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.`);
|
|
147
|
+
}
|
|
125
148
|
}
|
|
126
149
|
export async function runProfileAdd(name) {
|
|
127
150
|
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/commands/users.js
CHANGED
|
@@ -5,6 +5,15 @@ import { parsePositiveInt } from "../utils/validators.js";
|
|
|
5
5
|
export function setupUsersCommands(program) {
|
|
6
6
|
const users = program.command("users").description("User operations");
|
|
7
7
|
users.action(() => users.help());
|
|
8
|
+
users
|
|
9
|
+
.command("read <id>")
|
|
10
|
+
.description("Look up a single user by UUID, email, or name (resolves ambiguity the same way as other --assignee-style lookups).")
|
|
11
|
+
.action(handleAsyncCommand(async (id, _options, command) => {
|
|
12
|
+
const rootOpts = getRootOpts(command);
|
|
13
|
+
const service = await createLinearService(rootOpts);
|
|
14
|
+
const result = await service.getUser(id);
|
|
15
|
+
outputSuccess({ data: result });
|
|
16
|
+
}));
|
|
8
17
|
users
|
|
9
18
|
.command("list")
|
|
10
19
|
.description("List all users")
|
package/dist/config/config.d.ts
CHANGED
|
@@ -69,6 +69,17 @@ export interface ElLinearConfig {
|
|
|
69
69
|
* `DEFAULT_DUPLICATE_THRESHOLD` (0.35). Lower = more aggressive.
|
|
70
70
|
*/
|
|
71
71
|
duplicateThreshold?: number;
|
|
72
|
+
/**
|
|
73
|
+
* Jaccard title-similarity threshold (0-1) above which a duplicate
|
|
74
|
+
* candidate HARD blocks creation (throws, requires `--allow-duplicate`).
|
|
75
|
+
* A candidate scoring at/above `duplicateThreshold` but below this is
|
|
76
|
+
* advisory only — printed, creation proceeds. Defaults to
|
|
77
|
+
* `DEFAULT_HARD_BLOCK_THRESHOLD` (0.6). See DEV-5590 for why the gate
|
|
78
|
+
* is two-tier: single-threshold override-rate telemetry showed score
|
|
79
|
+
* alone doesn't separate genuine duplicates from legitimate distinct
|
|
80
|
+
* issues in the 0.35-0.6 range.
|
|
81
|
+
*/
|
|
82
|
+
duplicateHardBlockThreshold?: number;
|
|
72
83
|
/**
|
|
73
84
|
* OPT-IN SOP-label parent gate (DEV-5378). When `true`, `issues create`
|
|
74
85
|
* requires an issue carrying an SOP-type label (see `sopLabels`) to point
|
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
|
*
|
|
@@ -32,6 +32,44 @@ import type { LinearIssue } from "../types/linear.js";
|
|
|
32
32
|
* Overridable via `config.validation.duplicateThreshold`.
|
|
33
33
|
*/
|
|
34
34
|
export declare const DEFAULT_DUPLICATE_THRESHOLD = 0.35;
|
|
35
|
+
/**
|
|
36
|
+
* Threshold above which a candidate is a HARD block (creation refuses without
|
|
37
|
+
* `--allow-duplicate`). Below this (but at/above {@link DEFAULT_DUPLICATE_THRESHOLD})
|
|
38
|
+
* a candidate is ADVISORY ONLY — printed, but creation proceeds — DEV-5590.
|
|
39
|
+
*
|
|
40
|
+
* Why a second threshold instead of just retuning the first: `el-telemetry
|
|
41
|
+
* gates` measured a 52.2% override rate on the single-threshold (0.35) gate
|
|
42
|
+
* over 144 real fires. Analyzing the real ledger (`top_score` + `outcome` per
|
|
43
|
+
* fire, no title text is ever recorded — el-linear collects nothing by
|
|
44
|
+
* default) falsifies the obvious fix of "just raise the threshold": mean/
|
|
45
|
+
* median `top_score` for `blocked` fires (0.414 / 0.40) and `overridden`
|
|
46
|
+
* fires (0.421 / 0.40) are statistically indistinguishable, and simulating
|
|
47
|
+
* every cutoff from 0.35 to 0.56 against the real corpus held the override
|
|
48
|
+
* rate flat at 52–63% — *increasing* at some higher cutoffs. Score alone does
|
|
49
|
+
* not separate genuine duplicates from legitimate distinct issues in this
|
|
50
|
+
* workspace's real usage; no single threshold in the observed range is
|
|
51
|
+
* better than a coin flip.
|
|
52
|
+
*
|
|
53
|
+
* Given that, blocking hard on a weak signal is worse than not blocking at
|
|
54
|
+
* all: over half the stops were wrong. 0.6 sits above the entire analyzed
|
|
55
|
+
* range (only 1/144 historical fires scored this high) and is reserved for
|
|
56
|
+
* near-verbatim title overlap — at Jaccard >= 0.6, more than 6 of every 10
|
|
57
|
+
* combined salient tokens are shared, which is a qualitatively different
|
|
58
|
+
* (and much rarer) signal than the "shares a few topical words" fires that
|
|
59
|
+
* dominate the false-positive population. This preserves a real backstop for
|
|
60
|
+
* the obvious copy-paste case while no longer forcing a stop-and-override
|
|
61
|
+
* ritual on the ambiguous 0.35-0.6 band, where the data shows we're wrong
|
|
62
|
+
* about as often as we're right.
|
|
63
|
+
*
|
|
64
|
+
* Caveat for future tuning: this corpus has no title text, so it cannot
|
|
65
|
+
* validate a *tokenization* fix (further stopwording per DEV-4830) — only a
|
|
66
|
+
* threshold-shape fix. A real precision improvement (distinguishing WHICH
|
|
67
|
+
* 0.35-0.6 fires are genuine) needs the title corpus, which is intentionally
|
|
68
|
+
* not collected. Revisit if `el-telemetry gates` after this ships still shows
|
|
69
|
+
* an unhealthy override rate on the >=0.6 hard-block tier specifically.
|
|
70
|
+
* Overridable via `config.validation.duplicateHardBlockThreshold`.
|
|
71
|
+
*/
|
|
72
|
+
export declare const DEFAULT_HARD_BLOCK_THRESHOLD = 0.6;
|
|
35
73
|
/** A scored duplicate candidate, ready to print in the block. */
|
|
36
74
|
export interface DuplicateCandidate {
|
|
37
75
|
identifier: string;
|
|
@@ -71,6 +109,12 @@ export declare function scoreDuplicateCandidates(title: string, candidates: Line
|
|
|
71
109
|
/**
|
|
72
110
|
* Render the human/agent-facing block listing duplicate candidates, matching
|
|
73
111
|
* the shape of the validation "Suggestions:" blocks (id · title · state ·
|
|
74
|
-
* assignee).
|
|
112
|
+
* assignee).
|
|
113
|
+
*
|
|
114
|
+
* `mode: "block"` (default) is the body of the thrown error when the gate
|
|
115
|
+
* hard-blocks (score >= the hard-block threshold — DEV-5590). `mode:
|
|
116
|
+
* "advisory"` is printed as a warning when the score is below the hard-block
|
|
117
|
+
* threshold: creation already proceeded, so the trailing hint differs (no
|
|
118
|
+
* "re-run" — there's nothing to re-run).
|
|
75
119
|
*/
|
|
76
|
-
export declare function formatDuplicateBlock(candidates: DuplicateCandidate[]): string;
|
|
120
|
+
export declare function formatDuplicateBlock(candidates: DuplicateCandidate[], mode?: "block" | "advisory"): string;
|
|
@@ -31,6 +31,44 @@
|
|
|
31
31
|
* Overridable via `config.validation.duplicateThreshold`.
|
|
32
32
|
*/
|
|
33
33
|
export const DEFAULT_DUPLICATE_THRESHOLD = 0.35;
|
|
34
|
+
/**
|
|
35
|
+
* Threshold above which a candidate is a HARD block (creation refuses without
|
|
36
|
+
* `--allow-duplicate`). Below this (but at/above {@link DEFAULT_DUPLICATE_THRESHOLD})
|
|
37
|
+
* a candidate is ADVISORY ONLY — printed, but creation proceeds — DEV-5590.
|
|
38
|
+
*
|
|
39
|
+
* Why a second threshold instead of just retuning the first: `el-telemetry
|
|
40
|
+
* gates` measured a 52.2% override rate on the single-threshold (0.35) gate
|
|
41
|
+
* over 144 real fires. Analyzing the real ledger (`top_score` + `outcome` per
|
|
42
|
+
* fire, no title text is ever recorded — el-linear collects nothing by
|
|
43
|
+
* default) falsifies the obvious fix of "just raise the threshold": mean/
|
|
44
|
+
* median `top_score` for `blocked` fires (0.414 / 0.40) and `overridden`
|
|
45
|
+
* fires (0.421 / 0.40) are statistically indistinguishable, and simulating
|
|
46
|
+
* every cutoff from 0.35 to 0.56 against the real corpus held the override
|
|
47
|
+
* rate flat at 52–63% — *increasing* at some higher cutoffs. Score alone does
|
|
48
|
+
* not separate genuine duplicates from legitimate distinct issues in this
|
|
49
|
+
* workspace's real usage; no single threshold in the observed range is
|
|
50
|
+
* better than a coin flip.
|
|
51
|
+
*
|
|
52
|
+
* Given that, blocking hard on a weak signal is worse than not blocking at
|
|
53
|
+
* all: over half the stops were wrong. 0.6 sits above the entire analyzed
|
|
54
|
+
* range (only 1/144 historical fires scored this high) and is reserved for
|
|
55
|
+
* near-verbatim title overlap — at Jaccard >= 0.6, more than 6 of every 10
|
|
56
|
+
* combined salient tokens are shared, which is a qualitatively different
|
|
57
|
+
* (and much rarer) signal than the "shares a few topical words" fires that
|
|
58
|
+
* dominate the false-positive population. This preserves a real backstop for
|
|
59
|
+
* the obvious copy-paste case while no longer forcing a stop-and-override
|
|
60
|
+
* ritual on the ambiguous 0.35-0.6 band, where the data shows we're wrong
|
|
61
|
+
* about as often as we're right.
|
|
62
|
+
*
|
|
63
|
+
* Caveat for future tuning: this corpus has no title text, so it cannot
|
|
64
|
+
* validate a *tokenization* fix (further stopwording per DEV-4830) — only a
|
|
65
|
+
* threshold-shape fix. A real precision improvement (distinguishing WHICH
|
|
66
|
+
* 0.35-0.6 fires are genuine) needs the title corpus, which is intentionally
|
|
67
|
+
* not collected. Revisit if `el-telemetry gates` after this ships still shows
|
|
68
|
+
* an unhealthy override rate on the >=0.6 hard-block tier specifically.
|
|
69
|
+
* Overridable via `config.validation.duplicateHardBlockThreshold`.
|
|
70
|
+
*/
|
|
71
|
+
export const DEFAULT_HARD_BLOCK_THRESHOLD = 0.6;
|
|
34
72
|
/**
|
|
35
73
|
* Function words and issue-boilerplate tokens that carry no topical signal.
|
|
36
74
|
* Dropping them keeps the Jaccard score driven by the distinctive nouns
|
|
@@ -174,14 +212,26 @@ export function scoreDuplicateCandidates(title, candidates, threshold = DEFAULT_
|
|
|
174
212
|
/**
|
|
175
213
|
* Render the human/agent-facing block listing duplicate candidates, matching
|
|
176
214
|
* the shape of the validation "Suggestions:" blocks (id · title · state ·
|
|
177
|
-
* assignee).
|
|
215
|
+
* assignee).
|
|
216
|
+
*
|
|
217
|
+
* `mode: "block"` (default) is the body of the thrown error when the gate
|
|
218
|
+
* hard-blocks (score >= the hard-block threshold — DEV-5590). `mode:
|
|
219
|
+
* "advisory"` is printed as a warning when the score is below the hard-block
|
|
220
|
+
* threshold: creation already proceeded, so the trailing hint differs (no
|
|
221
|
+
* "re-run" — there's nothing to re-run).
|
|
178
222
|
*/
|
|
179
|
-
export function formatDuplicateBlock(candidates) {
|
|
223
|
+
export function formatDuplicateBlock(candidates, mode = "block") {
|
|
180
224
|
const lines = candidates.map((c) => ` ${c.identifier} · ${c.title} · ${c.state} · ${c.assignee} (similarity ${c.score})`);
|
|
181
|
-
|
|
225
|
+
const header = `Possible duplicate issue${candidates.length > 1 ? "s" : ""} found ` +
|
|
182
226
|
"(by title-keyword overlap):\n" +
|
|
183
227
|
`${lines.join("\n")}\n\n` +
|
|
184
|
-
" If one of these is the same work, comment on it instead of creating a new issue.\n"
|
|
228
|
+
" If one of these is the same work, comment on it instead of creating a new issue.\n";
|
|
229
|
+
if (mode === "advisory") {
|
|
230
|
+
return (header +
|
|
231
|
+
" This is advisory only (DEV-5590) — creation is proceeding. Pass " +
|
|
232
|
+
"--allow-duplicate to silence this notice next time.");
|
|
233
|
+
}
|
|
234
|
+
return (header +
|
|
185
235
|
" If this is genuinely distinct, re-run with --allow-duplicate to proceed " +
|
|
186
236
|
"(and consider --related-to to link the related issue).");
|
|
187
237
|
}
|
|
@@ -29,9 +29,15 @@ export interface GateEvent {
|
|
|
29
29
|
* `blocked` — the gate stopped creation. `overridden` — a gate-specific
|
|
30
30
|
* override flag let a would-block proceed. `fail-open` — the gate could not
|
|
31
31
|
* evaluate (infra/service error) and let creation proceed rather than block
|
|
32
|
-
* on trouble; tracked so degradation is measurable (DEV-5378).
|
|
32
|
+
* on trouble; tracked so degradation is measurable (DEV-5378). `advisory` —
|
|
33
|
+
* the gate fired below its hard-block threshold: printed, but never forced
|
|
34
|
+
* a stop (DEV-5590). Like `fail-open`, the tools-repo reader
|
|
35
|
+
* (`el-telemetry gates`) only recognizes `blocked`/`overridden` for the
|
|
36
|
+
* override-rate denominator — an unrecognized outcome is skipped rather
|
|
37
|
+
* than counted, so `advisory` fires are visible in the raw ledger but
|
|
38
|
+
* intentionally excluded from the metric.
|
|
33
39
|
*/
|
|
34
|
-
outcome: "blocked" | "overridden" | "fail-open";
|
|
40
|
+
outcome: "blocked" | "overridden" | "fail-open" | "advisory";
|
|
35
41
|
/** Highest candidate similarity that triggered the gate (0–1). */
|
|
36
42
|
topScore?: number;
|
|
37
43
|
/** How many candidates crossed the threshold. */
|
|
@@ -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
|
}
|
|
@@ -14,6 +14,14 @@ export declare class LinearService {
|
|
|
14
14
|
resolveIssueId(issueId: string): Promise<string>;
|
|
15
15
|
getTeams(limit?: number): Promise<LinearTeam[]>;
|
|
16
16
|
resolveUserId(nameOrEmailOrId: string): Promise<string>;
|
|
17
|
+
/**
|
|
18
|
+
* Single-user lookup by UUID, email, or name (DEV-5612) — the natural
|
|
19
|
+
* complement to `getUsers`/`users list`, so identifying one actor (e.g. an
|
|
20
|
+
* unrecognized member surfaced by the alias wizard) doesn't require
|
|
21
|
+
* dumping the whole workspace. Resolution/ambiguity semantics match every
|
|
22
|
+
* other `--assignee`-style lookup in the CLI via {@link resolveUserId}.
|
|
23
|
+
*/
|
|
24
|
+
getUser(nameOrEmailOrId: string): Promise<LinearUser>;
|
|
17
25
|
getUsers(activeOnly?: boolean, limit?: number, nameFilter?: string): Promise<LinearUser[]>;
|
|
18
26
|
getProjects(limit?: number, options?: {
|
|
19
27
|
nameFilter?: string;
|
|
@@ -99,6 +99,24 @@ export class LinearService {
|
|
|
99
99
|
}
|
|
100
100
|
throw notFoundError("User", nameOrEmailOrId);
|
|
101
101
|
}
|
|
102
|
+
/**
|
|
103
|
+
* Single-user lookup by UUID, email, or name (DEV-5612) — the natural
|
|
104
|
+
* complement to `getUsers`/`users list`, so identifying one actor (e.g. an
|
|
105
|
+
* unrecognized member surfaced by the alias wizard) doesn't require
|
|
106
|
+
* dumping the whole workspace. Resolution/ambiguity semantics match every
|
|
107
|
+
* other `--assignee`-style lookup in the CLI via {@link resolveUserId}.
|
|
108
|
+
*/
|
|
109
|
+
async getUser(nameOrEmailOrId) {
|
|
110
|
+
const id = await this.resolveUserId(nameOrEmailOrId);
|
|
111
|
+
const user = await this.client.user(id);
|
|
112
|
+
return {
|
|
113
|
+
id: user.id,
|
|
114
|
+
name: user.name,
|
|
115
|
+
displayName: user.displayName,
|
|
116
|
+
email: user.email,
|
|
117
|
+
active: user.active,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
102
120
|
async getUsers(activeOnly, limit = 100, nameFilter) {
|
|
103
121
|
const filter = {};
|
|
104
122
|
if (activeOnly) {
|
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;
|