@xpufx/paseo-top 0.4.3 → 0.4.4
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/client/pill-labels.ts +30 -6
- package/client/pill.tsx +40 -61
- package/client/settings-events.ts +33 -0
- package/client/surface.tsx +6 -2
- package/client/vendor/paseo-plugin-helper/components/Badge.tsx +12 -0
- package/client/vendor/paseo-plugin-helper/components/Button.tsx +22 -2
- package/client/vendor/paseo-plugin-helper/components/Card.tsx +32 -0
- package/client/vendor/paseo-plugin-helper/components/DataTable.tsx +17 -1
- package/client/vendor/paseo-plugin-helper/components/InlineButton.tsx +14 -1
- package/client/vendor/paseo-plugin-helper/components/InteractiveRow.tsx +142 -0
- package/client/vendor/paseo-plugin-helper/components/SearchInput.tsx +16 -2
- package/client/vendor/paseo-plugin-helper/components/SectionHeader.tsx +7 -0
- package/client/vendor/paseo-plugin-helper/components/Select.tsx +131 -63
- package/client/vendor/paseo-plugin-helper/components/index.ts +1 -0
- package/client/vendor/paseo-plugin-helper/custom-pills.tsx +4 -3
- package/client/vendor/paseo-plugin-helper/host.ts +12 -58
- package/client/vendor/paseo-plugin-helper/index.ts +1 -0
- package/client/vendor/paseo-plugin-helper/layout/ModalBody.tsx +17 -27
- package/client/vendor/paseo-plugin-helper/pill.tsx +68 -321
- package/client/vendor/paseo-plugin-helper/surface.tsx +62 -2
- package/client/vendor/paseo-plugin-helper/tickets.tsx +766 -0
- package/index.server.ts +7 -0
- package/package.json +11 -2
- package/server/vendor/paseo-plugin-helper/index.ts +2 -0
- package/server/vendor/paseo-plugin-helper/mcp-injection.ts +20 -2
- package/server/vendor/paseo-plugin-helper/plugins.ts +184 -19
- package/server/vendor/paseo-plugin-helper/tickets.ts +1281 -0
- package/shared/helper-version.ts +20 -0
- package/shared/multi-host.ts +11 -4
- package/shared/resources.ts +1 -1
- package/shared/vendor/paseo-plugin-helper/index.ts +1 -0
- package/shared/vendor/paseo-plugin-helper/tickets.ts +1010 -0
- package/shared/version.ts +1 -1
|
@@ -0,0 +1,1010 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { defineContract } from "./rpc";
|
|
3
|
+
|
|
4
|
+
// ---------------------------------------------------------------------------
|
|
5
|
+
// Label taxonomy & definitions (issues #122, #189, #200)
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
|
|
8
|
+
export const STATE_ORDER = [
|
|
9
|
+
"state/0-triage",
|
|
10
|
+
"state/1-wip",
|
|
11
|
+
"state/2-review",
|
|
12
|
+
"state/3-verify",
|
|
13
|
+
"state/4-done",
|
|
14
|
+
] as const;
|
|
15
|
+
export type StateLabel = (typeof STATE_ORDER)[number];
|
|
16
|
+
|
|
17
|
+
export const PRIORITY_ORDER = [
|
|
18
|
+
"priority/0-SOS",
|
|
19
|
+
"priority/1-high",
|
|
20
|
+
"priority/2-normal",
|
|
21
|
+
"priority/3-low",
|
|
22
|
+
"priority/4-backburner",
|
|
23
|
+
] as const;
|
|
24
|
+
export type PriorityLabel = (typeof PRIORITY_ORDER)[number];
|
|
25
|
+
|
|
26
|
+
export const ATTENTION_LABELS = [
|
|
27
|
+
"attention/0-orchestrator",
|
|
28
|
+
"attention/1-agent",
|
|
29
|
+
"attention/2-user",
|
|
30
|
+
"attention/3-ignore",
|
|
31
|
+
] as const;
|
|
32
|
+
export type AttentionLabel = (typeof ATTENTION_LABELS)[number];
|
|
33
|
+
|
|
34
|
+
export const SPEC_LABELS = [
|
|
35
|
+
"spec/0-needed",
|
|
36
|
+
"spec/1-checklist",
|
|
37
|
+
"spec/2-approved",
|
|
38
|
+
] as const;
|
|
39
|
+
export type SpecLabel = (typeof SPEC_LABELS)[number];
|
|
40
|
+
|
|
41
|
+
const STATE_SHORT: Record<string, string> = {
|
|
42
|
+
"state/0-triage": "Triage",
|
|
43
|
+
"state/1-wip": "WIP",
|
|
44
|
+
"state/2-review": "Review",
|
|
45
|
+
"state/3-verify": "Verify",
|
|
46
|
+
"state/4-done": "Done",
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const PRIORITY_SHORT: Record<string, string> = {
|
|
50
|
+
"priority/0-SOS": "SOS",
|
|
51
|
+
"priority/1-high": "High",
|
|
52
|
+
"priority/2-normal": "Normal",
|
|
53
|
+
"priority/3-low": "Low",
|
|
54
|
+
"priority/4-backburner": "Parked",
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
/** Compact display alias for a scoped label ("state/1-wip" -> "WIP"). */
|
|
58
|
+
export function shortLabelName(label: string): string {
|
|
59
|
+
return STATE_SHORT[label] ?? PRIORITY_SHORT[label] ?? label;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** The issue's current `state/*` label, or null when it carries none. */
|
|
63
|
+
export function currentStateLabel(labels: string[]): string | null {
|
|
64
|
+
for (const label of labels) {
|
|
65
|
+
if ((STATE_ORDER as readonly string[]).includes(label)) return label;
|
|
66
|
+
}
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** The issue's current `priority/*` label, defaulting to normal per spec §4.2. */
|
|
71
|
+
export function currentPriorityLabel(labels: string[]): string {
|
|
72
|
+
for (const label of labels) {
|
|
73
|
+
if ((PRIORITY_ORDER as readonly string[]).includes(label)) return label;
|
|
74
|
+
}
|
|
75
|
+
return "priority/2-normal";
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Next `state/*` promotion step, or null when already done. */
|
|
79
|
+
export function nextStateLabel(labels: string[]): string | null {
|
|
80
|
+
const current = currentStateLabel(labels);
|
|
81
|
+
if (!current) return "state/1-wip";
|
|
82
|
+
const idx = (STATE_ORDER as readonly string[]).indexOf(current);
|
|
83
|
+
if (idx < 0 || idx + 1 >= STATE_ORDER.length) return null;
|
|
84
|
+
return STATE_ORDER[idx + 1];
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// ---------------------------------------------------------------------------
|
|
88
|
+
// Schemas & Types
|
|
89
|
+
// ---------------------------------------------------------------------------
|
|
90
|
+
|
|
91
|
+
export const ForgeLabelSchema = z.object({
|
|
92
|
+
name: z.string(),
|
|
93
|
+
color: z.string().optional(),
|
|
94
|
+
description: z.string().optional(),
|
|
95
|
+
});
|
|
96
|
+
export type ForgeLabel = z.infer<typeof ForgeLabelSchema>;
|
|
97
|
+
|
|
98
|
+
export const ForgeIssueSchema = z.object({
|
|
99
|
+
number: z.number(),
|
|
100
|
+
title: z.string(),
|
|
101
|
+
state: z.string(),
|
|
102
|
+
labels: z.array(z.string()),
|
|
103
|
+
labelDetails: z.array(ForgeLabelSchema).default([]),
|
|
104
|
+
comments: z.number().int().nonnegative().default(0),
|
|
105
|
+
updatedAt: z.string().optional(),
|
|
106
|
+
createdAt: z.string().optional(),
|
|
107
|
+
author: z.string().optional(),
|
|
108
|
+
url: z.string().optional(),
|
|
109
|
+
body: z.string().optional(),
|
|
110
|
+
remoteUrl: z.string().optional(),
|
|
111
|
+
repo: z.string().optional(),
|
|
112
|
+
branch: z.string().optional(),
|
|
113
|
+
});
|
|
114
|
+
export type ForgeIssue = z.infer<typeof ForgeIssueSchema>;
|
|
115
|
+
|
|
116
|
+
export const AgentEnvelopeSchema = z.object({
|
|
117
|
+
commentId: z.number(),
|
|
118
|
+
sessionTitle: z.string(),
|
|
119
|
+
agentShortId: z.string(),
|
|
120
|
+
model: z.string().nullable().default(null),
|
|
121
|
+
repo: z.string().nullable().default(null),
|
|
122
|
+
branch: z.string().nullable().default(null),
|
|
123
|
+
postedAt: z.string().nullable().default(null),
|
|
124
|
+
commitShas: z.array(z.string().regex(/^[0-9a-f]{7,40}$/)).default([]),
|
|
125
|
+
paseoLinks: z.array(z.string()).default([]),
|
|
126
|
+
serverId: z.string().nullable().default(null),
|
|
127
|
+
});
|
|
128
|
+
export type AgentEnvelope = z.infer<typeof AgentEnvelopeSchema>;
|
|
129
|
+
|
|
130
|
+
export const IssueCommentSchema = z.object({
|
|
131
|
+
id: z.number(),
|
|
132
|
+
author: z.string(),
|
|
133
|
+
createdAt: z.string(),
|
|
134
|
+
updatedAt: z.string(),
|
|
135
|
+
body: z.string(),
|
|
136
|
+
url: z.string(),
|
|
137
|
+
envelope: AgentEnvelopeSchema.nullable().default(null),
|
|
138
|
+
});
|
|
139
|
+
export type IssueComment = z.infer<typeof IssueCommentSchema>;
|
|
140
|
+
|
|
141
|
+
export const IssueDetailSchema = z.object({
|
|
142
|
+
number: z.number(),
|
|
143
|
+
title: z.string(),
|
|
144
|
+
state: z.string(),
|
|
145
|
+
labels: z.array(z.string()),
|
|
146
|
+
labelDetails: z.array(ForgeLabelSchema).default([]),
|
|
147
|
+
body: z.string(),
|
|
148
|
+
author: z.string(),
|
|
149
|
+
createdAt: z.string(),
|
|
150
|
+
updatedAt: z.string(),
|
|
151
|
+
webUrl: z.string(),
|
|
152
|
+
comments: z.array(IssueCommentSchema),
|
|
153
|
+
envelopes: z.array(AgentEnvelopeSchema),
|
|
154
|
+
});
|
|
155
|
+
export type IssueDetail = z.infer<typeof IssueDetailSchema>;
|
|
156
|
+
|
|
157
|
+
export interface ForgeRepoIdentity {
|
|
158
|
+
host: string;
|
|
159
|
+
owner: string;
|
|
160
|
+
repo: string;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export type ForgeVisibility = "public" | "private" | "unknown";
|
|
164
|
+
export type ForgeAuthState =
|
|
165
|
+
| "authenticated"
|
|
166
|
+
| "lacks-write-scope"
|
|
167
|
+
| "invalid-token"
|
|
168
|
+
| "anonymous"
|
|
169
|
+
| "unknown";
|
|
170
|
+
|
|
171
|
+
export interface ForgeAccessState {
|
|
172
|
+
visibility: ForgeVisibility;
|
|
173
|
+
auth: ForgeAuthState;
|
|
174
|
+
canEdit: boolean;
|
|
175
|
+
visibilityLabel: string | null;
|
|
176
|
+
authLabel: string;
|
|
177
|
+
authIcon: string;
|
|
178
|
+
authVariant: "success" | "warning" | "danger" | "neutral";
|
|
179
|
+
summary: string;
|
|
180
|
+
requiredScopes: string;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export interface ForgeAccessInput {
|
|
184
|
+
repoPublic?: boolean | null;
|
|
185
|
+
tokenPresent?: boolean | null;
|
|
186
|
+
tokenValid?: boolean | null;
|
|
187
|
+
repoWritePermission?: boolean | null;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export function forgeWriteScopeList(): string {
|
|
191
|
+
return "repo, write:issue (or issues:write)";
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export function deriveForgeAccess(input: ForgeAccessInput = {}): ForgeAccessState {
|
|
195
|
+
const visibility: ForgeVisibility =
|
|
196
|
+
input.repoPublic === true
|
|
197
|
+
? "public"
|
|
198
|
+
: input.repoPublic === false
|
|
199
|
+
? "private"
|
|
200
|
+
: "unknown";
|
|
201
|
+
|
|
202
|
+
let auth: ForgeAuthState;
|
|
203
|
+
if (input.tokenValid === true) {
|
|
204
|
+
auth = input.repoWritePermission === false ? "lacks-write-scope" : "authenticated";
|
|
205
|
+
} else if (input.tokenPresent !== true) auth = "anonymous";
|
|
206
|
+
else if (input.tokenValid === false) auth = "invalid-token";
|
|
207
|
+
else auth = "unknown";
|
|
208
|
+
|
|
209
|
+
const canEdit = auth === "authenticated";
|
|
210
|
+
const visibilityLabel = visibility === "unknown" ? null : visibility;
|
|
211
|
+
|
|
212
|
+
let authLabel: string;
|
|
213
|
+
let authIcon: string;
|
|
214
|
+
let authVariant: ForgeAccessState["authVariant"];
|
|
215
|
+
if (auth === "authenticated") {
|
|
216
|
+
authLabel = "Authenticated";
|
|
217
|
+
authIcon = "KeyRound";
|
|
218
|
+
authVariant = "success";
|
|
219
|
+
} else if (auth === "lacks-write-scope") {
|
|
220
|
+
authLabel = "Token lacks write scope";
|
|
221
|
+
authIcon = "ShieldAlert";
|
|
222
|
+
authVariant = "warning";
|
|
223
|
+
} else if (auth === "invalid-token") {
|
|
224
|
+
authLabel = "Token rejected";
|
|
225
|
+
authIcon = "AlertTriangle";
|
|
226
|
+
authVariant = "danger";
|
|
227
|
+
} else if (auth === "anonymous") {
|
|
228
|
+
authLabel = "No token";
|
|
229
|
+
authIcon = "User";
|
|
230
|
+
authVariant = "neutral";
|
|
231
|
+
} else {
|
|
232
|
+
authLabel = "Token unverified";
|
|
233
|
+
authIcon = "AlertCircle";
|
|
234
|
+
authVariant = "warning";
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const scopeHint = `required scopes: ${forgeWriteScopeList()}.`;
|
|
238
|
+
const authClause =
|
|
239
|
+
auth === "authenticated"
|
|
240
|
+
? "Token accepted with write scope — reads and edits enabled."
|
|
241
|
+
: auth === "lacks-write-scope"
|
|
242
|
+
? `Token accepted but it cannot push — edits disabled; ${scopeHint}`
|
|
243
|
+
: auth === "invalid-token"
|
|
244
|
+
? "Saved token was rejected — edits disabled."
|
|
245
|
+
: auth === "anonymous"
|
|
246
|
+
? "No token saved — edits disabled."
|
|
247
|
+
: "Token state unverified — edits disabled.";
|
|
248
|
+
|
|
249
|
+
const summary =
|
|
250
|
+
visibility === "public"
|
|
251
|
+
? `Public repo — anonymous reads work. ${authClause}`
|
|
252
|
+
: visibility === "private"
|
|
253
|
+
? `Private repo — a valid token is required for reads. ${authClause}`
|
|
254
|
+
: `Repo visibility unknown (could not reach host). ${authClause}`;
|
|
255
|
+
|
|
256
|
+
return {
|
|
257
|
+
visibility,
|
|
258
|
+
auth,
|
|
259
|
+
canEdit,
|
|
260
|
+
visibilityLabel,
|
|
261
|
+
authLabel,
|
|
262
|
+
authIcon,
|
|
263
|
+
authVariant,
|
|
264
|
+
summary,
|
|
265
|
+
requiredScopes: forgeWriteScopeList(),
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
export function writeGateNotice(access: ForgeAccessState, capability: string): string {
|
|
270
|
+
if (access.auth === "lacks-write-scope") {
|
|
271
|
+
return `Read-only — this token cannot ${capability}; it lacks write scope. Add a token with ${access.requiredScopes}.`;
|
|
272
|
+
}
|
|
273
|
+
return `Read-only — ${capability} needs a valid token. ${access.summary}`;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const ENVELOPE_FOOTER_PATTERN =
|
|
277
|
+
/<sub>\s*🤖\s*\*\*(.+?)\*\*\s*\(`([^`)]+)`\)\s*·\s*`([^`]+)`\s*·\s*`([^`]+)`\s*·\s*_([^_]+)_\s*<\/sub>/;
|
|
278
|
+
|
|
279
|
+
export function stripAgentEnvelopeFooter(body: string | undefined | null): string {
|
|
280
|
+
if (!body || typeof body !== "string") return "";
|
|
281
|
+
return body.replace(ENVELOPE_FOOTER_PATTERN, "").replace(/---\s*$/, "").trim();
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// ---------------------------------------------------------------------------
|
|
285
|
+
// Markdown-lite Parser
|
|
286
|
+
// ---------------------------------------------------------------------------
|
|
287
|
+
|
|
288
|
+
export type MarkdownLiteSpan =
|
|
289
|
+
| { kind: "text"; text: string }
|
|
290
|
+
| { kind: "bold"; text: string }
|
|
291
|
+
| { kind: "italic"; text: string }
|
|
292
|
+
| { kind: "code"; text: string }
|
|
293
|
+
| { kind: "link"; text: string; url: string };
|
|
294
|
+
|
|
295
|
+
export type MarkdownLiteBlock =
|
|
296
|
+
| { kind: "paragraph"; spans: MarkdownLiteSpan[] }
|
|
297
|
+
| { kind: "heading"; level: 1 | 2 | 3; spans: MarkdownLiteSpan[] }
|
|
298
|
+
| { kind: "list"; ordered: boolean; items: MarkdownLiteSpan[][] }
|
|
299
|
+
| { kind: "code"; text: string; language?: string };
|
|
300
|
+
|
|
301
|
+
const INLINE_PATTERN =
|
|
302
|
+
/(`[^`]+`)|(\[([^\]]+)\]\((https?:\/\/[^\s)]+)\))|(\*\*([^*]+)\*\*)|(__([^_]+)__)|(\*([^*]+)\*)|(_([^_]+)_)/g;
|
|
303
|
+
|
|
304
|
+
export function parseMarkdownLiteInline(text: string): MarkdownLiteSpan[] {
|
|
305
|
+
if (!text) return [];
|
|
306
|
+
const spans: MarkdownLiteSpan[] = [];
|
|
307
|
+
let cursor = 0;
|
|
308
|
+
INLINE_PATTERN.lastIndex = 0;
|
|
309
|
+
let match: RegExpExecArray | null;
|
|
310
|
+
while ((match = INLINE_PATTERN.exec(text)) !== null) {
|
|
311
|
+
if (match.index > cursor) {
|
|
312
|
+
spans.push({ kind: "text", text: text.slice(cursor, match.index) });
|
|
313
|
+
}
|
|
314
|
+
if (match[1]) {
|
|
315
|
+
spans.push({ kind: "code", text: match[1].slice(1, -1) });
|
|
316
|
+
} else if (match[2]) {
|
|
317
|
+
spans.push({ kind: "link", text: match[3], url: match[4] });
|
|
318
|
+
} else if (match[5]) {
|
|
319
|
+
spans.push({ kind: "bold", text: match[6] });
|
|
320
|
+
} else if (match[7]) {
|
|
321
|
+
spans.push({ kind: "bold", text: match[8] });
|
|
322
|
+
} else if (match[9]) {
|
|
323
|
+
spans.push({ kind: "italic", text: match[10] });
|
|
324
|
+
} else if (match[11]) {
|
|
325
|
+
spans.push({ kind: "italic", text: match[12] });
|
|
326
|
+
}
|
|
327
|
+
cursor = match.index + match[0].length;
|
|
328
|
+
}
|
|
329
|
+
if (cursor < text.length) {
|
|
330
|
+
spans.push({ kind: "text", text: text.slice(cursor) });
|
|
331
|
+
}
|
|
332
|
+
return spans.filter((span) => {
|
|
333
|
+
if (span.kind === "link") return span.text.length > 0 && span.url.length > 0;
|
|
334
|
+
return span.text.length > 0;
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
const HEADING_PATTERN = /^(#{1,3})\s+(.+?)\s*$/;
|
|
339
|
+
const UNORDERED_PATTERN = /^\s*[-*]\s+(.+)$/;
|
|
340
|
+
const ORDERED_PATTERN = /^\s*\d+[.)]\s+(.+)$/;
|
|
341
|
+
const FENCE_PATTERN = /^\s*```\s*([A-Za-z0-9_+-]*)\s*$/;
|
|
342
|
+
|
|
343
|
+
export function parseMarkdownLite(body: string | undefined | null): MarkdownLiteBlock[] {
|
|
344
|
+
if (!body || typeof body !== "string") return [];
|
|
345
|
+
const blocks: MarkdownLiteBlock[] = [];
|
|
346
|
+
const paragraph: string[] = [];
|
|
347
|
+
const flushParagraph = () => {
|
|
348
|
+
if (paragraph.length === 0) return;
|
|
349
|
+
const text = paragraph.join("\n").trim();
|
|
350
|
+
paragraph.length = 0;
|
|
351
|
+
if (!text) return;
|
|
352
|
+
blocks.push({ kind: "paragraph", spans: parseMarkdownLiteInline(text) });
|
|
353
|
+
};
|
|
354
|
+
const closeList = (list: MarkdownLiteBlock | null) => {
|
|
355
|
+
if (list && list.kind === "list" && list.items.length > 0) blocks.push(list);
|
|
356
|
+
};
|
|
357
|
+
let openList: MarkdownLiteBlock | null = null;
|
|
358
|
+
let fenceLanguage: string | undefined;
|
|
359
|
+
let fenceLines: string[] | null = null;
|
|
360
|
+
for (const rawLine of body.split(/\r?\n/)) {
|
|
361
|
+
const fence = FENCE_PATTERN.exec(rawLine);
|
|
362
|
+
if (fence) {
|
|
363
|
+
if (fenceLines == null) {
|
|
364
|
+
flushParagraph();
|
|
365
|
+
closeList(openList);
|
|
366
|
+
openList = null;
|
|
367
|
+
fenceLanguage = fence[1] || undefined;
|
|
368
|
+
fenceLines = [];
|
|
369
|
+
} else {
|
|
370
|
+
blocks.push({
|
|
371
|
+
kind: "code",
|
|
372
|
+
text: fenceLines.join("\n").replace(/\n$/, ""),
|
|
373
|
+
...(fenceLanguage ? { language: fenceLanguage } : {}),
|
|
374
|
+
});
|
|
375
|
+
fenceLanguage = undefined;
|
|
376
|
+
fenceLines = null;
|
|
377
|
+
}
|
|
378
|
+
continue;
|
|
379
|
+
}
|
|
380
|
+
if (fenceLines != null) {
|
|
381
|
+
fenceLines.push(rawLine);
|
|
382
|
+
continue;
|
|
383
|
+
}
|
|
384
|
+
if (!rawLine.trim()) {
|
|
385
|
+
flushParagraph();
|
|
386
|
+
closeList(openList);
|
|
387
|
+
openList = null;
|
|
388
|
+
continue;
|
|
389
|
+
}
|
|
390
|
+
const heading = HEADING_PATTERN.exec(rawLine);
|
|
391
|
+
if (heading) {
|
|
392
|
+
flushParagraph();
|
|
393
|
+
closeList(openList);
|
|
394
|
+
openList = null;
|
|
395
|
+
blocks.push({
|
|
396
|
+
kind: "heading",
|
|
397
|
+
level: heading[1].length as 1 | 2 | 3,
|
|
398
|
+
spans: parseMarkdownLiteInline(heading[2]),
|
|
399
|
+
});
|
|
400
|
+
continue;
|
|
401
|
+
}
|
|
402
|
+
const unordered = UNORDERED_PATTERN.exec(rawLine);
|
|
403
|
+
const ordered = unordered ? null : ORDERED_PATTERN.exec(rawLine);
|
|
404
|
+
if (unordered || ordered) {
|
|
405
|
+
flushParagraph();
|
|
406
|
+
const isOrdered = !unordered;
|
|
407
|
+
const content = (unordered?.[1] ?? ordered?.[1] ?? "").trim();
|
|
408
|
+
if (!content) continue;
|
|
409
|
+
if (!openList || openList.kind !== "list" || openList.ordered !== isOrdered) {
|
|
410
|
+
closeList(openList);
|
|
411
|
+
openList = { kind: "list", ordered: isOrdered, items: [] };
|
|
412
|
+
}
|
|
413
|
+
(openList as { kind: "list"; ordered: boolean; items: MarkdownLiteSpan[][] }).items.push(
|
|
414
|
+
parseMarkdownLiteInline(content),
|
|
415
|
+
);
|
|
416
|
+
continue;
|
|
417
|
+
}
|
|
418
|
+
closeList(openList);
|
|
419
|
+
openList = null;
|
|
420
|
+
paragraph.push(rawLine);
|
|
421
|
+
}
|
|
422
|
+
if (fenceLines != null) {
|
|
423
|
+
blocks.push({
|
|
424
|
+
kind: "code",
|
|
425
|
+
text: fenceLines.join("\n").replace(/\n$/, ""),
|
|
426
|
+
...(fenceLanguage ? { language: fenceLanguage } : {}),
|
|
427
|
+
});
|
|
428
|
+
}
|
|
429
|
+
flushParagraph();
|
|
430
|
+
closeList(openList);
|
|
431
|
+
return blocks;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
// ---------------------------------------------------------------------------
|
|
435
|
+
// RPC Contracts
|
|
436
|
+
// ---------------------------------------------------------------------------
|
|
437
|
+
|
|
438
|
+
export const IssueNumberInput = z
|
|
439
|
+
.object({
|
|
440
|
+
directory: z.string().optional(),
|
|
441
|
+
remoteUrl: z.string().optional(),
|
|
442
|
+
issueNumber: z.number().int().positive().optional(),
|
|
443
|
+
number: z.number().int().positive().optional(),
|
|
444
|
+
})
|
|
445
|
+
.superRefine((value, ctx) => {
|
|
446
|
+
if (value.issueNumber == null && value.number == null) {
|
|
447
|
+
ctx.addIssue({
|
|
448
|
+
code: z.ZodIssueCode.custom,
|
|
449
|
+
message: "issueNumber (or number) is required",
|
|
450
|
+
});
|
|
451
|
+
}
|
|
452
|
+
});
|
|
453
|
+
export type IssueNumberInput = z.infer<typeof IssueNumberInput>;
|
|
454
|
+
|
|
455
|
+
export const IssueDetailInputSchema = IssueNumberInput;
|
|
456
|
+
export type IssueDetailInput = z.infer<typeof IssueDetailInputSchema>;
|
|
457
|
+
|
|
458
|
+
export const IssueDetailOutputSchema = z.object({
|
|
459
|
+
repo: z.string().nullable(),
|
|
460
|
+
issue: IssueDetailSchema.nullable(),
|
|
461
|
+
fetchedAt: z.string().datetime(),
|
|
462
|
+
repoPublic: z.boolean().nullable().default(null),
|
|
463
|
+
tokenPresent: z.boolean().default(false),
|
|
464
|
+
tokenValid: z.boolean().nullable().default(null),
|
|
465
|
+
repoWritePermission: z.boolean().nullable().default(null),
|
|
466
|
+
error: z.string().optional(),
|
|
467
|
+
});
|
|
468
|
+
export type IssueDetailOutput = z.infer<typeof IssueDetailOutputSchema>;
|
|
469
|
+
|
|
470
|
+
export const issueDetailContract = defineContract({
|
|
471
|
+
name: "forge.issue-detail",
|
|
472
|
+
description: "Full body, comments, and parsed Agent Envelopes for one issue",
|
|
473
|
+
input: IssueDetailInputSchema,
|
|
474
|
+
output: IssueDetailOutputSchema,
|
|
475
|
+
});
|
|
476
|
+
|
|
477
|
+
export const SetLabelInputSchema = IssueNumberInput.extend({
|
|
478
|
+
label: z.string().min(1).max(100),
|
|
479
|
+
});
|
|
480
|
+
export type SetLabelInput = z.infer<typeof SetLabelInputSchema>;
|
|
481
|
+
|
|
482
|
+
export const SetLabelOutputSchema = z.object({
|
|
483
|
+
number: z.number(),
|
|
484
|
+
labels: z.array(z.string()),
|
|
485
|
+
error: z.string().optional(),
|
|
486
|
+
});
|
|
487
|
+
export type SetLabelOutput = z.infer<typeof SetLabelOutputSchema>;
|
|
488
|
+
|
|
489
|
+
export const setLabelContract = defineContract({
|
|
490
|
+
name: "forge.set-label",
|
|
491
|
+
description: "Apply one scoped label; an exclusive scope evicts the rest",
|
|
492
|
+
input: SetLabelInputSchema,
|
|
493
|
+
output: SetLabelOutputSchema,
|
|
494
|
+
});
|
|
495
|
+
|
|
496
|
+
export const AddCommentInputSchema = IssueNumberInput.extend({
|
|
497
|
+
body: z.string().min(1).max(10000),
|
|
498
|
+
});
|
|
499
|
+
export type AddCommentInput = z.infer<typeof AddCommentInputSchema>;
|
|
500
|
+
|
|
501
|
+
export const AddCommentOutputSchema = z.object({
|
|
502
|
+
number: z.number(),
|
|
503
|
+
commentId: z.number().nullable(),
|
|
504
|
+
error: z.string().optional(),
|
|
505
|
+
});
|
|
506
|
+
export type AddCommentOutput = z.infer<typeof AddCommentOutputSchema>;
|
|
507
|
+
|
|
508
|
+
export const addCommentContract = defineContract({
|
|
509
|
+
name: "forge.add-comment",
|
|
510
|
+
description: "Post a quick comment (or steering note) to the issue thread",
|
|
511
|
+
input: AddCommentInputSchema,
|
|
512
|
+
output: AddCommentOutputSchema,
|
|
513
|
+
});
|
|
514
|
+
|
|
515
|
+
export const CREATE_ISSUE_TITLE_MAX = 200;
|
|
516
|
+
export const CREATE_ISSUE_BODY_MAX = 10000;
|
|
517
|
+
export const CREATE_ISSUE_LABEL_MAX = 50;
|
|
518
|
+
|
|
519
|
+
export const CreateIssueInputSchema = z.object({
|
|
520
|
+
directory: z.string().optional(),
|
|
521
|
+
remoteUrl: z.string().optional(),
|
|
522
|
+
title: z.string(),
|
|
523
|
+
body: z.string().optional(),
|
|
524
|
+
labels: z.array(z.string()).optional(),
|
|
525
|
+
});
|
|
526
|
+
export type CreateIssueInput = z.infer<typeof CreateIssueInputSchema>;
|
|
527
|
+
|
|
528
|
+
export const CreateIssueOutputSchema = z.object({
|
|
529
|
+
repo: z.string().nullable(),
|
|
530
|
+
host: z.string().nullable().default(null),
|
|
531
|
+
number: z.number().int().positive().nullable().default(null),
|
|
532
|
+
error: z.string().optional(),
|
|
533
|
+
});
|
|
534
|
+
export type CreateIssueOutput = z.infer<typeof CreateIssueOutputSchema>;
|
|
535
|
+
|
|
536
|
+
export const createIssueContract = defineContract({
|
|
537
|
+
name: "forge.create-issue",
|
|
538
|
+
description: "Create a new issue on the configured forge repo",
|
|
539
|
+
input: CreateIssueInputSchema,
|
|
540
|
+
output: CreateIssueOutputSchema,
|
|
541
|
+
});
|
|
542
|
+
|
|
543
|
+
export function validateCreateIssueInput(input: {
|
|
544
|
+
title?: unknown;
|
|
545
|
+
body?: unknown;
|
|
546
|
+
labels?: unknown;
|
|
547
|
+
}): string | null {
|
|
548
|
+
const title = typeof input.title === "string" ? input.title.trim() : "";
|
|
549
|
+
if (!title) return "Issue title must not be empty";
|
|
550
|
+
if (title.length > CREATE_ISSUE_TITLE_MAX) return "Issue title is too long";
|
|
551
|
+
const body = typeof input.body === "string" ? input.body : "";
|
|
552
|
+
if (body.length > CREATE_ISSUE_BODY_MAX) return "Issue description is too long";
|
|
553
|
+
if (input.labels !== undefined) {
|
|
554
|
+
if (!Array.isArray(input.labels)) return "Labels must be a list";
|
|
555
|
+
if (input.labels.length > CREATE_ISSUE_LABEL_MAX) return "Too many labels";
|
|
556
|
+
if (input.labels.some((label) => typeof label !== "string" || !label.trim())) {
|
|
557
|
+
return "Label must not be empty";
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
return null;
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
export function parseLabelList(text: string | undefined | null): string[] {
|
|
564
|
+
if (!text || typeof text !== "string") return [];
|
|
565
|
+
const names: string[] = [];
|
|
566
|
+
const seen = new Set<string>();
|
|
567
|
+
for (const part of text.split(",")) {
|
|
568
|
+
const trimmed = part.trim();
|
|
569
|
+
if (!trimmed || seen.has(trimmed)) continue;
|
|
570
|
+
seen.add(trimmed);
|
|
571
|
+
names.push(trimmed);
|
|
572
|
+
}
|
|
573
|
+
return names;
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
// ---------------------------------------------------------------------------
|
|
577
|
+
// Open & Search issues RPC contracts
|
|
578
|
+
// ---------------------------------------------------------------------------
|
|
579
|
+
|
|
580
|
+
export const OpenIssuesInputSchema = z.object({
|
|
581
|
+
workspaceId: z.string().optional(),
|
|
582
|
+
directory: z.string().optional(),
|
|
583
|
+
remoteUrl: z.string().optional(),
|
|
584
|
+
page: z.number().int().positive().default(1),
|
|
585
|
+
});
|
|
586
|
+
export type OpenIssuesInput = z.infer<typeof OpenIssuesInputSchema>;
|
|
587
|
+
|
|
588
|
+
export const OpenIssuesOutputSchema = z.object({
|
|
589
|
+
repo: z.string().nullable(),
|
|
590
|
+
host: z.string().nullable().default(null),
|
|
591
|
+
issues: z.array(ForgeIssueSchema),
|
|
592
|
+
openIssueCount: z.number().int().nonnegative().nullable().default(null),
|
|
593
|
+
page: z.number().int().positive().default(1),
|
|
594
|
+
hasMore: z.boolean().default(false),
|
|
595
|
+
derivedRemote: z.string().nullable().default(null),
|
|
596
|
+
remoteSource: z.enum(["explicit", "derived"]).nullable().default(null),
|
|
597
|
+
repoPublic: z.boolean().nullable().default(null),
|
|
598
|
+
tokenPresent: z.boolean().default(false),
|
|
599
|
+
tokenValid: z.boolean().nullable().default(null),
|
|
600
|
+
repoWritePermission: z.boolean().nullable().default(null),
|
|
601
|
+
error: z.string().optional(),
|
|
602
|
+
});
|
|
603
|
+
export type OpenIssuesOutput = z.infer<typeof OpenIssuesOutputSchema>;
|
|
604
|
+
|
|
605
|
+
export const openIssuesContract = defineContract({
|
|
606
|
+
name: "forge.open-issues",
|
|
607
|
+
description: "List open forge issues for the repo backing a workspace directory",
|
|
608
|
+
input: OpenIssuesInputSchema,
|
|
609
|
+
output: OpenIssuesOutputSchema,
|
|
610
|
+
});
|
|
611
|
+
|
|
612
|
+
export const SearchIssuesInputSchema = z.object({
|
|
613
|
+
workspaceId: z.string().optional(),
|
|
614
|
+
directory: z.string().optional(),
|
|
615
|
+
remoteUrl: z.string().optional(),
|
|
616
|
+
query: z.string(),
|
|
617
|
+
page: z.number().int().positive().default(1),
|
|
618
|
+
});
|
|
619
|
+
export type SearchIssuesInput = z.infer<typeof SearchIssuesInputSchema>;
|
|
620
|
+
|
|
621
|
+
export const SearchIssuesOutputSchema = z.object({
|
|
622
|
+
repo: z.string().nullable(),
|
|
623
|
+
host: z.string().nullable().default(null),
|
|
624
|
+
issues: z.array(ForgeIssueSchema),
|
|
625
|
+
page: z.number().int().positive().default(1),
|
|
626
|
+
hasMore: z.boolean().default(false),
|
|
627
|
+
error: z.string().optional(),
|
|
628
|
+
});
|
|
629
|
+
export type SearchIssuesOutput = z.infer<typeof SearchIssuesOutputSchema>;
|
|
630
|
+
|
|
631
|
+
export const searchIssuesContract = defineContract({
|
|
632
|
+
name: "forge.search-issues",
|
|
633
|
+
description: "Keyword-search forge issues (open and closed) for the repo backing a workspace directory",
|
|
634
|
+
input: SearchIssuesInputSchema,
|
|
635
|
+
output: SearchIssuesOutputSchema,
|
|
636
|
+
});
|
|
637
|
+
|
|
638
|
+
// ---------------------------------------------------------------------------
|
|
639
|
+
// Forge context RPC contracts
|
|
640
|
+
// ---------------------------------------------------------------------------
|
|
641
|
+
|
|
642
|
+
export const ForgeContextInputSchema = z.object({
|
|
643
|
+
workspaceId: z.string().optional(),
|
|
644
|
+
directory: z.string().optional(),
|
|
645
|
+
});
|
|
646
|
+
export type ForgeContextInput = z.infer<typeof ForgeContextInputSchema>;
|
|
647
|
+
|
|
648
|
+
export const ForgeContextOutputSchema = z.object({
|
|
649
|
+
directory: z.string().nullable().default(null),
|
|
650
|
+
derivedRemote: z.string().nullable().default(null),
|
|
651
|
+
derivedHost: z.string().nullable().default(null),
|
|
652
|
+
derivedRepo: z.string().nullable().default(null),
|
|
653
|
+
});
|
|
654
|
+
export type ForgeContextOutput = z.infer<typeof ForgeContextOutputSchema>;
|
|
655
|
+
|
|
656
|
+
export const forgeContextContract = defineContract({
|
|
657
|
+
name: "forge.context",
|
|
658
|
+
description: "Workspace git-origin forge coordinates",
|
|
659
|
+
input: ForgeContextInputSchema,
|
|
660
|
+
output: ForgeContextOutputSchema,
|
|
661
|
+
});
|
|
662
|
+
|
|
663
|
+
export const forgeForgeContextContract = defineContract({
|
|
664
|
+
name: "forge.forge-context",
|
|
665
|
+
description: "Git-origin forge coordinates for a workspace, independent of issue queries",
|
|
666
|
+
input: ForgeContextInputSchema,
|
|
667
|
+
output: ForgeContextOutputSchema,
|
|
668
|
+
});
|
|
669
|
+
|
|
670
|
+
// ---------------------------------------------------------------------------
|
|
671
|
+
// Agent envelope parsing
|
|
672
|
+
// ---------------------------------------------------------------------------
|
|
673
|
+
|
|
674
|
+
export const SHA_PATTERN = /\b[0-9a-f]{7,40}\b/g;
|
|
675
|
+
export const PASEO_LINK_PATTERN = /paseo:\/\/[^\s)>\]]+/g;
|
|
676
|
+
export const PASEO_SERVER_PATTERN = /paseo:\/\/h\/([^/\s]+)\/agent\//;
|
|
677
|
+
|
|
678
|
+
export function parseAgentEnvelope(
|
|
679
|
+
commentId: number,
|
|
680
|
+
body: string | undefined | null,
|
|
681
|
+
): AgentEnvelope | null {
|
|
682
|
+
if (!body || typeof body !== "string") return null;
|
|
683
|
+
const match = ENVELOPE_FOOTER_PATTERN.exec(body);
|
|
684
|
+
if (!match) return null;
|
|
685
|
+
const sessionTitle = match[1].trim();
|
|
686
|
+
const agentShortId = match[2].trim();
|
|
687
|
+
if (!sessionTitle || !agentShortId) return null;
|
|
688
|
+
const model = match[3].trim() || null;
|
|
689
|
+
const repoBranch = match[4].trim();
|
|
690
|
+
const postedAt = match[5].trim() || null;
|
|
691
|
+
let repo: string | null = null;
|
|
692
|
+
let branch: string | null = null;
|
|
693
|
+
if (repoBranch) {
|
|
694
|
+
const sep = repoBranch.lastIndexOf(":");
|
|
695
|
+
if (sep > 0) {
|
|
696
|
+
repo = repoBranch.slice(0, sep) || null;
|
|
697
|
+
branch = repoBranch.slice(sep + 1) || null;
|
|
698
|
+
} else {
|
|
699
|
+
branch = repoBranch;
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
const commitShas = Array.from(
|
|
703
|
+
new Set(
|
|
704
|
+
(body.match(SHA_PATTERN) ?? []).filter(
|
|
705
|
+
(sha) => sha !== agentShortId && /[0-9]/.test(sha) && /[a-f]/.test(sha),
|
|
706
|
+
),
|
|
707
|
+
),
|
|
708
|
+
);
|
|
709
|
+
const paseoLinks = Array.from(new Set(body.match(PASEO_LINK_PATTERN) ?? []));
|
|
710
|
+
const serverMatch = PASEO_SERVER_PATTERN.exec(paseoLinks[0] ?? "");
|
|
711
|
+
return {
|
|
712
|
+
commentId,
|
|
713
|
+
sessionTitle,
|
|
714
|
+
agentShortId,
|
|
715
|
+
model,
|
|
716
|
+
repo,
|
|
717
|
+
branch,
|
|
718
|
+
postedAt,
|
|
719
|
+
commitShas,
|
|
720
|
+
paseoLinks,
|
|
721
|
+
serverId: serverMatch ? serverMatch[1] : null,
|
|
722
|
+
};
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
// ---------------------------------------------------------------------------
|
|
726
|
+
// Remote coordinates & resolution
|
|
727
|
+
// ---------------------------------------------------------------------------
|
|
728
|
+
|
|
729
|
+
export interface ForgeRemote {
|
|
730
|
+
host: string;
|
|
731
|
+
owner: string;
|
|
732
|
+
repo: string;
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
export function parseForgeRemote(url: string | undefined | null): ForgeRemote | null {
|
|
736
|
+
if (!url || typeof url !== "string") return null;
|
|
737
|
+
const trimmed = url.trim().replace(/\/+$/, "");
|
|
738
|
+
const scp = trimmed.match(/^(?:[^@/]+@)?([^:/]+):(.+)$/);
|
|
739
|
+
let host: string | undefined;
|
|
740
|
+
let pathPart: string | undefined;
|
|
741
|
+
if (scp && !trimmed.includes("://")) {
|
|
742
|
+
host = scp[1];
|
|
743
|
+
pathPart = scp[2];
|
|
744
|
+
} else {
|
|
745
|
+
const withScheme = /^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(trimmed)
|
|
746
|
+
? trimmed
|
|
747
|
+
: `ssh://${trimmed}`;
|
|
748
|
+
try {
|
|
749
|
+
const parsed = new URL(withScheme);
|
|
750
|
+
host = parsed.hostname || undefined;
|
|
751
|
+
pathPart = parsed.pathname.replace(/^\/+/, "") || undefined;
|
|
752
|
+
} catch {
|
|
753
|
+
return null;
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
if (!host || !pathPart) return null;
|
|
757
|
+
const segments = pathPart.replace(/\.git$/, "").split("/").filter(Boolean);
|
|
758
|
+
if (segments.length < 2) return null;
|
|
759
|
+
const repo = segments.pop() as string;
|
|
760
|
+
const owner = segments.pop() as string;
|
|
761
|
+
return { host, owner, repo };
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
export const BARE_REPO_PATTERN = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
|
|
765
|
+
|
|
766
|
+
export type ForgeTargetResolution =
|
|
767
|
+
| { ok: true; host: string; repo: string; source: "explicit" | "derived" }
|
|
768
|
+
| { ok: false; error: string };
|
|
769
|
+
|
|
770
|
+
export function resolveForgeTarget(
|
|
771
|
+
explicitTarget: string | undefined | null,
|
|
772
|
+
gitRemoteUrl: string | undefined | null,
|
|
773
|
+
): ForgeTargetResolution {
|
|
774
|
+
const explicit = typeof explicitTarget === "string" ? explicitTarget.trim() : "";
|
|
775
|
+
const git = parseForgeRemote(gitRemoteUrl);
|
|
776
|
+
if (explicit) {
|
|
777
|
+
const parsed = parseForgeRemote(explicit);
|
|
778
|
+
if (parsed) {
|
|
779
|
+
return { ok: true, host: parsed.host, repo: `${parsed.owner}/${parsed.repo}`, source: "explicit" };
|
|
780
|
+
}
|
|
781
|
+
if (BARE_REPO_PATTERN.test(explicit)) {
|
|
782
|
+
if (git) return { ok: true, host: git.host, repo: explicit, source: "explicit" };
|
|
783
|
+
return { ok: false, error: `Selected forge "${explicit}" needs a git origin remote to supply its host` };
|
|
784
|
+
}
|
|
785
|
+
return { ok: false, error: `Selected forge "${explicit}" is not a valid forge remote or owner/repo` };
|
|
786
|
+
}
|
|
787
|
+
if (!git) return { ok: false, error: "No forge repo found for this workspace" };
|
|
788
|
+
return { ok: true, host: git.host, repo: `${git.owner}/${git.repo}`, source: "derived" };
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
export function isValidForgeTarget(target: string | undefined | null): boolean {
|
|
792
|
+
const value = typeof target === "string" ? target.trim() : "";
|
|
793
|
+
if (!value) return false;
|
|
794
|
+
return Boolean(parseForgeRemote(value)) || BARE_REPO_PATTERN.test(value);
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
export function forgeCapabilityFromRepo(payload: unknown): boolean | null {
|
|
798
|
+
if (!payload || typeof payload !== "object") return null;
|
|
799
|
+
const record = payload as Record<string, unknown>;
|
|
800
|
+
const permissions = record.permissions;
|
|
801
|
+
if (permissions && typeof permissions === "object") {
|
|
802
|
+
const perms = permissions as Record<string, unknown>;
|
|
803
|
+
if (typeof perms.push === "boolean" || typeof perms.admin === "boolean") {
|
|
804
|
+
return perms.push === true || perms.admin === true;
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
const accessLevel = record.access_level ?? record.accessLevel;
|
|
808
|
+
if (typeof accessLevel === "number") return accessLevel >= 30;
|
|
809
|
+
return null;
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
export function activeForgeForDirectory(
|
|
813
|
+
settings:
|
|
814
|
+
| {
|
|
815
|
+
activeForgeByDirectory?: Record<string, string>;
|
|
816
|
+
remotesByDirectory?: Record<string, string>;
|
|
817
|
+
forgesByDirectory?: Record<string, string[]>;
|
|
818
|
+
}
|
|
819
|
+
| undefined
|
|
820
|
+
| null,
|
|
821
|
+
directory: string | undefined | null,
|
|
822
|
+
): string | null {
|
|
823
|
+
const dir = typeof directory === "string" ? directory.trim() : "";
|
|
824
|
+
if (!dir) return null;
|
|
825
|
+
const activeMap = settings?.activeForgeByDirectory;
|
|
826
|
+
if (activeMap && Object.prototype.hasOwnProperty.call(activeMap, dir)) {
|
|
827
|
+
const active = activeMap[dir];
|
|
828
|
+
return typeof active === "string" && active.trim() ? active.trim() : null;
|
|
829
|
+
}
|
|
830
|
+
const remotesMap = settings?.remotesByDirectory;
|
|
831
|
+
if (remotesMap && Object.prototype.hasOwnProperty.call(remotesMap, dir)) {
|
|
832
|
+
const legacy = remotesMap[dir];
|
|
833
|
+
return typeof legacy === "string" && legacy.trim() ? legacy.trim() : null;
|
|
834
|
+
}
|
|
835
|
+
return null;
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
// ---------------------------------------------------------------------------
|
|
839
|
+
// Issue & label utilities
|
|
840
|
+
// ---------------------------------------------------------------------------
|
|
841
|
+
|
|
842
|
+
export function normalizeIssueNumber(input: {
|
|
843
|
+
issueNumber?: number;
|
|
844
|
+
number?: number;
|
|
845
|
+
}): number | null {
|
|
846
|
+
const candidate = input.issueNumber ?? input.number;
|
|
847
|
+
return typeof candidate === "number" && Number.isInteger(candidate) && candidate > 0
|
|
848
|
+
? candidate
|
|
849
|
+
: null;
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
export function scopeOfLabel(label: string): string | null {
|
|
853
|
+
const slash = label.indexOf("/");
|
|
854
|
+
if (slash <= 0 || slash + 1 >= label.length) return null;
|
|
855
|
+
const scope = label.slice(0, slash);
|
|
856
|
+
if (!/^[A-Za-z0-9_.-]+$/.test(scope)) return null;
|
|
857
|
+
return scope;
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
export function liveScopesFromLabels(allLabels: string[]): string[] {
|
|
861
|
+
const scopes: string[] = [];
|
|
862
|
+
for (const label of allLabels) {
|
|
863
|
+
const scope = scopeOfLabel(label);
|
|
864
|
+
if (scope && !scopes.includes(scope)) scopes.push(scope);
|
|
865
|
+
}
|
|
866
|
+
return scopes;
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
export function liveScopesFromIssues(issues: Pick<ForgeIssue, "labels">[]): string[] {
|
|
870
|
+
return liveScopesFromLabels(issues.flatMap((issue) => issue.labels));
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
function rankOf(label: string | null, order: readonly string[]): number {
|
|
874
|
+
if (!label) return order.length;
|
|
875
|
+
const idx = (order as readonly string[]).indexOf(label);
|
|
876
|
+
return idx < 0 ? order.length : idx;
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
export function rankIssues<T extends Pick<ForgeIssue, "labels" | "updatedAt">>(issues: T[]): T[] {
|
|
880
|
+
return [...issues].sort((a, b) => {
|
|
881
|
+
const pri =
|
|
882
|
+
rankOf(currentPriorityLabel(a.labels), PRIORITY_ORDER) -
|
|
883
|
+
rankOf(currentPriorityLabel(b.labels), PRIORITY_ORDER);
|
|
884
|
+
if (pri !== 0) return pri;
|
|
885
|
+
const state =
|
|
886
|
+
rankOf(currentStateLabel(a.labels), STATE_ORDER) -
|
|
887
|
+
rankOf(currentStateLabel(b.labels), STATE_ORDER);
|
|
888
|
+
if (state !== 0) return state;
|
|
889
|
+
return (b.updatedAt ?? "").localeCompare(a.updatedAt ?? "");
|
|
890
|
+
});
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
export const PASEO_LABEL_SCOPES = [
|
|
894
|
+
"state",
|
|
895
|
+
"priority",
|
|
896
|
+
"attention",
|
|
897
|
+
"spec",
|
|
898
|
+
"kind",
|
|
899
|
+
"target",
|
|
900
|
+
"format",
|
|
901
|
+
"size",
|
|
902
|
+
"dep",
|
|
903
|
+
"flag",
|
|
904
|
+
] as const;
|
|
905
|
+
|
|
906
|
+
export const INSTALL_LABEL_MODES = ["merge", "replace"] as const;
|
|
907
|
+
export type InstallLabelMode = (typeof INSTALL_LABEL_MODES)[number];
|
|
908
|
+
|
|
909
|
+
export interface ForgeLabelRef {
|
|
910
|
+
id?: number;
|
|
911
|
+
name: string;
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
export interface LabelDefinition {
|
|
915
|
+
name: string;
|
|
916
|
+
color: string;
|
|
917
|
+
exclusive: boolean;
|
|
918
|
+
description: string;
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
export interface LabelSetPlan {
|
|
922
|
+
mode: InstallLabelMode;
|
|
923
|
+
create: LabelDefinition[];
|
|
924
|
+
remove: ForgeLabelRef[];
|
|
925
|
+
skip: string[];
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
export const InstallLabelsInputSchema = z.object({
|
|
929
|
+
directory: z.string().optional(),
|
|
930
|
+
remoteUrl: z.string().optional(),
|
|
931
|
+
mode: z.enum(INSTALL_LABEL_MODES),
|
|
932
|
+
});
|
|
933
|
+
export type InstallLabelsInput = z.infer<typeof InstallLabelsInputSchema>;
|
|
934
|
+
|
|
935
|
+
export const InstallLabelsOutputSchema = z.object({
|
|
936
|
+
host: z.string().nullable().default(null),
|
|
937
|
+
repo: z.string().nullable().default(null),
|
|
938
|
+
mode: z.enum(INSTALL_LABEL_MODES).nullable().default(null),
|
|
939
|
+
created: z.array(z.string()).default([]),
|
|
940
|
+
skipped: z.array(z.string()).default([]),
|
|
941
|
+
removed: z.array(z.string()).default([]),
|
|
942
|
+
error: z.string().optional(),
|
|
943
|
+
});
|
|
944
|
+
export type InstallLabelsOutput = z.infer<typeof InstallLabelsOutputSchema>;
|
|
945
|
+
|
|
946
|
+
const STATE_COLORS = ["#1d76db", "#0e7c6b", "#a6700b", "#6e40c9", "#1a7f37"];
|
|
947
|
+
const PRIORITY_COLORS = ["#d1242f", "#e85d04", "#1d76db", "#59636e", "#8c959f"];
|
|
948
|
+
|
|
949
|
+
const LABEL_DEFS: LabelDefinition[] = [
|
|
950
|
+
...STATE_ORDER.map((name, i): LabelDefinition => ({
|
|
951
|
+
name,
|
|
952
|
+
color: STATE_COLORS[i] ?? "#59636e",
|
|
953
|
+
exclusive: true,
|
|
954
|
+
description: `Workflow state ${i}`,
|
|
955
|
+
})),
|
|
956
|
+
...PRIORITY_ORDER.map((name, i): LabelDefinition => ({
|
|
957
|
+
name,
|
|
958
|
+
color: PRIORITY_COLORS[i] ?? "#59636e",
|
|
959
|
+
exclusive: true,
|
|
960
|
+
description: `Priority ${i}`,
|
|
961
|
+
})),
|
|
962
|
+
...ATTENTION_LABELS.map((name): LabelDefinition => ({
|
|
963
|
+
name,
|
|
964
|
+
color: "#8250df",
|
|
965
|
+
exclusive: true,
|
|
966
|
+
description: "Who acts next",
|
|
967
|
+
})),
|
|
968
|
+
...SPEC_LABELS.map((name): LabelDefinition => ({
|
|
969
|
+
name,
|
|
970
|
+
color: "#0e7c6b",
|
|
971
|
+
exclusive: true,
|
|
972
|
+
description: "Spec readiness",
|
|
973
|
+
})),
|
|
974
|
+
];
|
|
975
|
+
|
|
976
|
+
export function paseoLabelSet(): LabelDefinition[] {
|
|
977
|
+
return LABEL_DEFS.map((def) => ({ ...def }));
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
export function paseoLabelScopes(): string[] {
|
|
981
|
+
const scopes: string[] = [];
|
|
982
|
+
for (const def of LABEL_DEFS) {
|
|
983
|
+
const scope = scopeOfLabel(def.name);
|
|
984
|
+
if (scope && !scopes.includes(scope)) scopes.push(scope);
|
|
985
|
+
}
|
|
986
|
+
return scopes;
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
export function planLabelSetInstall(
|
|
990
|
+
existing: ForgeLabelRef[],
|
|
991
|
+
mode: InstallLabelMode,
|
|
992
|
+
): LabelSetPlan {
|
|
993
|
+
const desired = paseoLabelSet();
|
|
994
|
+
const desiredNames = new Set(desired.map((def) => def.name));
|
|
995
|
+
const existingNames = new Set(
|
|
996
|
+
existing.map((label) => label.name).filter((name): name is string => typeof name === "string"),
|
|
997
|
+
);
|
|
998
|
+
const ownScopes = new Set(paseoLabelScopes());
|
|
999
|
+
const remove =
|
|
1000
|
+
mode === "replace"
|
|
1001
|
+
? existing.filter((label) => {
|
|
1002
|
+
const scope = scopeOfLabel(label.name);
|
|
1003
|
+
return scope !== null && ownScopes.has(scope) && !desiredNames.has(label.name);
|
|
1004
|
+
})
|
|
1005
|
+
: [];
|
|
1006
|
+
const create = desired.filter((def) => !existingNames.has(def.name));
|
|
1007
|
+
const skip = desired.filter((def) => existingNames.has(def.name)).map((def) => def.name);
|
|
1008
|
+
return { mode, create, remove, skip };
|
|
1009
|
+
}
|
|
1010
|
+
|