@enrichlayer/el-linear 1.7.0 → 1.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +69 -1
- package/claude-skills/linear-operations/SKILL.md +55 -0
- package/dist/auth/oauth-callback.js +14 -7
- package/dist/auth/oauth-fs.d.ts +22 -0
- package/dist/auth/oauth-fs.js +77 -6
- package/dist/auth/oauth-headless.d.ts +13 -8
- package/dist/auth/oauth-headless.js +18 -11
- package/dist/auth/oauth-storage.d.ts +11 -3
- package/dist/auth/oauth-storage.js +15 -8
- package/dist/auth/token-resolver.d.ts +9 -0
- package/dist/auth/token-resolver.js +57 -37
- package/dist/commands/init/index.js +2 -0
- package/dist/commands/init/oauth.d.ts +6 -0
- package/dist/commands/init/oauth.js +8 -2
- package/dist/commands/init/token.d.ts +0 -8
- package/dist/commands/init/token.js +13 -1
- package/dist/commands/issues/branch.d.ts +17 -0
- package/dist/commands/issues/branch.js +40 -0
- package/dist/commands/issues/description.d.ts +89 -0
- package/dist/commands/issues/description.js +187 -0
- package/dist/commands/issues.js +34 -215
- package/dist/commands/labels.js +7 -5
- package/dist/commands/profile/migrate-legacy.js +10 -33
- package/dist/commands/profile.js +1 -10
- package/dist/commands/projects.d.ts +5 -1
- package/dist/commands/projects.js +135 -64
- package/dist/commands/read-shortcut.d.ts +6 -0
- package/dist/commands/read-shortcut.js +6 -1
- package/dist/commands/refs.js +2 -1
- package/dist/commands/templates.js +127 -1
- package/dist/commands/users.js +2 -1
- package/dist/config/config.js +31 -9
- package/dist/config/issue-validation.js +1 -1
- package/dist/config/paths.d.ts +11 -0
- package/dist/config/paths.js +44 -6
- package/dist/config/term-enforcer.js +1 -1
- package/dist/main.js +37 -2
- package/dist/queries/templates.d.ts +3 -0
- package/dist/queries/templates.js +43 -0
- package/dist/utils/auth.js +7 -9
- package/dist/utils/auto-link-references.js +15 -1
- package/dist/utils/disk-cache.js +2 -1
- package/dist/utils/formatters/summary.d.ts +62 -0
- package/dist/utils/formatters/summary.js +755 -0
- package/dist/utils/graphql-issues-service.d.ts +106 -3
- package/dist/utils/graphql-issues-service.js +51 -37
- package/dist/utils/issue-reference-extractor.d.ts +9 -1
- package/dist/utils/issue-reference-extractor.js +16 -9
- package/dist/utils/issue-reference-wrapper.js +1 -54
- package/dist/utils/linear-service.d.ts +7 -3
- package/dist/utils/linear-service.js +27 -5
- package/dist/utils/markdown-prosemirror.js +17 -1
- package/dist/utils/mention-resolver.js +17 -5
- package/dist/utils/output.d.ts +5 -1
- package/dist/utils/output.js +28 -1
- package/dist/utils/protected-ranges.d.ts +33 -0
- package/dist/utils/protected-ranges.js +73 -0
- package/dist/utils/table-formatter.d.ts +36 -0
- package/dist/utils/table-formatter.js +46 -24
- package/dist/utils/validators.d.ts +9 -1
- package/dist/utils/validators.js +10 -0
- package/package.json +1 -1
|
@@ -0,0 +1,755 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Human-readable summary formatters for the `--format summary` output mode.
|
|
3
|
+
*
|
|
4
|
+
* These are pure functions: each takes the same shape that the JSON output
|
|
5
|
+
* path receives (a single resource object or a `{ data, meta }` envelope)
|
|
6
|
+
* and returns a plain string suitable for terminal display.
|
|
7
|
+
*
|
|
8
|
+
* Why this exists: every consumer (humans and LLMs) was piping
|
|
9
|
+
* `el-linear ... | python -c 'json.load(...)'` or jq-based extraction to
|
|
10
|
+
* pull out title/state/assignee. This codifies that recurring shape as a
|
|
11
|
+
* first-class output mode, so callers don't reinvent it in shell.
|
|
12
|
+
*
|
|
13
|
+
* The summary format is intentionally stable — it's a contract, not a
|
|
14
|
+
* pretty-print. Field ordering and labels should not change across
|
|
15
|
+
* patch releases without a CHANGELOG entry.
|
|
16
|
+
*/
|
|
17
|
+
const TITLE_TRUNC = 60;
|
|
18
|
+
const DESCRIPTION_LINE_LIMIT = 10;
|
|
19
|
+
const DESCRIPTION_CHAR_LIMIT = 4096;
|
|
20
|
+
const TRUNC_FOOTER = "... (truncated; --format json for full body)";
|
|
21
|
+
// ── tiny helpers ───────────────────────────────────────────────
|
|
22
|
+
/**
|
|
23
|
+
* Strip terminal control sequences from a string so the summary
|
|
24
|
+
* formatter cannot be hijacked by attacker-controlled content
|
|
25
|
+
* (issue/comment titles set by anyone with workspace write access).
|
|
26
|
+
*
|
|
27
|
+
* Removes:
|
|
28
|
+
*
|
|
29
|
+
* - C0 control bytes (0x00–0x1F) except `\t` (kept for legible
|
|
30
|
+
* embedded tabs) and `\n` (callers like `clipDescription` rely on
|
|
31
|
+
* it). `\x1B` (ESC) — the start byte for CSI / OSC / DCS / SS3
|
|
32
|
+
* sequences — is dropped along with the rest of C0.
|
|
33
|
+
* - DEL (0x7F).
|
|
34
|
+
* - C1 control bytes (0x80–0x9F).
|
|
35
|
+
*
|
|
36
|
+
* This is conservative — anything that would tell a terminal to move
|
|
37
|
+
* the cursor, switch screens, set a hyperlink target, change palette,
|
|
38
|
+
* etc. is silently dropped. Visible Unicode (including emoji and CJK)
|
|
39
|
+
* passes through untouched.
|
|
40
|
+
*/
|
|
41
|
+
// biome-ignore lint/suspicious/noControlCharactersInRegex: this regex is the sanitizer.
|
|
42
|
+
const TERMINAL_CONTROL_RE = /[\x00-\x08\x0B-\x1F\x7F-\x9F]/g;
|
|
43
|
+
function sanitizeForTerminal(value) {
|
|
44
|
+
return value.replace(TERMINAL_CONTROL_RE, "");
|
|
45
|
+
}
|
|
46
|
+
function truncate(s, n) {
|
|
47
|
+
// truncate(s, n<=0) → "" (no room even for an ellipsis)
|
|
48
|
+
// truncate(s, 1) → "…"
|
|
49
|
+
// truncate(s, n>=2) → first n-1 characters + ellipsis
|
|
50
|
+
// Width math elsewhere assumes the result is ≤ n characters; this
|
|
51
|
+
// keeps the contract honest at the small-n boundary.
|
|
52
|
+
if (s.length <= n)
|
|
53
|
+
return s;
|
|
54
|
+
if (n <= 0)
|
|
55
|
+
return "";
|
|
56
|
+
if (n === 1)
|
|
57
|
+
return "…";
|
|
58
|
+
return `${s.slice(0, n - 1)}…`;
|
|
59
|
+
}
|
|
60
|
+
function pad(s, n) {
|
|
61
|
+
if (s.length >= n)
|
|
62
|
+
return s;
|
|
63
|
+
return s + " ".repeat(n - s.length);
|
|
64
|
+
}
|
|
65
|
+
function indent(text, prefix = " ") {
|
|
66
|
+
return text
|
|
67
|
+
.split("\n")
|
|
68
|
+
.map((line) => prefix + line)
|
|
69
|
+
.join("\n");
|
|
70
|
+
}
|
|
71
|
+
function clipDescription(body) {
|
|
72
|
+
if (!body)
|
|
73
|
+
return null;
|
|
74
|
+
const sanitized = sanitizeForTerminal(body);
|
|
75
|
+
// Char cap first: a 5MB description with no newlines would otherwise
|
|
76
|
+
// pass the line-count check unchanged and dump verbatim to stdout.
|
|
77
|
+
let clipped = sanitized;
|
|
78
|
+
let charClipped = false;
|
|
79
|
+
if (clipped.length > DESCRIPTION_CHAR_LIMIT) {
|
|
80
|
+
clipped = clipped.slice(0, DESCRIPTION_CHAR_LIMIT);
|
|
81
|
+
charClipped = true;
|
|
82
|
+
}
|
|
83
|
+
const lines = clipped.split("\n");
|
|
84
|
+
if (lines.length <= DESCRIPTION_LINE_LIMIT && !charClipped) {
|
|
85
|
+
return indent(clipped);
|
|
86
|
+
}
|
|
87
|
+
const kept = lines.slice(0, DESCRIPTION_LINE_LIMIT).join("\n");
|
|
88
|
+
return `${indent(kept)}\n${indent(TRUNC_FOOTER)}`;
|
|
89
|
+
}
|
|
90
|
+
function s(v) {
|
|
91
|
+
if (v === null || v === undefined || v === "")
|
|
92
|
+
return "—";
|
|
93
|
+
return sanitizeForTerminal(String(v));
|
|
94
|
+
}
|
|
95
|
+
function asObj(v) {
|
|
96
|
+
if (v && typeof v === "object" && !Array.isArray(v)) {
|
|
97
|
+
return v;
|
|
98
|
+
}
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
function getName(v) {
|
|
102
|
+
const o = asObj(v);
|
|
103
|
+
if (!o)
|
|
104
|
+
return s(v);
|
|
105
|
+
const candidate = o.name ?? o.displayName ?? o.title ?? o.key ?? o.identifier ?? null;
|
|
106
|
+
return s(candidate);
|
|
107
|
+
}
|
|
108
|
+
function joinLabels(v) {
|
|
109
|
+
if (!Array.isArray(v) || v.length === 0)
|
|
110
|
+
return "";
|
|
111
|
+
return v
|
|
112
|
+
.map((l) => {
|
|
113
|
+
const o = asObj(l);
|
|
114
|
+
return o ? s(o.name) : s(l);
|
|
115
|
+
})
|
|
116
|
+
.filter((x) => x && x !== "—")
|
|
117
|
+
.join(", ");
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Render a list of rows as a fixed-width text table with a header,
|
|
121
|
+
* separator, body, and "<N> <noun>s" footer.
|
|
122
|
+
*
|
|
123
|
+
* Subsumes the formatXList family in this file — each one used to
|
|
124
|
+
* inline its own column-width math, header/separator wiring, and
|
|
125
|
+
* footer pluralization. Centralizing here cuts ~340 lines of
|
|
126
|
+
* mechanical boilerplate to a single 30-line helper plus per-resource
|
|
127
|
+
* column declarations.
|
|
128
|
+
*/
|
|
129
|
+
function renderTable(rows, columns, options) {
|
|
130
|
+
if (rows.length === 0)
|
|
131
|
+
return options.emptyText;
|
|
132
|
+
// Extract values once per row so we don't re-evaluate `extract` for
|
|
133
|
+
// the width pass and the body pass.
|
|
134
|
+
const values = rows.map((row) => columns.map((col) => {
|
|
135
|
+
const raw = col.extract(row);
|
|
136
|
+
return col.maxWidth ? truncate(raw, col.maxWidth) : raw;
|
|
137
|
+
}));
|
|
138
|
+
const widths = columns.map((col, i) => {
|
|
139
|
+
const fromRows = values.reduce((max, row) => Math.max(max, row[i].length), 0);
|
|
140
|
+
const computed = Math.max(col.minWidth, col.header.length, fromRows);
|
|
141
|
+
return col.maxWidth ? Math.min(col.maxWidth, computed) : computed;
|
|
142
|
+
});
|
|
143
|
+
const header = columns.map((col, i) => pad(col.header, widths[i])).join(" ");
|
|
144
|
+
const sep = "-".repeat(header.length);
|
|
145
|
+
const body = values
|
|
146
|
+
.map((row) => row.map((v, i) => pad(v, widths[i])).join(" "))
|
|
147
|
+
.join("\n");
|
|
148
|
+
const noun = `${rows.length} ${options.itemNoun}${rows.length === 1 ? "" : "s"}`;
|
|
149
|
+
return `${header}\n${sep}\n${body}\n\n${noun}`;
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* Render a list of label/value pairs with the labels right-padded so values
|
|
153
|
+
* align in a column. Empty / missing values are dropped before render so
|
|
154
|
+
* the summary doesn't show "Project: —" rows for resources where a field
|
|
155
|
+
* isn't applicable.
|
|
156
|
+
*/
|
|
157
|
+
function renderHeader(fields) {
|
|
158
|
+
const visible = fields.filter((f) => f.value && f.value !== "—");
|
|
159
|
+
if (visible.length === 0)
|
|
160
|
+
return "";
|
|
161
|
+
const labelWidth = Math.max(...visible.map((f) => f.label.length)) + 1;
|
|
162
|
+
return visible
|
|
163
|
+
.map((f) => `${pad(`${f.label}:`, labelWidth + 1)} ${f.value}`)
|
|
164
|
+
.join("\n");
|
|
165
|
+
}
|
|
166
|
+
// ── issues ─────────────────────────────────────────────────────
|
|
167
|
+
export function formatIssueSummary(issue) {
|
|
168
|
+
const identifier = s(issue.identifier);
|
|
169
|
+
const title = s(issue.title);
|
|
170
|
+
const headerLine = `${identifier} ${title}`;
|
|
171
|
+
const fields = [
|
|
172
|
+
{ label: "State", value: getName(issue.state) },
|
|
173
|
+
{ label: "Assignee", value: getName(issue.assignee) },
|
|
174
|
+
{ label: "Project", value: getName(issue.project) },
|
|
175
|
+
{ label: "Cycle", value: getName(issue.cycle) },
|
|
176
|
+
{ label: "Milestone", value: getName(issue.projectMilestone) },
|
|
177
|
+
{ label: "Labels", value: joinLabels(issue.labels) },
|
|
178
|
+
{ label: "URL", value: s(issue.url) },
|
|
179
|
+
];
|
|
180
|
+
const header = renderHeader(fields);
|
|
181
|
+
const body = clipDescription(issue.description);
|
|
182
|
+
const parts = [headerLine, header];
|
|
183
|
+
if (body)
|
|
184
|
+
parts.push("", body);
|
|
185
|
+
return parts.filter((p) => p !== "").join("\n");
|
|
186
|
+
}
|
|
187
|
+
export function formatIssueList(issues) {
|
|
188
|
+
return renderTable(issues.map((raw) => asObj(raw) ?? {}), [
|
|
189
|
+
{ header: "ID", minWidth: 2, extract: (i) => s(i.identifier) },
|
|
190
|
+
{
|
|
191
|
+
header: "TITLE",
|
|
192
|
+
minWidth: 5,
|
|
193
|
+
maxWidth: TITLE_TRUNC,
|
|
194
|
+
extract: (i) => s(i.title),
|
|
195
|
+
},
|
|
196
|
+
{ header: "STATE", minWidth: 5, extract: (i) => getName(i.state) },
|
|
197
|
+
{
|
|
198
|
+
header: "ASSIGNEE",
|
|
199
|
+
minWidth: 8,
|
|
200
|
+
extract: (i) => getName(i.assignee),
|
|
201
|
+
},
|
|
202
|
+
], { emptyText: "(no issues)", itemNoun: "issue" });
|
|
203
|
+
}
|
|
204
|
+
// ── projects ───────────────────────────────────────────────────
|
|
205
|
+
export function formatProjectSummary(project) {
|
|
206
|
+
const name = s(project.name);
|
|
207
|
+
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
|
+
})();
|
|
219
|
+
const progress = typeof project.progress === "number"
|
|
220
|
+
? `${Math.round(project.progress * 100)}%`
|
|
221
|
+
: "";
|
|
222
|
+
const fields = [
|
|
223
|
+
{ label: "State", value: s(project.state) },
|
|
224
|
+
{ label: "Lead", value: getName(project.lead) },
|
|
225
|
+
{ label: "Teams", value: teams },
|
|
226
|
+
{ label: "Target", value: s(project.targetDate) },
|
|
227
|
+
{ label: "Progress", value: progress },
|
|
228
|
+
{ label: "URL", value: s(project.url) },
|
|
229
|
+
];
|
|
230
|
+
const header = renderHeader(fields);
|
|
231
|
+
const body = clipDescription(project.description);
|
|
232
|
+
const parts = [headerLine, header];
|
|
233
|
+
if (body)
|
|
234
|
+
parts.push("", body);
|
|
235
|
+
return parts.filter((p) => p !== "").join("\n");
|
|
236
|
+
}
|
|
237
|
+
function pct(value) {
|
|
238
|
+
return typeof value === "number" ? `${Math.round(value * 100)}%` : "—";
|
|
239
|
+
}
|
|
240
|
+
export function formatProjectList(projects) {
|
|
241
|
+
return renderTable(projects.map((raw) => asObj(raw) ?? {}), [
|
|
242
|
+
{
|
|
243
|
+
header: "NAME",
|
|
244
|
+
minWidth: 4,
|
|
245
|
+
maxWidth: TITLE_TRUNC,
|
|
246
|
+
extract: (p) => s(p.name),
|
|
247
|
+
},
|
|
248
|
+
{ header: "STATE", minWidth: 5, extract: (p) => s(p.state) },
|
|
249
|
+
{ header: "PROGRESS", minWidth: 8, extract: (p) => pct(p.progress) },
|
|
250
|
+
{ header: "LEAD", minWidth: 4, extract: (p) => getName(p.lead) },
|
|
251
|
+
], { emptyText: "(no projects)", itemNoun: "project" });
|
|
252
|
+
}
|
|
253
|
+
// ── comments ───────────────────────────────────────────────────
|
|
254
|
+
export function formatCommentSummary(comment) {
|
|
255
|
+
const author = getName(comment.user);
|
|
256
|
+
const createdAt = s(comment.createdAt);
|
|
257
|
+
const body = clipDescription(comment.body);
|
|
258
|
+
const headerLine = `${author} ${createdAt}`;
|
|
259
|
+
const parts = [headerLine];
|
|
260
|
+
if (body)
|
|
261
|
+
parts.push("", body);
|
|
262
|
+
return parts.join("\n");
|
|
263
|
+
}
|
|
264
|
+
export function formatCommentList(comments) {
|
|
265
|
+
if (comments.length === 0)
|
|
266
|
+
return "(no comments)";
|
|
267
|
+
return comments
|
|
268
|
+
.map((c) => {
|
|
269
|
+
const obj = asObj(c) ?? {};
|
|
270
|
+
return formatCommentSummary(obj);
|
|
271
|
+
})
|
|
272
|
+
.join("\n\n---\n\n");
|
|
273
|
+
}
|
|
274
|
+
// ── cycles ─────────────────────────────────────────────────────
|
|
275
|
+
export function formatCycleSummary(cycle) {
|
|
276
|
+
const name = s(cycle.name ?? `Cycle ${s(cycle.number)}`);
|
|
277
|
+
const headerLine = name;
|
|
278
|
+
const progress = typeof cycle.progress === "number"
|
|
279
|
+
? `${Math.round(cycle.progress * 100)}%`
|
|
280
|
+
: "";
|
|
281
|
+
const fields = [
|
|
282
|
+
{ label: "Number", value: s(cycle.number) },
|
|
283
|
+
{ label: "Team", value: getName(cycle.team) },
|
|
284
|
+
{ label: "Active", value: cycle.isActive ? "yes" : "" },
|
|
285
|
+
{ label: "Starts", value: s(cycle.startsAt) },
|
|
286
|
+
{ label: "Ends", value: s(cycle.endsAt) },
|
|
287
|
+
{ label: "Progress", value: progress },
|
|
288
|
+
];
|
|
289
|
+
const header = renderHeader(fields);
|
|
290
|
+
// If this is a cycle "read" payload, it includes issues — append a list.
|
|
291
|
+
const issues = cycle.issues;
|
|
292
|
+
let issuesBlock = "";
|
|
293
|
+
if (Array.isArray(issues) && issues.length > 0) {
|
|
294
|
+
issuesBlock = `\n\nIssues:\n${formatIssueList(issues)}`;
|
|
295
|
+
}
|
|
296
|
+
return `${headerLine}\n${header}${issuesBlock}`;
|
|
297
|
+
}
|
|
298
|
+
export function formatCycleList(cycles) {
|
|
299
|
+
return renderTable(cycles.map((raw) => asObj(raw) ?? {}), [
|
|
300
|
+
{ header: "#", minWidth: 2, extract: (c) => s(c.number) },
|
|
301
|
+
{
|
|
302
|
+
header: "NAME",
|
|
303
|
+
minWidth: 4,
|
|
304
|
+
maxWidth: 40,
|
|
305
|
+
extract: (c) => s(c.name ?? `Cycle ${s(c.number)}`),
|
|
306
|
+
},
|
|
307
|
+
{ header: "TEAM", minWidth: 4, extract: (c) => getName(c.team) },
|
|
308
|
+
{
|
|
309
|
+
header: "ACTIVE",
|
|
310
|
+
minWidth: 6,
|
|
311
|
+
extract: (c) => (c.isActive ? "yes" : "no"),
|
|
312
|
+
},
|
|
313
|
+
{ header: "PROGRESS", minWidth: 8, extract: (c) => pct(c.progress) },
|
|
314
|
+
], { emptyText: "(no cycles)", itemNoun: "cycle" });
|
|
315
|
+
}
|
|
316
|
+
// ── milestones ─────────────────────────────────────────────────
|
|
317
|
+
export function formatMilestoneSummary(milestone) {
|
|
318
|
+
const name = s(milestone.name);
|
|
319
|
+
const fields = [
|
|
320
|
+
{ label: "Target", value: s(milestone.targetDate) },
|
|
321
|
+
{ label: "Project", value: getName(milestone.project) },
|
|
322
|
+
{ label: "Sort", value: s(milestone.sortOrder) },
|
|
323
|
+
];
|
|
324
|
+
const header = renderHeader(fields);
|
|
325
|
+
const body = clipDescription(milestone.description);
|
|
326
|
+
const parts = [name, header];
|
|
327
|
+
if (body)
|
|
328
|
+
parts.push("", body);
|
|
329
|
+
return parts.filter((p) => p !== "").join("\n");
|
|
330
|
+
}
|
|
331
|
+
export function formatMilestoneList(milestones) {
|
|
332
|
+
return renderTable(milestones.map((raw) => asObj(raw) ?? {}), [
|
|
333
|
+
{
|
|
334
|
+
header: "NAME",
|
|
335
|
+
minWidth: 4,
|
|
336
|
+
maxWidth: TITLE_TRUNC,
|
|
337
|
+
extract: (m) => s(m.name),
|
|
338
|
+
},
|
|
339
|
+
{ header: "TARGET", minWidth: 6, extract: (m) => s(m.targetDate) },
|
|
340
|
+
{ header: "PROJECT", minWidth: 7, extract: (m) => getName(m.project) },
|
|
341
|
+
], { emptyText: "(no milestones)", itemNoun: "milestone" });
|
|
342
|
+
}
|
|
343
|
+
// ── teams ──────────────────────────────────────────────────────
|
|
344
|
+
export function formatTeamList(teams) {
|
|
345
|
+
return renderTable(teams.map((raw) => asObj(raw) ?? {}), [
|
|
346
|
+
{ header: "KEY", minWidth: 3, extract: (t) => s(t.key) },
|
|
347
|
+
{
|
|
348
|
+
header: "NAME",
|
|
349
|
+
minWidth: 4,
|
|
350
|
+
maxWidth: TITLE_TRUNC,
|
|
351
|
+
extract: (t) => s(t.name),
|
|
352
|
+
},
|
|
353
|
+
{
|
|
354
|
+
header: "DESCRIPTION",
|
|
355
|
+
minWidth: 11,
|
|
356
|
+
maxWidth: 60,
|
|
357
|
+
extract: (t) => s(t.description),
|
|
358
|
+
},
|
|
359
|
+
], { emptyText: "(no teams)", itemNoun: "team" });
|
|
360
|
+
}
|
|
361
|
+
// ── labels ─────────────────────────────────────────────────────
|
|
362
|
+
export function formatLabelList(labels) {
|
|
363
|
+
return renderTable(labels.map((raw) => asObj(raw) ?? {}), [
|
|
364
|
+
{
|
|
365
|
+
header: "NAME",
|
|
366
|
+
minWidth: 4,
|
|
367
|
+
maxWidth: TITLE_TRUNC,
|
|
368
|
+
extract: (l) => s(l.name),
|
|
369
|
+
},
|
|
370
|
+
{ header: "SCOPE", minWidth: 5, extract: (l) => s(l.scope) },
|
|
371
|
+
{
|
|
372
|
+
header: "TEAM",
|
|
373
|
+
minWidth: 4,
|
|
374
|
+
extract: (l) => (l.team ? getName(l.team) : ""),
|
|
375
|
+
},
|
|
376
|
+
{ header: "COLOR", minWidth: 5, extract: (l) => s(l.color) },
|
|
377
|
+
], { emptyText: "(no labels)", itemNoun: "label" });
|
|
378
|
+
}
|
|
379
|
+
// ── users ──────────────────────────────────────────────────────
|
|
380
|
+
export function formatUserSummary(user) {
|
|
381
|
+
const name = s(user.name);
|
|
382
|
+
const fields = [
|
|
383
|
+
{ label: "Display name", value: s(user.displayName) },
|
|
384
|
+
{ label: "Email", value: s(user.email) },
|
|
385
|
+
{ label: "Active", value: user.active === false ? "no" : "yes" },
|
|
386
|
+
];
|
|
387
|
+
const header = renderHeader(fields);
|
|
388
|
+
return `${name}\n${header}`;
|
|
389
|
+
}
|
|
390
|
+
export function formatUserList(users) {
|
|
391
|
+
return renderTable(users.map((raw) => asObj(raw) ?? {}), [
|
|
392
|
+
{ header: "NAME", minWidth: 4, maxWidth: 30, extract: (u) => s(u.name) },
|
|
393
|
+
{
|
|
394
|
+
header: "DISPLAY",
|
|
395
|
+
minWidth: 7,
|
|
396
|
+
maxWidth: 25,
|
|
397
|
+
extract: (u) => s(u.displayName),
|
|
398
|
+
},
|
|
399
|
+
{
|
|
400
|
+
header: "EMAIL",
|
|
401
|
+
minWidth: 5,
|
|
402
|
+
maxWidth: 40,
|
|
403
|
+
extract: (u) => s(u.email),
|
|
404
|
+
},
|
|
405
|
+
{
|
|
406
|
+
header: "ACTIVE",
|
|
407
|
+
minWidth: 6,
|
|
408
|
+
extract: (u) => (u.active === false ? "no" : "yes"),
|
|
409
|
+
},
|
|
410
|
+
], { emptyText: "(no users)", itemNoun: "user" });
|
|
411
|
+
}
|
|
412
|
+
// ── documents ──────────────────────────────────────────────────
|
|
413
|
+
export function formatDocumentSummary(doc) {
|
|
414
|
+
const title = s(doc.title);
|
|
415
|
+
const fields = [
|
|
416
|
+
{ label: "Project", value: getName(doc.project) },
|
|
417
|
+
{ label: "Issue", value: getName(doc.issue) },
|
|
418
|
+
{ label: "Creator", value: getName(doc.creator) },
|
|
419
|
+
{ label: "Updated", value: s(doc.updatedAt) },
|
|
420
|
+
{ label: "URL", value: s(doc.url) },
|
|
421
|
+
];
|
|
422
|
+
const header = renderHeader(fields);
|
|
423
|
+
const body = clipDescription(doc.content);
|
|
424
|
+
const parts = [title, header];
|
|
425
|
+
if (body)
|
|
426
|
+
parts.push("", body);
|
|
427
|
+
return parts.filter((p) => p !== "").join("\n");
|
|
428
|
+
}
|
|
429
|
+
export function formatDocumentList(docs) {
|
|
430
|
+
return renderTable(docs.map((raw) => asObj(raw) ?? {}), [
|
|
431
|
+
{
|
|
432
|
+
header: "TITLE",
|
|
433
|
+
minWidth: 5,
|
|
434
|
+
maxWidth: TITLE_TRUNC,
|
|
435
|
+
extract: (d) => s(d.title),
|
|
436
|
+
},
|
|
437
|
+
{ header: "PROJECT", minWidth: 7, extract: (d) => getName(d.project) },
|
|
438
|
+
{
|
|
439
|
+
header: "UPDATED",
|
|
440
|
+
minWidth: 7,
|
|
441
|
+
extract: (d) => s(d.updatedAt).slice(0, 10),
|
|
442
|
+
},
|
|
443
|
+
], { emptyText: "(no documents)", itemNoun: "document" });
|
|
444
|
+
}
|
|
445
|
+
// ── templates ──────────────────────────────────────────────────
|
|
446
|
+
export function formatTemplateSummary(tpl) {
|
|
447
|
+
const name = s(tpl.name);
|
|
448
|
+
const fields = [
|
|
449
|
+
{ label: "Type", value: s(tpl.type) },
|
|
450
|
+
{ label: "Team", value: getName(tpl.team) },
|
|
451
|
+
{ label: "Creator", value: getName(tpl.creator) },
|
|
452
|
+
{ label: "Updated", value: s(tpl.updatedAt) },
|
|
453
|
+
{ label: "ID", value: s(tpl.id) },
|
|
454
|
+
];
|
|
455
|
+
const header = renderHeader(fields);
|
|
456
|
+
const body = clipDescription(tpl.description);
|
|
457
|
+
const parts = [name, header];
|
|
458
|
+
if (body)
|
|
459
|
+
parts.push("", body);
|
|
460
|
+
return parts.filter((p) => p !== "").join("\n");
|
|
461
|
+
}
|
|
462
|
+
export function formatTemplateList(tpls) {
|
|
463
|
+
return renderTable(tpls.map((raw) => asObj(raw) ?? {}), [
|
|
464
|
+
{
|
|
465
|
+
header: "NAME",
|
|
466
|
+
minWidth: 4,
|
|
467
|
+
maxWidth: TITLE_TRUNC,
|
|
468
|
+
extract: (t) => s(t.name),
|
|
469
|
+
},
|
|
470
|
+
{ header: "TYPE", minWidth: 4, extract: (t) => s(t.type) },
|
|
471
|
+
{ header: "TEAM", minWidth: 4, extract: (t) => getName(t.team) },
|
|
472
|
+
{ header: "ID", minWidth: 2, extract: (t) => s(t.id) },
|
|
473
|
+
], { emptyText: "(no templates)", itemNoun: "template" });
|
|
474
|
+
}
|
|
475
|
+
// ── attachments ────────────────────────────────────────────────
|
|
476
|
+
export function formatAttachmentList(attachments) {
|
|
477
|
+
return renderTable(attachments.map((raw) => asObj(raw) ?? {}), [
|
|
478
|
+
{
|
|
479
|
+
header: "TITLE",
|
|
480
|
+
minWidth: 5,
|
|
481
|
+
maxWidth: TITLE_TRUNC,
|
|
482
|
+
extract: (a) => s(a.title),
|
|
483
|
+
},
|
|
484
|
+
{ header: "URL", minWidth: 3, maxWidth: 60, extract: (a) => s(a.url) },
|
|
485
|
+
{
|
|
486
|
+
header: "CREATED",
|
|
487
|
+
minWidth: 7,
|
|
488
|
+
extract: (a) => s(a.createdAt).slice(0, 10),
|
|
489
|
+
},
|
|
490
|
+
], { emptyText: "(no attachments)", itemNoun: "attachment" });
|
|
491
|
+
}
|
|
492
|
+
// ── releases ───────────────────────────────────────────────────
|
|
493
|
+
export function formatReleaseSummary(release) {
|
|
494
|
+
const name = s(release.name);
|
|
495
|
+
const stage = asObj(release.stage);
|
|
496
|
+
const fields = [
|
|
497
|
+
{ label: "Version", value: s(release.version) },
|
|
498
|
+
{ label: "Stage", value: stage ? s(stage.name) : "" },
|
|
499
|
+
{ label: "Pipeline", value: getName(release.pipeline) },
|
|
500
|
+
{ label: "Start", value: s(release.startDate) },
|
|
501
|
+
{ label: "Target", value: s(release.targetDate) },
|
|
502
|
+
{ label: "URL", value: s(release.url) },
|
|
503
|
+
];
|
|
504
|
+
const header = renderHeader(fields);
|
|
505
|
+
const body = clipDescription(release.description);
|
|
506
|
+
const parts = [name, header];
|
|
507
|
+
if (body)
|
|
508
|
+
parts.push("", body);
|
|
509
|
+
return parts.filter((p) => p !== "").join("\n");
|
|
510
|
+
}
|
|
511
|
+
export function formatReleaseList(releases) {
|
|
512
|
+
return renderTable(releases.map((raw) => asObj(raw) ?? {}), [
|
|
513
|
+
{
|
|
514
|
+
header: "NAME",
|
|
515
|
+
minWidth: 4,
|
|
516
|
+
maxWidth: TITLE_TRUNC,
|
|
517
|
+
extract: (r) => s(r.name),
|
|
518
|
+
},
|
|
519
|
+
{ header: "VERSION", minWidth: 7, extract: (r) => s(r.version) },
|
|
520
|
+
{
|
|
521
|
+
header: "STAGE",
|
|
522
|
+
minWidth: 5,
|
|
523
|
+
extract: (r) => {
|
|
524
|
+
const stage = asObj(r.stage);
|
|
525
|
+
return stage ? s(stage.name) : "—";
|
|
526
|
+
},
|
|
527
|
+
},
|
|
528
|
+
{ header: "TARGET", minWidth: 6, extract: (r) => s(r.targetDate) },
|
|
529
|
+
], { emptyText: "(no releases)", itemNoun: "release" });
|
|
530
|
+
}
|
|
531
|
+
// ── search-results (cross-resource) ────────────────────────────
|
|
532
|
+
export function formatSearchResultList(results) {
|
|
533
|
+
return renderTable(results.map((raw) => asObj(raw) ?? {}), [
|
|
534
|
+
{ header: "TYPE", minWidth: 4, extract: (r) => s(r.type) },
|
|
535
|
+
{
|
|
536
|
+
header: "ID",
|
|
537
|
+
minWidth: 2,
|
|
538
|
+
maxWidth: 18,
|
|
539
|
+
extract: (r) => s(r.identifier ?? r.id),
|
|
540
|
+
},
|
|
541
|
+
{
|
|
542
|
+
header: "TITLE",
|
|
543
|
+
minWidth: 5,
|
|
544
|
+
maxWidth: TITLE_TRUNC,
|
|
545
|
+
extract: (r) => s(r.title ?? r.name),
|
|
546
|
+
},
|
|
547
|
+
], { emptyText: "(no results)", itemNoun: "result" });
|
|
548
|
+
}
|
|
549
|
+
// ── fallback ───────────────────────────────────────────────────
|
|
550
|
+
/**
|
|
551
|
+
* Generic fallback for resources we don't have a dedicated formatter for
|
|
552
|
+
* yet (e.g. profile/config/template/document). Renders any object as a
|
|
553
|
+
* label-padded key/value block, with a "..." footer hinting at JSON for
|
|
554
|
+
* the full payload. Lists fall back to a simple bulleted list.
|
|
555
|
+
*/
|
|
556
|
+
export function formatGenericSummary(value) {
|
|
557
|
+
if (Array.isArray(value)) {
|
|
558
|
+
if (value.length === 0)
|
|
559
|
+
return "(empty)";
|
|
560
|
+
return value
|
|
561
|
+
.map((v) => `- ${formatGenericInline(v)}`)
|
|
562
|
+
.concat([`\n${value.length} item${value.length === 1 ? "" : "s"}`])
|
|
563
|
+
.join("\n");
|
|
564
|
+
}
|
|
565
|
+
const obj = asObj(value);
|
|
566
|
+
if (!obj)
|
|
567
|
+
return s(value);
|
|
568
|
+
const fields = Object.entries(obj)
|
|
569
|
+
.filter(([k]) => !k.startsWith("_"))
|
|
570
|
+
.map(([k, v]) => ({ label: k, value: formatGenericInline(v) }));
|
|
571
|
+
return renderHeader(fields);
|
|
572
|
+
}
|
|
573
|
+
function formatGenericInline(v) {
|
|
574
|
+
if (v === null || v === undefined)
|
|
575
|
+
return "—";
|
|
576
|
+
if (Array.isArray(v)) {
|
|
577
|
+
if (v.length === 0)
|
|
578
|
+
return "(empty)";
|
|
579
|
+
const inner = v
|
|
580
|
+
.slice(0, 5)
|
|
581
|
+
.map((item) => {
|
|
582
|
+
const o = asObj(item);
|
|
583
|
+
if (o)
|
|
584
|
+
return s(o.name ?? o.identifier ?? o.id);
|
|
585
|
+
return s(item);
|
|
586
|
+
})
|
|
587
|
+
.join(", ");
|
|
588
|
+
return v.length > 5 ? `${inner}, … (${v.length} total)` : inner;
|
|
589
|
+
}
|
|
590
|
+
const o = asObj(v);
|
|
591
|
+
if (o)
|
|
592
|
+
return s(o.name ?? o.displayName ?? o.identifier ?? o.id ?? "{…}");
|
|
593
|
+
if (typeof v === "string" && v.length > 80) {
|
|
594
|
+
return `${v.slice(0, 80)}…`;
|
|
595
|
+
}
|
|
596
|
+
return s(v);
|
|
597
|
+
}
|
|
598
|
+
/**
|
|
599
|
+
* Heuristic — used by the central `outputSuccess` path which doesn't know
|
|
600
|
+
* which command produced the payload. Looks at the shape of the data to
|
|
601
|
+
* pick a formatter.
|
|
602
|
+
*
|
|
603
|
+
* Single-resource detection: uses signature fields (e.g. `identifier` +
|
|
604
|
+
* `state` for issues, `name` + `progress` for projects). When a payload
|
|
605
|
+
* doesn't match any known shape we fall through to `formatGenericSummary`.
|
|
606
|
+
*/
|
|
607
|
+
export function inferKindFromPayload(value) {
|
|
608
|
+
if (Array.isArray(value)) {
|
|
609
|
+
return inferListKind(value);
|
|
610
|
+
}
|
|
611
|
+
const obj = asObj(value);
|
|
612
|
+
if (!obj)
|
|
613
|
+
return "generic";
|
|
614
|
+
// list envelope: { data: [...], meta: {...} }
|
|
615
|
+
if (Array.isArray(obj.data)) {
|
|
616
|
+
return inferListKind(obj.data);
|
|
617
|
+
}
|
|
618
|
+
// single resource
|
|
619
|
+
if ("identifier" in obj && "title" in obj && "url" in obj)
|
|
620
|
+
return "issue";
|
|
621
|
+
if ("name" in obj &&
|
|
622
|
+
"progress" in obj &&
|
|
623
|
+
("state" in obj || "lead" in obj || "teams" in obj))
|
|
624
|
+
return "project";
|
|
625
|
+
if ("body" in obj && "createdAt" in obj && "user" in obj)
|
|
626
|
+
return "comment";
|
|
627
|
+
if (("number" in obj || "isActive" in obj) &&
|
|
628
|
+
("startsAt" in obj || "endsAt" in obj || "issues" in obj))
|
|
629
|
+
return "cycle";
|
|
630
|
+
if ("title" in obj && "content" in obj && "slugId" in obj)
|
|
631
|
+
return "document";
|
|
632
|
+
if ("name" in obj && "templateData" in obj)
|
|
633
|
+
return "template";
|
|
634
|
+
if ("name" in obj &&
|
|
635
|
+
"stage" in obj &&
|
|
636
|
+
("version" in obj || "pipeline" in obj))
|
|
637
|
+
return "release";
|
|
638
|
+
if ("targetDate" in obj && "name" in obj && !("teams" in obj))
|
|
639
|
+
return "milestone";
|
|
640
|
+
if ("displayName" in obj && "email" in obj)
|
|
641
|
+
return "user";
|
|
642
|
+
return "generic";
|
|
643
|
+
}
|
|
644
|
+
function inferListKind(items) {
|
|
645
|
+
if (items.length === 0) {
|
|
646
|
+
// Empty list — render a generic "(no results)" marker. We avoid
|
|
647
|
+
// falling through to "generic" because that renders envelope
|
|
648
|
+
// keys (data, meta) rather than treating the payload as the
|
|
649
|
+
// empty list it is.
|
|
650
|
+
return "empty-list";
|
|
651
|
+
}
|
|
652
|
+
const sample = asObj(items[0]) ?? {};
|
|
653
|
+
if ("identifier" in sample && "title" in sample) {
|
|
654
|
+
// could be issue or search result
|
|
655
|
+
if ("type" in sample && !("templateData" in sample)) {
|
|
656
|
+
return "search-result-list";
|
|
657
|
+
}
|
|
658
|
+
return "issue-list";
|
|
659
|
+
}
|
|
660
|
+
if ("body" in sample && "createdAt" in sample)
|
|
661
|
+
return "comment-list";
|
|
662
|
+
if ("progress" in sample && "name" in sample) {
|
|
663
|
+
if ("startsAt" in sample || "endsAt" in sample)
|
|
664
|
+
return "cycle-list";
|
|
665
|
+
return "project-list";
|
|
666
|
+
}
|
|
667
|
+
if ("title" in sample && ("content" in sample || "slugId" in sample))
|
|
668
|
+
return "document-list";
|
|
669
|
+
// templates always have type + name; some include team / templateData
|
|
670
|
+
if ("name" in sample &&
|
|
671
|
+
"type" in sample &&
|
|
672
|
+
("templateData" in sample || "creator" in sample))
|
|
673
|
+
return "template-list";
|
|
674
|
+
// releases: name + stage, typically with version or pipeline
|
|
675
|
+
if ("name" in sample &&
|
|
676
|
+
"stage" in sample &&
|
|
677
|
+
("version" in sample || "pipeline" in sample))
|
|
678
|
+
return "release-list";
|
|
679
|
+
if ("title" in sample && "url" in sample && !("identifier" in sample))
|
|
680
|
+
return "attachment-list";
|
|
681
|
+
if ("targetDate" in sample && "name" in sample)
|
|
682
|
+
return "milestone-list";
|
|
683
|
+
if ("key" in sample && "name" in sample)
|
|
684
|
+
return "team-list";
|
|
685
|
+
if ("color" in sample && "scope" in sample)
|
|
686
|
+
return "label-list";
|
|
687
|
+
if ("displayName" in sample && "email" in sample)
|
|
688
|
+
return "user-list";
|
|
689
|
+
return "generic";
|
|
690
|
+
}
|
|
691
|
+
/**
|
|
692
|
+
* Central dispatch: given a resource kind and payload, return the
|
|
693
|
+
* formatted string. The payload for list kinds may be either the raw
|
|
694
|
+
* array or a `{ data: [...] }` envelope — both are handled.
|
|
695
|
+
*/
|
|
696
|
+
export function dispatch(kind, payload) {
|
|
697
|
+
const obj = asObj(payload);
|
|
698
|
+
const list = (() => {
|
|
699
|
+
if (Array.isArray(payload))
|
|
700
|
+
return payload;
|
|
701
|
+
if (obj && Array.isArray(obj.data))
|
|
702
|
+
return obj.data;
|
|
703
|
+
return null;
|
|
704
|
+
})();
|
|
705
|
+
switch (kind) {
|
|
706
|
+
case "issue":
|
|
707
|
+
return formatIssueSummary((obj ?? {}));
|
|
708
|
+
case "issue-list":
|
|
709
|
+
return formatIssueList(list ?? []);
|
|
710
|
+
case "project":
|
|
711
|
+
return formatProjectSummary((obj ?? {}));
|
|
712
|
+
case "project-list":
|
|
713
|
+
return formatProjectList(list ?? []);
|
|
714
|
+
case "comment":
|
|
715
|
+
return formatCommentSummary((obj ?? {}));
|
|
716
|
+
case "comment-list":
|
|
717
|
+
return formatCommentList(list ?? []);
|
|
718
|
+
case "cycle":
|
|
719
|
+
return formatCycleSummary((obj ?? {}));
|
|
720
|
+
case "cycle-list":
|
|
721
|
+
return formatCycleList(list ?? []);
|
|
722
|
+
case "milestone":
|
|
723
|
+
return formatMilestoneSummary((obj ?? {}));
|
|
724
|
+
case "milestone-list":
|
|
725
|
+
return formatMilestoneList(list ?? []);
|
|
726
|
+
case "team-list":
|
|
727
|
+
return formatTeamList(list ?? []);
|
|
728
|
+
case "label-list":
|
|
729
|
+
return formatLabelList(list ?? []);
|
|
730
|
+
case "user":
|
|
731
|
+
return formatUserSummary((obj ?? {}));
|
|
732
|
+
case "user-list":
|
|
733
|
+
return formatUserList(list ?? []);
|
|
734
|
+
case "document":
|
|
735
|
+
return formatDocumentSummary((obj ?? {}));
|
|
736
|
+
case "document-list":
|
|
737
|
+
return formatDocumentList(list ?? []);
|
|
738
|
+
case "template":
|
|
739
|
+
return formatTemplateSummary((obj ?? {}));
|
|
740
|
+
case "template-list":
|
|
741
|
+
return formatTemplateList(list ?? []);
|
|
742
|
+
case "attachment-list":
|
|
743
|
+
return formatAttachmentList(list ?? []);
|
|
744
|
+
case "release":
|
|
745
|
+
return formatReleaseSummary((obj ?? {}));
|
|
746
|
+
case "release-list":
|
|
747
|
+
return formatReleaseList(list ?? []);
|
|
748
|
+
case "search-result-list":
|
|
749
|
+
return formatSearchResultList(list ?? []);
|
|
750
|
+
case "empty-list":
|
|
751
|
+
return "(no results)";
|
|
752
|
+
case "generic":
|
|
753
|
+
return formatGenericSummary(payload);
|
|
754
|
+
}
|
|
755
|
+
}
|