@alasano/pi-linear 0.1.1 → 0.3.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 +16 -2
- package/assets/linear_list_issues.png +0 -0
- package/assets/screenshot.png +0 -0
- package/extensions/params.ts +40 -1
- package/extensions/renderers/comments.ts +323 -0
- package/extensions/renderers/common.ts +305 -0
- package/extensions/renderers/documents.ts +326 -0
- package/extensions/renderers/initiatives.ts +344 -0
- package/extensions/renderers/issue-labels.ts +294 -0
- package/extensions/renderers/issue-relations.ts +318 -0
- package/extensions/renderers/issue-statuses.ts +199 -0
- package/extensions/renderers/issues.ts +373 -0
- package/extensions/renderers/milestones.ts +294 -0
- package/extensions/renderers/project-labels.ts +279 -0
- package/extensions/renderers/project-relations.ts +344 -0
- package/extensions/renderers/projects.ts +437 -0
- package/extensions/renderers/state.ts +35 -0
- package/extensions/renderers/teams.ts +246 -0
- package/extensions/renderers/users.ts +242 -0
- package/extensions/renderers/workspaces.ts +44 -0
- package/extensions/selections.ts +10 -3
- package/extensions/settings.ts +40 -7
- package/extensions/tools/comments.ts +30 -11
- package/extensions/tools/documents.ts +42 -11
- package/extensions/tools/initiatives.ts +43 -11
- package/extensions/tools/issue-labels.ts +36 -11
- package/extensions/tools/issue-relations.ts +32 -13
- package/extensions/tools/issue-statuses.ts +19 -11
- package/extensions/tools/issues.ts +53 -19
- package/extensions/tools/milestones.ts +31 -11
- package/extensions/tools/project-labels.ts +30 -11
- package/extensions/tools/project-relations.ts +32 -13
- package/extensions/tools/projects.ts +48 -16
- package/extensions/tools/teams.ts +23 -11
- package/extensions/tools/users.ts +23 -11
- package/extensions/tools/workspaces.ts +6 -0
- package/extensions/types.ts +12 -0
- package/package.json +1 -1
|
@@ -0,0 +1,373 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type AgentToolResult,
|
|
3
|
+
type Theme,
|
|
4
|
+
type ToolRenderResultOptions,
|
|
5
|
+
} from '@mariozechner/pi-coding-agent';
|
|
6
|
+
import { Text } from '@mariozechner/pi-tui';
|
|
7
|
+
import type { LinearIssue } from '../types';
|
|
8
|
+
import {
|
|
9
|
+
accentStyle,
|
|
10
|
+
asString,
|
|
11
|
+
cleanOneLine,
|
|
12
|
+
dimStyle,
|
|
13
|
+
expandedJson,
|
|
14
|
+
shouldShowJson,
|
|
15
|
+
jsonHint,
|
|
16
|
+
LinearListResultComponent,
|
|
17
|
+
mutedStyle,
|
|
18
|
+
renderLinearToolCall,
|
|
19
|
+
renderResponsiveTable,
|
|
20
|
+
toolOutputStyle,
|
|
21
|
+
truncate,
|
|
22
|
+
truncateLine,
|
|
23
|
+
type LinearToolRenderContext,
|
|
24
|
+
type TableColumn,
|
|
25
|
+
type ToolArgs,
|
|
26
|
+
} from './common';
|
|
27
|
+
|
|
28
|
+
type IssueLike = LinearIssue & {
|
|
29
|
+
archivedAt?: string | null;
|
|
30
|
+
completedAt?: string | null;
|
|
31
|
+
estimate?: number | null;
|
|
32
|
+
labels?: { nodes?: Array<{ id?: string; name?: string | null }> } | null;
|
|
33
|
+
priorityLabel?: string | null;
|
|
34
|
+
project?: { id?: string; name?: string | null } | null;
|
|
35
|
+
startedAt?: string | null;
|
|
36
|
+
trashed?: boolean | null;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
type IssueResultDetails = {
|
|
40
|
+
issue?: LinearIssue | null;
|
|
41
|
+
issues?: LinearIssue[];
|
|
42
|
+
success?: boolean;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const ISSUE_LIST_PREVIEW_LIMIT = 20;
|
|
46
|
+
const TITLE_LIMIT = 90;
|
|
47
|
+
const DESCRIPTION_LIMIT = 180;
|
|
48
|
+
const TABLE_TITLE_MIN_WIDTH = 24;
|
|
49
|
+
|
|
50
|
+
function issueDetails(result: AgentToolResult<any>): IssueResultDetails {
|
|
51
|
+
return (result.details ?? {}) as IssueResultDetails;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function issueId(issue: IssueLike): string {
|
|
55
|
+
return asString(issue.identifier) ?? asString(issue.id) ?? 'issue';
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function issueTitle(issue: IssueLike): string {
|
|
59
|
+
return truncate(cleanOneLine(asString(issue.title) ?? '(untitled)'), TITLE_LIMIT);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function priorityText(issue: IssueLike): string | undefined {
|
|
63
|
+
const label = asString(issue.priorityLabel);
|
|
64
|
+
if (label) return label;
|
|
65
|
+
if (typeof issue.priority !== 'number' || issue.priority <= 0) return undefined;
|
|
66
|
+
return `P${issue.priority}`;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function labelNames(issue: IssueLike): string[] {
|
|
70
|
+
const nodes = Array.isArray(issue.labels?.nodes) ? issue.labels.nodes : [];
|
|
71
|
+
return nodes.map((label) => asString(label.name)).filter((label): label is string => !!label);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function labelText(issue: IssueLike, limit = 3): string | undefined {
|
|
75
|
+
const labels = labelNames(issue);
|
|
76
|
+
if (labels.length === 0) return undefined;
|
|
77
|
+
|
|
78
|
+
const shown = labels.slice(0, limit).join(', ');
|
|
79
|
+
const hiddenCount = labels.length - limit;
|
|
80
|
+
return hiddenCount > 0 ? `${shown}, +${hiddenCount}` : shown;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function allLabelText(issue: IssueLike): string {
|
|
84
|
+
return labelNames(issue).join(', ') || '—';
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function metadataParts(
|
|
88
|
+
issue: IssueLike,
|
|
89
|
+
options: { includeProject?: boolean; includeDueDate?: boolean } = {},
|
|
90
|
+
) {
|
|
91
|
+
const state = asString(issue.state?.name);
|
|
92
|
+
const priority = priorityText(issue);
|
|
93
|
+
const assignee = asString(issue.assignee?.name);
|
|
94
|
+
const labels = labelText(issue);
|
|
95
|
+
const project = options.includeProject ? asString(issue.project?.name) : undefined;
|
|
96
|
+
const dueDate = options.includeDueDate ? asString(issue.dueDate) : undefined;
|
|
97
|
+
|
|
98
|
+
return [
|
|
99
|
+
state,
|
|
100
|
+
priority,
|
|
101
|
+
assignee ? `@${assignee}` : undefined,
|
|
102
|
+
labels,
|
|
103
|
+
project ? `project: ${project}` : undefined,
|
|
104
|
+
dueDate ? `due ${dueDate}` : undefined,
|
|
105
|
+
].filter((part): part is string => !!part);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function formatIssueListLine(issue: IssueLike, theme: Theme, width: number): string {
|
|
109
|
+
const id = issueId(issue).padEnd(9);
|
|
110
|
+
const title = issueTitle(issue);
|
|
111
|
+
const metadata = metadataParts(issue);
|
|
112
|
+
const suffix = metadata.length ? theme.fg('dim', ` · ${metadata.join(' · ')}`) : '';
|
|
113
|
+
|
|
114
|
+
return truncateLine(
|
|
115
|
+
` ${theme.fg('accent', id)} ${theme.fg('toolOutput', title)}${suffix}`,
|
|
116
|
+
width,
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function formatIssueTitle(issue: IssueLike, theme: Theme): string {
|
|
121
|
+
return `${theme.fg('accent', issueId(issue))} ${theme.fg('toolOutput', issueTitle(issue))}`;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function descriptionSnippet(issue: IssueLike): string | undefined {
|
|
125
|
+
const description = asString(issue.description);
|
|
126
|
+
if (!description) return undefined;
|
|
127
|
+
return truncate(cleanOneLine(description), DESCRIPTION_LIMIT);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function priorityStyle(theme: Theme, value: string): (text: string) => string {
|
|
131
|
+
const normalized = value.toLowerCase();
|
|
132
|
+
if (normalized === 'urgent') return (text) => theme.fg('error', text);
|
|
133
|
+
if (normalized === 'high') return (text) => theme.fg('warning', text);
|
|
134
|
+
if (normalized === 'low' || normalized === 'no priority' || value === '—') {
|
|
135
|
+
return dimStyle(theme);
|
|
136
|
+
}
|
|
137
|
+
return mutedStyle(theme);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function statusStyle(theme: Theme, value: string): (text: string) => string {
|
|
141
|
+
const normalized = value.toLowerCase();
|
|
142
|
+
if (normalized === 'done' || normalized === 'completed')
|
|
143
|
+
return (text) => theme.fg('success', text);
|
|
144
|
+
if (normalized === 'backlog' || value === '—') return dimStyle(theme);
|
|
145
|
+
return mutedStyle(theme);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const ISSUE_TABLE_COLUMNS: TableColumn<IssueLike>[] = [
|
|
149
|
+
{
|
|
150
|
+
id: 'id',
|
|
151
|
+
label: 'ID',
|
|
152
|
+
width: 8,
|
|
153
|
+
value: issueId,
|
|
154
|
+
style: (theme) => accentStyle(theme),
|
|
155
|
+
},
|
|
156
|
+
{
|
|
157
|
+
id: 'state',
|
|
158
|
+
label: 'Status',
|
|
159
|
+
width: 12,
|
|
160
|
+
value: (issue) => asString(issue.state?.name) ?? '—',
|
|
161
|
+
style: statusStyle,
|
|
162
|
+
},
|
|
163
|
+
{
|
|
164
|
+
id: 'priority',
|
|
165
|
+
label: 'Priority',
|
|
166
|
+
width: 11,
|
|
167
|
+
value: (issue) => priorityText(issue) ?? '—',
|
|
168
|
+
style: priorityStyle,
|
|
169
|
+
},
|
|
170
|
+
{
|
|
171
|
+
id: 'assignee',
|
|
172
|
+
label: 'Assignee',
|
|
173
|
+
width: 16,
|
|
174
|
+
value: (issue) => asString(issue.assignee?.name) ?? '—',
|
|
175
|
+
style: (theme) => mutedStyle(theme),
|
|
176
|
+
},
|
|
177
|
+
{
|
|
178
|
+
id: 'labels',
|
|
179
|
+
label: 'Labels',
|
|
180
|
+
width: 24,
|
|
181
|
+
value: allLabelText,
|
|
182
|
+
style: (theme) => dimStyle(theme),
|
|
183
|
+
},
|
|
184
|
+
];
|
|
185
|
+
|
|
186
|
+
function renderIssueTable(issues: IssueLike[], theme: Theme, width: number): string[] {
|
|
187
|
+
return renderResponsiveTable(issues, theme, width, {
|
|
188
|
+
columns: ISSUE_TABLE_COLUMNS,
|
|
189
|
+
primary: {
|
|
190
|
+
label: 'Title',
|
|
191
|
+
minWidth: TABLE_TITLE_MIN_WIDTH,
|
|
192
|
+
value: issueTitle,
|
|
193
|
+
style: (theme) => toolOutputStyle(theme),
|
|
194
|
+
},
|
|
195
|
+
dropOrder: ['labels', 'assignee', 'priority', 'state'],
|
|
196
|
+
fallback: formatIssueListLine,
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export function renderLinearIssueListCall(args: ToolArgs | undefined, theme: Theme): Text {
|
|
201
|
+
return renderLinearToolCall('linear_list_issues', args, theme, [
|
|
202
|
+
['query', 'query'],
|
|
203
|
+
['teamKey', 'team'],
|
|
204
|
+
['teamId', 'teamId'],
|
|
205
|
+
['stateName', 'state'],
|
|
206
|
+
['assigneeId', 'assignee'],
|
|
207
|
+
['first', 'first'],
|
|
208
|
+
['last', 'last'],
|
|
209
|
+
['orderBy', 'order'],
|
|
210
|
+
['includeArchived', 'archived'],
|
|
211
|
+
['filter', 'filter'],
|
|
212
|
+
['sort', 'sort'],
|
|
213
|
+
]);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
export function renderLinearIssueSearchCall(args: ToolArgs | undefined, theme: Theme): Text {
|
|
217
|
+
return renderLinearToolCall('linear_search_issues', args, theme, [
|
|
218
|
+
['term', 'search'],
|
|
219
|
+
['includeComments', 'comments'],
|
|
220
|
+
['teamId', 'teamId'],
|
|
221
|
+
['first', 'first'],
|
|
222
|
+
['last', 'last'],
|
|
223
|
+
['orderBy', 'order'],
|
|
224
|
+
['includeArchived', 'archived'],
|
|
225
|
+
['filter', 'filter'],
|
|
226
|
+
]);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
export function renderLinearGetIssueCall(args: ToolArgs | undefined, theme: Theme): Text {
|
|
230
|
+
return renderLinearToolCall('linear_get_issue', args, theme, [['issue', 'issue']]);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
export function renderLinearCreateIssueCall(args: ToolArgs | undefined, theme: Theme): Text {
|
|
234
|
+
return renderLinearToolCall('linear_create_issue', args, theme, [
|
|
235
|
+
['title', 'title'],
|
|
236
|
+
['teamKey', 'team'],
|
|
237
|
+
['teamId', 'teamId'],
|
|
238
|
+
['stateId', 'state'],
|
|
239
|
+
['assigneeId', 'assignee'],
|
|
240
|
+
['priority', 'priority'],
|
|
241
|
+
['labelIds', 'labels'],
|
|
242
|
+
['projectId', 'projectId'],
|
|
243
|
+
['parentId', 'parentId'],
|
|
244
|
+
['dueDate', 'due'],
|
|
245
|
+
['input', 'input'],
|
|
246
|
+
]);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
export function renderLinearUpdateIssueCall(args: ToolArgs | undefined, theme: Theme): Text {
|
|
250
|
+
return renderLinearToolCall('linear_update_issue', args, theme, [
|
|
251
|
+
['issue', 'issue'],
|
|
252
|
+
['title', 'title'],
|
|
253
|
+
['stateId', 'state'],
|
|
254
|
+
['assigneeId', 'assignee'],
|
|
255
|
+
['priority', 'priority'],
|
|
256
|
+
['dueDate', 'due'],
|
|
257
|
+
['clearDueDate', 'clearDue'],
|
|
258
|
+
['labelIds', 'labels'],
|
|
259
|
+
['addedLabelIds', 'addLabels'],
|
|
260
|
+
['removedLabelIds', 'removeLabels'],
|
|
261
|
+
['projectId', 'projectId'],
|
|
262
|
+
['parentId', 'parentId'],
|
|
263
|
+
['input', 'input'],
|
|
264
|
+
]);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
export function renderLinearDeleteIssueCall(args: ToolArgs | undefined, theme: Theme): Text {
|
|
268
|
+
return renderLinearToolCall('linear_delete_issue', args, theme, [
|
|
269
|
+
['issue', 'issue'],
|
|
270
|
+
['permanentlyDelete', 'permanent'],
|
|
271
|
+
]);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
export function renderLinearArchiveIssueCall(args: ToolArgs | undefined, theme: Theme): Text {
|
|
275
|
+
return renderLinearToolCall('linear_archive_issue', args, theme, [
|
|
276
|
+
['issue', 'issue'],
|
|
277
|
+
['trash', 'trash'],
|
|
278
|
+
]);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
export function renderLinearUnarchiveIssueCall(args: ToolArgs | undefined, theme: Theme): Text {
|
|
282
|
+
return renderLinearToolCall('linear_unarchive_issue', args, theme, [['issue', 'issue']]);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
export function renderLinearIssueListResult(
|
|
286
|
+
result: AgentToolResult<any>,
|
|
287
|
+
options: ToolRenderResultOptions,
|
|
288
|
+
theme: Theme,
|
|
289
|
+
context: LinearToolRenderContext,
|
|
290
|
+
): Text | LinearListResultComponent<IssueLike> {
|
|
291
|
+
if (options.isPartial) return new Text(theme.fg('warning', 'Loading issues…'), 0, 0);
|
|
292
|
+
if (shouldShowJson(options, context)) return expandedJson(result, theme);
|
|
293
|
+
|
|
294
|
+
const issues = Array.isArray(issueDetails(result).issues)
|
|
295
|
+
? (issueDetails(result).issues as IssueLike[])
|
|
296
|
+
: [];
|
|
297
|
+
|
|
298
|
+
return new LinearListResultComponent(issues, theme, {
|
|
299
|
+
noun: 'issue',
|
|
300
|
+
emptyLabel: 'No issues found',
|
|
301
|
+
previewLimit: ISSUE_LIST_PREVIEW_LIMIT,
|
|
302
|
+
renderItems: renderIssueTable,
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
export function renderLinearIssueResult(actionLabel: string) {
|
|
307
|
+
return (
|
|
308
|
+
result: AgentToolResult<any>,
|
|
309
|
+
options: ToolRenderResultOptions,
|
|
310
|
+
theme: Theme,
|
|
311
|
+
context: LinearToolRenderContext,
|
|
312
|
+
): Text => {
|
|
313
|
+
if (options.isPartial) return new Text(theme.fg('warning', `${actionLabel}…`), 0, 0);
|
|
314
|
+
if (shouldShowJson(options, context)) return expandedJson(result, theme);
|
|
315
|
+
|
|
316
|
+
const issue = issueDetails(result).issue as IssueLike | null | undefined;
|
|
317
|
+
if (!issue) {
|
|
318
|
+
return new Text(`\n${theme.fg('dim', 'Issue not found')}\n\n${jsonHint()}`, 0, 0);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
const metadata = metadataParts(issue, { includeProject: true, includeDueDate: true });
|
|
322
|
+
const description = descriptionSnippet(issue);
|
|
323
|
+
|
|
324
|
+
let text = `\n${theme.fg('success', `✓ ${actionLabel}`)} ${formatIssueTitle(issue, theme)}`;
|
|
325
|
+
if (metadata.length) text += `\n ${theme.fg('dim', metadata.join(' · '))}`;
|
|
326
|
+
if (description) text += `\n ${theme.fg('muted', description)}`;
|
|
327
|
+
if (issue.url) text += `\n ${theme.fg('dim', issue.url)}`;
|
|
328
|
+
text += `\n\n${jsonHint()}`;
|
|
329
|
+
|
|
330
|
+
return new Text(text, 0, 0);
|
|
331
|
+
};
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
export function renderLinearIssueSuccessResult(defaultActionLabel: string) {
|
|
335
|
+
return (
|
|
336
|
+
result: AgentToolResult<any>,
|
|
337
|
+
options: ToolRenderResultOptions,
|
|
338
|
+
theme: Theme,
|
|
339
|
+
context: { args?: unknown },
|
|
340
|
+
): Text => {
|
|
341
|
+
if (options.isPartial)
|
|
342
|
+
return new Text(theme.fg('warning', `${defaultActionLabel} issue…`), 0, 0);
|
|
343
|
+
if (shouldShowJson(options, context)) return expandedJson(result, theme);
|
|
344
|
+
|
|
345
|
+
const details = issueDetails(result);
|
|
346
|
+
const args = (context.args ?? {}) as {
|
|
347
|
+
issue?: unknown;
|
|
348
|
+
permanentlyDelete?: unknown;
|
|
349
|
+
trash?: unknown;
|
|
350
|
+
};
|
|
351
|
+
const issue = asString(args.issue) ?? 'issue';
|
|
352
|
+
const actionLabel =
|
|
353
|
+
defaultActionLabel === 'Deleted' && args.permanentlyDelete === true
|
|
354
|
+
? 'Permanently deleted'
|
|
355
|
+
: defaultActionLabel === 'Archived' && args.trash === true
|
|
356
|
+
? 'Trashed'
|
|
357
|
+
: defaultActionLabel;
|
|
358
|
+
|
|
359
|
+
if (details.success !== true) {
|
|
360
|
+
return new Text(
|
|
361
|
+
`\n${theme.fg('warning', `${actionLabel} status unknown`)}\n\n${jsonHint()}`,
|
|
362
|
+
0,
|
|
363
|
+
0,
|
|
364
|
+
);
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
return new Text(
|
|
368
|
+
`\n${theme.fg('success', `✓ ${actionLabel}`)} ${theme.fg('accent', issue)}\n\n${jsonHint()}`,
|
|
369
|
+
0,
|
|
370
|
+
0,
|
|
371
|
+
);
|
|
372
|
+
};
|
|
373
|
+
}
|
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type AgentToolResult,
|
|
3
|
+
type Theme,
|
|
4
|
+
type ToolRenderResultOptions,
|
|
5
|
+
} from '@mariozechner/pi-coding-agent';
|
|
6
|
+
import { Text } from '@mariozechner/pi-tui';
|
|
7
|
+
import {
|
|
8
|
+
accentStyle,
|
|
9
|
+
asString,
|
|
10
|
+
cleanOneLine,
|
|
11
|
+
dimStyle,
|
|
12
|
+
expandedJson,
|
|
13
|
+
shouldShowJson,
|
|
14
|
+
jsonHint,
|
|
15
|
+
LinearListResultComponent,
|
|
16
|
+
mutedStyle,
|
|
17
|
+
renderLinearToolCall,
|
|
18
|
+
renderResponsiveTable,
|
|
19
|
+
toolOutputStyle,
|
|
20
|
+
truncate,
|
|
21
|
+
truncateLine,
|
|
22
|
+
type LinearToolRenderContext,
|
|
23
|
+
type TableColumn,
|
|
24
|
+
type ToolArgs,
|
|
25
|
+
} from './common';
|
|
26
|
+
|
|
27
|
+
type MilestoneLike = {
|
|
28
|
+
id?: string | null;
|
|
29
|
+
name?: string | null;
|
|
30
|
+
description?: string | null;
|
|
31
|
+
status?: string | null;
|
|
32
|
+
progress?: number | null;
|
|
33
|
+
targetDate?: string | null;
|
|
34
|
+
sortOrder?: number | null;
|
|
35
|
+
createdAt?: string | null;
|
|
36
|
+
updatedAt?: string | null;
|
|
37
|
+
project?: {
|
|
38
|
+
id?: string | null;
|
|
39
|
+
name?: string | null;
|
|
40
|
+
url?: string | null;
|
|
41
|
+
} | null;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
type MilestoneResultDetails = {
|
|
45
|
+
milestone?: MilestoneLike | null;
|
|
46
|
+
milestones?: MilestoneLike[];
|
|
47
|
+
success?: boolean;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const MILESTONE_LIST_PREVIEW_LIMIT = 20;
|
|
51
|
+
const NAME_LIMIT = 90;
|
|
52
|
+
const DESCRIPTION_LIMIT = 180;
|
|
53
|
+
const TABLE_NAME_MIN_WIDTH = 24;
|
|
54
|
+
|
|
55
|
+
function milestoneDetails(result: AgentToolResult<any>): MilestoneResultDetails {
|
|
56
|
+
return (result.details ?? {}) as MilestoneResultDetails;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function milestoneName(milestone: MilestoneLike): string {
|
|
60
|
+
return truncate(cleanOneLine(asString(milestone.name) ?? '(unnamed milestone)'), NAME_LIMIT);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function milestoneProject(milestone: MilestoneLike): string | undefined {
|
|
64
|
+
return asString(milestone.project?.name) ?? asString(milestone.project?.id);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function milestoneStatus(milestone: MilestoneLike): string | undefined {
|
|
68
|
+
return asString(milestone.status);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function milestoneTarget(milestone: MilestoneLike): string | undefined {
|
|
72
|
+
return asString(milestone.targetDate);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function milestoneProgress(milestone: MilestoneLike): string | undefined {
|
|
76
|
+
const progress = milestone.progress;
|
|
77
|
+
if (typeof progress !== 'number' || !Number.isFinite(progress)) return undefined;
|
|
78
|
+
|
|
79
|
+
const percent = progress >= 0 && progress <= 1 ? progress * 100 : progress;
|
|
80
|
+
return `${Math.round(percent)}%`;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function metadataParts(milestone: MilestoneLike): string[] {
|
|
84
|
+
const project = milestoneProject(milestone);
|
|
85
|
+
const status = milestoneStatus(milestone);
|
|
86
|
+
const progress = milestoneProgress(milestone);
|
|
87
|
+
const target = milestoneTarget(milestone);
|
|
88
|
+
|
|
89
|
+
return [
|
|
90
|
+
project ? `project: ${project}` : undefined,
|
|
91
|
+
status ? `status: ${status}` : undefined,
|
|
92
|
+
progress ? `progress: ${progress}` : undefined,
|
|
93
|
+
target ? `target: ${target}` : undefined,
|
|
94
|
+
].filter((part): part is string => !!part);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function descriptionSnippet(milestone: MilestoneLike): string | undefined {
|
|
98
|
+
const description = asString(milestone.description);
|
|
99
|
+
if (!description) return undefined;
|
|
100
|
+
return truncate(cleanOneLine(description), DESCRIPTION_LIMIT);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function formatMilestoneListLine(milestone: MilestoneLike, theme: Theme, width: number): string {
|
|
104
|
+
const name = milestoneName(milestone);
|
|
105
|
+
const metadata = metadataParts(milestone);
|
|
106
|
+
const suffix = metadata.length ? theme.fg('dim', ` · ${metadata.join(' · ')}`) : '';
|
|
107
|
+
|
|
108
|
+
return truncateLine(` ${theme.fg('toolOutput', name)}${suffix}`, width);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function formatMilestoneTitle(milestone: MilestoneLike, theme: Theme): string {
|
|
112
|
+
const id = asString(milestone.id);
|
|
113
|
+
const name = theme.fg('toolOutput', milestoneName(milestone));
|
|
114
|
+
return id ? `${name} ${theme.fg('dim', `(${truncate(id, 8)})`)}` : name;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function statusStyle(theme: Theme, value: string): (text: string) => string {
|
|
118
|
+
const normalized = value.toLowerCase();
|
|
119
|
+
if (normalized === 'completed' || normalized === 'done')
|
|
120
|
+
return (text) => theme.fg('success', text);
|
|
121
|
+
if (normalized === 'canceled' || normalized === 'cancelled')
|
|
122
|
+
return (text) => theme.fg('error', text);
|
|
123
|
+
if (normalized === 'planned' || value === '—') return dimStyle(theme);
|
|
124
|
+
return mutedStyle(theme);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const MILESTONE_TABLE_COLUMNS: TableColumn<MilestoneLike>[] = [
|
|
128
|
+
{
|
|
129
|
+
id: 'project',
|
|
130
|
+
label: 'Project',
|
|
131
|
+
width: 20,
|
|
132
|
+
value: (milestone) => milestoneProject(milestone) ?? '—',
|
|
133
|
+
style: (theme) => accentStyle(theme),
|
|
134
|
+
},
|
|
135
|
+
{
|
|
136
|
+
id: 'status',
|
|
137
|
+
label: 'Status',
|
|
138
|
+
width: 12,
|
|
139
|
+
value: (milestone) => milestoneStatus(milestone) ?? '—',
|
|
140
|
+
style: statusStyle,
|
|
141
|
+
},
|
|
142
|
+
{
|
|
143
|
+
id: 'progress',
|
|
144
|
+
label: 'Progress',
|
|
145
|
+
width: 9,
|
|
146
|
+
value: (milestone) => milestoneProgress(milestone) ?? '—',
|
|
147
|
+
style: (theme) => mutedStyle(theme),
|
|
148
|
+
},
|
|
149
|
+
{
|
|
150
|
+
id: 'target',
|
|
151
|
+
label: 'Target',
|
|
152
|
+
width: 12,
|
|
153
|
+
value: (milestone) => milestoneTarget(milestone) ?? '—',
|
|
154
|
+
style: (theme) => dimStyle(theme),
|
|
155
|
+
},
|
|
156
|
+
];
|
|
157
|
+
|
|
158
|
+
function renderMilestoneTable(milestones: MilestoneLike[], theme: Theme, width: number): string[] {
|
|
159
|
+
return renderResponsiveTable(milestones, theme, width, {
|
|
160
|
+
columns: MILESTONE_TABLE_COLUMNS,
|
|
161
|
+
primary: {
|
|
162
|
+
label: 'Name',
|
|
163
|
+
minWidth: TABLE_NAME_MIN_WIDTH,
|
|
164
|
+
value: milestoneName,
|
|
165
|
+
style: (theme) => toolOutputStyle(theme),
|
|
166
|
+
},
|
|
167
|
+
dropOrder: ['progress', 'target', 'status', 'project'],
|
|
168
|
+
fallback: formatMilestoneListLine,
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function renderMilestoneCard(
|
|
173
|
+
result: AgentToolResult<any>,
|
|
174
|
+
options: ToolRenderResultOptions,
|
|
175
|
+
theme: Theme,
|
|
176
|
+
context: LinearToolRenderContext,
|
|
177
|
+
actionLabel: string,
|
|
178
|
+
): Text {
|
|
179
|
+
if (options.isPartial) return new Text(theme.fg('warning', `${actionLabel}…`), 0, 0);
|
|
180
|
+
if (shouldShowJson(options, context)) return expandedJson(result, theme);
|
|
181
|
+
|
|
182
|
+
const milestone = milestoneDetails(result).milestone;
|
|
183
|
+
if (!milestone) {
|
|
184
|
+
return new Text(`\n${theme.fg('dim', 'Milestone not found')}\n\n${jsonHint()}`, 0, 0);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const metadata = metadataParts(milestone);
|
|
188
|
+
const description = descriptionSnippet(milestone);
|
|
189
|
+
|
|
190
|
+
let text = `\n${theme.fg('success', `✓ ${actionLabel}`)} ${formatMilestoneTitle(milestone, theme)}`;
|
|
191
|
+
if (metadata.length) text += `\n ${theme.fg('dim', metadata.join(' · '))}`;
|
|
192
|
+
if (description) text += `\n ${theme.fg('muted', description)}`;
|
|
193
|
+
text += `\n\n${jsonHint()}`;
|
|
194
|
+
|
|
195
|
+
return new Text(text, 0, 0);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export function renderLinearMilestoneListCall(args: ToolArgs | undefined, theme: Theme): Text {
|
|
199
|
+
return renderLinearToolCall('linear_list_milestones', args, theme, [
|
|
200
|
+
['first', 'first'],
|
|
201
|
+
['last', 'last'],
|
|
202
|
+
['orderBy', 'order'],
|
|
203
|
+
['includeArchived', 'archived'],
|
|
204
|
+
['filter', 'filter'],
|
|
205
|
+
]);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export function renderLinearMilestoneGetCall(args: ToolArgs | undefined, theme: Theme): Text {
|
|
209
|
+
return renderLinearToolCall('linear_get_milestone', args, theme, [
|
|
210
|
+
['milestoneId', 'milestoneId'],
|
|
211
|
+
]);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
export function renderLinearMilestoneSaveCall(args: ToolArgs | undefined, theme: Theme): Text {
|
|
215
|
+
return renderLinearToolCall('linear_save_milestone', args, theme, [
|
|
216
|
+
['milestoneId', 'milestoneId'],
|
|
217
|
+
['name', 'name'],
|
|
218
|
+
['projectId', 'projectId'],
|
|
219
|
+
['targetDate', 'target'],
|
|
220
|
+
]);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
export function renderLinearMilestoneDeleteCall(args: ToolArgs | undefined, theme: Theme): Text {
|
|
224
|
+
return renderLinearToolCall('linear_delete_milestone', args, theme, [
|
|
225
|
+
['milestoneId', 'milestoneId'],
|
|
226
|
+
]);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
export function renderLinearMilestoneListResult(
|
|
230
|
+
result: AgentToolResult<any>,
|
|
231
|
+
options: ToolRenderResultOptions,
|
|
232
|
+
theme: Theme,
|
|
233
|
+
context: LinearToolRenderContext,
|
|
234
|
+
): Text | LinearListResultComponent<MilestoneLike> {
|
|
235
|
+
if (options.isPartial) return new Text(theme.fg('warning', 'Loading milestones…'), 0, 0);
|
|
236
|
+
if (shouldShowJson(options, context)) return expandedJson(result, theme);
|
|
237
|
+
|
|
238
|
+
const milestones = Array.isArray(milestoneDetails(result).milestones)
|
|
239
|
+
? (milestoneDetails(result).milestones as MilestoneLike[])
|
|
240
|
+
: [];
|
|
241
|
+
|
|
242
|
+
return new LinearListResultComponent(milestones, theme, {
|
|
243
|
+
noun: 'milestone',
|
|
244
|
+
emptyLabel: 'No milestones found',
|
|
245
|
+
previewLimit: MILESTONE_LIST_PREVIEW_LIMIT,
|
|
246
|
+
renderItems: renderMilestoneTable,
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
export function renderLinearMilestoneResult(actionLabel: string) {
|
|
251
|
+
return (
|
|
252
|
+
result: AgentToolResult<any>,
|
|
253
|
+
options: ToolRenderResultOptions,
|
|
254
|
+
theme: Theme,
|
|
255
|
+
context: LinearToolRenderContext,
|
|
256
|
+
): Text => renderMilestoneCard(result, options, theme, context, actionLabel);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
export function renderLinearMilestoneSaveResult() {
|
|
260
|
+
return (
|
|
261
|
+
result: AgentToolResult<any>,
|
|
262
|
+
options: ToolRenderResultOptions,
|
|
263
|
+
theme: Theme,
|
|
264
|
+
context: { args?: unknown },
|
|
265
|
+
): Text => {
|
|
266
|
+
const args = (context.args ?? {}) as { milestoneId?: unknown };
|
|
267
|
+
const actionLabel = asString(args.milestoneId) ? 'Updated milestone' : 'Created milestone';
|
|
268
|
+
return renderMilestoneCard(result, options, theme, context, actionLabel);
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
export function renderLinearMilestoneDeleteResult(
|
|
273
|
+
result: AgentToolResult<any>,
|
|
274
|
+
options: ToolRenderResultOptions,
|
|
275
|
+
theme: Theme,
|
|
276
|
+
context: { args?: unknown },
|
|
277
|
+
): Text {
|
|
278
|
+
if (options.isPartial) return new Text(theme.fg('warning', 'Deleting milestone…'), 0, 0);
|
|
279
|
+
if (shouldShowJson(options, context)) return expandedJson(result, theme);
|
|
280
|
+
|
|
281
|
+
const details = milestoneDetails(result);
|
|
282
|
+
const args = (context.args ?? {}) as { milestoneId?: unknown };
|
|
283
|
+
const milestoneId = asString(args.milestoneId) ?? 'milestone';
|
|
284
|
+
|
|
285
|
+
if (details.success !== true) {
|
|
286
|
+
return new Text(`\n${theme.fg('warning', 'Delete status unknown')}\n\n${jsonHint()}`, 0, 0);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
return new Text(
|
|
290
|
+
`\n${theme.fg('success', '✓ Deleted milestone')} ${theme.fg('accent', milestoneId)}\n\n${jsonHint()}`,
|
|
291
|
+
0,
|
|
292
|
+
0,
|
|
293
|
+
);
|
|
294
|
+
}
|