@enrichlayer/el-linear 1.24.0 → 1.26.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 +72 -2
- package/claude-skills/linear-operations/SKILL.md +39 -0
- package/dist/commands/issue-id.js +14 -1
- package/dist/commands/issues.js +10 -0
- package/dist/commands/search.js +14 -3
- package/dist/utils/formatters/summary.d.ts +22 -8
- package/dist/utils/formatters/summary.js +391 -55
- package/dist/utils/output.js +46 -21
- package/dist/utils/relation-candidate-prompt.d.ts +57 -0
- package/dist/utils/relation-candidate-prompt.js +94 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -528,11 +528,48 @@ continue to accept their per-command formats too: `table`, `md`,
|
|
|
528
528
|
`markdown`, `csv` — those go to the per-command rendering path. The
|
|
529
529
|
global `summary` value works on every read/list command.
|
|
530
530
|
|
|
531
|
-
`--format summary` does not compose with `--jq` (jq is JSON-only)
|
|
532
|
-
`--fields` (fields filter the JSON shape, not the rendered text). Use
|
|
531
|
+
`--format summary` does not compose with `--jq` (jq is JSON-only). Use
|
|
533
532
|
`--raw` together with `--format summary` to render a list envelope as a
|
|
534
533
|
bare item-list rather than an envelope.
|
|
535
534
|
|
|
535
|
+
#### `--fields` on `--format summary` — project additional columns
|
|
536
|
+
|
|
537
|
+
`--fields` on a summary render is a **projection request**, not a
|
|
538
|
+
JSON-shape filter (DEV-4750). For list outputs it sets the column set;
|
|
539
|
+
for single-resource outputs it filters the labelled field block beneath
|
|
540
|
+
the headline. Order is preserved: `--fields project,identifier,title`
|
|
541
|
+
renders `PROJECT ID TITLE`.
|
|
542
|
+
|
|
543
|
+
Currently wired through:
|
|
544
|
+
|
|
545
|
+
| Resource | Defaults | Extras you can request |
|
|
546
|
+
|----------|----------|------------------------|
|
|
547
|
+
| `issues list` | `id, title, state, assignee` | `project, cycle, milestone, labels, url, priority, estimate, createdAt, updatedAt, team` |
|
|
548
|
+
| `projects list` | `name, state, progress, lead` | `teams, target, url, updatedAt` |
|
|
549
|
+
| `issues read` (single) | `state, assignee, project, cycle, milestone, labels, url` | `priority, estimate, created, updated` |
|
|
550
|
+
| `projects read` (single) | `state, lead, teams, target, progress, url` | (filter only — no extras) |
|
|
551
|
+
|
|
552
|
+
`identifier`, `id`, `title` and `name` are headline-only on single-resource summaries and are filtered out of the labelled block (they remain on the title line above). `status` is accepted as a synonym for `state`, `owner` for `assignee`, `targetDate` for `target`.
|
|
553
|
+
|
|
554
|
+
```bash
|
|
555
|
+
# issues list with project column (the gap that motivated DEV-4750):
|
|
556
|
+
el-linear issues list --status "In Progress" --format summary \
|
|
557
|
+
--fields identifier,title,status,assignee,project
|
|
558
|
+
# ID TITLE STATE ASSIGNEE PROJECT
|
|
559
|
+
# -----------------------------------------------------------------------------
|
|
560
|
+
# DEV-1 Migrate auth middleware In Progress Alice Auth Refactor
|
|
561
|
+
# ...
|
|
562
|
+
|
|
563
|
+
# projects list with teams column:
|
|
564
|
+
el-linear projects list --format summary --fields name,state,progress,lead,teams
|
|
565
|
+
# NAME STATE PROGRESS LEAD TEAMS
|
|
566
|
+
# ---------------------------------------------------------
|
|
567
|
+
# Auth Refactor started 65% Alice DEV, FE
|
|
568
|
+
# ...
|
|
569
|
+
```
|
|
570
|
+
|
|
571
|
+
Unrecognized field names are reported as a `_warnings:` line appended after the summary block (`fields_unprojectable: --format summary on issues list does not project foo, bar; ...`) — same signal scripts get on the JSON path. Resources whose summary formatter doesn't yet wire `--fields` (cycles, milestones, comments, teams, labels, users, documents, templates, attachments, releases, search results) emit the same warning and render their default summary.
|
|
572
|
+
|
|
536
573
|
### Windowed metadata (`WindowedMeta`)
|
|
537
574
|
|
|
538
575
|
When a command returns less than its complete result set — because it
|
|
@@ -729,6 +766,39 @@ existing markdown or Slack links, angle-bracket autolinks, and bare URLs, so
|
|
|
729
766
|
it's safe to pipe documents that already contain a mix of formatted links
|
|
730
767
|
and bare identifiers.
|
|
731
768
|
|
|
769
|
+
## Relation-candidate confirmation prompt
|
|
770
|
+
|
|
771
|
+
When `el-linear search` or `el-linear issues search` returns results that
|
|
772
|
+
carry issue identifiers (the "I just ran a duplicate/related check" shape),
|
|
773
|
+
the JSON envelope embeds a structured `_warnings` line:
|
|
774
|
+
|
|
775
|
+
```json
|
|
776
|
+
{
|
|
777
|
+
"data": [{ "identifier": "DEV-2134", "title": "…" }, /* … */],
|
|
778
|
+
"meta": { "count": 3, "query": "auth flicker" },
|
|
779
|
+
"_warnings": [
|
|
780
|
+
"relation_candidates: Found 3 candidate related issues (DEV-2134, FIN-77, ALL-672). To link them as related: reply with the IDs you want linked (e.g. \"link DEV-2134 and FIN-77\"). To skip linking: reply \"no links\". (Agent-inferred IDs are blocked by auto-mode; user-named IDs pass — DEV-4494.)"
|
|
781
|
+
]
|
|
782
|
+
}
|
|
783
|
+
```
|
|
784
|
+
|
|
785
|
+
Why it exists: Claude Code's auto-mode permission classifier blocks
|
|
786
|
+
`el-linear issues relate <source> --related-to "<ids>"` calls when the IDs
|
|
787
|
+
were inferred by the agent from its own search rather than typed by the
|
|
788
|
+
user — because each listed peer is a write target. The prompt nudges the
|
|
789
|
+
caller (typically an agent driving the `linear-operations` skill) to surface
|
|
790
|
+
the candidates verbatim and have the user name which IDs to link; any
|
|
791
|
+
subsequent `issues relate` call then carries user-specified IDs and the
|
|
792
|
+
guard passes naturally. **The fix is not to weaken the guard** — see
|
|
793
|
+
[DEV-4494](https://linear.app/verticalint/issue/DEV-4494/) for the original
|
|
794
|
+
incident (PYT-213 triage, 2026-06-04).
|
|
795
|
+
|
|
796
|
+
The `relation_candidates:` prefix is a stable token so a skill or harness
|
|
797
|
+
can grep for it without parsing free-form prose, matching the existing
|
|
798
|
+
`results_truncated:` convention. Non-issue rows (projects, documents,
|
|
799
|
+
initiatives) are ignored; the warning is suppressed when no result carries
|
|
800
|
+
an identifier.
|
|
801
|
+
|
|
732
802
|
## Use with Claude Code
|
|
733
803
|
|
|
734
804
|
el-linear ships a Claude Code skill at `claude-skills/linear-operations/SKILL.md`.
|
|
@@ -181,6 +181,45 @@ el-linear issues search "keywords from proposed title" --include-closed 2>&1
|
|
|
181
181
|
el-linear issues create "Title" --team ENG --related-to "ENG-456,ENG-789" ... 2>&1
|
|
182
182
|
```
|
|
183
183
|
|
|
184
|
+
### Surfacing relation candidates — explicit user reply required ([DEV-4494](https://linear.app/verticalint/issue/DEV-4494/))
|
|
185
|
+
|
|
186
|
+
When `el-linear issues search` (or the cross-resource `search`) returns rows
|
|
187
|
+
carrying issue identifiers, the JSON envelope embeds a `_warnings` line
|
|
188
|
+
starting with `relation_candidates:` that enumerates the candidate IDs and
|
|
189
|
+
asks the user to reply with which ones to link. Treat it as a hard step,
|
|
190
|
+
not a hint:
|
|
191
|
+
|
|
192
|
+
1. **Surface the IDs to the user verbatim.** Show the `relation_candidates:`
|
|
193
|
+
line (or paraphrase it preserving every ID + the example reply). Do **not**
|
|
194
|
+
skip ahead to `issues relate`.
|
|
195
|
+
2. **Wait for an explicit reply naming the IDs to link** (e.g. `link DEV-2134
|
|
196
|
+
and ALL-672`) or a clear skip (`no links`).
|
|
197
|
+
3. **Only the user-named IDs** go into the next `el-linear issues relate
|
|
198
|
+
<source> --related-to "<ids>"` call. Never pass IDs the user did not name,
|
|
199
|
+
even if your earlier search obviously surfaced them.
|
|
200
|
+
|
|
201
|
+
Why this matters: Claude Code's auto-mode permission classifier blocks
|
|
202
|
+
`issues relate --related-to "<ids>"` when the IDs were *agent-inferred*
|
|
203
|
+
(came from your own search) rather than *user-specified* (typed by the human),
|
|
204
|
+
because each listed peer is a write target. Routing the IDs through an
|
|
205
|
+
explicit human reply converts them from agent-inferred → user-specified;
|
|
206
|
+
the existing search step (above) stays intact; auto-mode's guard is not
|
|
207
|
+
weakened. The fix is the loop shape, not the guard.
|
|
208
|
+
|
|
209
|
+
Anti-patterns:
|
|
210
|
+
|
|
211
|
+
- **Calling `issues relate` directly off your own search output** — even if
|
|
212
|
+
the IDs are real and the candidates look obvious, this is the exact path
|
|
213
|
+
the auto-mode guard refuses.
|
|
214
|
+
- **Splitting one relate call into N single-ID calls** to "look smaller" —
|
|
215
|
+
same provenance problem, same block, just multiplied.
|
|
216
|
+
- **Asking the user a yes/no question** ("Should I link these?") instead of
|
|
217
|
+
having them name the IDs — yes answers stay agent-inferred, the reply
|
|
218
|
+
must carry the IDs to convert them to user-specified.
|
|
219
|
+
|
|
220
|
+
If `--include-closed` search returns no matches, no `relation_candidates:`
|
|
221
|
+
warning is emitted (nothing to confirm) and the flow proceeds normally.
|
|
222
|
+
|
|
184
223
|
### Viewing existing relations
|
|
185
224
|
|
|
186
225
|
```bash
|
|
@@ -21,7 +21,20 @@ import { handleAsyncCommand, outputSuccess } from "../utils/output.js";
|
|
|
21
21
|
// Branch naming conventions — kept in sync with branch-name validators
|
|
22
22
|
// elsewhere in the workspace. Any addition here is the single point of
|
|
23
23
|
// truth that all skills rely on.
|
|
24
|
-
|
|
24
|
+
//
|
|
25
|
+
// Accepted prefixes (DEV-4777 adds bug/spike; DEV-4660 added codex; both
|
|
26
|
+
// mirror tools-repo DEV-4417):
|
|
27
|
+
// feature | fix | chore | refactor | dev — SOP-canonical + Linear-CLI direct
|
|
28
|
+
// bug | spike — sanctioned Linear type labels
|
|
29
|
+
// codex — Codex-authored branches (codex/<TEAM>-<N>-slug)
|
|
30
|
+
// New authoring surfaces (Codex, future agent prefixes) and sanctioned Linear
|
|
31
|
+
// type labels (bug, spike) get first-class issue detection so commit guards,
|
|
32
|
+
// MR descriptions, and session handoff don't go dark on a branch a human
|
|
33
|
+
// wouldn't even notice was prefixed differently.
|
|
34
|
+
//
|
|
35
|
+
// Mirror of cli/el-git/src/commands/context.ts:BRANCH_RE in the
|
|
36
|
+
// vertical-int/tools repo. If you change one, change the other.
|
|
37
|
+
const BRANCH_RE = /^(?:feature|fix|chore|refactor|bug|spike|dev|codex)[-/]([A-Z]{2,4})-(\d+)(?:[-/](.*))?$/i;
|
|
25
38
|
export function parseBranchName(branch) {
|
|
26
39
|
const m = branch.match(BRANCH_RE);
|
|
27
40
|
if (!m) {
|
package/dist/commands/issues.js
CHANGED
|
@@ -14,6 +14,7 @@ import { createIssuesService } from "../utils/issues-service-bootstrap.js";
|
|
|
14
14
|
import { createLinearService, } from "../utils/linear-service.js";
|
|
15
15
|
import { logger } from "../utils/logger.js";
|
|
16
16
|
import { handleAsyncCommand, outputSuccess, outputWarning, warnIfTruncated, } from "../utils/output.js";
|
|
17
|
+
import { buildRelationCandidatePrompt } from "../utils/relation-candidate-prompt.js";
|
|
17
18
|
import { getRootOpts } from "../utils/root-opts.js";
|
|
18
19
|
import { formatCsv, formatMarkdown, formatTable, } from "../utils/table-formatter.js";
|
|
19
20
|
import { parsePositiveInt, parsePriorityFilter, splitList, validatePriority, } from "../utils/validators.js";
|
|
@@ -269,6 +270,15 @@ async function handleSearchIssues(query, options, command) {
|
|
|
269
270
|
outputWarning("excluded terminal states (Done / Canceled) by default; pass --include-closed to include them");
|
|
270
271
|
}
|
|
271
272
|
warnIfTruncated(result.length, limit);
|
|
273
|
+
// DEV-4494: surface the explicit "reply with the IDs to link" prompt
|
|
274
|
+
// whenever an issue search returns candidate identifiers. The
|
|
275
|
+
// `linear-operations` skill consumes this `_warnings` line and shows it
|
|
276
|
+
// to the user so any subsequent `issues relate` call is user-specified
|
|
277
|
+
// rather than agent-inferred (which auto-mode blocks).
|
|
278
|
+
const relationPrompt = buildRelationCandidatePrompt(result);
|
|
279
|
+
if (relationPrompt) {
|
|
280
|
+
outputWarning(relationPrompt);
|
|
281
|
+
}
|
|
272
282
|
outputIssues(result, options.format, options.fields, { query });
|
|
273
283
|
}
|
|
274
284
|
/**
|
package/dist/commands/search.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { resolveTeam, resolveUserDisplayName } from "../config/resolver.js";
|
|
2
2
|
import { SEMANTIC_SEARCH_QUERY } from "../queries/search.js";
|
|
3
3
|
import { createGraphQLService } from "../utils/graphql-service.js";
|
|
4
|
-
import { handleAsyncCommand, outputSuccess } from "../utils/output.js";
|
|
4
|
+
import { handleAsyncCommand, outputSuccess, outputWarning, } from "../utils/output.js";
|
|
5
|
+
import { buildRelationCandidatePrompt } from "../utils/relation-candidate-prompt.js";
|
|
5
6
|
import { getRootOpts } from "../utils/root-opts.js";
|
|
6
7
|
import { parsePositiveInt } from "../utils/validators.js";
|
|
7
8
|
const TEMPLATES_QUERY = `
|
|
@@ -180,9 +181,19 @@ export function setupSearchCommands(program) {
|
|
|
180
181
|
const templates = templateResult.templates ?? [];
|
|
181
182
|
data = [...data, ...searchTemplates(templates, query)];
|
|
182
183
|
}
|
|
184
|
+
const finalData = data.slice(0, limit);
|
|
185
|
+
// DEV-4494: when results carry issue identifiers, nudge the
|
|
186
|
+
// caller to surface them to the user verbatim and have the
|
|
187
|
+
// user name which IDs to link via `issues relate`. Keeps
|
|
188
|
+
// agent-inferred IDs out of relate calls without weakening
|
|
189
|
+
// auto-mode's guard.
|
|
190
|
+
const relationPrompt = buildRelationCandidatePrompt(finalData);
|
|
191
|
+
if (relationPrompt) {
|
|
192
|
+
outputWarning(relationPrompt);
|
|
193
|
+
}
|
|
183
194
|
outputSuccess({
|
|
184
|
-
data:
|
|
185
|
-
meta: { count:
|
|
195
|
+
data: finalData,
|
|
196
|
+
meta: { count: finalData.length, query },
|
|
186
197
|
});
|
|
187
198
|
}));
|
|
188
199
|
}
|
|
@@ -14,10 +14,16 @@
|
|
|
14
14
|
* pretty-print. Field ordering and labels should not change across
|
|
15
15
|
* patch releases without a CHANGELOG entry.
|
|
16
16
|
*/
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
17
|
+
/**
|
|
18
|
+
* Drain the formatter-level `_warnings` buffer. Returns the collected
|
|
19
|
+
* warnings and resets the queue. Called by `outputSuccess` after every
|
|
20
|
+
* summary render to surface unprojectable-field hints to scripts.
|
|
21
|
+
*/
|
|
22
|
+
export declare function drainSummaryFieldWarnings(): string[];
|
|
23
|
+
export declare function formatIssueSummary(issue: Record<string, unknown>, fields?: string[]): string;
|
|
24
|
+
export declare function formatIssueList(issues: unknown[], fields?: string[]): string;
|
|
25
|
+
export declare function formatProjectSummary(project: Record<string, unknown>, fields?: string[]): string;
|
|
26
|
+
export declare function formatProjectList(projects: unknown[], fields?: string[]): string;
|
|
21
27
|
export declare function formatCommentSummary(comment: Record<string, unknown>): string;
|
|
22
28
|
export declare function formatCommentList(comments: unknown[]): string;
|
|
23
29
|
export declare function formatCycleSummary(cycle: Record<string, unknown>): string;
|
|
@@ -55,11 +61,19 @@ export type ResourceKind = "issue" | "issue-list" | "project" | "project-list" |
|
|
|
55
61
|
*/
|
|
56
62
|
export declare function inferKindFromPayload(value: unknown): ResourceKind;
|
|
57
63
|
/**
|
|
58
|
-
* Central dispatch: given a resource kind
|
|
59
|
-
* formatted string. The payload for list kinds
|
|
60
|
-
* array or a `{ data: [...] }` envelope — both are
|
|
64
|
+
* Central dispatch: given a resource kind, payload, and optional `--fields`
|
|
65
|
+
* projection list, return the formatted string. The payload for list kinds
|
|
66
|
+
* may be either the raw array or a `{ data: [...] }` envelope — both are
|
|
67
|
+
* handled.
|
|
68
|
+
*
|
|
69
|
+
* `fields` is forwarded to formatters that wire `--fields` projection
|
|
70
|
+
* (issues, projects). For other kinds it's an opt-out path: we emit a
|
|
71
|
+
* `fields_unprojectable` warning and render defaults. The eventual
|
|
72
|
+
* direction is to wire projection through every list formatter; until
|
|
73
|
+
* then the warning gives consumers a deterministic signal instead of
|
|
74
|
+
* silently ignoring their flag.
|
|
61
75
|
*/
|
|
62
|
-
export declare function dispatch(kind: ResourceKind, payload: unknown): string;
|
|
76
|
+
export declare function dispatch(kind: ResourceKind, payload: unknown, fields?: string[]): string;
|
|
63
77
|
/**
|
|
64
78
|
* One-line confirmation render for the `--quiet` write path.
|
|
65
79
|
*
|
|
@@ -116,6 +116,109 @@ function joinLabels(v) {
|
|
|
116
116
|
.filter((x) => x && x !== "—")
|
|
117
117
|
.join(", ");
|
|
118
118
|
}
|
|
119
|
+
/**
|
|
120
|
+
* Select the ordered column set for a `--fields` request. Returns
|
|
121
|
+
* `{ columns, unprojectable }` so the caller can render and warn.
|
|
122
|
+
*
|
|
123
|
+
* Resolution order per requested name (case-insensitive):
|
|
124
|
+
*
|
|
125
|
+
* 1. Synonym lookup (`status` → `state`, `id` → `identifier`).
|
|
126
|
+
* 2. Match against a `defaults` column header.
|
|
127
|
+
* 3. Match against an `extras` entry.
|
|
128
|
+
* 4. Otherwise → `unprojectable`.
|
|
129
|
+
*
|
|
130
|
+
* The returned columns preserve the order the user requested. This is the
|
|
131
|
+
* contract: `--fields project,identifier,title` renders `PROJECT ID TITLE`.
|
|
132
|
+
*/
|
|
133
|
+
function selectColumns(projection, requested) {
|
|
134
|
+
const columns = [];
|
|
135
|
+
const unprojectable = [];
|
|
136
|
+
const synonyms = projection.synonyms ?? {};
|
|
137
|
+
const extras = projection.extras ?? {};
|
|
138
|
+
// Build a lowercased default-header lookup once.
|
|
139
|
+
const defaultByHeader = new Map();
|
|
140
|
+
for (const col of projection.defaults) {
|
|
141
|
+
defaultByHeader.set(col.header.toLowerCase(), col);
|
|
142
|
+
}
|
|
143
|
+
for (const raw of requested) {
|
|
144
|
+
const name = raw.trim().toLowerCase();
|
|
145
|
+
if (!name)
|
|
146
|
+
continue;
|
|
147
|
+
const canonical = synonyms[name] ?? name;
|
|
148
|
+
const fromDefaults = defaultByHeader.get(canonical);
|
|
149
|
+
if (fromDefaults) {
|
|
150
|
+
columns.push(fromDefaults);
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
const fromExtras = extras[canonical];
|
|
154
|
+
if (fromExtras) {
|
|
155
|
+
columns.push(fromExtras);
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
unprojectable.push(raw);
|
|
159
|
+
}
|
|
160
|
+
return { columns, unprojectable };
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* Filter the single-resource HeaderField list by user-requested field
|
|
164
|
+
* names (DEV-4750). Matches each requested name against the field's
|
|
165
|
+
* lowercased label and an optional synonym map. Unknown names are
|
|
166
|
+
* reported in `unprojectable[]`.
|
|
167
|
+
*
|
|
168
|
+
* Order of the returned fields preserves the user's request order. The
|
|
169
|
+
* implicit headline (identifier line for issues, name for projects) is
|
|
170
|
+
* the caller's responsibility — `--fields` only filters the labelled
|
|
171
|
+
* key/value block beneath it.
|
|
172
|
+
*/
|
|
173
|
+
function selectHeaderFields(defaults, requested, synonyms = {}) {
|
|
174
|
+
const byLabel = new Map();
|
|
175
|
+
for (const f of defaults) {
|
|
176
|
+
byLabel.set(f.label.toLowerCase(), f);
|
|
177
|
+
}
|
|
178
|
+
const fields = [];
|
|
179
|
+
const unprojectable = [];
|
|
180
|
+
for (const raw of requested) {
|
|
181
|
+
const name = raw.trim().toLowerCase();
|
|
182
|
+
if (!name)
|
|
183
|
+
continue;
|
|
184
|
+
const canonical = synonyms[name] ?? name;
|
|
185
|
+
const match = byLabel.get(canonical);
|
|
186
|
+
if (match) {
|
|
187
|
+
fields.push(match);
|
|
188
|
+
}
|
|
189
|
+
else {
|
|
190
|
+
unprojectable.push(raw);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
return { fields, unprojectable };
|
|
194
|
+
}
|
|
195
|
+
/**
|
|
196
|
+
* Module-level sink for unprojectable `--fields` names surfaced from the
|
|
197
|
+
* summary formatters. The output dispatcher (`outputSuccess` in
|
|
198
|
+
* `utils/output.ts`) drains this between renders and forwards them to
|
|
199
|
+
* `outputWarning` so they ride out on the next JSON envelope's
|
|
200
|
+
* `_warnings`. Kept here rather than in output.ts so the formatter stays
|
|
201
|
+
* the single source of truth on what is and isn't projectable.
|
|
202
|
+
*
|
|
203
|
+
* Internal — consumers should call `drainSummaryFieldWarnings()`.
|
|
204
|
+
*/
|
|
205
|
+
const summaryFieldWarnings = [];
|
|
206
|
+
function recordUnprojectable(resource, names) {
|
|
207
|
+
if (names.length === 0)
|
|
208
|
+
return;
|
|
209
|
+
summaryFieldWarnings.push(`fields_unprojectable: --format summary on ${resource} does not project ${names.join(", ")}; ` +
|
|
210
|
+
`use --format json --fields ${names.join(",")} or omit the field(s).`);
|
|
211
|
+
}
|
|
212
|
+
/**
|
|
213
|
+
* Drain the formatter-level `_warnings` buffer. Returns the collected
|
|
214
|
+
* warnings and resets the queue. Called by `outputSuccess` after every
|
|
215
|
+
* summary render to surface unprojectable-field hints to scripts.
|
|
216
|
+
*/
|
|
217
|
+
export function drainSummaryFieldWarnings() {
|
|
218
|
+
const out = [...summaryFieldWarnings];
|
|
219
|
+
summaryFieldWarnings.length = 0;
|
|
220
|
+
return out;
|
|
221
|
+
}
|
|
119
222
|
/**
|
|
120
223
|
* Render a list of rows as a fixed-width text table with a header,
|
|
121
224
|
* separator, body, and "<N> <noun>s" footer.
|
|
@@ -164,62 +267,219 @@ function renderHeader(fields) {
|
|
|
164
267
|
.join("\n");
|
|
165
268
|
}
|
|
166
269
|
// ── issues ─────────────────────────────────────────────────────
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
270
|
+
/**
|
|
271
|
+
* Map a user-requested field name → the canonical lookup key used by
|
|
272
|
+
* `selectColumns` for the issues *list* formatter. Canonical keys are
|
|
273
|
+
* the lowercased column header (`id`, `title`, `state`, `assignee`)
|
|
274
|
+
* for defaults; extras are looked up by their key in
|
|
275
|
+
* `ISSUE_LIST_EXTRAS`.
|
|
276
|
+
*
|
|
277
|
+
* `identifier` (the JSON key) → `id` (the column header). `status` and
|
|
278
|
+
* `owner` are natural-language aliases for `state` and `assignee`.
|
|
279
|
+
*
|
|
280
|
+
* `prioritylabel` / `projectmilestone` are the raw JSON-field spellings a
|
|
281
|
+
* script author might reach for; they map to the `priority` / `milestone`
|
|
282
|
+
* column keys so the list formatter accepts the same names the
|
|
283
|
+
* single-resource formatter does (`ISSUE_SUMMARY_SYNONYMS`) — keeping the
|
|
284
|
+
* two surfaces in sync.
|
|
285
|
+
*/
|
|
286
|
+
const ISSUE_LIST_SYNONYMS = {
|
|
287
|
+
identifier: "id",
|
|
288
|
+
status: "state",
|
|
289
|
+
owner: "assignee",
|
|
290
|
+
prioritylabel: "priority",
|
|
291
|
+
projectmilestone: "milestone",
|
|
292
|
+
};
|
|
293
|
+
/**
|
|
294
|
+
* Map a user-requested field name → the canonical lowercased label used
|
|
295
|
+
* by the single-resource issue formatter. Single-resource labels are
|
|
296
|
+
* full words (`Created`, `Updated`), so `createdat` / `updatedat`
|
|
297
|
+
* (JSON-field spellings) need mapping. `status` / `owner` carry over.
|
|
298
|
+
*/
|
|
299
|
+
const ISSUE_SUMMARY_SYNONYMS = {
|
|
300
|
+
status: "state",
|
|
301
|
+
owner: "assignee",
|
|
302
|
+
createdat: "created",
|
|
303
|
+
updatedat: "updated",
|
|
304
|
+
prioritylabel: "priority",
|
|
305
|
+
projectmilestone: "milestone",
|
|
306
|
+
};
|
|
307
|
+
function buildIssueHeaderFields(issue) {
|
|
308
|
+
return [
|
|
172
309
|
{ label: "State", value: getName(issue.state) },
|
|
173
310
|
{ label: "Assignee", value: getName(issue.assignee) },
|
|
174
311
|
{ label: "Project", value: getName(issue.project) },
|
|
175
312
|
{ label: "Cycle", value: getName(issue.cycle) },
|
|
176
313
|
{ label: "Milestone", value: getName(issue.projectMilestone) },
|
|
177
314
|
{ label: "Labels", value: joinLabels(issue.labels) },
|
|
315
|
+
{ label: "Priority", value: s(issue.priorityLabel ?? issue.priority) },
|
|
316
|
+
{ label: "Estimate", value: s(issue.estimate) },
|
|
317
|
+
{ label: "Created", value: s(issue.createdAt) },
|
|
318
|
+
{ label: "Updated", value: s(issue.updatedAt) },
|
|
178
319
|
{ label: "URL", value: s(issue.url) },
|
|
179
320
|
];
|
|
180
|
-
|
|
321
|
+
}
|
|
322
|
+
export function formatIssueSummary(issue, fields) {
|
|
323
|
+
const identifier = s(issue.identifier);
|
|
324
|
+
const title = s(issue.title);
|
|
325
|
+
const headerLine = `${identifier} ${title}`;
|
|
326
|
+
const allFields = buildIssueHeaderFields(issue);
|
|
327
|
+
let headerFields;
|
|
328
|
+
if (fields && fields.length > 0) {
|
|
329
|
+
// `identifier` / `title` make up the implicit headline above —
|
|
330
|
+
// strip them from the user's request so a `--fields title,state`
|
|
331
|
+
// doesn't try to render "Title:" inside the labelled block.
|
|
332
|
+
const filtered = fields.filter((f) => {
|
|
333
|
+
const name = f.trim().toLowerCase();
|
|
334
|
+
return name !== "identifier" && name !== "id" && name !== "title";
|
|
335
|
+
});
|
|
336
|
+
const selected = selectHeaderFields(allFields, filtered, ISSUE_SUMMARY_SYNONYMS);
|
|
337
|
+
recordUnprojectable("issue", selected.unprojectable);
|
|
338
|
+
headerFields = selected.fields;
|
|
339
|
+
}
|
|
340
|
+
else {
|
|
341
|
+
// Default issue summary historically omits priority / estimate / dates —
|
|
342
|
+
// keep that surface stable unless the caller asks for those columns.
|
|
343
|
+
const defaultLabels = new Set([
|
|
344
|
+
"state",
|
|
345
|
+
"assignee",
|
|
346
|
+
"project",
|
|
347
|
+
"cycle",
|
|
348
|
+
"milestone",
|
|
349
|
+
"labels",
|
|
350
|
+
"url",
|
|
351
|
+
]);
|
|
352
|
+
headerFields = allFields.filter((f) => defaultLabels.has(f.label.toLowerCase()));
|
|
353
|
+
}
|
|
354
|
+
const header = renderHeader(headerFields);
|
|
181
355
|
const body = clipDescription(issue.description);
|
|
182
356
|
const parts = [headerLine, header];
|
|
183
357
|
if (body)
|
|
184
358
|
parts.push("", body);
|
|
185
359
|
return parts.filter((p) => p !== "").join("\n");
|
|
186
360
|
}
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
361
|
+
const ISSUE_LIST_DEFAULTS = [
|
|
362
|
+
{ header: "ID", minWidth: 2, extract: (i) => s(i.identifier) },
|
|
363
|
+
{
|
|
364
|
+
header: "TITLE",
|
|
365
|
+
minWidth: 5,
|
|
366
|
+
maxWidth: TITLE_TRUNC,
|
|
367
|
+
extract: (i) => s(i.title),
|
|
368
|
+
},
|
|
369
|
+
{ header: "STATE", minWidth: 5, extract: (i) => getName(i.state) },
|
|
370
|
+
{
|
|
371
|
+
header: "ASSIGNEE",
|
|
372
|
+
minWidth: 8,
|
|
373
|
+
extract: (i) => getName(i.assignee),
|
|
374
|
+
},
|
|
375
|
+
];
|
|
376
|
+
const ISSUE_LIST_EXTRAS = {
|
|
377
|
+
project: {
|
|
378
|
+
header: "PROJECT",
|
|
379
|
+
minWidth: 7,
|
|
380
|
+
maxWidth: 40,
|
|
381
|
+
extract: (i) => getName(i.project),
|
|
382
|
+
},
|
|
383
|
+
cycle: {
|
|
384
|
+
header: "CYCLE",
|
|
385
|
+
minWidth: 5,
|
|
386
|
+
maxWidth: 20,
|
|
387
|
+
extract: (i) => getName(i.cycle),
|
|
388
|
+
},
|
|
389
|
+
milestone: {
|
|
390
|
+
header: "MILESTONE",
|
|
391
|
+
minWidth: 9,
|
|
392
|
+
maxWidth: 30,
|
|
393
|
+
extract: (i) => getName(i.projectMilestone),
|
|
394
|
+
},
|
|
395
|
+
labels: {
|
|
396
|
+
header: "LABELS",
|
|
397
|
+
minWidth: 6,
|
|
398
|
+
maxWidth: 40,
|
|
399
|
+
extract: (i) => joinLabels(i.labels),
|
|
400
|
+
},
|
|
401
|
+
url: {
|
|
402
|
+
header: "URL",
|
|
403
|
+
minWidth: 3,
|
|
404
|
+
maxWidth: 60,
|
|
405
|
+
extract: (i) => s(i.url),
|
|
406
|
+
},
|
|
407
|
+
priority: {
|
|
408
|
+
header: "PRIORITY",
|
|
409
|
+
minWidth: 8,
|
|
410
|
+
extract: (i) => s(i.priorityLabel ?? i.priority),
|
|
411
|
+
},
|
|
412
|
+
estimate: {
|
|
413
|
+
header: "ESTIMATE",
|
|
414
|
+
minWidth: 8,
|
|
415
|
+
extract: (i) => s(i.estimate),
|
|
416
|
+
},
|
|
417
|
+
createdat: {
|
|
418
|
+
header: "CREATED",
|
|
419
|
+
minWidth: 7,
|
|
420
|
+
extract: (i) => s(i.createdAt).slice(0, 10),
|
|
421
|
+
},
|
|
422
|
+
updatedat: {
|
|
423
|
+
header: "UPDATED",
|
|
424
|
+
minWidth: 7,
|
|
425
|
+
extract: (i) => s(i.updatedAt).slice(0, 10),
|
|
426
|
+
},
|
|
427
|
+
team: {
|
|
428
|
+
header: "TEAM",
|
|
429
|
+
minWidth: 4,
|
|
430
|
+
extract: (i) => {
|
|
431
|
+
// issues carry team as either {key, name} or a bare key string.
|
|
432
|
+
const team = i.team;
|
|
433
|
+
const o = asObj(team);
|
|
434
|
+
if (o)
|
|
435
|
+
return s(o.key ?? o.name);
|
|
436
|
+
return s(team);
|
|
201
437
|
},
|
|
202
|
-
|
|
438
|
+
},
|
|
439
|
+
};
|
|
440
|
+
export function formatIssueList(issues, fields) {
|
|
441
|
+
const rows = issues.map((raw) => asObj(raw) ?? {});
|
|
442
|
+
let columns = ISSUE_LIST_DEFAULTS;
|
|
443
|
+
if (fields && fields.length > 0) {
|
|
444
|
+
const selected = selectColumns({
|
|
445
|
+
defaults: ISSUE_LIST_DEFAULTS,
|
|
446
|
+
extras: ISSUE_LIST_EXTRAS,
|
|
447
|
+
synonyms: ISSUE_LIST_SYNONYMS,
|
|
448
|
+
}, fields);
|
|
449
|
+
recordUnprojectable("issues list", selected.unprojectable);
|
|
450
|
+
// If every requested name was unprojectable, fall back to defaults
|
|
451
|
+
// so the user still sees something useful with the warning attached.
|
|
452
|
+
columns =
|
|
453
|
+
selected.columns.length > 0 ? selected.columns : ISSUE_LIST_DEFAULTS;
|
|
454
|
+
}
|
|
455
|
+
return renderTable(rows, columns, {
|
|
456
|
+
emptyText: "(no issues)",
|
|
457
|
+
itemNoun: "issue",
|
|
458
|
+
});
|
|
203
459
|
}
|
|
204
460
|
// ── projects ───────────────────────────────────────────────────
|
|
205
|
-
|
|
461
|
+
const PROJECT_SYNONYMS = {
|
|
462
|
+
targetdate: "target",
|
|
463
|
+
};
|
|
464
|
+
function joinTeamKeys(value) {
|
|
465
|
+
if (!Array.isArray(value) || value.length === 0)
|
|
466
|
+
return "";
|
|
467
|
+
return value
|
|
468
|
+
.map((team) => {
|
|
469
|
+
const o = asObj(team);
|
|
470
|
+
return o ? s(o.key ?? o.name) : s(team);
|
|
471
|
+
})
|
|
472
|
+
.filter((x) => x && x !== "—")
|
|
473
|
+
.join(", ");
|
|
474
|
+
}
|
|
475
|
+
export function formatProjectSummary(project, fields) {
|
|
206
476
|
const name = s(project.name);
|
|
207
477
|
const headerLine = name;
|
|
208
|
-
const teams = (
|
|
209
|
-
const t = project.teams;
|
|
210
|
-
if (!Array.isArray(t) || t.length === 0)
|
|
211
|
-
return "";
|
|
212
|
-
return t
|
|
213
|
-
.map((team) => {
|
|
214
|
-
const o = asObj(team);
|
|
215
|
-
return o ? s(o.key ?? o.name) : s(team);
|
|
216
|
-
})
|
|
217
|
-
.join(", ");
|
|
218
|
-
})();
|
|
478
|
+
const teams = joinTeamKeys(project.teams);
|
|
219
479
|
const progress = typeof project.progress === "number"
|
|
220
480
|
? `${Math.round(project.progress * 100)}%`
|
|
221
481
|
: "";
|
|
222
|
-
const
|
|
482
|
+
const allFields = [
|
|
223
483
|
{ label: "State", value: s(project.state) },
|
|
224
484
|
{ label: "Lead", value: getName(project.lead) },
|
|
225
485
|
{ label: "Teams", value: teams },
|
|
@@ -227,7 +487,20 @@ export function formatProjectSummary(project) {
|
|
|
227
487
|
{ label: "Progress", value: progress },
|
|
228
488
|
{ label: "URL", value: s(project.url) },
|
|
229
489
|
];
|
|
230
|
-
|
|
490
|
+
let headerFields;
|
|
491
|
+
if (fields && fields.length > 0) {
|
|
492
|
+
// `name` is the implicit headline above; drop it from the request
|
|
493
|
+
// so a user passing `--fields name,teams` doesn't render "Name:" in
|
|
494
|
+
// the labelled block beneath.
|
|
495
|
+
const filtered = fields.filter((f) => f.trim().toLowerCase() !== "name");
|
|
496
|
+
const selected = selectHeaderFields(allFields, filtered, PROJECT_SYNONYMS);
|
|
497
|
+
recordUnprojectable("project", selected.unprojectable);
|
|
498
|
+
headerFields = selected.fields;
|
|
499
|
+
}
|
|
500
|
+
else {
|
|
501
|
+
headerFields = allFields;
|
|
502
|
+
}
|
|
503
|
+
const header = renderHeader(headerFields);
|
|
231
504
|
const body = clipDescription(project.description);
|
|
232
505
|
const parts = [headerLine, header];
|
|
233
506
|
if (body)
|
|
@@ -237,18 +510,58 @@ export function formatProjectSummary(project) {
|
|
|
237
510
|
function pct(value) {
|
|
238
511
|
return typeof value === "number" ? `${Math.round(value * 100)}%` : "—";
|
|
239
512
|
}
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
513
|
+
const PROJECT_LIST_DEFAULTS = [
|
|
514
|
+
{
|
|
515
|
+
header: "NAME",
|
|
516
|
+
minWidth: 4,
|
|
517
|
+
maxWidth: TITLE_TRUNC,
|
|
518
|
+
extract: (p) => s(p.name),
|
|
519
|
+
},
|
|
520
|
+
{ header: "STATE", minWidth: 5, extract: (p) => s(p.state) },
|
|
521
|
+
{ header: "PROGRESS", minWidth: 8, extract: (p) => pct(p.progress) },
|
|
522
|
+
{ header: "LEAD", minWidth: 4, extract: (p) => getName(p.lead) },
|
|
523
|
+
];
|
|
524
|
+
const PROJECT_LIST_EXTRAS = {
|
|
525
|
+
teams: {
|
|
526
|
+
header: "TEAMS",
|
|
527
|
+
minWidth: 5,
|
|
528
|
+
maxWidth: 30,
|
|
529
|
+
extract: (p) => joinTeamKeys(p.teams),
|
|
530
|
+
},
|
|
531
|
+
target: {
|
|
532
|
+
header: "TARGET",
|
|
533
|
+
minWidth: 6,
|
|
534
|
+
extract: (p) => s(p.targetDate),
|
|
535
|
+
},
|
|
536
|
+
url: {
|
|
537
|
+
header: "URL",
|
|
538
|
+
minWidth: 3,
|
|
539
|
+
maxWidth: 60,
|
|
540
|
+
extract: (p) => s(p.url),
|
|
541
|
+
},
|
|
542
|
+
updatedat: {
|
|
543
|
+
header: "UPDATED",
|
|
544
|
+
minWidth: 7,
|
|
545
|
+
extract: (p) => s(p.updatedAt).slice(0, 10),
|
|
546
|
+
},
|
|
547
|
+
};
|
|
548
|
+
export function formatProjectList(projects, fields) {
|
|
549
|
+
const rows = projects.map((raw) => asObj(raw) ?? {});
|
|
550
|
+
let columns = PROJECT_LIST_DEFAULTS;
|
|
551
|
+
if (fields && fields.length > 0) {
|
|
552
|
+
const selected = selectColumns({
|
|
553
|
+
defaults: PROJECT_LIST_DEFAULTS,
|
|
554
|
+
extras: PROJECT_LIST_EXTRAS,
|
|
555
|
+
synonyms: PROJECT_SYNONYMS,
|
|
556
|
+
}, fields);
|
|
557
|
+
recordUnprojectable("projects list", selected.unprojectable);
|
|
558
|
+
columns =
|
|
559
|
+
selected.columns.length > 0 ? selected.columns : PROJECT_LIST_DEFAULTS;
|
|
560
|
+
}
|
|
561
|
+
return renderTable(rows, columns, {
|
|
562
|
+
emptyText: "(no projects)",
|
|
563
|
+
itemNoun: "project",
|
|
564
|
+
});
|
|
252
565
|
}
|
|
253
566
|
// ── comments ───────────────────────────────────────────────────
|
|
254
567
|
export function formatCommentSummary(comment) {
|
|
@@ -694,11 +1007,30 @@ function inferListKind(items) {
|
|
|
694
1007
|
return "generic";
|
|
695
1008
|
}
|
|
696
1009
|
/**
|
|
697
|
-
*
|
|
698
|
-
*
|
|
699
|
-
*
|
|
1010
|
+
* Resources where `--fields` projection is wired through to the formatter
|
|
1011
|
+
* (DEV-4750). For any other kind, passing `--fields` records a
|
|
1012
|
+
* deterministic unprojectable warning and renders the default summary.
|
|
700
1013
|
*/
|
|
701
|
-
|
|
1014
|
+
const FIELDS_PROJECTED = new Set([
|
|
1015
|
+
"issue",
|
|
1016
|
+
"issue-list",
|
|
1017
|
+
"project",
|
|
1018
|
+
"project-list",
|
|
1019
|
+
]);
|
|
1020
|
+
/**
|
|
1021
|
+
* Central dispatch: given a resource kind, payload, and optional `--fields`
|
|
1022
|
+
* projection list, return the formatted string. The payload for list kinds
|
|
1023
|
+
* may be either the raw array or a `{ data: [...] }` envelope — both are
|
|
1024
|
+
* handled.
|
|
1025
|
+
*
|
|
1026
|
+
* `fields` is forwarded to formatters that wire `--fields` projection
|
|
1027
|
+
* (issues, projects). For other kinds it's an opt-out path: we emit a
|
|
1028
|
+
* `fields_unprojectable` warning and render defaults. The eventual
|
|
1029
|
+
* direction is to wire projection through every list formatter; until
|
|
1030
|
+
* then the warning gives consumers a deterministic signal instead of
|
|
1031
|
+
* silently ignoring their flag.
|
|
1032
|
+
*/
|
|
1033
|
+
export function dispatch(kind, payload, fields) {
|
|
702
1034
|
const obj = asObj(payload);
|
|
703
1035
|
const list = (() => {
|
|
704
1036
|
if (Array.isArray(payload))
|
|
@@ -707,15 +1039,19 @@ export function dispatch(kind, payload) {
|
|
|
707
1039
|
return obj.data;
|
|
708
1040
|
return null;
|
|
709
1041
|
})();
|
|
1042
|
+
const hasFields = Boolean(fields && fields.length > 0);
|
|
1043
|
+
if (hasFields && !FIELDS_PROJECTED.has(kind)) {
|
|
1044
|
+
recordUnprojectable(kind === "generic" ? "this resource" : kind, fields);
|
|
1045
|
+
}
|
|
710
1046
|
switch (kind) {
|
|
711
1047
|
case "issue":
|
|
712
|
-
return formatIssueSummary((obj ?? {}));
|
|
1048
|
+
return formatIssueSummary((obj ?? {}), fields);
|
|
713
1049
|
case "issue-list":
|
|
714
|
-
return formatIssueList(list ?? []);
|
|
1050
|
+
return formatIssueList(list ?? [], fields);
|
|
715
1051
|
case "project":
|
|
716
|
-
return formatProjectSummary((obj ?? {}));
|
|
1052
|
+
return formatProjectSummary((obj ?? {}), fields);
|
|
717
1053
|
case "project-list":
|
|
718
|
-
return formatProjectList(list ?? []);
|
|
1054
|
+
return formatProjectList(list ?? [], fields);
|
|
719
1055
|
case "comment":
|
|
720
1056
|
return formatCommentSummary((obj ?? {}));
|
|
721
1057
|
case "comment-list":
|
package/dist/utils/output.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { execFileSync } from "node:child_process";
|
|
2
|
-
import { dispatch as dispatchSummary, formatLine, inferKindFromPayload, } from "./formatters/summary.js";
|
|
2
|
+
import { dispatch as dispatchSummary, drainSummaryFieldWarnings, formatLine, inferKindFromPayload, } from "./formatters/summary.js";
|
|
3
3
|
import { logger } from "./logger.js";
|
|
4
4
|
import { sanitizeForLog } from "./sanitize-for-log.js";
|
|
5
5
|
const warningBuffer = [];
|
|
@@ -56,9 +56,14 @@ function filterFields(obj, fields) {
|
|
|
56
56
|
* the heuristic would fall through to "generic", silently breaking
|
|
57
57
|
* the issue-list table. Caching the pre-filter shape keeps the
|
|
58
58
|
* formatter accurate regardless of how the user pared the JSON.
|
|
59
|
+
*
|
|
60
|
+
* `fields` (when set) is forwarded to the formatter as a projection
|
|
61
|
+
* request (DEV-4750). For summary the JSON-shape `filterFields` is a
|
|
62
|
+
* no-op — the formatter needs the full object to extract nested values
|
|
63
|
+
* like `project.name` or `teams[].key`.
|
|
59
64
|
*/
|
|
60
|
-
function emitSummary(payload, kind) {
|
|
61
|
-
logger.info(dispatchSummary(kind, payload));
|
|
65
|
+
function emitSummary(payload, kind, fields) {
|
|
66
|
+
logger.info(dispatchSummary(kind, payload, fields ?? undefined));
|
|
62
67
|
}
|
|
63
68
|
/**
|
|
64
69
|
* Typed wrapper around `outputSuccess` for list responses — builds the
|
|
@@ -95,17 +100,7 @@ export function outputSingle(data) {
|
|
|
95
100
|
outputSuccess(data);
|
|
96
101
|
}
|
|
97
102
|
export function outputSuccess(data) {
|
|
98
|
-
|
|
99
|
-
let output;
|
|
100
|
-
if (warnings.length > 0 &&
|
|
101
|
-
data !== null &&
|
|
102
|
-
typeof data === "object" &&
|
|
103
|
-
!Array.isArray(data)) {
|
|
104
|
-
output = { ...data, _warnings: warnings };
|
|
105
|
-
}
|
|
106
|
-
else {
|
|
107
|
-
output = data;
|
|
108
|
-
}
|
|
103
|
+
let output = data;
|
|
109
104
|
// Capture the kind from the original envelope shape BEFORE --raw /
|
|
110
105
|
// --fields stripping. Otherwise filtering away signature fields (e.g.
|
|
111
106
|
// `title` on an issue) breaks shape inference and the summary
|
|
@@ -115,9 +110,13 @@ export function outputSuccess(data) {
|
|
|
115
110
|
// precedence on the write path and independent of --raw / --fields / --jq
|
|
116
111
|
// (those reshape the payload the flag exists to avoid) — so we emit from
|
|
117
112
|
// the full pre-filter object, otherwise `--fields identifier` would strip
|
|
118
|
-
// the state/url formatLine needs and break shape inference.
|
|
113
|
+
// the state/url formatLine needs and break shape inference. Drain any
|
|
114
|
+
// buffered warnings to preserve pre-DEV-4750 behavior (they were
|
|
115
|
+
// silently dropped on the quiet path; the contract is "one line only").
|
|
119
116
|
if (quietMode) {
|
|
120
117
|
logger.info(formatLine(output));
|
|
118
|
+
drainWarnings();
|
|
119
|
+
drainSummaryFieldWarnings();
|
|
121
120
|
return;
|
|
122
121
|
}
|
|
123
122
|
// --raw: unwrap { data: [...] } to just the array
|
|
@@ -130,8 +129,12 @@ export function outputSuccess(data) {
|
|
|
130
129
|
output = obj.data;
|
|
131
130
|
}
|
|
132
131
|
}
|
|
133
|
-
// --fields
|
|
134
|
-
|
|
132
|
+
// --fields on summary format is a *projection request* — the formatter
|
|
133
|
+
// consumes it directly to extend/replace its column set (DEV-4750). The
|
|
134
|
+
// JSON-shape `filterFields` step would otherwise strip nested values
|
|
135
|
+
// (project.name, teams[].key) the formatter needs to render. Skip the
|
|
136
|
+
// JSON filter when summary is active and let the formatter do the work.
|
|
137
|
+
if (fieldsFilter && outputFormat !== "summary") {
|
|
135
138
|
if (Array.isArray(output)) {
|
|
136
139
|
output = filterFields(output, fieldsFilter);
|
|
137
140
|
}
|
|
@@ -145,13 +148,35 @@ export function outputSuccess(data) {
|
|
|
145
148
|
}
|
|
146
149
|
}
|
|
147
150
|
}
|
|
148
|
-
// summary format takes the post-raw
|
|
149
|
-
//
|
|
150
|
-
// JSON-shape filter — it doesn't compose with text output.
|
|
151
|
+
// summary format takes the post-raw value and renders it as a
|
|
152
|
+
// human-readable block. We bypass the jq path because jq is a
|
|
153
|
+
// JSON-shape filter — it doesn't compose with text output. The
|
|
154
|
+
// formatter records any unprojectable `--fields` names; we drain
|
|
155
|
+
// them after dispatch and append them as `_warnings: …` lines so
|
|
156
|
+
// the signal is visible without breaking the text contract.
|
|
151
157
|
if (outputFormat === "summary") {
|
|
152
|
-
emitSummary(output, inferredKind);
|
|
158
|
+
emitSummary(output, inferredKind, fieldsFilter);
|
|
159
|
+
const summaryWarnings = [
|
|
160
|
+
...drainWarnings(),
|
|
161
|
+
...drainSummaryFieldWarnings(),
|
|
162
|
+
];
|
|
163
|
+
if (summaryWarnings.length > 0) {
|
|
164
|
+
for (const w of summaryWarnings) {
|
|
165
|
+
logger.info(`_warnings: ${w}`);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
153
168
|
return;
|
|
154
169
|
}
|
|
170
|
+
// JSON path: drain buffered warnings (including any
|
|
171
|
+
// fields_unprojectable surfaced from a prior dispatch) and embed
|
|
172
|
+
// them as `_warnings` on the envelope.
|
|
173
|
+
const warnings = [...drainWarnings(), ...drainSummaryFieldWarnings()];
|
|
174
|
+
if (warnings.length > 0 &&
|
|
175
|
+
output !== null &&
|
|
176
|
+
typeof output === "object" &&
|
|
177
|
+
!Array.isArray(output)) {
|
|
178
|
+
output = { ...output, _warnings: warnings };
|
|
179
|
+
}
|
|
155
180
|
if (jqFilter) {
|
|
156
181
|
const json = JSON.stringify(output);
|
|
157
182
|
// Normalize common shell-escape artifacts (zsh history expansion)
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Relation-candidate confirmation prompt — DEV-4494.
|
|
3
|
+
*
|
|
4
|
+
* When `el-linear search` or `el-linear issues search` returns results that
|
|
5
|
+
* carry issue identifiers (the "I just ran a dup-check" shape), we emit a
|
|
6
|
+
* structured `_warnings` line that nudges the *caller* (typically a Claude
|
|
7
|
+
* agent driving `linear-operations`) to surface the IDs to the user verbatim
|
|
8
|
+
* and wait for an explicit reply naming which ones to link.
|
|
9
|
+
*
|
|
10
|
+
* Why the explicit reply matters
|
|
11
|
+
* ------------------------------
|
|
12
|
+
* Claude Code's auto-mode permission classifier blocks
|
|
13
|
+
* `el-linear issues relate <source> --related-to "<ids>"` when the IDs were
|
|
14
|
+
* inferred by the agent from its own search rather than typed by the user —
|
|
15
|
+
* because creating a relation writes onto *every* listed peer issue, and the
|
|
16
|
+
* IDs must be user-specified, not agent-inferred, to clear the guard.
|
|
17
|
+
*
|
|
18
|
+
* The fix isn't to weaken the guard. It's to tighten the loop: surface the
|
|
19
|
+
* candidates, ask the human to name which IDs to link, and only THEN call
|
|
20
|
+
* `issues relate` — at which point the IDs are user-specified by
|
|
21
|
+
* construction and the guard passes naturally.
|
|
22
|
+
*
|
|
23
|
+
* This module produces the warning. The actual UX is enforced by the
|
|
24
|
+
* `linear-operations` skill (it consumes the warning and shows it to the
|
|
25
|
+
* user) and by the existing auto-mode guard (it continues to block
|
|
26
|
+
* agent-inferred relate calls).
|
|
27
|
+
*
|
|
28
|
+
* Reference: https://linear.app/verticalint/issue/DEV-4494/
|
|
29
|
+
*/
|
|
30
|
+
/**
|
|
31
|
+
* Extract issue identifiers from a heterogeneous result array.
|
|
32
|
+
*
|
|
33
|
+
* Accepts the union of shapes used across the search commands:
|
|
34
|
+
* - `issues search` rows (`LinearIssue`) carry `identifier` at the top level
|
|
35
|
+
* - cross-resource `search` rows transform to `{ type: "issue", identifier }`
|
|
36
|
+
* for issue rows; non-issue rows (`project`, `document`, …) have no
|
|
37
|
+
* identifier and are skipped.
|
|
38
|
+
*
|
|
39
|
+
* Deduplicates and preserves insertion order so the prompt enumerates IDs
|
|
40
|
+
* in the same order they appear on screen.
|
|
41
|
+
*/
|
|
42
|
+
export declare function extractCandidateIdentifiers(rows: unknown[]): string[];
|
|
43
|
+
/**
|
|
44
|
+
* Build the relation-candidate confirmation warning string, or `null` when
|
|
45
|
+
* the result set has no identifier-bearing rows (nothing to confirm).
|
|
46
|
+
*
|
|
47
|
+
* Shape (single line, structured-prose so a skill can match on the prefix):
|
|
48
|
+
*
|
|
49
|
+
* relation_candidates: Found N candidate related issues (DEV-1, DEV-2, …).
|
|
50
|
+
* To link them as related: reply with the IDs you want linked
|
|
51
|
+
* (e.g. "link DEV-1 and DEV-2"). To skip linking: reply "no links".
|
|
52
|
+
*
|
|
53
|
+
* The `relation_candidates:` prefix matches the existing `results_truncated:`
|
|
54
|
+
* convention in `outputWarning` callers — a stable token a skill / agent
|
|
55
|
+
* harness can grep for without parsing free-form prose.
|
|
56
|
+
*/
|
|
57
|
+
export declare function buildRelationCandidatePrompt(rows: unknown[]): string | null;
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Relation-candidate confirmation prompt — DEV-4494.
|
|
3
|
+
*
|
|
4
|
+
* When `el-linear search` or `el-linear issues search` returns results that
|
|
5
|
+
* carry issue identifiers (the "I just ran a dup-check" shape), we emit a
|
|
6
|
+
* structured `_warnings` line that nudges the *caller* (typically a Claude
|
|
7
|
+
* agent driving `linear-operations`) to surface the IDs to the user verbatim
|
|
8
|
+
* and wait for an explicit reply naming which ones to link.
|
|
9
|
+
*
|
|
10
|
+
* Why the explicit reply matters
|
|
11
|
+
* ------------------------------
|
|
12
|
+
* Claude Code's auto-mode permission classifier blocks
|
|
13
|
+
* `el-linear issues relate <source> --related-to "<ids>"` when the IDs were
|
|
14
|
+
* inferred by the agent from its own search rather than typed by the user —
|
|
15
|
+
* because creating a relation writes onto *every* listed peer issue, and the
|
|
16
|
+
* IDs must be user-specified, not agent-inferred, to clear the guard.
|
|
17
|
+
*
|
|
18
|
+
* The fix isn't to weaken the guard. It's to tighten the loop: surface the
|
|
19
|
+
* candidates, ask the human to name which IDs to link, and only THEN call
|
|
20
|
+
* `issues relate` — at which point the IDs are user-specified by
|
|
21
|
+
* construction and the guard passes naturally.
|
|
22
|
+
*
|
|
23
|
+
* This module produces the warning. The actual UX is enforced by the
|
|
24
|
+
* `linear-operations` skill (it consumes the warning and shows it to the
|
|
25
|
+
* user) and by the existing auto-mode guard (it continues to block
|
|
26
|
+
* agent-inferred relate calls).
|
|
27
|
+
*
|
|
28
|
+
* Reference: https://linear.app/verticalint/issue/DEV-4494/
|
|
29
|
+
*/
|
|
30
|
+
/** Cap how many candidate IDs the prompt enumerates inline. */
|
|
31
|
+
const MAX_CANDIDATES_IN_PROMPT = 10;
|
|
32
|
+
/**
|
|
33
|
+
* Extract issue identifiers from a heterogeneous result array.
|
|
34
|
+
*
|
|
35
|
+
* Accepts the union of shapes used across the search commands:
|
|
36
|
+
* - `issues search` rows (`LinearIssue`) carry `identifier` at the top level
|
|
37
|
+
* - cross-resource `search` rows transform to `{ type: "issue", identifier }`
|
|
38
|
+
* for issue rows; non-issue rows (`project`, `document`, …) have no
|
|
39
|
+
* identifier and are skipped.
|
|
40
|
+
*
|
|
41
|
+
* Deduplicates and preserves insertion order so the prompt enumerates IDs
|
|
42
|
+
* in the same order they appear on screen.
|
|
43
|
+
*/
|
|
44
|
+
export function extractCandidateIdentifiers(rows) {
|
|
45
|
+
const seen = new Set();
|
|
46
|
+
const out = [];
|
|
47
|
+
for (const row of rows) {
|
|
48
|
+
if (row === null || typeof row !== "object")
|
|
49
|
+
continue;
|
|
50
|
+
const r = row;
|
|
51
|
+
const id = typeof r.identifier === "string" ? r.identifier : undefined;
|
|
52
|
+
if (!id)
|
|
53
|
+
continue;
|
|
54
|
+
if (seen.has(id))
|
|
55
|
+
continue;
|
|
56
|
+
seen.add(id);
|
|
57
|
+
out.push(id);
|
|
58
|
+
}
|
|
59
|
+
return out;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Build the relation-candidate confirmation warning string, or `null` when
|
|
63
|
+
* the result set has no identifier-bearing rows (nothing to confirm).
|
|
64
|
+
*
|
|
65
|
+
* Shape (single line, structured-prose so a skill can match on the prefix):
|
|
66
|
+
*
|
|
67
|
+
* relation_candidates: Found N candidate related issues (DEV-1, DEV-2, …).
|
|
68
|
+
* To link them as related: reply with the IDs you want linked
|
|
69
|
+
* (e.g. "link DEV-1 and DEV-2"). To skip linking: reply "no links".
|
|
70
|
+
*
|
|
71
|
+
* The `relation_candidates:` prefix matches the existing `results_truncated:`
|
|
72
|
+
* convention in `outputWarning` callers — a stable token a skill / agent
|
|
73
|
+
* harness can grep for without parsing free-form prose.
|
|
74
|
+
*/
|
|
75
|
+
export function buildRelationCandidatePrompt(rows) {
|
|
76
|
+
const ids = extractCandidateIdentifiers(rows);
|
|
77
|
+
if (ids.length === 0)
|
|
78
|
+
return null;
|
|
79
|
+
const shown = ids.slice(0, MAX_CANDIDATES_IN_PROMPT);
|
|
80
|
+
const overflow = ids.length - shown.length;
|
|
81
|
+
const idList = overflow > 0
|
|
82
|
+
? `${shown.join(", ")}, … (+${overflow} more)`
|
|
83
|
+
: shown.join(", ");
|
|
84
|
+
// Build two concrete example IDs from the head of the list so the
|
|
85
|
+
// "reply with the IDs you want linked" example is realistic for the
|
|
86
|
+
// caller's actual search rather than a fixed placeholder. Single-result
|
|
87
|
+
// case still reads naturally ("link DEV-1").
|
|
88
|
+
const example = shown.length >= 2 ? `link ${shown[0]} and ${shown[1]}` : `link ${shown[0]}`;
|
|
89
|
+
const noun = ids.length === 1 ? "candidate related issue" : "candidate related issues";
|
|
90
|
+
return (`relation_candidates: Found ${ids.length} ${noun} (${idList}). ` +
|
|
91
|
+
`To link them as related: reply with the IDs you want linked ` +
|
|
92
|
+
`(e.g. "${example}"). To skip linking: reply "no links". ` +
|
|
93
|
+
`(Agent-inferred IDs are blocked by auto-mode; user-named IDs pass — DEV-4494.)`);
|
|
94
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@enrichlayer/el-linear",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.26.0",
|
|
4
4
|
"description": "A pragmatic CLI for Linear.app — deterministic team/label/member resolution, structured issue validation, configurable term enforcement, and a GraphQL escape hatch.",
|
|
5
5
|
"main": "dist/main.js",
|
|
6
6
|
"types": "dist/main.d.ts",
|