@xpufx/paseo-forges 0.1.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.
Files changed (128) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +109 -0
  3. package/client/active-forge.ts +47 -0
  4. package/client/board-alert.tsx +236 -0
  5. package/client/foreign-link.tsx +33 -0
  6. package/client/hook-queue-panel.tsx +412 -0
  7. package/client/issues-pill.tsx +1976 -0
  8. package/client/label-chip.tsx +116 -0
  9. package/client/linkifier.tsx +280 -0
  10. package/client/pill-label.ts +183 -0
  11. package/client/vendor/paseo-plugin-helper/command-center.ts +43 -0
  12. package/client/vendor/paseo-plugin-helper/components/AboutSection.tsx +493 -0
  13. package/client/vendor/paseo-plugin-helper/components/AttentionBeacon.tsx +250 -0
  14. package/client/vendor/paseo-plugin-helper/components/Badge.tsx +156 -0
  15. package/client/vendor/paseo-plugin-helper/components/Button.tsx +178 -0
  16. package/client/vendor/paseo-plugin-helper/components/Card.tsx +225 -0
  17. package/client/vendor/paseo-plugin-helper/components/CodeBlock.tsx +196 -0
  18. package/client/vendor/paseo-plugin-helper/components/Collapsible.tsx +277 -0
  19. package/client/vendor/paseo-plugin-helper/components/CommandBox.tsx +172 -0
  20. package/client/vendor/paseo-plugin-helper/components/CopyButton.tsx +180 -0
  21. package/client/vendor/paseo-plugin-helper/components/DataTable.tsx +200 -0
  22. package/client/vendor/paseo-plugin-helper/components/EmptyState.tsx +97 -0
  23. package/client/vendor/paseo-plugin-helper/components/HighlightedText.tsx +70 -0
  24. package/client/vendor/paseo-plugin-helper/components/InlineButton.tsx +73 -0
  25. package/client/vendor/paseo-plugin-helper/components/KeyValue.tsx +446 -0
  26. package/client/vendor/paseo-plugin-helper/components/MetricGauge.tsx +247 -0
  27. package/client/vendor/paseo-plugin-helper/components/ProgressBar.tsx +117 -0
  28. package/client/vendor/paseo-plugin-helper/components/Responsive.tsx +53 -0
  29. package/client/vendor/paseo-plugin-helper/components/SearchInput.tsx +118 -0
  30. package/client/vendor/paseo-plugin-helper/components/SectionHeader.tsx +80 -0
  31. package/client/vendor/paseo-plugin-helper/components/Select.tsx +215 -0
  32. package/client/vendor/paseo-plugin-helper/components/StatusDot.tsx +80 -0
  33. package/client/vendor/paseo-plugin-helper/components/Tabs.tsx +319 -0
  34. package/client/vendor/paseo-plugin-helper/components/TextInput.tsx +150 -0
  35. package/client/vendor/paseo-plugin-helper/components/Toggle.tsx +163 -0
  36. package/client/vendor/paseo-plugin-helper/components/TruncatedText.tsx +157 -0
  37. package/client/vendor/paseo-plugin-helper/components/index.ts +25 -0
  38. package/client/vendor/paseo-plugin-helper/custom-pills.tsx +224 -0
  39. package/client/vendor/paseo-plugin-helper/forge-icon.tsx +79 -0
  40. package/client/vendor/paseo-plugin-helper/host.ts +277 -0
  41. package/client/vendor/paseo-plugin-helper/icon.tsx +39 -0
  42. package/client/vendor/paseo-plugin-helper/index.ts +28 -0
  43. package/client/vendor/paseo-plugin-helper/layout/ActionBar.tsx +49 -0
  44. package/client/vendor/paseo-plugin-helper/layout/FormRow.tsx +103 -0
  45. package/client/vendor/paseo-plugin-helper/layout/Grid.tsx +65 -0
  46. package/client/vendor/paseo-plugin-helper/layout/ModalBody.tsx +378 -0
  47. package/client/vendor/paseo-plugin-helper/layout/ModalContent.tsx +49 -0
  48. package/client/vendor/paseo-plugin-helper/layout/Row.tsx +39 -0
  49. package/client/vendor/paseo-plugin-helper/layout/Stack.tsx +39 -0
  50. package/client/vendor/paseo-plugin-helper/layout/index.ts +7 -0
  51. package/client/vendor/paseo-plugin-helper/panel.tsx +81 -0
  52. package/client/vendor/paseo-plugin-helper/pill.tsx +884 -0
  53. package/client/vendor/paseo-plugin-helper/query-refresh.ts +79 -0
  54. package/client/vendor/paseo-plugin-helper/query.ts +66 -0
  55. package/client/vendor/paseo-plugin-helper/settings-screen.tsx +372 -0
  56. package/client/vendor/paseo-plugin-helper/settings.ts +181 -0
  57. package/client/vendor/paseo-plugin-helper/shared-settings.ts +46 -0
  58. package/client/vendor/paseo-plugin-helper/snapshot.ts +68 -0
  59. package/client/vendor/paseo-plugin-helper/surface.tsx +80 -0
  60. package/client/vendor/paseo-plugin-helper/theme/color-utils.ts +118 -0
  61. package/client/vendor/paseo-plugin-helper/theme/flair.ts +76 -0
  62. package/client/vendor/paseo-plugin-helper/theme/host-variables.ts +121 -0
  63. package/client/vendor/paseo-plugin-helper/theme/index.ts +7 -0
  64. package/client/vendor/paseo-plugin-helper/theme/provider.tsx +214 -0
  65. package/client/vendor/paseo-plugin-helper/theme/responsive.ts +213 -0
  66. package/client/vendor/paseo-plugin-helper/theme/tokens.ts +161 -0
  67. package/client/vendor/paseo-plugin-helper/theme/useResponsive.ts +57 -0
  68. package/client/vendor/paseo-plugin-helper/utils/clipboard.ts +149 -0
  69. package/client/vendor/paseo-plugin-helper/utils/haptics.ts +34 -0
  70. package/client/webhook-card.tsx +198 -0
  71. package/docs/specs/forge-workflow-gui.md +820 -0
  72. package/docs/workflow.md +339 -0
  73. package/examples/README.md +108 -0
  74. package/examples/hook-service/README.md +66 -0
  75. package/examples/hook-service/forge-hook.service +28 -0
  76. package/examples/hook-service/hook-server.mjs +265 -0
  77. package/examples/hook-service/hook.env.example +21 -0
  78. package/examples/labels/README.md +64 -0
  79. package/examples/labels/label-base.yaml +117 -0
  80. package/examples/skills/coding-agent/SKILL.md +262 -0
  81. package/examples/skills/coding-agent-fgjx/SKILL.md +271 -0
  82. package/examples/skills/orchestrator/SKILL.md +133 -0
  83. package/examples/skills/orchestrator-fgjx/SKILL.md +139 -0
  84. package/examples/tools/README.md +68 -0
  85. package/examples/tools/fgjx +464 -0
  86. package/package.json +38 -0
  87. package/paseo-plugin.json +4 -0
  88. package/server/forge-client.ts +484 -0
  89. package/server/forge-guard.ts +70 -0
  90. package/server/git-origin.ts +70 -0
  91. package/server/hook-queue.ts +127 -0
  92. package/server/issues.ts +542 -0
  93. package/server/settings.ts +52 -0
  94. package/server/vendor/paseo-plugin-helper/agent.ts +85 -0
  95. package/server/vendor/paseo-plugin-helper/custom-pills.ts +344 -0
  96. package/server/vendor/paseo-plugin-helper/index.ts +18 -0
  97. package/server/vendor/paseo-plugin-helper/jsonc.ts +78 -0
  98. package/server/vendor/paseo-plugin-helper/logger.ts +210 -0
  99. package/server/vendor/paseo-plugin-helper/mcp-config.ts +367 -0
  100. package/server/vendor/paseo-plugin-helper/mcp-injection.ts +85 -0
  101. package/server/vendor/paseo-plugin-helper/network.ts +91 -0
  102. package/server/vendor/paseo-plugin-helper/plugins.ts +160 -0
  103. package/server/vendor/paseo-plugin-helper/process.ts +186 -0
  104. package/server/vendor/paseo-plugin-helper/redact.ts +86 -0
  105. package/server/vendor/paseo-plugin-helper/rpc-guard.ts +77 -0
  106. package/server/vendor/paseo-plugin-helper/settings.ts +97 -0
  107. package/server/vendor/paseo-plugin-helper/shared-settings.ts +243 -0
  108. package/server/vendor/paseo-plugin-helper/storage.ts +244 -0
  109. package/server/vendor/paseo-plugin-helper/system.ts +128 -0
  110. package/server/vendor/paseo-plugin-helper/task.ts +116 -0
  111. package/server/vendor/paseo-plugin-helper/version.ts +153 -0
  112. package/server/vendor/paseo-plugin-helper/workspace-beacon.ts +418 -0
  113. package/shared/hook-queue.ts +140 -0
  114. package/shared/issues.ts +1750 -0
  115. package/shared/vendor/paseo-plugin-helper/README.md +11 -0
  116. package/shared/vendor/paseo-plugin-helper/async.ts +35 -0
  117. package/shared/vendor/paseo-plugin-helper/custom-pills.ts +169 -0
  118. package/shared/vendor/paseo-plugin-helper/forge.ts +110 -0
  119. package/shared/vendor/paseo-plugin-helper/formatters.ts +271 -0
  120. package/shared/vendor/paseo-plugin-helper/highlight.ts +184 -0
  121. package/shared/vendor/paseo-plugin-helper/index.ts +10 -0
  122. package/shared/vendor/paseo-plugin-helper/rpc.ts +72 -0
  123. package/shared/vendor/paseo-plugin-helper/settings.ts +138 -0
  124. package/shared/vendor/paseo-plugin-helper/suite-settings.ts +17 -0
  125. package/shared/vendor/paseo-plugin-helper/suppressed.ts +31 -0
  126. package/shared/vendor/paseo-plugin-helper/types.ts +36 -0
  127. package/shared/version.ts +2 -0
  128. package/shared/webhook.ts +223 -0
@@ -0,0 +1,1750 @@
1
+ import { z } from "zod";
2
+ import {
3
+ defineContract,
4
+ defineSettingsContract,
5
+ normalizeForgeHost,
6
+ normalizeSearchQuery,
7
+ truncate,
8
+ } from "paseo-plugin-helper/shared";
9
+
10
+ export const FORGES_PLUGIN_ID = "forges";
11
+
12
+ /**
13
+ * Label metadata as Forgejo's REST API returns it. `color` is a hex string
14
+ * without the leading `#`; `description` is the optional label tooltip.
15
+ */
16
+ export const ForgeLabelSchema = z.object({
17
+ name: z.string(),
18
+ color: z.string().optional(),
19
+ description: z.string().optional(),
20
+ });
21
+ export type ForgeLabel = z.infer<typeof ForgeLabelSchema>;
22
+
23
+ export const ForgeIssueSchema = z.object({
24
+ number: z.number(),
25
+ title: z.string(),
26
+ state: z.string(),
27
+ /** Label names view, kept for scope/priority/filter logic. */
28
+ labels: z.array(z.string()),
29
+ /** Full label objects (name/color/description) for rendering. */
30
+ labelDetails: z.array(ForgeLabelSchema).default([]),
31
+ updatedAt: z.string().optional(),
32
+ });
33
+ export type ForgeIssue = z.infer<typeof ForgeIssueSchema>;
34
+
35
+ /** Normalize a Forgejo label color into `#rrggbb`, or null when unusable. */
36
+ export function normalizeLabelColor(color: string | undefined | null): string | null {
37
+ if (typeof color !== "string") return null;
38
+ const hex = color.trim().replace(/^#/, "");
39
+ if (/^[0-9a-fA-F]{3}$/.test(hex)) {
40
+ return `#${hex.split("").map((char) => char + char).join("")}`.toLowerCase();
41
+ }
42
+ if (/^[0-9a-fA-F]{6}$/.test(hex)) return `#${hex}`.toLowerCase();
43
+ return null;
44
+ }
45
+
46
+ /** Text colors a label pill may use; black on light, white on dark. */
47
+ export const LABEL_TEXT_ON_LIGHT = "#000000";
48
+ export const LABEL_TEXT_ON_DARK = "#ffffff";
49
+
50
+ /**
51
+ * Readable text color for a label background (WCAG relative luminance): light
52
+ * backgrounds take black text, dark ones white. Null when the background is not
53
+ * a usable hex color, so callers fall back to the neutral theme chip.
54
+ */
55
+ export function labelTextColor(color: string | undefined | null): string | null {
56
+ const background = normalizeLabelColor(color);
57
+ if (!background) return null;
58
+ return relativeLuminance(background) > 0.45 ? LABEL_TEXT_ON_LIGHT : LABEL_TEXT_ON_DARK;
59
+ }
60
+
61
+ function relativeLuminance(hex: string): number {
62
+ const channel = (offset: number): number => {
63
+ const value = parseInt(hex.slice(offset, offset + 2), 16) / 255;
64
+ return value <= 0.03928 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4;
65
+ };
66
+ return 0.2126 * channel(1) + 0.7152 * channel(3) + 0.0722 * channel(5);
67
+ }
68
+
69
+ /** Scope and value halves of a scoped label name (`"state/1-wip"`). */
70
+ export interface ScopedLabelParts {
71
+ scope: string;
72
+ value: string;
73
+ }
74
+
75
+ /**
76
+ * Split a scoped label name on its first `/` into scope and value. Null when
77
+ * the name is unscoped or the separator sits at either end.
78
+ */
79
+ export function splitScopedLabel(name: string): ScopedLabelParts | null {
80
+ const slash = name.indexOf("/");
81
+ if (slash <= 0 || slash + 1 >= name.length) return null;
82
+ return { scope: name.slice(0, slash), value: name.slice(slash + 1) };
83
+ }
84
+
85
+ /**
86
+ * The darker shade Forgejo paints the scope half of a scoped label with.
87
+ * Mirrors the channel-proportional darkening in Gitea/Forgejo's `RenderLabel`
88
+ * so a scoped pill reads like the site's; null when the color is unusable.
89
+ */
90
+ export function darkenLabelColor(color: string | undefined | null): string | null {
91
+ const background = normalizeLabelColor(color);
92
+ if (!background) return null;
93
+ const channels = [
94
+ parseInt(background.slice(1, 3), 16),
95
+ parseInt(background.slice(3, 5), 16),
96
+ parseInt(background.slice(5, 7), 16),
97
+ ];
98
+ const [r, g, b] = channels;
99
+ const brightness = (0.2126729 * r + 0.7151522 * g + 0.072175 * b) / 255;
100
+ const contrast = 0.01 + brightness * 0.03;
101
+ const darken = contrast + Math.max(brightness + contrast - 1, 0);
102
+ const factor = Math.max(brightness - darken, 0) / Math.max(brightness, 1 / 255);
103
+ return `#${channels
104
+ .map((channel) =>
105
+ Math.min(Math.round(channel * factor), 255)
106
+ .toString(16)
107
+ .padStart(2, "0"),
108
+ )
109
+ .join("")}`;
110
+ }
111
+
112
+ /**
113
+ * One segment of a label pill: the whole name of an unscoped label, or one
114
+ * side of a scoped name. Colors are absent when the forge gave none, in which
115
+ * case the segment falls back to the neutral theme chip.
116
+ */
117
+ export interface LabelChipHalf {
118
+ text: string;
119
+ background?: string;
120
+ textColor?: string;
121
+ }
122
+
123
+ /**
124
+ * Longest value half a chip renders before it is tail-ellipsized. Scoped names
125
+ * such as `attention/0-orchestrator` are what widen a chip past a wrap line, so
126
+ * the value is bounded while the scope half stays whole and legible.
127
+ */
128
+ export const LABEL_VALUE_MAX_LENGTH = 10;
129
+
130
+ /**
131
+ * Compact one chip half for the wrapping label rows. A value wider than
132
+ * `LABEL_VALUE_MAX_LENGTH` is tail-ellipsized, so several chips share a line
133
+ * instead of one per row. Mirrors `shortLabelName` as a pure, unit-testable
134
+ * display transform; callers never print the raw label name.
135
+ */
136
+ export function compactLabelValue(value: string): string {
137
+ return truncate(value, LABEL_VALUE_MAX_LENGTH);
138
+ }
139
+
140
+ /**
141
+ * How a label renders: a single-segment pill for an unscoped name, or a
142
+ * two-segment pill for a scoped name. A scoped name is always split, so the
143
+ * raw `scope/value` slash form is never rendered even when no color is known.
144
+ */
145
+ export type LabelChipPlan =
146
+ | { kind: "single"; half: LabelChipHalf }
147
+ | { kind: "scoped"; scope: LabelChipHalf; value: LabelChipHalf };
148
+
149
+ export function planLabelChip(label: ForgeLabel): LabelChipPlan {
150
+ const background = normalizeLabelColor(label.color);
151
+ const textColor = labelTextColor(label.color);
152
+ const parts = splitScopedLabel(label.name);
153
+ if (!parts) {
154
+ const text = compactLabelValue(label.name);
155
+ return {
156
+ kind: "single",
157
+ half:
158
+ background && textColor
159
+ ? { text, background, textColor }
160
+ : { text },
161
+ };
162
+ }
163
+ const value = compactLabelValue(parts.value);
164
+ if (!background || !textColor) {
165
+ return {
166
+ kind: "scoped",
167
+ scope: { text: parts.scope },
168
+ value: { text: value },
169
+ };
170
+ }
171
+ return {
172
+ kind: "scoped",
173
+ scope: {
174
+ text: parts.scope,
175
+ background: darkenLabelColor(label.color) ?? background,
176
+ textColor,
177
+ },
178
+ value: { text: value, background, textColor },
179
+ };
180
+ }
181
+
182
+ export const OpenIssuesInputSchema = z.object({
183
+ directory: z.string().optional(),
184
+ remoteUrl: z.string().optional(),
185
+ page: z.number().int().positive().default(1),
186
+ });
187
+ export type OpenIssuesInput = z.infer<typeof OpenIssuesInputSchema>;
188
+
189
+ export const OpenIssuesOutputSchema = z.object({
190
+ repo: z.string().nullable(),
191
+ host: z.string().nullable().default(null),
192
+ issues: z.array(ForgeIssueSchema),
193
+ openIssueCount: z.number().int().nonnegative().nullable().default(null),
194
+ page: z.number().int().positive().default(1),
195
+ hasMore: z.boolean().default(false),
196
+ derivedRemote: z.string().nullable().default(null),
197
+ remoteSource: z.enum(["explicit", "derived"]).nullable().default(null),
198
+ repoPublic: z.boolean().nullable().default(null),
199
+ tokenPresent: z.boolean().default(false),
200
+ tokenValid: z.boolean().nullable().default(null),
201
+ /** Repo-reported write capability; null when the host returned none (#193). */
202
+ repoWritePermission: z.boolean().nullable().default(null),
203
+ error: z.string().optional(),
204
+ });
205
+ export type OpenIssuesOutput = z.infer<typeof OpenIssuesOutputSchema>;
206
+
207
+ export const openIssuesContract = defineContract({
208
+ name: "forge.open-issues",
209
+ description: "List open forge issues for the repo backing a workspace directory",
210
+ input: OpenIssuesInputSchema,
211
+ output: OpenIssuesOutputSchema,
212
+ });
213
+
214
+ export const SearchIssuesInputSchema = z.object({
215
+ directory: z.string().optional(),
216
+ remoteUrl: z.string().optional(),
217
+ query: z.string(),
218
+ page: z.number().int().positive().default(1),
219
+ });
220
+ export type SearchIssuesInput = z.infer<typeof SearchIssuesInputSchema>;
221
+
222
+ export const SearchIssuesOutputSchema = z.object({
223
+ repo: z.string().nullable(),
224
+ host: z.string().nullable().default(null),
225
+ // Live keyword search returns both open and closed issues, so the rows carry
226
+ // their own state instead of the open-only shape of `openIssuesContract`.
227
+ issues: z.array(ForgeIssueSchema),
228
+ page: z.number().int().positive().default(1),
229
+ hasMore: z.boolean().default(false),
230
+ error: z.string().optional(),
231
+ });
232
+ export type SearchIssuesOutput = z.infer<typeof SearchIssuesOutputSchema>;
233
+
234
+ export const searchIssuesContract = defineContract({
235
+ name: "forge.search-issues",
236
+ description: "Keyword-search forge issues (open and closed) for the repo backing a workspace directory",
237
+ input: SearchIssuesInputSchema,
238
+ output: SearchIssuesOutputSchema,
239
+ });
240
+
241
+ /**
242
+ * Monotonic generation gate for debounced remote search (issue #139). Responses
243
+ * can settle out of order since each keystroke's query races on the network, so
244
+ * every dispatch takes a fresh generation and only the newest one may publish.
245
+ */
246
+ export function createRemoteSearchGate(): {
247
+ begin: () => number;
248
+ accept: (generation: number) => boolean;
249
+ } {
250
+ let latest = 0;
251
+ return {
252
+ begin: () => {
253
+ latest += 1;
254
+ return latest;
255
+ },
256
+ accept: (generation: number) => generation === latest,
257
+ };
258
+ }
259
+
260
+ /**
261
+ * Instant client-side filter for the loaded issues snapshot: a bare number
262
+ * (`#123` or `123`) matches the issue number by prefix; otherwise the query
263
+ * matches anywhere in the title or a label. The query is normalized first
264
+ * (`normalizeSearchQuery`), so `meta` and `"meta"` filter identically while the
265
+ * raw, quoted query still goes to the remote forge (which reads quotes as a
266
+ * phrase operator).
267
+ */
268
+ export function issueMatchesQuery(issue: ForgeIssue, query: string): boolean {
269
+ const q = normalizeSearchQuery(query).toLowerCase();
270
+ if (!q) return true;
271
+ const digits = q.startsWith("#") ? q.slice(1) : q;
272
+ if (/^\d+$/.test(digits) && String(issue.number).startsWith(digits)) return true;
273
+ return (
274
+ issue.title.toLowerCase().includes(q) ||
275
+ issue.labels.some((label) => label.toLowerCase().includes(q))
276
+ );
277
+ }
278
+
279
+ /**
280
+ * Which issue list a search surface renders. The instant client-side filter is
281
+ * always the fallback; a remote result wins only when the toggle is on, it
282
+ * carried no error, and it was produced for the query currently in the box —
283
+ * so a slow response from an earlier keystroke never displaces fresher results.
284
+ */
285
+ export function resolveIssueSearchLayer(input: {
286
+ query: string;
287
+ remoteEnabled: boolean;
288
+ remoteQuery: string | null;
289
+ remoteIssues: ForgeIssue[] | null;
290
+ remoteError: string | null;
291
+ clientIssues: ForgeIssue[];
292
+ }): { issues: ForgeIssue[]; source: "client" | "remote" } {
293
+ const active = input.query.trim();
294
+ const isCurrent =
295
+ input.remoteEnabled &&
296
+ input.remoteIssues !== null &&
297
+ input.remoteError === null &&
298
+ input.remoteQuery !== null &&
299
+ input.remoteQuery === active;
300
+ if (isCurrent) return { issues: input.remoteIssues as ForgeIssue[], source: "remote" };
301
+ return { issues: input.clientIssues, source: "client" };
302
+ }
303
+
304
+ /**
305
+ * Workspace git-origin forge coordinates, separate from `openIssuesContract`.
306
+ * The settings form needs a host to key `tokensByHost` even while the issues
307
+ * query is loading, errored, or has no payload, so this probe never depends on
308
+ * the issues result.
309
+ */
310
+ export const ForgeContextInputSchema = z.object({
311
+ directory: z.string().optional(),
312
+ });
313
+ export type ForgeContextInput = z.infer<typeof ForgeContextInputSchema>;
314
+
315
+ export const ForgeContextOutputSchema = z.object({
316
+ directory: z.string().nullable().default(null),
317
+ derivedRemote: z.string().nullable().default(null),
318
+ derivedHost: z.string().nullable().default(null),
319
+ derivedRepo: z.string().nullable().default(null),
320
+ });
321
+ export type ForgeContextOutput = z.infer<typeof ForgeContextOutputSchema>;
322
+
323
+ export const forgeContextContract = defineContract({
324
+ name: "forge.forge-context",
325
+ description: "Git-origin forge coordinates for a workspace, independent of issue queries",
326
+ input: ForgeContextInputSchema,
327
+ output: ForgeContextOutputSchema,
328
+ });
329
+
330
+ export interface ForgeRemote {
331
+ host: string;
332
+ owner: string;
333
+ repo: string;
334
+ }
335
+
336
+ /**
337
+ * Parse a git remote URL into forge coordinates. Handles
338
+ * git@host:owner/repo(.git), https://host/owner/repo(.git), and
339
+ * ssh://git@host/owner/repo(.git). Returns null when the URL does not
340
+ * carry an owner/repo path.
341
+ */
342
+ export function parseForgeRemote(url: string | undefined | null): ForgeRemote | null {
343
+ if (!url || typeof url !== "string") return null;
344
+ const trimmed = url.trim().replace(/\/+$/, "");
345
+ const scp = trimmed.match(/^(?:[^@/]+@)?([^:/]+):(.+)$/);
346
+ let host: string | undefined;
347
+ let pathPart: string | undefined;
348
+ if (scp && !trimmed.includes("://")) {
349
+ host = scp[1];
350
+ pathPart = scp[2];
351
+ } else {
352
+ const withScheme = /^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(trimmed)
353
+ ? trimmed
354
+ : `ssh://${trimmed}`;
355
+ try {
356
+ const parsed = new URL(withScheme);
357
+ host = parsed.hostname || undefined;
358
+ pathPart = parsed.pathname.replace(/^\/+/, "") || undefined;
359
+ } catch {
360
+ return null;
361
+ }
362
+ }
363
+ if (!host || !pathPart) return null;
364
+ const segments = pathPart.replace(/\.git$/, "").split("/").filter(Boolean);
365
+ if (segments.length < 2) return null;
366
+ const repo = segments.pop() as string;
367
+ const owner = segments.pop() as string;
368
+ return { host, owner, repo };
369
+ }
370
+
371
+ // ---------------------------------------------------------------------------
372
+ // Explicit remote URL override (issue #109 operator redirect).
373
+ // A workspace may pin its forge coordinates via the settings screen
374
+ // instead of relying on the git origin remote (which can carry SSH
375
+ // aliases unknown to the API client). Precedence: explicit config > git remote.
376
+ // ---------------------------------------------------------------------------
377
+
378
+ const BARE_REPO_PATTERN = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
379
+
380
+ export const ForgeSettingsSchema = z.object({
381
+ remotesByDirectory: z.record(z.string(), z.string()).default({}),
382
+ tokensByHost: z.record(z.string(), z.string()).default({}),
383
+ namesByDirectory: z.record(z.string(), z.string()).default({}),
384
+ // Multi-forge selection (issue #137): the forge remotes a workspace may
385
+ // watch, and which one is active. Explicit selection wins absolutely; when
386
+ // nothing is selected the server derives the remote from git origin.
387
+ forgesByDirectory: z.record(z.string(), z.array(z.string())).default({}),
388
+ activeForgeByDirectory: z.record(z.string(), z.string()).default({}),
389
+ });
390
+ export type ForgeSettings = z.infer<typeof ForgeSettingsSchema>;
391
+
392
+ export const forgeSettingsContract = defineSettingsContract({
393
+ name: "forges.settings",
394
+ schema: ForgeSettingsSchema,
395
+ description: "Forges plugin settings: forge selection, remote overrides and host tokens",
396
+ });
397
+
398
+ export interface ResolvedForgeRepo {
399
+ host: string;
400
+ repo: string;
401
+ }
402
+
403
+ /**
404
+ * Resolve forge coordinates with explicit-config-wins precedence.
405
+ * The explicit value accepts every `parseForgeRemote` form
406
+ * (scp-like, ssh://, https://) plus a bare `owner/repo`, which borrows
407
+ * its host from the git remote. Returns null when neither yields coords.
408
+ */
409
+ export function resolveForgeRepo(
410
+ explicitRemote: string | undefined | null,
411
+ gitRemoteUrl: string | undefined | null,
412
+ ): ResolvedForgeRepo | null {
413
+ const git = parseForgeRemote(gitRemoteUrl);
414
+ const explicit = typeof explicitRemote === "string" ? explicitRemote.trim() : "";
415
+ if (explicit) {
416
+ const parsed = parseForgeRemote(explicit);
417
+ if (parsed) return { host: parsed.host, repo: `${parsed.owner}/${parsed.repo}` };
418
+ if (BARE_REPO_PATTERN.test(explicit) && git) {
419
+ return { host: git.host, repo: explicit };
420
+ }
421
+ }
422
+ if (!git) return null;
423
+ return { host: git.host, repo: `${git.owner}/${git.repo}` };
424
+ }
425
+
426
+ export type ForgeTargetResolution =
427
+ | { ok: true; host: string; repo: string; source: "explicit" | "derived" }
428
+ | { ok: false; error: string };
429
+
430
+ /**
431
+ * Strict forge-coordinate resolution for the server. An explicit target wins
432
+ * absolutely: when it is invalid the result is an error and git origin is
433
+ * NEVER consulted as a silent fallback. Git origin is only used to derive
434
+ * coordinates when nothing is explicit, or to supply the host for a bare
435
+ * `owner/repo` that the git remote can qualify.
436
+ */
437
+ export function resolveForgeTarget(
438
+ explicitTarget: string | undefined | null,
439
+ gitRemoteUrl: string | undefined | null,
440
+ ): ForgeTargetResolution {
441
+ const explicit = typeof explicitTarget === "string" ? explicitTarget.trim() : "";
442
+ const git = parseForgeRemote(gitRemoteUrl);
443
+ if (explicit) {
444
+ const parsed = parseForgeRemote(explicit);
445
+ if (parsed) {
446
+ return { ok: true, host: parsed.host, repo: `${parsed.owner}/${parsed.repo}`, source: "explicit" };
447
+ }
448
+ if (BARE_REPO_PATTERN.test(explicit)) {
449
+ if (git) return { ok: true, host: git.host, repo: explicit, source: "explicit" };
450
+ return { ok: false, error: `Selected forge "${explicit}" needs a git origin remote to supply its host` };
451
+ }
452
+ return { ok: false, error: `Selected forge "${explicit}" is not a valid forge remote or owner/repo` };
453
+ }
454
+ if (!git) return { ok: false, error: "No forge repo found for this workspace" };
455
+ return { ok: true, host: git.host, repo: `${git.owner}/${git.repo}`, source: "derived" };
456
+ }
457
+
458
+ /** A forge target parses as a remote URL or a bare `owner/repo`. */
459
+ export function isValidForgeTarget(target: string | undefined | null): boolean {
460
+ const value = typeof target === "string" ? target.trim() : "";
461
+ if (!value) return false;
462
+ return Boolean(parseForgeRemote(value)) || BARE_REPO_PATTERN.test(value);
463
+ }
464
+
465
+ /**
466
+ * Host whose token applies to a workspace's settings form. An explicit target
467
+ * that carries a host wins; a bare `owner/repo` — or anything unparseable —
468
+ * borrows the host of the git-derived remote. Null when neither yields a host,
469
+ * so the token field stays disabled instead of being keyed under a missing
470
+ * host. The derived remote is supplied independently of any issue query, so a
471
+ * stored token keeps displaying while issues load or fail (regression #152).
472
+ */
473
+ export function effectiveForgeHost(
474
+ activeTarget: string | undefined | null,
475
+ derivedRemote: string | undefined | null,
476
+ ): string | null {
477
+ const derived = parseForgeRemote(derivedRemote)?.host ?? null;
478
+ const target = typeof activeTarget === "string" ? activeTarget.trim() : "";
479
+ if (!target) return derived;
480
+ return parseForgeRemote(target)?.host ?? derived;
481
+ }
482
+
483
+ /**
484
+ * Forge remotes a workspace may watch, in first-seen order. Configured targets
485
+ * come first, then the legacy single remote override, so pre-#137 installs keep
486
+ * their pinned remote without a settings migration write. Blank duplicates are
487
+ * dropped.
488
+ */
489
+ export function forgeTargetsForWorkspace(
490
+ settings:
491
+ | Partial<Pick<ForgeSettings, "forgesByDirectory" | "remotesByDirectory">>
492
+ | undefined
493
+ | null,
494
+ directory: string | undefined | null,
495
+ ): string[] {
496
+ const dir = typeof directory === "string" ? directory.trim() : "";
497
+ if (!dir) return [];
498
+ const targets: string[] = [];
499
+ const push = (value: unknown) => {
500
+ if (typeof value !== "string") return;
501
+ const trimmed = value.trim();
502
+ if (trimmed && !targets.includes(trimmed)) targets.push(trimmed);
503
+ };
504
+ for (const target of settings?.forgesByDirectory?.[dir] ?? []) push(target);
505
+ push(settings?.remotesByDirectory?.[dir]);
506
+ return targets;
507
+ }
508
+
509
+ /**
510
+ * The workspace's explicitly selected forge target, or null to derive from the
511
+ * git origin remote. Explicit selection wins absolutely: a present-but-blank
512
+ * `activeForgeByDirectory` entry means "auto" and suppresses the legacy remote,
513
+ * while an absent entry falls back to the legacy remote for compatibility.
514
+ * The value is returned verbatim so an invalid selection fails loudly rather
515
+ * than silently deriving.
516
+ */
517
+ export function activeForgeForDirectory(
518
+ settings:
519
+ | Partial<
520
+ Pick<
521
+ ForgeSettings,
522
+ "activeForgeByDirectory" | "forgesByDirectory" | "remotesByDirectory"
523
+ >
524
+ >
525
+ | undefined
526
+ | null,
527
+ directory: string | undefined | null,
528
+ ): string | null {
529
+ const dir = typeof directory === "string" ? directory.trim() : "";
530
+ if (!dir) return null;
531
+ const activeMap = settings?.activeForgeByDirectory;
532
+ if (activeMap && Object.prototype.hasOwnProperty.call(activeMap, dir)) {
533
+ const active = activeMap[dir];
534
+ return typeof active === "string" && active.trim() ? active.trim() : null;
535
+ }
536
+ const legacy = settings?.remotesByDirectory?.[dir];
537
+ return typeof legacy === "string" && legacy.trim() ? legacy.trim() : null;
538
+ }
539
+
540
+ export interface ForgeIssueLink {
541
+ host: string;
542
+ owner: string;
543
+ repo: string;
544
+ number: number;
545
+ /** Anchor comment id when the URL carries `#issuecomment-<id>`. */
546
+ commentId?: number;
547
+ url: string;
548
+ }
549
+
550
+ const ISSUE_URL_PATTERN =
551
+ /https?:\/\/([^/\s#?]+)\/([^/\s#?]+)\/([^/\s#?]+)\/issues\/(\d+)(?:#issuecomment-(\d+))?(?![/\w])/g;
552
+
553
+ /**
554
+ * Extract issue URLs (host/owner/repo/issues/N) from chat text for the
555
+ * timeline linkifier. Returns one entry per match, in order.
556
+ */
557
+ export function extractForgeIssueUrls(text: string | undefined | null): ForgeIssueLink[] {
558
+ if (!text || typeof text !== "string") return [];
559
+ const links: ForgeIssueLink[] = [];
560
+ ISSUE_URL_PATTERN.lastIndex = 0;
561
+ let match: RegExpExecArray | null;
562
+ while ((match = ISSUE_URL_PATTERN.exec(text)) !== null) {
563
+ links.push({
564
+ host: match[1],
565
+ owner: match[2],
566
+ repo: match[3],
567
+ number: Number(match[4]),
568
+ commentId: match[5] != null ? Number(match[5]) : undefined,
569
+ url: match[0],
570
+ });
571
+ }
572
+ return links;
573
+ }
574
+
575
+ /** Ranges of quoted content where bare-URL extraction must not match. */
576
+ function quotedRanges(text: string): Array<{ start: number; end: number }> {
577
+ const ranges: Array<{ start: number; end: number }> = [];
578
+ const push = (start: number, end: number) => {
579
+ if (end > start) ranges.push({ start, end });
580
+ };
581
+ const fencePattern = /```[\s\S]*?(?:```|$)/g;
582
+ let fence: RegExpExecArray | null;
583
+ while ((fence = fencePattern.exec(text)) !== null) push(fence.index, fence.index + fence[0].length);
584
+ const inFence = (index: number) => ranges.some((range) => index >= range.start && index < range.end);
585
+ const codePattern = /`[^`\n]+`/g;
586
+ let code: RegExpExecArray | null;
587
+ while ((code = codePattern.exec(text)) !== null) {
588
+ if (!inFence(code.index)) push(code.index, code.index + code[0].length);
589
+ }
590
+ const linkPattern = /\[([^\]\n]+)\]\(([^)\s]+)\)/g;
591
+ let link: RegExpExecArray | null;
592
+ while ((link = linkPattern.exec(text)) !== null) {
593
+ if (!inFence(link.index)) push(link.index, link.index + link[0].length);
594
+ }
595
+ return ranges;
596
+ }
597
+
598
+ /**
599
+ * Extract only bare issue URLs: markdown-linked `[text](url)` targets and
600
+ * quoted code are skipped because the card renders them inline instead of
601
+ * duplicating them as rows (#143).
602
+ */
603
+ export function extractBareForgeIssueUrls(text: string | undefined | null): ForgeIssueLink[] {
604
+ if (!text || typeof text !== "string") return [];
605
+ const quoted = quotedRanges(text);
606
+ const inQuoted = (index: number) => quoted.some((range) => index >= range.start && index < range.end);
607
+ const links: ForgeIssueLink[] = [];
608
+ ISSUE_URL_PATTERN.lastIndex = 0;
609
+ let match: RegExpExecArray | null;
610
+ while ((match = ISSUE_URL_PATTERN.exec(text)) !== null) {
611
+ if (inQuoted(match.index)) continue;
612
+ links.push({
613
+ host: match[1],
614
+ owner: match[2],
615
+ repo: match[3],
616
+ number: Number(match[4]),
617
+ commentId: match[5] != null ? Number(match[5]) : undefined,
618
+ url: match[0],
619
+ });
620
+ }
621
+ return links;
622
+ }
623
+
624
+ // ---------------------------------------------------------------------------
625
+ // Cross-repo link classification (issue #108). A rendered issue link is only
626
+ // `local` when BOTH its host and its owner/repo match the workspace's active
627
+ // forge identity. Everything else — a different repo on the same host, the
628
+ // same repo on a different host, or an unresolvable active target — is
629
+ // `foreign`, so the marker errs toward warning the reader rather than falsely
630
+ // claiming a link belongs to this workspace's board.
631
+ // ---------------------------------------------------------------------------
632
+
633
+ /** A workspace's active forge identity: host plus `owner/repo`. */
634
+ export interface ForgeRepoIdentity {
635
+ host?: string | null;
636
+ repo?: string | null;
637
+ }
638
+
639
+ export type ForgeLinkScope = "local" | "foreign";
640
+
641
+ function normalizeForgeRepo(repo: string | null | undefined): string | null {
642
+ if (!repo || typeof repo !== "string") return null;
643
+ const normalized = repo.trim().replace(/^\/+|\/+$/g, "").toLowerCase();
644
+ return normalized || null;
645
+ }
646
+
647
+ /** Classify an extracted issue link against the workspace's active forge. */
648
+ export function classifyForgeLink(
649
+ link: Pick<ForgeIssueLink, "host" | "owner" | "repo">,
650
+ active: ForgeRepoIdentity | null | undefined,
651
+ ): ForgeLinkScope {
652
+ const linkHost = normalizeForgeHost(link.host);
653
+ const linkRepo = normalizeForgeRepo(`${link.owner}/${link.repo}`);
654
+ const activeHost = normalizeForgeHost(active?.host);
655
+ const activeRepo = normalizeForgeRepo(active?.repo);
656
+ if (!linkHost || !linkRepo || !activeHost || !activeRepo) return "foreign";
657
+ return linkHost === activeHost && linkRepo === activeRepo ? "local" : "foreign";
658
+ }
659
+
660
+ /** The issue link a URL points at, or null when it is not a forge issue URL. */
661
+ export function forgeIssueLinkFromUrl(url: string | undefined | null): ForgeIssueLink | null {
662
+ return extractForgeIssueUrls(url)[0] ?? null;
663
+ }
664
+
665
+ /**
666
+ * Classify a raw URL for the markdown renderers: null when the URL is not a
667
+ * forge issue URL (so unrelated links stay unstyled), otherwise local/foreign
668
+ * against the active forge.
669
+ */
670
+ export function classifyForgeUrl(
671
+ url: string | undefined | null,
672
+ active: ForgeRepoIdentity | null | undefined,
673
+ ): ForgeLinkScope | null {
674
+ const link = forgeIssueLinkFromUrl(url);
675
+ return link ? classifyForgeLink(link, active) : null;
676
+ }
677
+
678
+ /**
679
+ * Display form of a remote URL: the API speaks HTTPS, so scp-like and
680
+ * ssh:// remotes render as `https://host/owner/repo`. Unparseable input
681
+ * passes through untouched.
682
+ */
683
+ export function displayRemoteForApi(url: string | undefined | null): string | null {
684
+ if (!url || typeof url !== "string" || !url.trim()) return null;
685
+ const parsed = parseForgeRemote(url);
686
+ if (!parsed) return url.trim();
687
+ return `https://${parsed.host}/${parsed.owner}/${parsed.repo}`;
688
+ }
689
+
690
+ /**
691
+ * Pill label for an issue count. Null (unknown) renders a placeholder,
692
+ * never a false zero.
693
+ */
694
+ export function formatIssueCountLabel(count: number | null | undefined): string {
695
+ if (count == null) return "issues --";
696
+ return count === 1 ? "1 issue" : `${count} issues`;
697
+ }
698
+
699
+ // ---------------------------------------------------------------------------
700
+ // Repo access state (issues #152, #193). Writes need an accepted token on BOTH
701
+ // public and private repos, so edit capability derives from visibility AND
702
+ // credential presence/validity, never from visibility alone. Since #193 it also
703
+ // requires write capability on the repo, which is read from the repo response's
704
+ // permission object rather than inferred from token validity. This is the
705
+ // single source of truth both client pages render from.
706
+ // ---------------------------------------------------------------------------
707
+
708
+ export type ForgeVisibility = "public" | "private" | "unknown";
709
+ export type ForgeAuthState =
710
+ | "authenticated"
711
+ | "lacks-write-scope"
712
+ | "invalid-token"
713
+ | "anonymous"
714
+ | "unknown";
715
+
716
+ /**
717
+ * Write capability the host reported for the configured token against the repo.
718
+ * null means the host returned no permission object, so edit capability falls
719
+ * back to the bare token-validity heuristic (issue #193).
720
+ */
721
+ export type ForgeRepoWritePermission = boolean | null;
722
+
723
+ /**
724
+ * The scopes a Forgejo/Gitea token needs for this plugin's write surface,
725
+ * by forge family. `read:user` is what the identity probe (`GET /user`, still
726
+ * the fallback capability path) requires; a token accepted there without a
727
+ * write scope is valid but under-scoped, not rejected.
728
+ */
729
+ export const FORGE_WRITE_SCOPES: Record<"forgejo" | "github" | "gitlab", string[]> = {
730
+ forgejo: ["read:user", "read:repository", "write:issue"],
731
+ github: ["read:user", "repo"],
732
+ gitlab: ["read_user", "read_api", "api"],
733
+ };
734
+
735
+ /** Human-readable minimum scopes for a forge, e.g. `read:user, read:repository, write:issue`. */
736
+ export function forgeWriteScopeList(
737
+ family: "forgejo" | "github" | "gitlab" = "forgejo",
738
+ ): string {
739
+ return FORGE_WRITE_SCOPES[family].join(", ");
740
+ }
741
+
742
+ /**
743
+ * Resolve write capability from a forge repo payload (issue #193) so a
744
+ * host-accepted token that lacks write scope is not treated as edit-capable.
745
+ * One mapping per forge family keeps #137 multi-forge coherent:
746
+ * - Forgejo/Gitea and GitHub: `permissions.push` (or `permissions.admin`).
747
+ * - GitLab: `access_level >= 30` (Developer/maintainer; 30 is Developer).
748
+ * Returns null when the payload carries no recognizable permission object, so
749
+ * callers keep the token-validity fallback instead of guessing `false`.
750
+ */
751
+ export function forgeCapabilityFromRepo(payload: unknown): ForgeRepoWritePermission {
752
+ if (!payload || typeof payload !== "object") return null;
753
+ const record = payload as Record<string, unknown>;
754
+ const permissions = record.permissions;
755
+ if (permissions && typeof permissions === "object") {
756
+ const perms = permissions as Record<string, unknown>;
757
+ if (typeof perms.push === "boolean" || typeof perms.admin === "boolean") {
758
+ return perms.push === true || perms.admin === true;
759
+ }
760
+ }
761
+ const accessLevel = record.access_level ?? record.accessLevel;
762
+ if (typeof accessLevel === "number") return accessLevel >= 30;
763
+ return null;
764
+ }
765
+
766
+ export interface ForgeAccessInput {
767
+ /** Anonymous repo probe: true public, false private/missing, null unknown. */
768
+ repoPublic?: boolean | null;
769
+ /** Whether a token is configured for the host at all. */
770
+ tokenPresent?: boolean | null;
771
+ /** Token probe: true accepted, false rejected, null not probed. */
772
+ tokenValid?: boolean | null;
773
+ /** Repo-reported write capability; null when the host returned none. */
774
+ repoWritePermission?: ForgeRepoWritePermission;
775
+ }
776
+
777
+ export interface ForgeAccessState {
778
+ visibility: ForgeVisibility;
779
+ auth: ForgeAuthState;
780
+ /** Labels and comments require an accepted, write-scoped token. */
781
+ canEdit: boolean;
782
+ /** Chip label for visibility, or null when unknown. */
783
+ visibilityLabel: string | null;
784
+ /** Chip label for auth state (always known). */
785
+ authLabel: string;
786
+ /** Lucide icon name for the auth chip. */
787
+ authIcon: string;
788
+ /** Status variant for the auth chip. */
789
+ authVariant: "success" | "danger" | "warning" | "neutral";
790
+ /** One-line human explanation shared by both pages. */
791
+ summary: string;
792
+ /** Minimum token scopes for the write surface, e.g. for a token hint. */
793
+ requiredScopes: string;
794
+ }
795
+
796
+ /**
797
+ * Derive the combined access state from the probes. `canEdit` requires an
798
+ * accepted token AND write capability: false when the host explicitly reports
799
+ * the token cannot push, and (issue #193) otherwise the bare validity result
800
+ * when the host returned no permission object.
801
+ */
802
+ export function deriveForgeAccess(input: ForgeAccessInput = {}): ForgeAccessState {
803
+ const visibility: ForgeVisibility =
804
+ input.repoPublic === true
805
+ ? "public"
806
+ : input.repoPublic === false
807
+ ? "private"
808
+ : "unknown";
809
+
810
+ let auth: ForgeAuthState;
811
+ if (input.tokenValid === true) {
812
+ auth = input.repoWritePermission === false ? "lacks-write-scope" : "authenticated";
813
+ } else if (input.tokenPresent !== true) auth = "anonymous";
814
+ else if (input.tokenValid === false) auth = "invalid-token";
815
+ else auth = "unknown";
816
+
817
+ const canEdit = auth === "authenticated";
818
+
819
+ const visibilityLabel = visibility === "unknown" ? null : visibility;
820
+
821
+ let authLabel: string;
822
+ let authIcon: string;
823
+ let authVariant: ForgeAccessState["authVariant"];
824
+ if (auth === "authenticated") {
825
+ authLabel = "Authenticated";
826
+ authIcon = "KeyRound";
827
+ authVariant = "success";
828
+ } else if (auth === "lacks-write-scope") {
829
+ authLabel = "Token lacks write scope";
830
+ authIcon = "ShieldAlert";
831
+ authVariant = "warning";
832
+ } else if (auth === "invalid-token") {
833
+ authLabel = "Token rejected";
834
+ authIcon = "AlertTriangle";
835
+ authVariant = "danger";
836
+ } else if (auth === "anonymous") {
837
+ authLabel = "No token";
838
+ authIcon = "User";
839
+ authVariant = "neutral";
840
+ } else {
841
+ authLabel = "Token unverified";
842
+ authIcon = "AlertCircle";
843
+ authVariant = "warning";
844
+ }
845
+
846
+ return {
847
+ visibility,
848
+ auth,
849
+ canEdit,
850
+ visibilityLabel,
851
+ authLabel,
852
+ authIcon,
853
+ authVariant,
854
+ summary: accessSummary(visibility, auth),
855
+ requiredScopes: forgeWriteScopeList(),
856
+ };
857
+ }
858
+
859
+ /**
860
+ * Read-only explanation shared by every gated write surface. An under-scoped
861
+ * token names the missing scopes instead of an opaque failure; any other
862
+ * non-editable state falls back to the combined access summary.
863
+ */
864
+ export function writeGateNotice(access: ForgeAccessState, capability: string): string {
865
+ if (access.auth === "lacks-write-scope") {
866
+ return `Read-only — this token cannot ${capability}; it lacks write scope. Add a token with ${access.requiredScopes}.`;
867
+ }
868
+ return `Read-only — ${capability} needs a valid token. ${access.summary}`;
869
+ }
870
+
871
+ function accessSummary(visibility: ForgeVisibility, auth: ForgeAuthState): string {
872
+ const scopeHint = `required scopes: ${forgeWriteScopeList()}.`;
873
+
874
+ const authClause =
875
+ auth === "authenticated"
876
+ ? "Token accepted with write scope — reads and edits enabled."
877
+ : auth === "lacks-write-scope"
878
+ ? `Token accepted but it cannot push — edits disabled; ${scopeHint}`
879
+ : auth === "invalid-token"
880
+ ? "Saved token was rejected — edits disabled."
881
+ : auth === "anonymous"
882
+ ? "No token saved — edits disabled."
883
+ : "Token state unverified — edits disabled.";
884
+
885
+ if (visibility === "public") {
886
+ return `Public repo — anonymous reads work. ${authClause}`;
887
+ }
888
+ if (visibility === "private") {
889
+ return `Private repo — a valid token is required for reads. ${authClause}`;
890
+ }
891
+ return `Repo visibility unknown (could not reach host). ${authClause}`;
892
+ }
893
+
894
+ /**
895
+ * Key under which a workspace's display name is persisted. A linked worktree
896
+ * shares its main checkout's name, so the project root wins when known and the
897
+ * workspace directory is the fallback. Trailing separators are trimmed so the
898
+ * read and write paths always agree.
899
+ */
900
+ export function workspaceNameKey(
901
+ directory: string | undefined | null,
902
+ projectRootPath: string | undefined | null,
903
+ ): string {
904
+ const normalize = (value: string | undefined | null): string =>
905
+ typeof value === "string" ? value.trim().replace(/[\\/]+$/, "") : "";
906
+ return normalize(projectRootPath) || normalize(directory);
907
+ }
908
+
909
+ /**
910
+ * Display name for a workspace: explicit user label wins, otherwise the
911
+ * resolved repo (owner/repo) is inferred. Null when neither exists. The caller
912
+ * passes the same key `workspaceNameKey` produces on write.
913
+ */
914
+ export function displayNameForDirectory(
915
+ settings: Pick<ForgeSettings, "namesByDirectory"> | undefined | null,
916
+ nameKey: string | undefined | null,
917
+ inferredRepo: string | undefined | null,
918
+ ): string | null {
919
+ if (nameKey) {
920
+ const stored = settings?.namesByDirectory?.[nameKey];
921
+ if (typeof stored === "string" && stored.trim()) return stored.trim();
922
+ }
923
+ if (typeof inferredRepo === "string" && inferredRepo.trim()) return inferredRepo.trim();
924
+ return null;
925
+ }
926
+
927
+ // ---------------------------------------------------------------------------
928
+ // Scoped label vocabularies (verified against the live board; see spec §4.1).
929
+ // Gitea-family scoped labels are exclusive: applying one label in a scope evicts
930
+ // the previous label in that scope at the DB level, so the client only ever
931
+ // sends "add", never "remove".
932
+ // ---------------------------------------------------------------------------
933
+
934
+ export const STATE_ORDER = [
935
+ "state/0-triage",
936
+ "state/1-wip",
937
+ "state/2-review",
938
+ "state/3-verify",
939
+ "state/4-done",
940
+ ] as const;
941
+ export type StateLabel = (typeof STATE_ORDER)[number];
942
+
943
+ export const PRIORITY_ORDER = [
944
+ "priority/0-SOS",
945
+ "priority/1-high",
946
+ "priority/2-normal",
947
+ "priority/3-low",
948
+ "priority/4-backburner",
949
+ ] as const;
950
+ export type PriorityLabel = (typeof PRIORITY_ORDER)[number];
951
+
952
+ export const ATTENTION_LABELS = [
953
+ "attention/0-orchestrator",
954
+ "attention/1-agent",
955
+ "attention/2-user",
956
+ "attention/3-ignore",
957
+ ] as const;
958
+
959
+ export const SPEC_LABELS = [
960
+ "spec/0-needed",
961
+ "spec/1-checklist",
962
+ "spec/2-approved",
963
+ ] as const;
964
+
965
+ const STATE_SHORT: Record<string, string> = {
966
+ "state/0-triage": "Triage",
967
+ "state/1-wip": "WIP",
968
+ "state/2-review": "Review",
969
+ "state/3-verify": "Verify",
970
+ "state/4-done": "Done",
971
+ };
972
+
973
+ const PRIORITY_SHORT: Record<string, string> = {
974
+ "priority/0-SOS": "SOS",
975
+ "priority/1-high": "High",
976
+ "priority/2-normal": "Normal",
977
+ "priority/3-low": "Low",
978
+ "priority/4-backburner": "Parked",
979
+ };
980
+
981
+ /** Compact display alias for a scoped label ("state/1-wip" -> "WIP"). */
982
+ export function shortLabelName(label: string): string {
983
+ return STATE_SHORT[label] ?? PRIORITY_SHORT[label] ?? label;
984
+ }
985
+
986
+ /** The issue's current `state/*` label, or null when it carries none. */
987
+ export function currentStateLabel(labels: string[]): string | null {
988
+ for (const label of labels) {
989
+ if ((STATE_ORDER as readonly string[]).includes(label)) return label;
990
+ }
991
+ return null;
992
+ }
993
+
994
+ /** The issue's current `priority/*` label, defaulting to normal per spec §4.2. */
995
+ export function currentPriorityLabel(labels: string[]): string {
996
+ for (const label of labels) {
997
+ if ((PRIORITY_ORDER as readonly string[]).includes(label)) return label;
998
+ }
999
+ return "priority/2-normal";
1000
+ }
1001
+
1002
+ /** Next `state/*` promotion step, or null when already done. */
1003
+ export function nextStateLabel(labels: string[]): string | null {
1004
+ const current = currentStateLabel(labels);
1005
+ if (!current) return "state/1-wip";
1006
+ const idx = (STATE_ORDER as readonly string[]).indexOf(current);
1007
+ if (idx < 0 || idx + 1 >= STATE_ORDER.length) return null;
1008
+ return STATE_ORDER[idx + 1];
1009
+ }
1010
+
1011
+ // ---------------------------------------------------------------------------
1012
+ // Live label sync (issue #122, decision #121.2): the board is the source of
1013
+ // truth. Scopes are derived from the labels actually present on open issues,
1014
+ // not from the hardcoded vocabularies above (kept only as fallback/display).
1015
+ // ---------------------------------------------------------------------------
1016
+
1017
+ /** Scope prefix of a `scope/name` label, or null for unscoped labels. */
1018
+ export function scopeOfLabel(label: string): string | null {
1019
+ const slash = label.indexOf("/");
1020
+ if (slash <= 0 || slash + 1 >= label.length) return null;
1021
+ const scope = label.slice(0, slash);
1022
+ if (!/^[A-Za-z0-9_.-]+$/.test(scope)) return null;
1023
+ return scope;
1024
+ }
1025
+
1026
+ /** Distinct scopes observed across a set of board labels, in first-seen order. */
1027
+ export function liveScopesFromLabels(allLabels: string[]): string[] {
1028
+ const scopes: string[] = [];
1029
+ for (const label of allLabels) {
1030
+ const scope = scopeOfLabel(label);
1031
+ if (scope && !scopes.includes(scope)) scopes.push(scope);
1032
+ }
1033
+ return scopes;
1034
+ }
1035
+
1036
+ /** Distinct scopes observed across a list of issues. */
1037
+ export function liveScopesFromIssues(issues: Pick<ForgeIssue, "labels">[]): string[] {
1038
+ return liveScopesFromLabels(issues.flatMap((issue) => issue.labels));
1039
+ }
1040
+
1041
+ function rankOf(label: string | null, order: readonly string[]): number {
1042
+ if (!label) return order.length;
1043
+ const idx = (order as readonly string[]).indexOf(label);
1044
+ return idx < 0 ? order.length : idx;
1045
+ }
1046
+
1047
+ /**
1048
+ * Rank open issues for display: priority first (SOS..backburner, unknown
1049
+ * last), then state order (triage..done), then most recently updated.
1050
+ */
1051
+ export function rankIssues<T extends Pick<ForgeIssue, "labels" | "updatedAt">>(issues: T[]): T[] {
1052
+ return [...issues].sort((a, b) => {
1053
+ const pri = rankOf(currentPriorityLabel(a.labels), PRIORITY_ORDER) -
1054
+ rankOf(currentPriorityLabel(b.labels), PRIORITY_ORDER);
1055
+ if (pri !== 0) return pri;
1056
+ const state = rankOf(currentStateLabel(a.labels), STATE_ORDER) -
1057
+ rankOf(currentStateLabel(b.labels), STATE_ORDER);
1058
+ if (state !== 0) return state;
1059
+ return (b.updatedAt ?? "").localeCompare(a.updatedAt ?? "");
1060
+ });
1061
+ }
1062
+
1063
+ // ---------------------------------------------------------------------------
1064
+ // Optional label-set install (issue #122, decision #121.3): ships our board
1065
+ // taxonomy as data a foreign board may install. Never applied automatically:
1066
+ // the client requires an explicit action plus a keep/replace choice, and the
1067
+ // server only ever writes after resolving an explicit forge target.
1068
+ // ---------------------------------------------------------------------------
1069
+
1070
+ export interface LabelDefinition {
1071
+ name: string;
1072
+ color: string;
1073
+ exclusive: boolean;
1074
+ description: string;
1075
+ }
1076
+
1077
+ /**
1078
+ * Scope prefixes our workflow understands. Used as the fallback vocabulary
1079
+ * when validating a set-label against a board, and to decide which existing
1080
+ * labels an install may replace (see `planLabelSetInstall`).
1081
+ */
1082
+ export const PASEO_LABEL_SCOPES = [
1083
+ "state",
1084
+ "priority",
1085
+ "attention",
1086
+ "spec",
1087
+ "kind",
1088
+ "target",
1089
+ "format",
1090
+ "size",
1091
+ "dep",
1092
+ "flag",
1093
+ ] as const;
1094
+
1095
+ const STATE_COLORS = ["#1d76db", "#0e7c6b", "#a6700b", "#6e40c9", "#1a7f37"];
1096
+ const PRIORITY_COLORS = ["#d1242f", "#e85d04", "#1d76db", "#59636e", "#8c959f"];
1097
+
1098
+ const LABEL_DEFS: LabelDefinition[] = [
1099
+ ...STATE_ORDER.map((name, i): LabelDefinition => ({
1100
+ name,
1101
+ color: STATE_COLORS[i] ?? "#59636e",
1102
+ exclusive: true,
1103
+ description: `Workflow state ${i}`,
1104
+ })),
1105
+ ...PRIORITY_ORDER.map((name, i): LabelDefinition => ({
1106
+ name,
1107
+ color: PRIORITY_COLORS[i] ?? "#59636e",
1108
+ exclusive: true,
1109
+ description: `Priority ${i}`,
1110
+ })),
1111
+ ...ATTENTION_LABELS.map((name): LabelDefinition => ({
1112
+ name,
1113
+ color: "#8250df",
1114
+ exclusive: true,
1115
+ description: "Who acts next",
1116
+ })),
1117
+ ...SPEC_LABELS.map((name): LabelDefinition => ({
1118
+ name,
1119
+ color: "#0e7c6b",
1120
+ exclusive: true,
1121
+ description: "Spec readiness",
1122
+ })),
1123
+ ];
1124
+
1125
+ /** Our board taxonomy as installable data (see decision #121.3). */
1126
+ export function paseoLabelSet(): LabelDefinition[] {
1127
+ return LABEL_DEFS.map((def) => ({ ...def }));
1128
+ }
1129
+
1130
+ /** Scopes our installable set occupies; a `replace` removes only these. */
1131
+ export function paseoLabelScopes(): string[] {
1132
+ const scopes: string[] = [];
1133
+ for (const def of LABEL_DEFS) {
1134
+ const scope = scopeOfLabel(def.name);
1135
+ if (scope && !scopes.includes(scope)) scopes.push(scope);
1136
+ }
1137
+ return scopes;
1138
+ }
1139
+
1140
+ export const INSTALL_LABEL_MODES = ["merge", "replace"] as const;
1141
+ export type InstallLabelMode = (typeof INSTALL_LABEL_MODES)[number];
1142
+
1143
+ export interface ForgeLabelRef {
1144
+ id?: number;
1145
+ name: string;
1146
+ }
1147
+
1148
+ export interface LabelSetPlan {
1149
+ mode: InstallLabelMode;
1150
+ /** Our labels the target is missing; safe to POST. */
1151
+ create: LabelDefinition[];
1152
+ /** Target labels to DELETE, scoped to what our taxonomy replaces. */
1153
+ remove: ForgeLabelRef[];
1154
+ /** Our labels the target already carries; left untouched. */
1155
+ skip: string[];
1156
+ }
1157
+
1158
+ /**
1159
+ * Diff our taxonomy against a board's current labels.
1160
+ *
1161
+ * `merge` only creates missing labels. `replace` additionally removes the
1162
+ * target's labels that share a scope with our installable set but are not
1163
+ * part of it (e.g. a foreign `state/ready-for-review`); labels in unrelated
1164
+ * scopes — and our own already-present labels — are never touched, so an
1165
+ * install cannot silently destroy an unrelated vocabulary.
1166
+ */
1167
+ export function planLabelSetInstall(
1168
+ existing: ForgeLabelRef[],
1169
+ mode: InstallLabelMode,
1170
+ ): LabelSetPlan {
1171
+ const desired = paseoLabelSet();
1172
+ const desiredNames = new Set(desired.map((def) => def.name));
1173
+ const existingNames = new Set(
1174
+ existing.map((label) => label.name).filter((name): name is string => typeof name === "string"),
1175
+ );
1176
+ const ownScopes = new Set(paseoLabelScopes());
1177
+ const remove =
1178
+ mode === "replace"
1179
+ ? existing.filter((label) => {
1180
+ const scope = scopeOfLabel(label.name);
1181
+ return scope !== null && ownScopes.has(scope) && !desiredNames.has(label.name);
1182
+ })
1183
+ : [];
1184
+ const create = desired.filter((def) => !existingNames.has(def.name));
1185
+ const skip = desired.filter((def) => existingNames.has(def.name)).map((def) => def.name);
1186
+ return { mode, create, remove, skip };
1187
+ }
1188
+
1189
+ export const InstallLabelsInputSchema = z.object({
1190
+ directory: z.string().optional(),
1191
+ remoteUrl: z.string().optional(),
1192
+ // Required, no default: the keep/replace choice must never be implicit.
1193
+ mode: z.enum(INSTALL_LABEL_MODES),
1194
+ });
1195
+ export type InstallLabelsInput = z.infer<typeof InstallLabelsInputSchema>;
1196
+
1197
+ export const InstallLabelsOutputSchema = z.object({
1198
+ host: z.string().nullable().default(null),
1199
+ repo: z.string().nullable().default(null),
1200
+ mode: z.enum(INSTALL_LABEL_MODES).nullable().default(null),
1201
+ created: z.array(z.string()).default([]),
1202
+ skipped: z.array(z.string()).default([]),
1203
+ removed: z.array(z.string()).default([]),
1204
+ error: z.string().optional(),
1205
+ });
1206
+ export type InstallLabelsOutput = z.infer<typeof InstallLabelsOutputSchema>;
1207
+
1208
+ export const installLabelsContract = defineContract({
1209
+ name: "forge.install-labels",
1210
+ description: "Copy the Paseo label taxonomy onto the configured forge repo after an explicit user choice",
1211
+ input: InstallLabelsInputSchema,
1212
+ output: InstallLabelsOutputSchema,
1213
+ });
1214
+
1215
+ // ---------------------------------------------------------------------------
1216
+ // Agent Envelope (parsed telemetry, spec §4.4).
1217
+ // Every agent comment ends with the stamped envelope footer:
1218
+ // <sub>🤖 **<SessionTitle>** (`<shortId>`) · `<model>` ·
1219
+ // `<repo>:<branch>` · _<UTC timestamp>_</sub>
1220
+ // ---------------------------------------------------------------------------
1221
+
1222
+ export const AgentEnvelopeSchema = z.object({
1223
+ commentId: z.number(),
1224
+ sessionTitle: z.string(),
1225
+ agentShortId: z.string(),
1226
+ model: z.string().nullable().default(null),
1227
+ repo: z.string().nullable().default(null),
1228
+ branch: z.string().nullable().default(null),
1229
+ postedAt: z.string().nullable().default(null),
1230
+ commitShas: z.array(z.string().regex(/^[0-9a-f]{7,40}$/)).default([]),
1231
+ paseoLinks: z.array(z.string()).default([]),
1232
+ serverId: z.string().nullable().default(null),
1233
+ });
1234
+ export type AgentEnvelope = z.infer<typeof AgentEnvelopeSchema>;
1235
+
1236
+ export const IssueCommentSchema = z.object({
1237
+ id: z.number(),
1238
+ author: z.string(),
1239
+ createdAt: z.string(),
1240
+ updatedAt: z.string(),
1241
+ body: z.string(),
1242
+ url: z.string(),
1243
+ envelope: AgentEnvelopeSchema.nullable().default(null),
1244
+ });
1245
+ export type IssueComment = z.infer<typeof IssueCommentSchema>;
1246
+
1247
+ export const IssueDetailSchema = z.object({
1248
+ number: z.number(),
1249
+ title: z.string(),
1250
+ state: z.string(),
1251
+ labels: z.array(z.string()),
1252
+ labelDetails: z.array(ForgeLabelSchema).default([]),
1253
+ body: z.string(),
1254
+ author: z.string(),
1255
+ createdAt: z.string(),
1256
+ updatedAt: z.string(),
1257
+ webUrl: z.string(),
1258
+ comments: z.array(IssueCommentSchema),
1259
+ envelopes: z.array(AgentEnvelopeSchema),
1260
+ });
1261
+ export type IssueDetail = z.infer<typeof IssueDetailSchema>;
1262
+
1263
+ const ENVELOPE_FOOTER_PATTERN =
1264
+ /<sub>\s*🤖\s*\*\*(.+?)\*\*\s*\(`([^`)]+)`\)\s*·\s*`([^`]+)`\s*·\s*`([^`]+)`\s*·\s*_([^_]+)_\s*<\/sub>/;
1265
+
1266
+ /** Remove the agent envelope footer so comment bodies render without duplication. */
1267
+ export function stripAgentEnvelopeFooter(body: string | undefined | null): string {
1268
+ if (!body || typeof body !== "string") return "";
1269
+ return body.replace(ENVELOPE_FOOTER_PATTERN, "").replace(/---\s*$/, "").trim();
1270
+ }
1271
+
1272
+ const SHA_PATTERN = /\b[0-9a-f]{7,40}\b/g;
1273
+ const PASEO_LINK_PATTERN = /paseo:\/\/[^\s)>\]]+/g;
1274
+ const PASEO_SERVER_PATTERN = /paseo:\/\/h\/([^/\s]+)\/agent\//;
1275
+
1276
+ /**
1277
+ * Parse the agent envelope footer of one comment body into structured
1278
+ * telemetry. Returns null when the comment carries no parseable footer;
1279
+ * envelope parsing never fails the detail RPC.
1280
+ */
1281
+ export function parseAgentEnvelope(
1282
+ commentId: number,
1283
+ body: string | undefined | null,
1284
+ ): AgentEnvelope | null {
1285
+ if (!body || typeof body !== "string") return null;
1286
+ const match = ENVELOPE_FOOTER_PATTERN.exec(body);
1287
+ if (!match) return null;
1288
+ const sessionTitle = match[1].trim();
1289
+ const agentShortId = match[2].trim();
1290
+ if (!sessionTitle || !agentShortId) return null;
1291
+ const model = match[3].trim() || null;
1292
+ const repoBranch = match[4].trim();
1293
+ const postedAt = match[5].trim() || null;
1294
+ let repo: string | null = null;
1295
+ let branch: string | null = null;
1296
+ if (repoBranch) {
1297
+ const sep = repoBranch.lastIndexOf(":");
1298
+ if (sep > 0) {
1299
+ repo = repoBranch.slice(0, sep) || null;
1300
+ branch = repoBranch.slice(sep + 1) || null;
1301
+ } else {
1302
+ branch = repoBranch;
1303
+ }
1304
+ }
1305
+ const commitShas = Array.from(
1306
+ new Set(
1307
+ (body.match(SHA_PATTERN) ?? []).filter(
1308
+ (sha) => sha !== agentShortId && /[0-9]/.test(sha) && /[a-f]/.test(sha),
1309
+ ),
1310
+ ),
1311
+ );
1312
+ const paseoLinks = Array.from(new Set(body.match(PASEO_LINK_PATTERN) ?? []));
1313
+ const serverMatch = PASEO_SERVER_PATTERN.exec(paseoLinks[0] ?? "");
1314
+ return {
1315
+ commentId,
1316
+ sessionTitle,
1317
+ agentShortId,
1318
+ model,
1319
+ repo,
1320
+ branch,
1321
+ postedAt,
1322
+ commitShas,
1323
+ paseoLinks,
1324
+ serverId: serverMatch ? serverMatch[1] : null,
1325
+ };
1326
+ }
1327
+
1328
+ // ---------------------------------------------------------------------------
1329
+ // Detail / write contracts (spec §5.2–§5.4).
1330
+ // Input accepts `issueNumber` (primary) with `number` as a deprecated alias
1331
+ // so spec-shaped payloads keep working; handlers normalize via
1332
+ // `normalizeIssueNumber`.
1333
+ // ---------------------------------------------------------------------------
1334
+
1335
+ const IssueNumberInput = z
1336
+ .object({
1337
+ directory: z.string().optional(),
1338
+ remoteUrl: z.string().optional(),
1339
+ issueNumber: z.number().int().positive().optional(),
1340
+ number: z.number().int().positive().optional(),
1341
+ })
1342
+ .superRefine((value, ctx) => {
1343
+ if (value.issueNumber == null && value.number == null) {
1344
+ ctx.addIssue({
1345
+ code: z.ZodIssueCode.custom,
1346
+ message: "issueNumber (or number) is required",
1347
+ });
1348
+ }
1349
+ });
1350
+ export type IssueNumberInput = z.infer<typeof IssueNumberInput>;
1351
+
1352
+ /** Resolve the canonical issue number from either input spelling. */
1353
+ export function normalizeIssueNumber(
1354
+ input: IssueNumberInput | { issueNumber?: number; number?: number },
1355
+ ): number | null {
1356
+ const issueNumber = (input as { issueNumber?: unknown }).issueNumber;
1357
+ if (typeof issueNumber === "number" && Number.isInteger(issueNumber) && issueNumber > 0) {
1358
+ return issueNumber;
1359
+ }
1360
+ const legacy = (input as { number?: unknown }).number;
1361
+ if (typeof legacy === "number" && Number.isInteger(legacy) && legacy > 0) {
1362
+ return legacy;
1363
+ }
1364
+ return null;
1365
+ }
1366
+
1367
+ export const IssueDetailInputSchema = IssueNumberInput;
1368
+ export type IssueDetailInput = z.infer<typeof IssueDetailInputSchema>;
1369
+
1370
+ export const IssueDetailOutputSchema = z.object({
1371
+ repo: z.string().nullable(),
1372
+ issue: IssueDetailSchema.nullable(),
1373
+ fetchedAt: z.string().datetime(),
1374
+ repoPublic: z.boolean().nullable().default(null),
1375
+ tokenPresent: z.boolean().default(false),
1376
+ tokenValid: z.boolean().nullable().default(null),
1377
+ /** Repo-reported write capability; null when the host returned none (#193). */
1378
+ repoWritePermission: z.boolean().nullable().default(null),
1379
+ error: z.string().optional(),
1380
+ });
1381
+ export type IssueDetailOutput = z.infer<typeof IssueDetailOutputSchema>;
1382
+
1383
+ export const issueDetailContract = defineContract({
1384
+ name: "forge.issue-detail",
1385
+ description: "Full body, comments, and parsed Agent Envelopes for one issue",
1386
+ input: IssueDetailInputSchema,
1387
+ output: IssueDetailOutputSchema,
1388
+ });
1389
+
1390
+ export const SetLabelInputSchema = IssueNumberInput.extend({
1391
+ label: z.string().min(1).max(100),
1392
+ });
1393
+ export type SetLabelInput = z.infer<typeof SetLabelInputSchema>;
1394
+
1395
+ export const SetLabelOutputSchema = z.object({
1396
+ number: z.number(),
1397
+ labels: z.array(z.string()),
1398
+ error: z.string().optional(),
1399
+ });
1400
+ export type SetLabelOutput = z.infer<typeof SetLabelOutputSchema>;
1401
+
1402
+ export const setLabelContract = defineContract({
1403
+ name: "forge.set-label",
1404
+ description: "Apply one scoped label; an exclusive scope evicts the rest",
1405
+ input: SetLabelInputSchema,
1406
+ output: SetLabelOutputSchema,
1407
+ });
1408
+
1409
+ export const AddCommentInputSchema = IssueNumberInput.extend({
1410
+ body: z.string().min(1).max(10000),
1411
+ });
1412
+ export type AddCommentInput = z.infer<typeof AddCommentInputSchema>;
1413
+
1414
+ export const AddCommentOutputSchema = z.object({
1415
+ number: z.number(),
1416
+ commentId: z.number().nullable(),
1417
+ error: z.string().optional(),
1418
+ });
1419
+ export type AddCommentOutput = z.infer<typeof AddCommentOutputSchema>;
1420
+
1421
+ export const addCommentContract = defineContract({
1422
+ name: "forge.add-comment",
1423
+ description: "Post a quick comment (or steering note) to the issue thread",
1424
+ input: AddCommentInputSchema,
1425
+ output: AddCommentOutputSchema,
1426
+ });
1427
+
1428
+ // ---------------------------------------------------------------------------
1429
+ // Create issue (issue #200). Mirrors the other write verbs: the daemon resolves
1430
+ // the active forge and attaches the token, and the client only ever sees the
1431
+ // resulting issue number. Validation lives in a pure function so the composer
1432
+ // and the handler reject the same payloads.
1433
+ // ---------------------------------------------------------------------------
1434
+
1435
+ export const CREATE_ISSUE_TITLE_MAX = 200;
1436
+ export const CREATE_ISSUE_BODY_MAX = 10000;
1437
+ export const CREATE_ISSUE_LABEL_MAX = 50;
1438
+
1439
+ export const CreateIssueInputSchema = z.object({
1440
+ directory: z.string().optional(),
1441
+ remoteUrl: z.string().optional(),
1442
+ title: z.string(),
1443
+ body: z.string().optional(),
1444
+ labels: z.array(z.string()).optional(),
1445
+ });
1446
+ export type CreateIssueInput = z.infer<typeof CreateIssueInputSchema>;
1447
+
1448
+ export const CreateIssueOutputSchema = z.object({
1449
+ repo: z.string().nullable(),
1450
+ host: z.string().nullable().default(null),
1451
+ number: z.number().int().positive().nullable().default(null),
1452
+ error: z.string().optional(),
1453
+ });
1454
+ export type CreateIssueOutput = z.infer<typeof CreateIssueOutputSchema>;
1455
+
1456
+ export const createIssueContract = defineContract({
1457
+ name: "forge.create-issue",
1458
+ description: "Create a new issue on the configured forge repo",
1459
+ input: CreateIssueInputSchema,
1460
+ output: CreateIssueOutputSchema,
1461
+ });
1462
+
1463
+ /**
1464
+ * Validate a create-issue payload; null when it is well-formed. A title is
1465
+ * required (issue #200); the body and labels stay optional.
1466
+ */
1467
+ export function validateCreateIssueInput(input: {
1468
+ title?: unknown;
1469
+ body?: unknown;
1470
+ labels?: unknown;
1471
+ }): string | null {
1472
+ const title = typeof input.title === "string" ? input.title.trim() : "";
1473
+ if (!title) return "Issue title must not be empty";
1474
+ if (title.length > CREATE_ISSUE_TITLE_MAX) return "Issue title is too long";
1475
+ const body = typeof input.body === "string" ? input.body : "";
1476
+ if (body.length > CREATE_ISSUE_BODY_MAX) return "Issue description is too long";
1477
+ if (input.labels !== undefined) {
1478
+ if (!Array.isArray(input.labels)) return "Labels must be a list";
1479
+ if (input.labels.length > CREATE_ISSUE_LABEL_MAX) return "Too many labels";
1480
+ if (input.labels.some((label) => typeof label !== "string" || !label.trim())) {
1481
+ return "Label must not be empty";
1482
+ }
1483
+ }
1484
+ return null;
1485
+ }
1486
+
1487
+ /** Split a free-text labels field into trimmed, de-duplicated, non-empty names. */
1488
+ export function parseLabelList(text: string | undefined | null): string[] {
1489
+ if (!text || typeof text !== "string") return [];
1490
+ const names: string[] = [];
1491
+ for (const part of text.split(",")) {
1492
+ const name = part.trim();
1493
+ if (name && !names.includes(name)) names.push(name);
1494
+ }
1495
+ return names;
1496
+ }
1497
+
1498
+ // ---------------------------------------------------------------------------
1499
+ // Markdown-lite (issue #136): focused renderer input for forge issue
1500
+ // descriptions/comments. Covers paragraphs, headings, unordered/ordered
1501
+ // list lines, Markdown links, inline code, bold/italic, and fenced code
1502
+ // blocks. Pure string parsing: no RPC, no side effects, no dependencies.
1503
+ // ---------------------------------------------------------------------------
1504
+
1505
+ export type MarkdownLiteSpan =
1506
+ | { kind: "text"; text: string }
1507
+ | { kind: "bold"; text: string }
1508
+ | { kind: "italic"; text: string }
1509
+ | { kind: "code"; text: string }
1510
+ | { kind: "link"; text: string; url: string };
1511
+
1512
+ export type MarkdownLiteBlock =
1513
+ | { kind: "paragraph"; spans: MarkdownLiteSpan[] }
1514
+ | { kind: "heading"; level: 1 | 2 | 3; spans: MarkdownLiteSpan[] }
1515
+ | { kind: "list"; ordered: boolean; items: MarkdownLiteSpan[][] }
1516
+ | { kind: "code"; text: string; language?: string };
1517
+
1518
+ const INLINE_PATTERN =
1519
+ /(`[^`\n]+`)|(\[([^\]\n]+)\]\(([^)\s]+)\))|(\*\*([^*\n]+)\*\*)|(__([^_\n]+)__)|(\*([^*\n]+)\*)|(_([^_\n]+)_)/g;
1520
+
1521
+ /** Split one line of prose into text/bold/italic/code/link spans. */
1522
+ export function parseMarkdownLiteInline(text: string): MarkdownLiteSpan[] {
1523
+ if (!text) return [];
1524
+ const spans: MarkdownLiteSpan[] = [];
1525
+ let cursor = 0;
1526
+ INLINE_PATTERN.lastIndex = 0;
1527
+ let match: RegExpExecArray | null;
1528
+ while ((match = INLINE_PATTERN.exec(text)) !== null) {
1529
+ if (match.index > cursor) {
1530
+ spans.push({ kind: "text", text: text.slice(cursor, match.index) });
1531
+ }
1532
+ if (match[1]) {
1533
+ spans.push({ kind: "code", text: match[1].slice(1, -1) });
1534
+ } else if (match[2]) {
1535
+ spans.push({ kind: "link", text: match[3], url: match[4] });
1536
+ } else if (match[5]) {
1537
+ spans.push({ kind: "bold", text: match[6] });
1538
+ } else if (match[7]) {
1539
+ spans.push({ kind: "bold", text: match[8] });
1540
+ } else if (match[9]) {
1541
+ spans.push({ kind: "italic", text: match[10] });
1542
+ } else if (match[11]) {
1543
+ spans.push({ kind: "italic", text: match[12] });
1544
+ }
1545
+ cursor = match.index + match[0].length;
1546
+ }
1547
+ if (cursor < text.length) {
1548
+ spans.push({ kind: "text", text: text.slice(cursor) });
1549
+ }
1550
+ return spans.filter((span) => {
1551
+ if (span.kind === "link") return span.text.length > 0 && span.url.length > 0;
1552
+ return span.text.length > 0;
1553
+ });
1554
+ }
1555
+
1556
+ const HEADING_PATTERN = /^(#{1,3})\s+(.+?)\s*$/;
1557
+ const UNORDERED_PATTERN = /^\s*[-*]\s+(.+)$/;
1558
+ const ORDERED_PATTERN = /^\s*\d+[.)]\s+(.+)$/;
1559
+ const FENCE_PATTERN = /^\s*```\s*([A-Za-z0-9_+-]*)\s*$/;
1560
+
1561
+ /** Split a Markdown body into render blocks for the compact composer view. */
1562
+ export function parseMarkdownLite(body: string | undefined | null): MarkdownLiteBlock[] {
1563
+ if (!body || typeof body !== "string") return [];
1564
+ const blocks: MarkdownLiteBlock[] = [];
1565
+ const paragraph: string[] = [];
1566
+ const flushParagraph = () => {
1567
+ if (paragraph.length === 0) return;
1568
+ const text = paragraph.join("\n").trim();
1569
+ paragraph.length = 0;
1570
+ if (!text) return;
1571
+ blocks.push({ kind: "paragraph", spans: parseMarkdownLiteInline(text) });
1572
+ };
1573
+ const closeList = (list: MarkdownLiteBlock | null) => {
1574
+ if (list && list.kind === "list" && list.items.length > 0) blocks.push(list);
1575
+ };
1576
+ let openList: MarkdownLiteBlock | null = null;
1577
+ let fenceLanguage: string | undefined;
1578
+ let fenceLines: string[] | null = null;
1579
+ for (const rawLine of body.split(/\r?\n/)) {
1580
+ const fence = FENCE_PATTERN.exec(rawLine);
1581
+ if (fence) {
1582
+ if (fenceLines == null) {
1583
+ flushParagraph();
1584
+ closeList(openList);
1585
+ openList = null;
1586
+ fenceLanguage = fence[1] || undefined;
1587
+ fenceLines = [];
1588
+ } else {
1589
+ blocks.push({
1590
+ kind: "code",
1591
+ text: fenceLines.join("\n").replace(/\n$/, ""),
1592
+ ...(fenceLanguage ? { language: fenceLanguage } : {}),
1593
+ });
1594
+ fenceLanguage = undefined;
1595
+ fenceLines = null;
1596
+ }
1597
+ continue;
1598
+ }
1599
+ if (fenceLines != null) {
1600
+ fenceLines.push(rawLine);
1601
+ continue;
1602
+ }
1603
+ if (!rawLine.trim()) {
1604
+ flushParagraph();
1605
+ closeList(openList);
1606
+ openList = null;
1607
+ continue;
1608
+ }
1609
+ const heading = HEADING_PATTERN.exec(rawLine);
1610
+ if (heading) {
1611
+ flushParagraph();
1612
+ closeList(openList);
1613
+ openList = null;
1614
+ blocks.push({
1615
+ kind: "heading",
1616
+ level: heading[1].length as 1 | 2 | 3,
1617
+ spans: parseMarkdownLiteInline(heading[2]),
1618
+ });
1619
+ continue;
1620
+ }
1621
+ const unordered = UNORDERED_PATTERN.exec(rawLine);
1622
+ const ordered = unordered ? null : ORDERED_PATTERN.exec(rawLine);
1623
+ if (unordered || ordered) {
1624
+ flushParagraph();
1625
+ const isOrdered = !unordered;
1626
+ const content = (unordered?.[1] ?? ordered?.[1] ?? "").trim();
1627
+ if (!content) continue;
1628
+ if (!openList || openList.kind !== "list" || openList.ordered !== isOrdered) {
1629
+ closeList(openList);
1630
+ openList = { kind: "list", ordered: isOrdered, items: [] };
1631
+ }
1632
+ (openList as { kind: "list"; ordered: boolean; items: MarkdownLiteSpan[][] }).items.push(
1633
+ parseMarkdownLiteInline(content),
1634
+ );
1635
+ continue;
1636
+ }
1637
+ closeList(openList);
1638
+ openList = null;
1639
+ paragraph.push(rawLine);
1640
+ }
1641
+ if (fenceLines != null) {
1642
+ blocks.push({
1643
+ kind: "code",
1644
+ text: fenceLines.join("\n").replace(/\n$/, ""),
1645
+ ...(fenceLanguage ? { language: fenceLanguage } : {}),
1646
+ });
1647
+ }
1648
+ flushParagraph();
1649
+ closeList(openList);
1650
+ return blocks;
1651
+ }
1652
+ // The autonomous board check stamps a raw text delta into chat
1653
+ // ("[Autonomous Trigger] Forgejo Board Alert: ..."). The composer view
1654
+ // restyles it as a structured card; this parser extracts the payload so
1655
+ // the timeline transformer can build typed renderer data. Pure string
1656
+ // parsing: no RPC, no side effects.
1657
+ // ---------------------------------------------------------------------------
1658
+
1659
+ export const BoardAlertIssueSchema = z.object({
1660
+ number: z.number().int().positive(),
1661
+ title: z.string(),
1662
+ labels: z.array(z.string()).default([]),
1663
+ action: z.string().default(""),
1664
+ url: z.string().url().optional(),
1665
+ });
1666
+ export type BoardAlertIssue = z.infer<typeof BoardAlertIssueSchema>;
1667
+
1668
+ export const BoardAlertSchema = z.object({
1669
+ headline: z.string(),
1670
+ issues: z.array(BoardAlertIssueSchema),
1671
+ });
1672
+ export type BoardAlert = z.infer<typeof BoardAlertSchema>;
1673
+
1674
+ export const boardAlertTimelineSchema = z.object({
1675
+ headline: z.string(),
1676
+ issues: z.array(BoardAlertIssueSchema),
1677
+ });
1678
+ export type BoardAlertTimelineData = z.infer<typeof boardAlertTimelineSchema>;
1679
+
1680
+ const BOARD_ALERT_MARKERS = ["forgejo board alert", "forgejo board actionable delta"];
1681
+
1682
+ const BOARD_ISSUE_LINE = /^\s*[-*]\s*issue\s*#(\d+)\s*:\s*(.+?)\s*$/i;
1683
+ const BOARD_LABELS_LINE = /^\s*labels\s*:\s*(.+?)\s*$/i;
1684
+ const BOARD_ACTION_LINE = /^\s*action\s*:\s*(.+?)\s*$/i;
1685
+
1686
+ /** True when chat text carries a board-alert delta dump. */
1687
+ export function isBoardAlertText(text: string | undefined | null): boolean {
1688
+ if (!text || typeof text !== "string") return false;
1689
+ const lower = text.toLowerCase();
1690
+ return BOARD_ALERT_MARKERS.some((marker) => lower.includes(marker));
1691
+ }
1692
+
1693
+ /**
1694
+ * Parse a raw board-alert dump into structured card data.
1695
+ * Returns null when the text is not a board alert or carries no issues.
1696
+ */
1697
+ export function parseBoardAlert(text: string | undefined | null): BoardAlert | null {
1698
+ if (!isBoardAlertText(text)) return null;
1699
+ const body = text as string;
1700
+ const linksByNumber = new Map<number, string>();
1701
+ for (const link of extractForgeIssueUrls(body)) {
1702
+ if (!linksByNumber.has(link.number)) linksByNumber.set(link.number, link.url);
1703
+ }
1704
+ const lines = body.split(/\r?\n/);
1705
+ const issues: BoardAlertIssue[] = [];
1706
+ let current: { number: number; title: string; labels: string[]; action: string; url?: string } | null = null;
1707
+ const flush = () => {
1708
+ if (current) {
1709
+ const parsed = BoardAlertIssueSchema.safeParse(current);
1710
+ if (parsed.success) issues.push(parsed.data);
1711
+ current = null;
1712
+ }
1713
+ };
1714
+ for (const line of lines) {
1715
+ const issueMatch = BOARD_ISSUE_LINE.exec(line);
1716
+ if (issueMatch) {
1717
+ flush();
1718
+ const number = Number(issueMatch[1]);
1719
+ const rawTitle = issueMatch[2].trim();
1720
+ const urlInTitle = extractForgeIssueUrls(rawTitle)[0]?.url;
1721
+ const title = urlInTitle ? rawTitle.replace(urlInTitle, "").replace(/\s{2,}/g, " ").trim() : rawTitle;
1722
+ current = {
1723
+ number,
1724
+ title,
1725
+ labels: [],
1726
+ action: "",
1727
+ ...(linksByNumber.get(number) ? { url: linksByNumber.get(number) as string } : {}),
1728
+ };
1729
+ continue;
1730
+ }
1731
+ if (!current) continue;
1732
+ const labelsMatch = BOARD_LABELS_LINE.exec(line);
1733
+ if (labelsMatch) {
1734
+ current.labels = labelsMatch[1]
1735
+ .split(",")
1736
+ .map((label) => label.trim())
1737
+ .filter(Boolean);
1738
+ continue;
1739
+ }
1740
+ const actionMatch = BOARD_ACTION_LINE.exec(line);
1741
+ if (actionMatch) {
1742
+ current.action = actionMatch[1].trim();
1743
+ }
1744
+ }
1745
+ flush();
1746
+ if (issues.length === 0) return null;
1747
+ const headlineMatch = /forgejo board alert\s*:?\s*([^\n]*)/i.exec(body);
1748
+ const headline = headlineMatch?.[1]?.trim() || "New actionable items detected";
1749
+ return { headline, issues };
1750
+ }