@yannelli/paseo-linear-plugin 0.1.0 → 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/NOTICE +8 -4
- package/OVERVIEW.md +22 -13
- package/README.md +134 -29
- package/client/agent-options.ts +200 -0
- package/client/breadcrumbs.tsx +110 -0
- package/client/browser.tsx +275 -0
- package/client/compat.ts +107 -0
- package/client/connect.tsx +136 -0
- package/client/create-issue.tsx +291 -0
- package/client/glyphs.tsx +117 -0
- package/client/issue-detail.tsx +366 -0
- package/client/issue-list.tsx +373 -0
- package/client/issue-row.tsx +152 -0
- package/client/issue-sections.tsx +325 -0
- package/client/issue-tree.ts +132 -0
- package/client/key-scope.tsx +23 -0
- package/client/launch-choices.ts +125 -0
- package/client/launch-fields.tsx +152 -0
- package/client/launch-plan.ts +227 -0
- package/client/launch.tsx +484 -0
- package/client/markdown.tsx +240 -0
- package/client/model-browser.tsx +296 -0
- package/client/pickers.tsx +238 -0
- package/client/provider-icon.tsx +78 -0
- package/client/queries.ts +211 -0
- package/client/settings-connection.tsx +109 -0
- package/client/settings-fields.tsx +173 -0
- package/client/settings-keys.tsx +117 -0
- package/client/settings-projects.tsx +281 -0
- package/client/settings-prompts.tsx +198 -0
- package/client/store.ts +71 -0
- package/client/timeline-card.tsx +63 -0
- package/client/ui.tsx +352 -0
- package/index.client.tsx +111 -0
- package/index.server.ts +7 -3
- package/package.json +4 -3
- package/paseo-plugin.json +2 -2
- package/server/cache.ts +68 -0
- package/server/credentials.ts +101 -0
- package/server/graphql.ts +68 -0
- package/server/handlers.ts +172 -0
- package/server/linear.ts +11 -64
- package/server/queries.ts +280 -0
- package/shared/linear.ts +268 -0
- package/shared/markdown.ts +416 -0
- package/shared/prompts.ts +150 -0
- package/shared/settings.ts +68 -0
- package/index.client.ts +0 -7
- package/server/issues.ts +0 -10
|
@@ -0,0 +1,416 @@
|
|
|
1
|
+
// Small parser for Linear-flavored markdown. Pure and runtime-neutral so the client
|
|
2
|
+
// renderer and the tests share it. Unknown syntax falls back to text; it never throws.
|
|
3
|
+
|
|
4
|
+
export type MarkdownText = { type: "text"; text: string };
|
|
5
|
+
export type MarkdownCode = { type: "code"; text: string };
|
|
6
|
+
export type MarkdownSpan = { type: "strong" | "emphasis" | "strike"; children: MarkdownInline[] };
|
|
7
|
+
export type MarkdownLink = { type: "link"; url: string; children: MarkdownInline[] };
|
|
8
|
+
export type MarkdownBreak = { type: "break" };
|
|
9
|
+
export type MarkdownInline =
|
|
10
|
+
| MarkdownText
|
|
11
|
+
| MarkdownCode
|
|
12
|
+
| MarkdownSpan
|
|
13
|
+
| MarkdownLink
|
|
14
|
+
| MarkdownBreak;
|
|
15
|
+
|
|
16
|
+
export type MarkdownHeadingLevel = 1 | 2 | 3 | 4 | 5 | 6;
|
|
17
|
+
export type MarkdownAlign = "left" | "center" | "right" | null;
|
|
18
|
+
export type MarkdownTableRow = MarkdownInline[][];
|
|
19
|
+
/** `checked` is null for a plain item, true or false for a task item. */
|
|
20
|
+
export type MarkdownListItem = { checked: boolean | null; children: MarkdownBlock[] };
|
|
21
|
+
|
|
22
|
+
export type MarkdownBlock =
|
|
23
|
+
| { type: "heading"; level: MarkdownHeadingLevel; children: MarkdownInline[] }
|
|
24
|
+
| { type: "paragraph"; children: MarkdownInline[] }
|
|
25
|
+
| { type: "list"; ordered: boolean; start: number; items: MarkdownListItem[] }
|
|
26
|
+
| { type: "code"; language: string | null; text: string }
|
|
27
|
+
| { type: "blockquote"; children: MarkdownBlock[] }
|
|
28
|
+
| { type: "rule" }
|
|
29
|
+
| { type: "table"; align: MarkdownAlign[]; header: MarkdownTableRow; rows: MarkdownTableRow[] };
|
|
30
|
+
|
|
31
|
+
const MAX_DEPTH = 12;
|
|
32
|
+
const HEADING = /^ {0,3}(#{1,6})(?:[ \t]+(.*?))?(?:[ \t]+#+)?[ \t]*$/;
|
|
33
|
+
const RULE = /^ {0,3}([-*_])(?:[ \t]*\1){2,}[ \t]*$/;
|
|
34
|
+
const FENCE = /^( {0,3})(`{3,}|~{3,})(.*)$/;
|
|
35
|
+
const QUOTE = /^ {0,3}> ?/;
|
|
36
|
+
const LIST_ITEM = /^([ \t]*)([-*+]|\d{1,9}[.)])(?:([ \t]+)(.*))?$/;
|
|
37
|
+
const TASK = /^\[([ xX])\](?:[ \t]+|$)/;
|
|
38
|
+
const DIVIDER = /^[ \t]*\|?[ \t]*:?-+:?[ \t]*(?:\|[ \t]*:?-+:?[ \t]*)*\|?[ \t]*$/;
|
|
39
|
+
|
|
40
|
+
export function safeUrl(url: string): string | null {
|
|
41
|
+
const trimmed = url.trim();
|
|
42
|
+
return /^(?:https?:\/\/|mailto:)\S/i.test(trimmed) ? trimmed : null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function parseMarkdown(source: string): MarkdownBlock[] {
|
|
46
|
+
const text = typeof source === "string" ? source : "";
|
|
47
|
+
try {
|
|
48
|
+
return parseBlocks(text.replace(/\r\n?/g, "\n").split("\n"), 0);
|
|
49
|
+
} catch {
|
|
50
|
+
return text.trim() ? [{ type: "paragraph", children: [{ type: "text", text }] }] : [];
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function isBlank(line: string | undefined): boolean {
|
|
55
|
+
return line === undefined || /^[ \t]*$/.test(line);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Leading whitespace in columns (tabs stop at multiples of 4), measured up to `limit`.
|
|
59
|
+
function leading(line: string, limit = Infinity): { cols: number; chars: number } {
|
|
60
|
+
let cols = 0;
|
|
61
|
+
let chars = 0;
|
|
62
|
+
for (; chars < line.length && cols < limit; chars++) {
|
|
63
|
+
if (line[chars] === " ") cols += 1;
|
|
64
|
+
else if (line[chars] === "\t") cols += 4 - (cols % 4);
|
|
65
|
+
else break;
|
|
66
|
+
}
|
|
67
|
+
return { cols, chars };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const indentOf = (line: string) => leading(line).cols;
|
|
71
|
+
|
|
72
|
+
function dedent(line: string, cols: number): string {
|
|
73
|
+
const lead = leading(line, cols);
|
|
74
|
+
return " ".repeat(Math.max(0, lead.cols - cols)) + line.slice(lead.chars);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
type Fence = { indent: number; marker: string; language: string | null };
|
|
78
|
+
|
|
79
|
+
function openFence(line: string): Fence | null {
|
|
80
|
+
const match = FENCE.exec(line);
|
|
81
|
+
if (!match || (match[2][0] === "`" && match[3].includes("`"))) return null;
|
|
82
|
+
const language = match[3].trim().split(/\s+/)[0] || null;
|
|
83
|
+
return { indent: match[1].length, marker: match[2], language };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function closesFence(line: string, marker: string): boolean {
|
|
87
|
+
const run = /^ {0,3}(`+|~+)[ \t]*$/.exec(line)?.[1];
|
|
88
|
+
return run !== undefined && run[0] === marker[0] && run.length >= marker.length;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Escaped pipes become a placeholder so they survive the split, then turn back into "|".
|
|
92
|
+
function splitRow(line: string): string[] {
|
|
93
|
+
const row = line.trim().replace(/\\\|/g, "\u0000").replace(/^\|/, "").replace(/\|$/, "");
|
|
94
|
+
return row.split("|").map((cell) => cell.replace(/\u0000/g, "|").trim());
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function alignOf(cell: string): MarkdownAlign {
|
|
98
|
+
const left = cell.startsWith(":");
|
|
99
|
+
const right = cell.endsWith(":");
|
|
100
|
+
return left && right ? "center" : right ? "right" : left ? "left" : null;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function isTableStart(lines: string[], i: number): boolean {
|
|
104
|
+
const divider = lines[i + 1];
|
|
105
|
+
if (divider === undefined || !lines[i].includes("|") || !divider.includes("|")) return false;
|
|
106
|
+
return DIVIDER.test(divider) && splitRow(lines[i]).length === splitRow(divider).length;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// True when the line starts a block that may interrupt a paragraph. Lists only
|
|
110
|
+
// interrupt with a non-empty bullet or a list starting at 1, as in CommonMark.
|
|
111
|
+
function startsBlock(lines: string[], i: number): boolean {
|
|
112
|
+
const line = lines[i];
|
|
113
|
+
if (HEADING.test(line) || RULE.test(line) || QUOTE.test(line) || openFence(line)) return true;
|
|
114
|
+
if (isTableStart(lines, i)) return true;
|
|
115
|
+
const item = LIST_ITEM.exec(line);
|
|
116
|
+
if (!item?.[4]?.trim()) return false;
|
|
117
|
+
return !/\d/.test(item[2]) || parseInt(item[2], 10) === 1;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function parseBlocks(lines: string[], depth: number): MarkdownBlock[] {
|
|
121
|
+
const blocks: MarkdownBlock[] = [];
|
|
122
|
+
let i = 0;
|
|
123
|
+
while (i < lines.length) {
|
|
124
|
+
const line = lines[i];
|
|
125
|
+
const fence = openFence(line);
|
|
126
|
+
const heading = HEADING.exec(line);
|
|
127
|
+
if (isBlank(line)) i++;
|
|
128
|
+
else if (fence) i = parseFence(lines, i, fence, blocks);
|
|
129
|
+
else if (heading) {
|
|
130
|
+
const level = heading[1].length as MarkdownHeadingLevel;
|
|
131
|
+
blocks.push({ type: "heading", level, children: parseInline(heading[2] ?? "") });
|
|
132
|
+
i++;
|
|
133
|
+
} else if (RULE.test(line)) {
|
|
134
|
+
blocks.push({ type: "rule" });
|
|
135
|
+
i++;
|
|
136
|
+
} else if (depth < MAX_DEPTH && QUOTE.test(line)) i = parseQuote(lines, i, depth, blocks);
|
|
137
|
+
else if (depth < MAX_DEPTH && LIST_ITEM.test(line)) i = parseList(lines, i, depth, blocks);
|
|
138
|
+
else if (isTableStart(lines, i)) i = parseTable(lines, i, blocks);
|
|
139
|
+
else i = parseParagraph(lines, i, blocks);
|
|
140
|
+
}
|
|
141
|
+
return blocks;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function parseFence(lines: string[], start: number, fence: Fence, out: MarkdownBlock[]): number {
|
|
145
|
+
const body: string[] = [];
|
|
146
|
+
let i = start + 1;
|
|
147
|
+
while (i < lines.length && !closesFence(lines[i], fence.marker)) {
|
|
148
|
+
body.push(dedent(lines[i++], fence.indent));
|
|
149
|
+
}
|
|
150
|
+
out.push({ type: "code", language: fence.language, text: body.join("\n") });
|
|
151
|
+
return i + 1;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function parseQuote(lines: string[], start: number, depth: number, out: MarkdownBlock[]): number {
|
|
155
|
+
const body: string[] = [];
|
|
156
|
+
let i = start;
|
|
157
|
+
for (; i < lines.length; i++) {
|
|
158
|
+
const line = lines[i];
|
|
159
|
+
if (QUOTE.test(line)) body.push(line.replace(QUOTE, ""));
|
|
160
|
+
else if (isBlank(line) || isBlank(body[body.length - 1]) || startsBlock(lines, i)) break;
|
|
161
|
+
else body.push(line);
|
|
162
|
+
}
|
|
163
|
+
out.push({ type: "blockquote", children: parseBlocks(body, depth + 1) });
|
|
164
|
+
return i;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function parseList(lines: string[], start: number, depth: number, out: MarkdownBlock[]): number {
|
|
168
|
+
const kind = (marker: string) => /\d/.test(marker);
|
|
169
|
+
const first = LIST_ITEM.exec(lines[start]);
|
|
170
|
+
const ordered = kind(first?.[2] ?? "-");
|
|
171
|
+
const items: MarkdownListItem[] = [];
|
|
172
|
+
let i = start;
|
|
173
|
+
let match = first;
|
|
174
|
+
while (match && kind(match[2]) === ordered) {
|
|
175
|
+
const indent = indentOf(match[1]);
|
|
176
|
+
const contentCol = indent + match[2].length + Math.min(match[3]?.length ?? 1, 4);
|
|
177
|
+
const task = TASK.exec(match[4] ?? "");
|
|
178
|
+
const body = [(match[4] ?? "").slice(task?.[0].length ?? 0)];
|
|
179
|
+
let j = i + 1;
|
|
180
|
+
// Lines indented 2+ columns past the marker belong to the item, as do blank lines
|
|
181
|
+
// followed by such a line. Unindented text is a lazy paragraph continuation.
|
|
182
|
+
while (j < lines.length) {
|
|
183
|
+
const line = lines[j];
|
|
184
|
+
if (isBlank(line)) {
|
|
185
|
+
let k = j;
|
|
186
|
+
while (k < lines.length && isBlank(lines[k])) k++;
|
|
187
|
+
if (k >= lines.length || indentOf(lines[k]) < indent + 2) break;
|
|
188
|
+
for (; j < k; j++) body.push("");
|
|
189
|
+
} else if (indentOf(line) >= indent + 2) {
|
|
190
|
+
body.push(dedent(line, contentCol));
|
|
191
|
+
j++;
|
|
192
|
+
} else if (LIST_ITEM.test(line) || startsBlock(lines, j)) break;
|
|
193
|
+
else {
|
|
194
|
+
body.push(line);
|
|
195
|
+
j++;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
items.push({ checked: task ? task[1] !== " " : null, children: parseBlocks(body, depth + 1) });
|
|
199
|
+
i = j;
|
|
200
|
+
let k = j;
|
|
201
|
+
while (k < lines.length && isBlank(lines[k])) k++;
|
|
202
|
+
match = k < lines.length ? LIST_ITEM.exec(lines[k]) : null;
|
|
203
|
+
if (match && kind(match[2]) === ordered) i = k;
|
|
204
|
+
}
|
|
205
|
+
const startAt = ordered ? parseInt(first?.[2] ?? "1", 10) : 1;
|
|
206
|
+
out.push({ type: "list", ordered, start: startAt, items });
|
|
207
|
+
return Math.max(i, start + 1);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function parseTable(lines: string[], start: number, out: MarkdownBlock[]): number {
|
|
211
|
+
const header = splitRow(lines[start]);
|
|
212
|
+
const align = splitRow(lines[start + 1]).map(alignOf);
|
|
213
|
+
const cells = (row: string[]) => header.map((_, c) => parseInline(row[c] ?? ""));
|
|
214
|
+
const rows: MarkdownTableRow[] = [];
|
|
215
|
+
let i = start + 2;
|
|
216
|
+
while (i < lines.length && lines[i].includes("|") && !startsBlock(lines, i)) {
|
|
217
|
+
rows.push(cells(splitRow(lines[i++])));
|
|
218
|
+
}
|
|
219
|
+
out.push({ type: "table", align, header: cells(header), rows });
|
|
220
|
+
return i;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function parseParagraph(lines: string[], start: number, out: MarkdownBlock[]): number {
|
|
224
|
+
const body = [lines[start]];
|
|
225
|
+
let i = start + 1;
|
|
226
|
+
while (i < lines.length && !isBlank(lines[i]) && !startsBlock(lines, i)) body.push(lines[i++]);
|
|
227
|
+
out.push({ type: "paragraph", children: parseInline(joinLines(body)) });
|
|
228
|
+
return i;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// Soft wraps become spaces. Two trailing spaces or a trailing backslash become "\n",
|
|
232
|
+
// which the inline parser turns into a break node.
|
|
233
|
+
function joinLines(lines: string[]): string {
|
|
234
|
+
return lines
|
|
235
|
+
.map((raw, k) => {
|
|
236
|
+
const line = raw.trimStart();
|
|
237
|
+
if (k === lines.length - 1) return line.trimEnd();
|
|
238
|
+
if (/ {2,}$/.test(line)) return `${line.trimEnd()}\n`;
|
|
239
|
+
if (/(?:^|[^\\])\\$/.test(line)) return `${line.slice(0, -1)}\n`;
|
|
240
|
+
return `${line.trimEnd()} `;
|
|
241
|
+
})
|
|
242
|
+
.join("");
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
type Token = { nodes: MarkdownInline[]; end: number };
|
|
246
|
+
|
|
247
|
+
const PUNCT = /[!-/:-@[-`{-~]/;
|
|
248
|
+
const ESCAPED = /\\([!-/:-@[-`{-~])/g;
|
|
249
|
+
// Letters and digits, approximated with ranges instead of \p{L} so older JS engines
|
|
250
|
+
// parse it. Skips the general punctuation, symbol and emoji (surrogate) ranges.
|
|
251
|
+
const ALNUM = /[0-9A-Za-zÀ-ɏͰ-Ⰰ-豈-ᅵ]/;
|
|
252
|
+
const SPECIAL = /[\\\n`*_~[!<hH]/g;
|
|
253
|
+
const AUTOLINK = /<(?:([a-z][a-z0-9+.-]{1,31}:[^\s<>]*)|([^\s<>@]+@[^\s<>@]+\.[^\s<>@]+))>/iy;
|
|
254
|
+
const BARE_URL = /https?:\/\/[^\s<>]+/iy;
|
|
255
|
+
// "(url)" or "(url "title")". The url is <wrapped> or may hold one level of balanced parens.
|
|
256
|
+
const LINK_DEST =
|
|
257
|
+
/\([ \t]*(?:<([^<>\n]*)>|((?:[^\s()\\]|\\.|\((?:[^\s()\\]|\\.)*\))*))(?:[ \t\n]+(?:"[^"]*"|'[^']*'|\([^)]*\)))?[ \t\n]*\)/y;
|
|
258
|
+
const isSpace = (ch: string | undefined) => ch === undefined || /\s/.test(ch);
|
|
259
|
+
const isWord = (ch: string | undefined) => ch !== undefined && ALNUM.test(ch);
|
|
260
|
+
const textToken = (text: string, end: number): Token => ({ nodes: [{ type: "text", text }], end });
|
|
261
|
+
|
|
262
|
+
// Caps delimiter and bracket scans per top-level inline run so that adversarial input
|
|
263
|
+
// stays linear. Once spent, the remaining delimiters are kept as plain text.
|
|
264
|
+
let scanBudget = 0;
|
|
265
|
+
|
|
266
|
+
function parseInline(text: string, depth = 0, inLink = false): MarkdownInline[] {
|
|
267
|
+
if (depth === 0) scanBudget = 50000;
|
|
268
|
+
if (depth > MAX_DEPTH) return text ? [{ type: "text", text }] : [];
|
|
269
|
+
const out: MarkdownInline[] = [];
|
|
270
|
+
let i = 0;
|
|
271
|
+
while (i < text.length) {
|
|
272
|
+
const token = readToken(text, i, depth, inLink);
|
|
273
|
+
for (const node of token.nodes) push(out, node);
|
|
274
|
+
i = Math.max(token.end, i + 1);
|
|
275
|
+
}
|
|
276
|
+
return out;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function push(out: MarkdownInline[], node: MarkdownInline): void {
|
|
280
|
+
const last = out[out.length - 1];
|
|
281
|
+
if (node.type !== "text") out.push(node);
|
|
282
|
+
else if (last?.type === "text") last.text += node.text;
|
|
283
|
+
else if (node.text) out.push({ type: "text", text: node.text });
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function readToken(text: string, i: number, depth: number, inLink: boolean): Token {
|
|
287
|
+
const ch = text[i];
|
|
288
|
+
const next = text[i + 1];
|
|
289
|
+
if (ch === "\\" && next !== undefined && PUNCT.test(next)) return textToken(next, i + 2);
|
|
290
|
+
if (ch === "\n") return { nodes: [{ type: "break" }], end: i + 1 };
|
|
291
|
+
if (ch === "`") {
|
|
292
|
+
const span = codeSpan(text, i);
|
|
293
|
+
if (span) return { nodes: [{ type: "code", text: span.text }], end: span.end };
|
|
294
|
+
const run = runLength(text, i);
|
|
295
|
+
return textToken("`".repeat(run), i + run);
|
|
296
|
+
}
|
|
297
|
+
if (ch === "*" || ch === "_" || ch === "~") return emphasis(text, i, depth, inLink);
|
|
298
|
+
if (!inLink) {
|
|
299
|
+
let token: Token | null = null;
|
|
300
|
+
if (ch === "[" || (ch === "!" && next === "[")) token = readLink(text, i, depth);
|
|
301
|
+
else if (ch === "<") token = readAutolink(text, i);
|
|
302
|
+
else if (ch === "h" || ch === "H") token = readBareUrl(text, i);
|
|
303
|
+
if (token) return token;
|
|
304
|
+
}
|
|
305
|
+
SPECIAL.lastIndex = i + 1;
|
|
306
|
+
const end = SPECIAL.exec(text)?.index ?? text.length;
|
|
307
|
+
return textToken(text.slice(i, end), end);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function runLength(text: string, i: number): number {
|
|
311
|
+
let n = 0;
|
|
312
|
+
while (text[i + n] === text[i]) n++;
|
|
313
|
+
return n;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function codeSpan(text: string, i: number): { text: string; end: number } | null {
|
|
317
|
+
const size = runLength(text, i);
|
|
318
|
+
for (let j = text.indexOf("`", i + size); j >= 0; j = text.indexOf("`", j)) {
|
|
319
|
+
const run = runLength(text, j);
|
|
320
|
+
if (run === size) {
|
|
321
|
+
let code = text.slice(i + size, j).replace(/\n/g, " ");
|
|
322
|
+
if (/^ .*[^ ].* $/.test(code)) code = code.slice(1, -1);
|
|
323
|
+
return { text: code, end: j + run };
|
|
324
|
+
}
|
|
325
|
+
j += run;
|
|
326
|
+
}
|
|
327
|
+
return null;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function emphasis(text: string, i: number, depth: number, inLink: boolean): Token {
|
|
331
|
+
const ch = text[i];
|
|
332
|
+
const run = runLength(text, i);
|
|
333
|
+
if (isSpace(text[i + run]) || (ch === "_" && isWord(text[i - 1]))) {
|
|
334
|
+
return textToken(text.slice(i, i + run), i + run);
|
|
335
|
+
}
|
|
336
|
+
const sizes = [3, 2, 1].filter((size) => size <= run && (ch !== "~" || size === 2));
|
|
337
|
+
for (const size of sizes) {
|
|
338
|
+
const close = findCloser(text, i + size, ch, size);
|
|
339
|
+
if (close < 0) continue;
|
|
340
|
+
const children = parseInline(text.slice(i + size, close), depth + 1, inLink);
|
|
341
|
+
let node: MarkdownInline = { type: size === 1 ? "emphasis" : "strong", children };
|
|
342
|
+
if (ch === "~") node = { type: "strike", children };
|
|
343
|
+
if (size === 3) node = { type: "strong", children: [{ type: "emphasis", children }] };
|
|
344
|
+
return { nodes: [node], end: close + size };
|
|
345
|
+
}
|
|
346
|
+
return textToken(ch, i + 1);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// Finds the closing delimiter run, skipping escapes, code spans and nested openers of
|
|
350
|
+
// the same character. Returns the index where the closing `size` characters begin.
|
|
351
|
+
function findCloser(text: string, from: number, ch: string, size: number): number {
|
|
352
|
+
let nested = 0;
|
|
353
|
+
let j = from;
|
|
354
|
+
while (j < text.length && scanBudget-- > 0) {
|
|
355
|
+
const c = text[j];
|
|
356
|
+
if (c === "\\") j += 2;
|
|
357
|
+
else if (c === "`") j = codeSpan(text, j)?.end ?? j + runLength(text, j);
|
|
358
|
+
else if (c !== ch) j++;
|
|
359
|
+
else {
|
|
360
|
+
const run = runLength(text, j);
|
|
361
|
+
const before = text[j - 1];
|
|
362
|
+
const after = text[j + run];
|
|
363
|
+
if (j > from && !isSpace(before) && !(ch === "_" && isWord(after))) {
|
|
364
|
+
const used = Math.min(nested, run);
|
|
365
|
+
nested -= used;
|
|
366
|
+
if (run - used >= size) return j + used;
|
|
367
|
+
} else if (!isSpace(after) && !(ch === "_" && isWord(before))) nested += run;
|
|
368
|
+
j += run;
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
return -1;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
function readLink(text: string, i: number, depth: number): Token | null {
|
|
375
|
+
const image = text[i] === "!";
|
|
376
|
+
const open = image ? i + 1 : i;
|
|
377
|
+
let close = -1;
|
|
378
|
+
for (let j = open, level = 0; j < text.length && close < 0 && scanBudget-- > 0; j++) {
|
|
379
|
+
if (text[j] === "\\") j++;
|
|
380
|
+
else if (text[j] === "[") level++;
|
|
381
|
+
else if (text[j] === "]" && --level === 0) close = j;
|
|
382
|
+
}
|
|
383
|
+
LINK_DEST.lastIndex = close + 1;
|
|
384
|
+
const dest = close < 0 ? null : LINK_DEST.exec(text);
|
|
385
|
+
if (!dest) return null;
|
|
386
|
+
const target = (dest[1] ?? dest[2]).replace(ESCAPED, "$1");
|
|
387
|
+
let children = parseInline(text.slice(open + 1, close), depth + 1, true);
|
|
388
|
+
if (children.length === 0) children = [{ type: "text", text: image ? "image" : target }];
|
|
389
|
+
const url = safeUrl(target);
|
|
390
|
+
const end = close + 1 + dest[0].length;
|
|
391
|
+
return { nodes: url ? [{ type: "link", url, children }] : children, end };
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
function readAutolink(text: string, i: number): Token | null {
|
|
395
|
+
AUTOLINK.lastIndex = i;
|
|
396
|
+
const match = AUTOLINK.exec(text);
|
|
397
|
+
if (!match) return null;
|
|
398
|
+
const label: MarkdownInline[] = [{ type: "text", text: match[1] ?? match[2] }];
|
|
399
|
+
const url = safeUrl(match[1] ?? `mailto:${match[2]}`);
|
|
400
|
+
const end = i + match[0].length;
|
|
401
|
+
return { nodes: url ? [{ type: "link", url, children: label }] : label, end };
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
function readBareUrl(text: string, i: number): Token | null {
|
|
405
|
+
if (isWord(text[i - 1])) return null;
|
|
406
|
+
BARE_URL.lastIndex = i;
|
|
407
|
+
let url = BARE_URL.exec(text)?.[0] ?? "";
|
|
408
|
+
const count = (ch: string) => url.split(ch).length - 1;
|
|
409
|
+
while (/[.,:;!?'"*_~)\]]$/.test(url) && !(url.endsWith(")") && count("(") >= count(")"))) {
|
|
410
|
+
url = url.slice(0, -1);
|
|
411
|
+
}
|
|
412
|
+
const safe = safeUrl(url);
|
|
413
|
+
const label: MarkdownInline[] = [{ type: "text", text: url }];
|
|
414
|
+
if (!safe) return null;
|
|
415
|
+
return { nodes: [{ type: "link", url: safe, children: label }], end: i + url.length };
|
|
416
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import type { IssueDetail } from "./linear";
|
|
2
|
+
import type { AgentAction, LinearSettings, ProjectConfig } from "./settings";
|
|
3
|
+
|
|
4
|
+
export const DEFAULT_TEMPLATES: Record<AgentAction, string> = {
|
|
5
|
+
implement: [
|
|
6
|
+
"You are working on Linear issue {{identifier}}: {{title}}",
|
|
7
|
+
"",
|
|
8
|
+
"{{issue}}",
|
|
9
|
+
"",
|
|
10
|
+
"Implement what this issue asks for on branch {{branch}}.",
|
|
11
|
+
"1. Read the relevant code and confirm the expected behavior before you edit.",
|
|
12
|
+
"2. Make the smallest complete change. Add or update tests where the project has them.",
|
|
13
|
+
"3. Run the project's checks and fix what fails.",
|
|
14
|
+
"4. Finish with a summary: what changed, how you verified it, and what is still open.",
|
|
15
|
+
].join("\n"),
|
|
16
|
+
review: [
|
|
17
|
+
"Review the work for Linear issue {{identifier}}: {{title}}",
|
|
18
|
+
"",
|
|
19
|
+
"{{issue}}",
|
|
20
|
+
"",
|
|
21
|
+
"Compare the current changes with the issue. Use the linked pull request if there is one,",
|
|
22
|
+
"otherwise the diff of this branch against its base branch.",
|
|
23
|
+
"Report:",
|
|
24
|
+
"- Each requirement: met, partly met, or missing.",
|
|
25
|
+
"- Bugs, regressions, and risky changes, with file and line references.",
|
|
26
|
+
"- Missing or weak tests.",
|
|
27
|
+
"Do not edit files unless you are asked to.",
|
|
28
|
+
].join("\n"),
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export const TEMPLATE_VARIABLES = [
|
|
32
|
+
{ name: "identifier", description: "Issue key, such as ENG-123" },
|
|
33
|
+
{ name: "title", description: "Issue title" },
|
|
34
|
+
{ name: "url", description: "Link to the issue in Linear" },
|
|
35
|
+
{ name: "branch", description: "Branch name Linear suggests for the issue" },
|
|
36
|
+
{ name: "issue", description: "Full issue snapshot: fields, description, sub-issues, comments" },
|
|
37
|
+
] as const;
|
|
38
|
+
|
|
39
|
+
export const ACTION_LABELS: Record<AgentAction, { title: string; verb: string; icon: string }> = {
|
|
40
|
+
implement: { title: "Implement", verb: "Start agent", icon: "Play" },
|
|
41
|
+
review: { title: "Review", verb: "Start review", icon: "ScanSearch" },
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
export interface IssueSnapshotOptions {
|
|
45
|
+
includeComments: boolean;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function issueSnapshot(issue: IssueDetail, options: IssueSnapshotOptions): string {
|
|
49
|
+
const lines = [
|
|
50
|
+
`Linear issue ${issue.identifier}: ${issue.title}`,
|
|
51
|
+
`URL: ${issue.url}`,
|
|
52
|
+
`Team: ${issue.team.name}`,
|
|
53
|
+
`Status: ${issue.state.name}`,
|
|
54
|
+
`Priority: ${issue.priorityLabel}`,
|
|
55
|
+
];
|
|
56
|
+
if (issue.assignee) lines.push(`Assignee: ${issue.assignee.displayName}`);
|
|
57
|
+
if (issue.project) lines.push(`Project: ${issue.project.name}`);
|
|
58
|
+
if (issue.labels.length > 0) {
|
|
59
|
+
lines.push(`Labels: ${issue.labels.map((label) => label.name).join(", ")}`);
|
|
60
|
+
}
|
|
61
|
+
if (issue.parent) lines.push(`Parent: ${issue.parent.identifier} ${issue.parent.title}`);
|
|
62
|
+
lines.push("", "Description:", issue.description?.trim() || "No description.");
|
|
63
|
+
if (issue.children.length > 0) {
|
|
64
|
+
lines.push("", "Sub-issues:");
|
|
65
|
+
for (const child of issue.children) {
|
|
66
|
+
lines.push(`- ${child.identifier} [${child.state.name}] ${child.title}`);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
const links = issue.attachments.filter((attachment) => attachment.url);
|
|
70
|
+
if (links.length > 0) {
|
|
71
|
+
lines.push("", "Links:");
|
|
72
|
+
for (const link of links) lines.push(`- ${link.title}: ${link.url}`);
|
|
73
|
+
}
|
|
74
|
+
if (options.includeComments && issue.comments.length > 0) {
|
|
75
|
+
lines.push("", "Comments (oldest first):");
|
|
76
|
+
for (const comment of issue.comments) {
|
|
77
|
+
const author = comment.user?.displayName ?? "Unknown";
|
|
78
|
+
lines.push(`- ${author} (${comment.createdAt.slice(0, 10)}): ${comment.body.trim()}`);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return lines.join("\n");
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function baseTemplate(settings: LinearSettings, action: AgentAction): string {
|
|
85
|
+
const custom = settings.templates[action].trim();
|
|
86
|
+
return custom || DEFAULT_TEMPLATES[action];
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function actionTemplate(
|
|
90
|
+
settings: LinearSettings,
|
|
91
|
+
project: ProjectConfig | null,
|
|
92
|
+
action: AgentAction,
|
|
93
|
+
): string {
|
|
94
|
+
const base = baseTemplate(settings, action);
|
|
95
|
+
const override = project?.[action];
|
|
96
|
+
if (!override || override.mode === "default" || !override.text.trim()) return base;
|
|
97
|
+
if (override.mode === "replace") return override.text.trim();
|
|
98
|
+
return `${base}\n\n${override.text.trim()}`;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function renderTemplate(template: string, values: Record<string, string>): string {
|
|
102
|
+
return template.replace(/\{\{\s*([a-zA-Z]+)\s*\}\}/g, (match, name: string) =>
|
|
103
|
+
values[name] !== undefined ? values[name] : match,
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export interface ComposePromptInput {
|
|
108
|
+
action: AgentAction;
|
|
109
|
+
issue: IssueDetail;
|
|
110
|
+
settings: LinearSettings;
|
|
111
|
+
project: ProjectConfig | null;
|
|
112
|
+
includeComments: boolean;
|
|
113
|
+
extraInstructions: string;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function composePrompt(input: ComposePromptInput): string {
|
|
117
|
+
const { issue, project } = input;
|
|
118
|
+
const template = actionTemplate(input.settings, project, input.action);
|
|
119
|
+
const snapshot = issueSnapshot(issue, { includeComments: input.includeComments });
|
|
120
|
+
const sections = [
|
|
121
|
+
renderTemplate(template, {
|
|
122
|
+
identifier: issue.identifier,
|
|
123
|
+
title: issue.title,
|
|
124
|
+
url: issue.url,
|
|
125
|
+
branch: issue.branchName,
|
|
126
|
+
issue: snapshot,
|
|
127
|
+
}),
|
|
128
|
+
];
|
|
129
|
+
// A template that drops {{issue}} still gets the snapshot so the agent never works blind.
|
|
130
|
+
if (!/\{\{\s*issue\s*\}\}/.test(template)) sections.push(snapshot);
|
|
131
|
+
const instructions = project?.instructions.trim();
|
|
132
|
+
if (instructions) sections.push(`Project instructions:\n${instructions}`);
|
|
133
|
+
const steps = project?.steps.map((step) => step.trim()).filter(Boolean) ?? [];
|
|
134
|
+
if (steps.length > 0) {
|
|
135
|
+
sections.push(
|
|
136
|
+
`Project steps:\n${steps.map((step, index) => `${index + 1}. ${step}`).join("\n")}`,
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
const extra = input.extraInstructions.trim();
|
|
140
|
+
if (extra) sections.push(`Additional instructions:\n${extra}`);
|
|
141
|
+
return sections.join("\n\n");
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export function agentTitle(action: AgentAction, issue: { identifier: string; title: string }) {
|
|
145
|
+
const prefix = action === "review" ? "Review" : issue.identifier;
|
|
146
|
+
const title = action === "review" ? `${issue.identifier} ${issue.title}` : issue.title;
|
|
147
|
+
return `${prefix}: ${title}`.slice(0, 120);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export const AGENT_LABELS = { issue: "linear.issue", action: "linear.action" } as const;
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { defineSettings } from "@getpaseo/plugin";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
|
|
4
|
+
export const AGENT_ACTIONS = ["implement", "review"] as const;
|
|
5
|
+
export type AgentAction = (typeof AGENT_ACTIONS)[number];
|
|
6
|
+
|
|
7
|
+
export const PROMPT_MODES = ["default", "append", "replace"] as const;
|
|
8
|
+
export type PromptMode = (typeof PROMPT_MODES)[number];
|
|
9
|
+
|
|
10
|
+
const ActionOverrideSchema = z.object({
|
|
11
|
+
mode: z.enum(PROMPT_MODES).default("default"),
|
|
12
|
+
text: z.string().default(""),
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
export const ProjectConfigSchema = z.object({
|
|
16
|
+
projectId: z.string(),
|
|
17
|
+
displayName: z.string(),
|
|
18
|
+
rootPath: z.string(),
|
|
19
|
+
teamIds: z.array(z.string()).default([]),
|
|
20
|
+
instructions: z.string().default(""),
|
|
21
|
+
steps: z.array(z.string()).default([]),
|
|
22
|
+
implement: ActionOverrideSchema.prefault({}),
|
|
23
|
+
review: ActionOverrideSchema.prefault({}),
|
|
24
|
+
});
|
|
25
|
+
export type ProjectConfig = z.infer<typeof ProjectConfigSchema>;
|
|
26
|
+
|
|
27
|
+
export const ISOLATIONS = ["worktree", "workspace"] as const;
|
|
28
|
+
export type Isolation = (typeof ISOLATIONS)[number];
|
|
29
|
+
|
|
30
|
+
export const linearSettings = defineSettings({
|
|
31
|
+
id: "linear",
|
|
32
|
+
scope: "host",
|
|
33
|
+
version: 1,
|
|
34
|
+
schema: z.object({
|
|
35
|
+
// Empty templates mean "use the built-in prompt" so improved defaults reach existing users.
|
|
36
|
+
templates: z
|
|
37
|
+
.object({ implement: z.string().default(""), review: z.string().default("") })
|
|
38
|
+
.prefault({}),
|
|
39
|
+
launch: z
|
|
40
|
+
.object({
|
|
41
|
+
provider: z.string().default(""),
|
|
42
|
+
isolation: z.enum(ISOLATIONS).default("worktree"),
|
|
43
|
+
includeComments: z.boolean().default(true),
|
|
44
|
+
moveToStarted: z.boolean().default(true),
|
|
45
|
+
assignToMe: z.boolean().default(true),
|
|
46
|
+
})
|
|
47
|
+
.prefault({}),
|
|
48
|
+
projects: z.array(ProjectConfigSchema).default([]),
|
|
49
|
+
}),
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
export type LinearSettings = z.output<typeof linearSettings.schema>;
|
|
53
|
+
|
|
54
|
+
export function emptyProjectConfig(project: {
|
|
55
|
+
projectId: string;
|
|
56
|
+
displayName: string;
|
|
57
|
+
rootPath: string;
|
|
58
|
+
}): ProjectConfig {
|
|
59
|
+
return ProjectConfigSchema.parse(project);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function upsertProject(
|
|
63
|
+
settings: LinearSettings,
|
|
64
|
+
next: ProjectConfig,
|
|
65
|
+
): LinearSettings["projects"] {
|
|
66
|
+
const others = settings.projects.filter((entry) => entry.projectId !== next.projectId);
|
|
67
|
+
return [...others, next];
|
|
68
|
+
}
|
package/index.client.ts
DELETED
package/server/issues.ts
DELETED
|
@@ -1,10 +0,0 @@
|
|
|
1
|
-
import type { PluginAttachmentSearchPayload, RpcInput } from "@getpaseo/plugin";
|
|
2
|
-
import { searchIssuesRpc } from "../shared/issues";
|
|
3
|
-
import { createLinearIssueSearch } from "./linear";
|
|
4
|
-
|
|
5
|
-
export async function searchIssues({
|
|
6
|
-
query,
|
|
7
|
-
}: RpcInput<typeof searchIssuesRpc>): Promise<PluginAttachmentSearchPayload> {
|
|
8
|
-
const linear = createLinearIssueSearch({ apiKey: process.env.LINEAR_API_KEY ?? "" });
|
|
9
|
-
return linear.search(query);
|
|
10
|
-
}
|