@xpufx/paseo-top 0.4.2 → 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.
Files changed (43) hide show
  1. package/README.md +11 -12
  2. package/client/multi-host-view.tsx +177 -0
  3. package/client/multi-host.ts +98 -0
  4. package/client/pill-labels.ts +30 -6
  5. package/client/pill.tsx +42 -61
  6. package/client/settings-events.ts +33 -0
  7. package/client/surface.tsx +10 -9
  8. package/client/vendor/paseo-plugin-helper/components/Badge.tsx +12 -0
  9. package/client/vendor/paseo-plugin-helper/components/Button.tsx +29 -3
  10. package/client/vendor/paseo-plugin-helper/components/Card.tsx +32 -0
  11. package/client/vendor/paseo-plugin-helper/components/DataTable.tsx +17 -1
  12. package/client/vendor/paseo-plugin-helper/components/InlineButton.tsx +17 -2
  13. package/client/vendor/paseo-plugin-helper/components/InteractiveRow.tsx +142 -0
  14. package/client/vendor/paseo-plugin-helper/components/SearchInput.tsx +16 -2
  15. package/client/vendor/paseo-plugin-helper/components/SectionHeader.tsx +7 -0
  16. package/client/vendor/paseo-plugin-helper/components/Select.tsx +131 -63
  17. package/client/vendor/paseo-plugin-helper/components/index.ts +1 -0
  18. package/client/vendor/paseo-plugin-helper/custom-pills.tsx +4 -3
  19. package/client/vendor/paseo-plugin-helper/host.ts +12 -58
  20. package/client/vendor/paseo-plugin-helper/index.ts +2 -0
  21. package/client/vendor/paseo-plugin-helper/layout/ModalBody.tsx +19 -29
  22. package/client/vendor/paseo-plugin-helper/layout/ModalContent.tsx +24 -22
  23. package/client/vendor/paseo-plugin-helper/panel.tsx +11 -3
  24. package/client/vendor/paseo-plugin-helper/pill.tsx +135 -339
  25. package/client/vendor/paseo-plugin-helper/styles/index.ts +1 -0
  26. package/client/vendor/paseo-plugin-helper/styles/recipes.ts +550 -0
  27. package/client/vendor/paseo-plugin-helper/surface.tsx +62 -2
  28. package/client/vendor/paseo-plugin-helper/theme/provider.tsx +3 -39
  29. package/client/vendor/paseo-plugin-helper/theme/tokens.ts +40 -1
  30. package/client/vendor/paseo-plugin-helper/tickets.tsx +766 -0
  31. package/index.server.ts +7 -0
  32. package/package.json +12 -3
  33. package/paseo-plugin.json +1 -1
  34. package/server/vendor/paseo-plugin-helper/index.ts +2 -0
  35. package/server/vendor/paseo-plugin-helper/mcp-injection.ts +20 -2
  36. package/server/vendor/paseo-plugin-helper/plugins.ts +184 -19
  37. package/server/vendor/paseo-plugin-helper/tickets.ts +1281 -0
  38. package/shared/helper-version.ts +20 -0
  39. package/shared/multi-host.ts +348 -0
  40. package/shared/resources.ts +1 -1
  41. package/shared/vendor/paseo-plugin-helper/index.ts +1 -0
  42. package/shared/vendor/paseo-plugin-helper/tickets.ts +1010 -0
  43. package/shared/version.ts +1 -1
@@ -0,0 +1,1281 @@
1
+ import { readFile, stat } from "node:fs/promises";
2
+ import { homedir } from "node:os";
3
+ import { isAbsolute, join, resolve } from "node:path";
4
+ import { PluginStorage } from "./storage";
5
+ import { createPluginLogger } from "./logger";
6
+ import type { HandleableServerContext } from "./settings";
7
+ import {
8
+ BARE_REPO_PATTERN,
9
+ INSTALL_LABEL_MODES,
10
+ IssueDetailSchema,
11
+ PASEO_LABEL_SCOPES,
12
+ ForgeIssueSchema,
13
+ activeForgeForDirectory,
14
+ forgeCapabilityFromRepo,
15
+ forgeContextContract,
16
+ forgeForgeContextContract,
17
+ openIssuesContract,
18
+ searchIssuesContract,
19
+ issueDetailContract,
20
+ setLabelContract,
21
+ addCommentContract,
22
+ createIssueContract,
23
+ liveScopesFromIssues,
24
+ liveScopesFromLabels,
25
+ normalizeIssueNumber,
26
+ parseAgentEnvelope,
27
+ parseForgeRemote,
28
+ planLabelSetInstall,
29
+ rankIssues,
30
+ resolveForgeTarget,
31
+ scopeOfLabel,
32
+ validateCreateIssueInput,
33
+ type AddCommentInput,
34
+ type AddCommentOutput,
35
+ type CreateIssueInput,
36
+ type CreateIssueOutput,
37
+ type ForgeContextInput,
38
+ type ForgeContextOutput,
39
+ type ForgeIssue,
40
+ type ForgeLabel,
41
+ type InstallLabelsInput,
42
+ type InstallLabelsOutput,
43
+ type IssueDetail,
44
+ type IssueDetailInput,
45
+ type IssueDetailOutput,
46
+ type OpenIssuesInput,
47
+ type OpenIssuesOutput,
48
+ type SearchIssuesInput,
49
+ type SearchIssuesOutput,
50
+ type SetLabelInput,
51
+ type SetLabelOutput,
52
+ } from "../../../shared/vendor/paseo-plugin-helper/tickets";
53
+ import type { RpcOutput } from "../../../shared/vendor/paseo-plugin-helper/rpc";
54
+
55
+ // ============================================================================
56
+ // Git Origin utilities
57
+ // ============================================================================
58
+
59
+ const ORIGIN_SECTION = /\[remote\s+"origin"\][^\[]*?url\s*=\s*(.+)/;
60
+ const GITDIR_POINTER = /^\s*gitdir:\s*(.+?)\s*$/m;
61
+
62
+ /** `[remote "origin"]` URL from git config text, or null when absent. */
63
+ export function parseOriginUrl(config: string | null | undefined): string | null {
64
+ if (!config) return null;
65
+ const match = ORIGIN_SECTION.exec(config);
66
+ const url = match?.[1]?.trim();
67
+ return url || null;
68
+ }
69
+
70
+ /** `gitdir:` target from a linked-worktree `.git` file, or null. */
71
+ export function parseGitDirPointer(contents: string | null | undefined): string | null {
72
+ if (!contents) return null;
73
+ const match = GITDIR_POINTER.exec(contents);
74
+ return match?.[1]?.trim() || null;
75
+ }
76
+
77
+ async function readText(filePath: string): Promise<string | null> {
78
+ try {
79
+ return await readFile(filePath, "utf8");
80
+ } catch {
81
+ return null;
82
+ }
83
+ }
84
+
85
+ async function isFile(filePath: string): Promise<boolean> {
86
+ try {
87
+ return (await stat(filePath)).isFile();
88
+ } catch {
89
+ return false;
90
+ }
91
+ }
92
+
93
+ /** Resolve a gitdir/commondir value relative to the directory that held it. */
94
+ function resolveGitPath(value: string, base: string): string {
95
+ return isAbsolute(value) ? value : resolve(base, value);
96
+ }
97
+
98
+ /**
99
+ * Git directory that holds the shared config for a checkout.
100
+ * A normal checkout's `.git` is a directory. A linked worktree's `.git` is a
101
+ * file `gitdir: <path>`; that gitdir carries a `commondir` pointing at the main
102
+ * repo's git dir. Submodule-style gitdirs have no `commondir`, so the gitdir
103
+ * itself is used. Null when there is no usable git pointer.
104
+ */
105
+ async function commonGitDir(directory: string): Promise<string | null> {
106
+ const dotGit = join(directory, ".git");
107
+ if (!(await isFile(dotGit))) return dotGit;
108
+ const pointer = parseGitDirPointer(await readText(dotGit));
109
+ if (!pointer) return null;
110
+ const gitDir = resolveGitPath(pointer, directory);
111
+ const commondir = (await readText(join(gitDir, "commondir")))?.trim();
112
+ if (commondir) return resolveGitPath(commondir, gitDir);
113
+ return gitDir;
114
+ }
115
+
116
+ /**
117
+ * Read the workspace's git origin URL without shelling out. Handles both a
118
+ * normal `.git` directory and a linked-worktree `.git` file. Never throws:
119
+ * an unreadable or non-git directory yields null.
120
+ */
121
+ export async function gitOriginForDirectory(directory: string): Promise<string | null> {
122
+ const gitDir = await commonGitDir(directory);
123
+ if (!gitDir) return null;
124
+ return parseOriginUrl(await readText(join(gitDir, "config")));
125
+ }
126
+
127
+ // ============================================================================
128
+ // Forge Guard
129
+ // ============================================================================
130
+
131
+ export const NEGATIVE_PROBE_TTL_MS = 300_000;
132
+
133
+ export type GuardLogLevel = "info" | "debug" | "warn";
134
+
135
+ interface ProbeEntry {
136
+ speaksForgeApi: boolean;
137
+ at: number;
138
+ }
139
+
140
+ export class ForgeGuard {
141
+ private readonly probes = new Map<string, ProbeEntry>();
142
+ private readonly quietLogged = new Set<string>();
143
+ private readonly failures = new Map<string, number>();
144
+ private readonly now: () => number;
145
+ private readonly negativeProbeTtlMs: number;
146
+
147
+ constructor(options: { now?: () => number; negativeProbeTtlMs?: number } = {}) {
148
+ this.now = options.now ?? Date.now;
149
+ this.negativeProbeTtlMs = options.negativeProbeTtlMs ?? NEGATIVE_PROBE_TTL_MS;
150
+ }
151
+
152
+ /**
153
+ * Cached verdict: a host that speaks the forge API stays cached, a negative
154
+ * verdict expires. Null means the caller must probe.
155
+ */
156
+ cachedProbe(host: string): boolean | null {
157
+ const entry = this.probes.get(host);
158
+ if (!entry) return null;
159
+ if (entry.speaksForgeApi) return true;
160
+ return this.now() - entry.at < this.negativeProbeTtlMs ? false : null;
161
+ }
162
+
163
+ recordProbe(host: string, speaksForgeApi: boolean): void {
164
+ this.probes.set(host, { speaksForgeApi, at: this.now() });
165
+ }
166
+
167
+ /** Skip-log level for a non-forge host: info the first time, debug after. */
168
+ skipLogLevel(host: string): GuardLogLevel {
169
+ if (this.quietLogged.has(host)) return "debug";
170
+ this.quietLogged.add(host);
171
+ return "info";
172
+ }
173
+
174
+ /** List-failure level: warn the first time per repo, debug on repeats. */
175
+ failureLogLevel(host: string, repo: string): GuardLogLevel {
176
+ const key = `${host}/${repo}`;
177
+ const count = (this.failures.get(key) ?? 0) + 1;
178
+ this.failures.set(key, count);
179
+ return count <= 1 ? "warn" : "debug";
180
+ }
181
+
182
+ /** A successful list clears the backoff so the next failure warns again. */
183
+ noteSuccess(host: string, repo: string): void {
184
+ this.failures.delete(`${host}/${repo}`);
185
+ }
186
+ }
187
+
188
+ // ============================================================================
189
+ // Forge Client
190
+ // ============================================================================
191
+
192
+ export interface ForgeClientOptions {
193
+ host: string;
194
+ token?: string;
195
+ timeoutMs?: number;
196
+ }
197
+
198
+ const FORGE_PROBE_TIMEOUT_MS = 5000;
199
+
200
+ interface ApiLabel {
201
+ name?: unknown;
202
+ color?: unknown;
203
+ description?: unknown;
204
+ }
205
+
206
+ function labelList(value: unknown): ForgeLabel[] {
207
+ if (!Array.isArray(value)) return [];
208
+ const labels: ForgeLabel[] = [];
209
+ for (const entry of value as unknown[]) {
210
+ if (entry && typeof entry === "object") {
211
+ const record = entry as ApiLabel;
212
+ if (typeof record.name !== "string" || !record.name) continue;
213
+ labels.push({
214
+ name: record.name,
215
+ ...(typeof record.color === "string" && record.color ? { color: record.color } : {}),
216
+ ...(typeof record.description === "string" && record.description
217
+ ? { description: record.description }
218
+ : {}),
219
+ });
220
+ } else if (typeof entry === "string" && entry) {
221
+ labels.push({ name: entry });
222
+ }
223
+ }
224
+ return labels;
225
+ }
226
+
227
+ function labelNames(value: unknown): string[] {
228
+ return labelList(value).map((label) => label.name);
229
+ }
230
+
231
+ function asText(value: unknown, fallback = ""): string {
232
+ return typeof value === "string" ? value : fallback;
233
+ }
234
+
235
+ function asLogin(value: unknown): string {
236
+ if (value && typeof value === "object") {
237
+ const login = (value as Record<string, unknown>).login;
238
+ if (typeof login === "string" && login) return login;
239
+ }
240
+ return "unknown";
241
+ }
242
+
243
+ function toIssues(rows: unknown[], openOnly: boolean): ForgeIssue[] {
244
+ const issues: ForgeIssue[] = [];
245
+ for (const row of rows) {
246
+ if (!row || typeof row !== "object") continue;
247
+ const record = row as Record<string, unknown>;
248
+ const candidate = {
249
+ number: record.number,
250
+ title: record.title,
251
+ state: record.state,
252
+ labels: labelNames(record.labels),
253
+ labelDetails: labelList(record.labels),
254
+ updatedAt: typeof record.updated_at === "string" ? record.updated_at : undefined,
255
+ };
256
+ const parsed = ForgeIssueSchema.safeParse(candidate);
257
+ if (parsed.success && (!openOnly || parsed.data.state === "open")) issues.push(parsed.data);
258
+ }
259
+ return issues;
260
+ }
261
+
262
+ export interface ForgejoLabel {
263
+ id: number;
264
+ name: string;
265
+ color?: string;
266
+ exclusive?: boolean;
267
+ description?: string;
268
+ }
269
+
270
+ export interface ForgejoComment {
271
+ id: number;
272
+ author: string;
273
+ createdAt: string;
274
+ updatedAt: string;
275
+ body: string;
276
+ url: string;
277
+ }
278
+
279
+ function toLabel(entry: unknown): ForgejoLabel | null {
280
+ if (!entry || typeof entry !== "object") return null;
281
+ const record = entry as Record<string, unknown>;
282
+ if (typeof record.name !== "string" || !record.name) return null;
283
+ return {
284
+ id: typeof record.id === "number" ? record.id : 0,
285
+ name: record.name,
286
+ color: typeof record.color === "string" ? record.color : undefined,
287
+ exclusive: typeof record.exclusive === "boolean" ? record.exclusive : undefined,
288
+ description: typeof record.description === "string" ? record.description : undefined,
289
+ };
290
+ }
291
+
292
+ export interface ForgejoIssueDetail {
293
+ number: number;
294
+ title: string;
295
+ state: string;
296
+ labels: string[];
297
+ labelDetails: ForgeLabel[];
298
+ body: string;
299
+ author: string;
300
+ createdAt: string;
301
+ updatedAt: string;
302
+ webUrl: string;
303
+ comments: ForgejoComment[];
304
+ }
305
+
306
+ function toComment(entry: unknown): ForgejoComment | null {
307
+ if (!entry || typeof entry !== "object") return null;
308
+ const record = entry as Record<string, unknown>;
309
+ if (typeof record.id !== "number") return null;
310
+ const body = asText(record.body);
311
+ return {
312
+ id: record.id,
313
+ author: asLogin(record.user),
314
+ createdAt: asText(record.created_at),
315
+ updatedAt: asText(record.updated_at, asText(record.created_at)),
316
+ body,
317
+ url: asText(record.html_url),
318
+ };
319
+ }
320
+
321
+ function toDetail(repo: string, host: string, payload: unknown): ForgejoIssueDetail | null {
322
+ if (!payload || typeof payload !== "object") return null;
323
+ const root = payload as Record<string, unknown>;
324
+ const rawIssue = (root.issue ?? root) as Record<string, unknown>;
325
+ if (!rawIssue || typeof rawIssue !== "object" || typeof rawIssue.number !== "number") {
326
+ return null;
327
+ }
328
+ const rawComments = Array.isArray(root.comments) ? root.comments : [];
329
+ const comments: ForgejoComment[] = [];
330
+ for (const entry of rawComments) {
331
+ const comment = toComment(entry);
332
+ if (comment) comments.push(comment);
333
+ }
334
+ return {
335
+ number: rawIssue.number,
336
+ title: asText(rawIssue.title, `Issue #${rawIssue.number}`),
337
+ state: asText(rawIssue.state, "open"),
338
+ labels: labelNames(rawIssue.labels),
339
+ labelDetails: labelList(rawIssue.labels),
340
+ body: asText(rawIssue.body),
341
+ author: asLogin(rawIssue.user),
342
+ createdAt: asText(rawIssue.created_at),
343
+ updatedAt: asText(rawIssue.updated_at),
344
+ webUrl:
345
+ asText(rawIssue.html_url) ||
346
+ `https://${host}/${repo}/issues/${rawIssue.number}`,
347
+ comments,
348
+ };
349
+ }
350
+
351
+ export class ForgeClient {
352
+ readonly host: string;
353
+ private readonly baseUrl: string;
354
+ private readonly token?: string;
355
+ private readonly timeoutMs: number;
356
+
357
+ constructor(options: ForgeClientOptions) {
358
+ this.host = options.host;
359
+ this.baseUrl = `https://${options.host}/api/v1`;
360
+ this.token = options.token?.trim() ? options.token.trim() : undefined;
361
+ this.timeoutMs = options.timeoutMs ?? 15000;
362
+ }
363
+
364
+ private async requestWithStatus(
365
+ path: string,
366
+ init?: RequestInit,
367
+ timeoutMs = this.timeoutMs,
368
+ ): Promise<{ status: number; payload: unknown | null; ok: boolean }> {
369
+ const controller = new AbortController();
370
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
371
+ try {
372
+ const headers: Record<string, string> = {
373
+ Accept: "application/json",
374
+ ...(init?.headers as Record<string, string> | undefined),
375
+ };
376
+ if (this.token) headers.Authorization = `token ${this.token}`;
377
+ if (init?.body !== undefined && !headers["Content-Type"]) {
378
+ headers["Content-Type"] = "application/json";
379
+ }
380
+ const res = await fetch(`${this.baseUrl}${path}`, {
381
+ ...init,
382
+ headers,
383
+ signal: controller.signal as any,
384
+ });
385
+ if (!res.ok) return { status: res.status, payload: null, ok: false };
386
+ if (res.status === 204) return { status: res.status, payload: {}, ok: true };
387
+ const text = await res.text();
388
+ if (!text) return { status: res.status, payload: {}, ok: true };
389
+ try {
390
+ return { status: res.status, payload: JSON.parse(text) as unknown, ok: true };
391
+ } catch {
392
+ return { status: res.status, payload: null, ok: false };
393
+ }
394
+ } catch {
395
+ return { status: 0, payload: null, ok: false };
396
+ } finally {
397
+ clearTimeout(timer);
398
+ }
399
+ }
400
+
401
+ private async request(
402
+ path: string,
403
+ init?: RequestInit,
404
+ timeoutMs = this.timeoutMs,
405
+ ): Promise<unknown | null> {
406
+ const { ok, payload } = await this.requestWithStatus(path, init, timeoutMs);
407
+ return ok ? payload : null;
408
+ }
409
+
410
+ async repoIsPublic(repo: string): Promise<boolean | null> {
411
+ const controller = new AbortController();
412
+ const timer = setTimeout(() => controller.abort(), this.timeoutMs);
413
+ try {
414
+ const res = await fetch(`${this.baseUrl}/repos/${repo}`, {
415
+ headers: { Accept: "application/json" },
416
+ signal: controller.signal as any,
417
+ });
418
+ if (res.ok) return true;
419
+ if (res.status === 404 || res.status === 403) return false;
420
+ return null;
421
+ } catch {
422
+ return null;
423
+ } finally {
424
+ clearTimeout(timer);
425
+ }
426
+ }
427
+
428
+ async isForgeHost(): Promise<boolean> {
429
+ const payload = await this.request("/version", undefined, FORGE_PROBE_TIMEOUT_MS);
430
+ if (!payload || typeof payload !== "object") return false;
431
+ return typeof (payload as Record<string, unknown>).version === "string";
432
+ }
433
+
434
+ hasToken(): boolean {
435
+ return Boolean(this.token);
436
+ }
437
+
438
+ async tokenIsValid(): Promise<boolean | null> {
439
+ if (!this.token) return null;
440
+ const { status, ok } = await this.requestWithStatus("/user");
441
+ if (ok) return true;
442
+ if (status === 403) return true;
443
+ if (status === 0) return null;
444
+ return false;
445
+ }
446
+
447
+ async repoWritePermission(repo: string): Promise<boolean | null> {
448
+ if (!this.token) return null;
449
+ const { ok, payload } = await this.requestWithStatus(`/repos/${repo}`);
450
+ if (ok) return forgeCapabilityFromRepo(payload);
451
+ const anonymous = await this.anonymousRepo(repo);
452
+ if (anonymous === null) return null;
453
+ return forgeCapabilityFromRepo(anonymous);
454
+ }
455
+
456
+ async openIssueCount(repo: string): Promise<number | null> {
457
+ const count = (payload: unknown): number | null => {
458
+ if (!payload || typeof payload !== "object") return null;
459
+ const value = (payload as Record<string, unknown>).open_issues_count;
460
+ return typeof value === "number" && Number.isInteger(value) && value >= 0 ? value : null;
461
+ };
462
+ const authed = await this.requestWithStatus(`/repos/${repo}`);
463
+ if (authed.ok) return count(authed.payload);
464
+ return count(await this.anonymousRepo(repo));
465
+ }
466
+
467
+ private async anonymousRepo(repo: string): Promise<unknown | null> {
468
+ const controller = new AbortController();
469
+ const timer = setTimeout(() => controller.abort(), this.timeoutMs);
470
+ try {
471
+ const res = await fetch(`${this.baseUrl}/repos/${repo}`, {
472
+ headers: { Accept: "application/json" },
473
+ signal: controller.signal as any,
474
+ });
475
+ if (!res.ok) return null;
476
+ const text = await res.text();
477
+ if (!text) return null;
478
+ try {
479
+ return JSON.parse(text) as unknown;
480
+ } catch {
481
+ return null;
482
+ }
483
+ } catch {
484
+ return null;
485
+ } finally {
486
+ clearTimeout(timer);
487
+ }
488
+ }
489
+
490
+ async listIssues(repo: string, page = 1, limit = 50): Promise<{ issues: ForgeIssue[]; hasMore: boolean } | null> {
491
+ const payload = await this.request(
492
+ `/repos/${repo}/issues?state=open&type=issues&limit=${limit}&page=${page}`,
493
+ );
494
+ if (!Array.isArray(payload)) return null;
495
+ return { issues: toIssues(payload, true), hasMore: payload.length >= limit };
496
+ }
497
+
498
+ async searchIssues(
499
+ repo: string,
500
+ query: string,
501
+ page = 1,
502
+ limit = 50,
503
+ ): Promise<{ issues: ForgeIssue[]; hasMore: boolean } | null> {
504
+ const payload = await this.request(
505
+ `/repos/${repo}/issues?state=all&type=issues&q=${encodeURIComponent(query)}&limit=${limit}&page=${page}`,
506
+ );
507
+ if (!Array.isArray(payload)) return null;
508
+ return { issues: toIssues(payload, false), hasMore: payload.length >= limit };
509
+ }
510
+
511
+ async listComments(repo: string, issueNumber: number): Promise<ForgejoComment[] | null> {
512
+ const payload = await this.request(
513
+ `/repos/${repo}/issues/${issueNumber}/comments`,
514
+ );
515
+ if (!Array.isArray(payload)) return null;
516
+ const comments: ForgejoComment[] = [];
517
+ for (const entry of payload) {
518
+ const comment = toComment(entry);
519
+ if (comment) comments.push(comment);
520
+ }
521
+ return comments;
522
+ }
523
+
524
+ async getIssue(repo: string, host: string, issueNumber: number): Promise<ForgejoIssueDetail | null> {
525
+ const payload = await this.request(`/repos/${repo}/issues/${issueNumber}`);
526
+ const detail = toDetail(repo, host, payload);
527
+ if (!detail) return null;
528
+ const comments = await this.listComments(repo, issueNumber);
529
+ if (comments) detail.comments = comments;
530
+ return detail;
531
+ }
532
+
533
+ async listLabels(repo: string): Promise<ForgejoLabel[] | null> {
534
+ const labels: ForgejoLabel[] = [];
535
+ for (let page = 1; page <= 10; page += 1) {
536
+ const payload = await this.request(`/repos/${repo}/labels?limit=100&page=${page}`);
537
+ if (!Array.isArray(payload)) return null;
538
+ for (const entry of payload) {
539
+ const label = toLabel(entry);
540
+ if (label) labels.push(label);
541
+ }
542
+ if (payload.length < 100) break;
543
+ }
544
+ return labels;
545
+ }
546
+
547
+ async createLabel(
548
+ repo: string,
549
+ label: { name: string; color: string; exclusive: boolean; description: string },
550
+ ): Promise<boolean> {
551
+ const created = await this.request(`/repos/${repo}/labels`, {
552
+ method: "POST",
553
+ body: JSON.stringify(label),
554
+ });
555
+ return created !== null;
556
+ }
557
+
558
+ async deleteLabel(repo: string, id: number): Promise<boolean> {
559
+ const removed = await this.request(`/repos/${repo}/labels/${id}`, { method: "DELETE" });
560
+ return removed !== null;
561
+ }
562
+
563
+ async setLabels(
564
+ repo: string,
565
+ issueNumber: number,
566
+ add: string[],
567
+ remove: string[],
568
+ ): Promise<string[] | null> {
569
+ const current = await this.request(`/repos/${repo}/issues/${issueNumber}`);
570
+ if (!current || typeof current !== "object") return null;
571
+ const existing = labelNames((current as Record<string, unknown>).labels);
572
+ const next = existing.filter((label) => !remove.includes(label));
573
+ for (const label of add) {
574
+ if (!next.includes(label)) next.push(label);
575
+ }
576
+ const updated = await this.request(`/repos/${repo}/issues/${issueNumber}`, {
577
+ method: "PATCH",
578
+ body: JSON.stringify({ labels: next }),
579
+ });
580
+ if (!updated || typeof updated !== "object") return null;
581
+ return labelNames((updated as Record<string, unknown>).labels);
582
+ }
583
+
584
+ async addComment(repo: string, issueNumber: number, body: string): Promise<number | null> {
585
+ const created = await this.request(`/repos/${repo}/issues/${issueNumber}/comments`, {
586
+ method: "POST",
587
+ body: JSON.stringify({ body }),
588
+ });
589
+ if (!created || typeof created !== "object") return null;
590
+ const id = (created as Record<string, unknown>).id;
591
+ return typeof id === "number" ? id : null;
592
+ }
593
+
594
+ async createIssue(
595
+ repo: string,
596
+ issue: { title: string; body?: string; labels?: string[] },
597
+ ): Promise<number | null> {
598
+ const created = await this.request(`/repos/${repo}/issues`, {
599
+ method: "POST",
600
+ body: JSON.stringify({
601
+ title: issue.title,
602
+ ...(issue.body ? { body: issue.body } : {}),
603
+ ...(issue.labels && issue.labels.length ? { labels: issue.labels } : {}),
604
+ }),
605
+ });
606
+ if (!created || typeof created !== "object") return null;
607
+ const number = (created as Record<string, unknown>).number;
608
+ return typeof number === "number" ? number : null;
609
+ }
610
+ }
611
+
612
+ // ============================================================================
613
+ // Token and Forge resolution
614
+ // ============================================================================
615
+
616
+ export async function resolveForgeToken(
617
+ host: string,
618
+ options?: TicketHandlerOptions,
619
+ ): Promise<string | undefined> {
620
+ if (options?.resolveToken) {
621
+ try {
622
+ const explicit = await options.resolveToken(host);
623
+ if (explicit && explicit.trim()) return explicit.trim();
624
+ } catch {}
625
+ }
626
+
627
+ const storagePluginId = options?.storagePluginId ?? "forges";
628
+ try {
629
+ const storage = new PluginStorage<{ tokensByHost?: Record<string, string> }>(
630
+ storagePluginId,
631
+ "settings.json",
632
+ );
633
+ const data = await storage.readAsync();
634
+ const token = data?.tokensByHost?.[host];
635
+ if (typeof token === "string" && token.trim()) {
636
+ return token.trim();
637
+ }
638
+ } catch {}
639
+
640
+ if (process.env.FORGEJO_TOKEN?.trim()) {
641
+ return process.env.FORGEJO_TOKEN.trim();
642
+ }
643
+ if (process.env.GITEA_TOKEN?.trim()) {
644
+ return process.env.GITEA_TOKEN.trim();
645
+ }
646
+
647
+ try {
648
+ const teaConfigPath = join(homedir(), ".config", "tea", "config.yml");
649
+ const content = await readFile(teaConfigPath, "utf8");
650
+ const lines = content.split("\n");
651
+ let currentLoginMatches = false;
652
+ for (const line of lines) {
653
+ if (line.includes(host) || line.includes("https://" + host)) {
654
+ currentLoginMatches = true;
655
+ } else if (line.trim().startsWith("- name:")) {
656
+ currentLoginMatches = line.includes(host);
657
+ }
658
+ if (currentLoginMatches && line.trim().startsWith("token:")) {
659
+ const token = line.replace(/.*token:\s*/, "").trim();
660
+ if (token) return token;
661
+ }
662
+ }
663
+ } catch {}
664
+
665
+ return undefined;
666
+ }
667
+
668
+ export async function resolveDefaultForgeHost(): Promise<string> {
669
+ if (process.env.FORGEJO_HOST?.trim()) {
670
+ return process.env.FORGEJO_HOST.trim().replace(/^https?:\/\//, "").replace(/\/+$/, "");
671
+ }
672
+ if (process.env.GITEA_HOST?.trim()) {
673
+ return process.env.GITEA_HOST.trim().replace(/^https?:\/\//, "").replace(/\/+$/, "");
674
+ }
675
+ try {
676
+ const teaConfigPath = join(homedir(), ".config", "tea", "config.yml");
677
+ const content = await readFile(teaConfigPath, "utf8");
678
+ const lines = content.split("\n");
679
+ let defaultLoginUrl = "";
680
+ let firstLoginUrl = "";
681
+ let inDefaultLogin = false;
682
+ for (const line of lines) {
683
+ if (line.trim().startsWith("- name:")) {
684
+ inDefaultLogin = false;
685
+ }
686
+ if (line.trim().startsWith("default: true")) {
687
+ inDefaultLogin = true;
688
+ }
689
+ if (line.trim().startsWith("url:") || line.trim().startsWith("ssh_host:")) {
690
+ const urlOrHost = line.replace(/.*(?:url|ssh_host):\s*/, "").trim();
691
+ if (urlOrHost) {
692
+ const host = urlOrHost.replace(/^https?:\/\//, "").replace(/\/+$/, "");
693
+ if (inDefaultLogin && !defaultLoginUrl) {
694
+ defaultLoginUrl = host;
695
+ }
696
+ if (!firstLoginUrl) {
697
+ firstLoginUrl = host;
698
+ }
699
+ }
700
+ }
701
+ }
702
+ if (defaultLoginUrl) return defaultLoginUrl;
703
+ if (firstLoginUrl) return firstLoginUrl;
704
+ } catch {}
705
+
706
+ return "forge.mrs.uppidi.com";
707
+ }
708
+
709
+ export async function storedForgeSelection(
710
+ directory: string | undefined,
711
+ storagePluginId = "forges",
712
+ ): Promise<string | undefined> {
713
+ if (!directory) return undefined;
714
+ try {
715
+ const storage = new PluginStorage<Record<string, any>>(storagePluginId, "settings.json");
716
+ const settings = await storage.readAsync();
717
+ return activeForgeForDirectory(settings, directory) ?? undefined;
718
+ } catch {
719
+ return undefined;
720
+ }
721
+ }
722
+
723
+ type OpenIssuesResult = RpcOutput<typeof openIssuesContract>;
724
+
725
+ type ResolvedRepo =
726
+ | { ok: true; host: string; repo: string; derivedRemote: string | null; remoteSource: "explicit" | "derived" }
727
+ | { ok: false; derivedRemote: string | null; error: string };
728
+
729
+ function validateSetLabel(label: string, boardLabels: string[]): string | null {
730
+ const trimmed = label.trim();
731
+ if (!trimmed) return "Label must not be empty";
732
+ if (trimmed.length > 100) return "Label is too long";
733
+ if (/[\s,;]/.test(trimmed)) return `Unsupported label: ${trimmed}`;
734
+ const scope = scopeOfLabel(trimmed);
735
+ if (scope) {
736
+ const live = new Set(liveScopesFromLabels(boardLabels));
737
+ const fallback = new Set<string>(PASEO_LABEL_SCOPES);
738
+ if (!live.has(scope) && !fallback.has(scope)) {
739
+ return `Unknown label scope: ${trimmed}`;
740
+ }
741
+ }
742
+ return null;
743
+ }
744
+
745
+ function toIssueDetail(detail: ForgejoIssueDetail): IssueDetail | null {
746
+ const comments: IssueDetail["comments"] = detail.comments.map((comment) => ({
747
+ id: comment.id,
748
+ author: comment.author,
749
+ createdAt: comment.createdAt,
750
+ updatedAt: comment.updatedAt,
751
+ body: comment.body,
752
+ url: comment.url,
753
+ envelope: parseAgentEnvelope(comment.id, comment.body),
754
+ }));
755
+ const candidate = {
756
+ ...detail,
757
+ comments,
758
+ envelopes: comments
759
+ .map((comment) => comment.envelope)
760
+ .filter((envelope): envelope is NonNullable<typeof envelope> => envelope !== null),
761
+ };
762
+ const parsed = IssueDetailSchema.safeParse(candidate);
763
+ return parsed.success ? parsed.data : null;
764
+ }
765
+
766
+ export interface TicketHandlerOptions {
767
+ storagePluginId?: string;
768
+ resolveToken?: (host: string) => Promise<string | undefined | null>;
769
+ guard?: ForgeGuard;
770
+ }
771
+
772
+ export interface TicketHandlers {
773
+ handleOpenIssues(input: OpenIssuesInput): Promise<OpenIssuesOutput>;
774
+ handleSearchIssues(input: SearchIssuesInput): Promise<SearchIssuesOutput>;
775
+ handleForgeContext(input: ForgeContextInput): Promise<ForgeContextOutput>;
776
+ handleIssueDetail(input: IssueDetailInput): Promise<IssueDetailOutput>;
777
+ handleSetLabel(input: SetLabelInput): Promise<SetLabelOutput>;
778
+ handleAddComment(input: AddCommentInput): Promise<AddCommentOutput>;
779
+ handleCreateIssue(input: CreateIssueInput): Promise<CreateIssueOutput>;
780
+ handleInstallLabels(input: InstallLabelsInput): Promise<InstallLabelsOutput>;
781
+ }
782
+
783
+ export function createTicketHandlers(options: TicketHandlerOptions = {}): TicketHandlers {
784
+ const storagePluginId = options.storagePluginId ?? "forges";
785
+ const log = createPluginLogger(storagePluginId, { banner: false });
786
+ const guard = options.guard ?? new ForgeGuard();
787
+
788
+ function logAt(level: GuardLogLevel, message: string, context: Record<string, unknown>): void {
789
+ if (level === "warn") log.warn(message, context);
790
+ else if (level === "info") log.info(message, context);
791
+ else log.debug(message, context);
792
+ }
793
+
794
+ async function clientFor(host: string): Promise<ForgeClient> {
795
+ return new ForgeClient({ host, token: await resolveForgeToken(host, options) });
796
+ }
797
+
798
+ async function probeForgeHost(client: ForgeClient): Promise<boolean> {
799
+ const cached = guard.cachedProbe(client.host);
800
+ if (cached !== null) return cached;
801
+ const speaks = await client.isForgeHost();
802
+ guard.recordProbe(client.host, speaks);
803
+ return speaks;
804
+ }
805
+
806
+ async function resolveRepo(
807
+ directory?: string,
808
+ explicitRemote?: string,
809
+ ): Promise<ResolvedRepo> {
810
+ const stored = await storedForgeSelection(directory, storagePluginId);
811
+ const explicit = explicitRemote?.trim() ? explicitRemote.trim() : stored?.trim();
812
+ const remoteUrl = directory ? await gitOriginForDirectory(directory) : null;
813
+ let resolved = resolveForgeTarget(explicit, remoteUrl);
814
+ if (!resolved.ok && explicit && BARE_REPO_PATTERN.test(explicit)) {
815
+ const defaultHost = await resolveDefaultForgeHost();
816
+ if (defaultHost) {
817
+ const fallbackTarget = `https://${defaultHost}/${explicit}`;
818
+ const fallbackResolved = resolveForgeTarget(fallbackTarget, remoteUrl);
819
+ if (fallbackResolved.ok) {
820
+ resolved = fallbackResolved;
821
+ }
822
+ }
823
+ }
824
+ if (!resolved.ok) {
825
+ return { ok: false, derivedRemote: remoteUrl, error: resolved.error };
826
+ }
827
+ return {
828
+ ok: true,
829
+ host: resolved.host,
830
+ repo: resolved.repo,
831
+ derivedRemote: remoteUrl,
832
+ remoteSource: resolved.source,
833
+ };
834
+ }
835
+
836
+ async function fetchIssueDetail(
837
+ repo: string,
838
+ host: string,
839
+ issueNumber: number,
840
+ ): Promise<IssueDetail | null> {
841
+ const client = await clientFor(host);
842
+ const detail = await client.getIssue(repo, host, issueNumber);
843
+ return detail ? toIssueDetail(detail) : null;
844
+ }
845
+
846
+ const handlers: TicketHandlers = {
847
+ async handleForgeContext(input: ForgeContextInput): Promise<ForgeContextOutput> {
848
+ const directory =
849
+ typeof input?.directory === "string" && input.directory.trim()
850
+ ? input.directory.trim()
851
+ : null;
852
+ if (!directory) {
853
+ return { directory: null, derivedRemote: null, derivedHost: null, derivedRepo: null };
854
+ }
855
+ const derivedRemote = await gitOriginForDirectory(directory);
856
+ const parsed = parseForgeRemote(derivedRemote);
857
+ return {
858
+ directory,
859
+ derivedRemote,
860
+ derivedHost: parsed?.host ?? null,
861
+ derivedRepo: parsed ? `${parsed.owner}/${parsed.repo}` : null,
862
+ };
863
+ },
864
+
865
+ async handleOpenIssues(input: OpenIssuesInput): Promise<OpenIssuesOutput> {
866
+ const resolved = await resolveRepo(input?.directory, input?.remoteUrl);
867
+ if (!resolved.ok) {
868
+ return {
869
+ repo: null,
870
+ host: null,
871
+ issues: [],
872
+ openIssueCount: null,
873
+ derivedRemote: resolved.derivedRemote,
874
+ remoteSource: null,
875
+ repoPublic: null,
876
+ tokenPresent: false,
877
+ tokenValid: null,
878
+ repoWritePermission: null,
879
+ page: 1,
880
+ hasMore: false,
881
+ error: resolved.error,
882
+ };
883
+ }
884
+ const { host, repo, derivedRemote, remoteSource } = resolved;
885
+ const client = await clientFor(host);
886
+ if (!(await probeForgeHost(client))) {
887
+ logAt(guard.skipLogLevel(host), "skipping non-forge remote", { repo, host });
888
+ const message =
889
+ remoteSource === "explicit"
890
+ ? `Selected forge ${host}/${repo} is unreachable or not a forge API host`
891
+ : "Not a forge repo for this workspace";
892
+ return {
893
+ repo,
894
+ host,
895
+ issues: [],
896
+ openIssueCount: null,
897
+ page: 1,
898
+ hasMore: false,
899
+ derivedRemote,
900
+ remoteSource,
901
+ repoPublic: null,
902
+ tokenPresent: false,
903
+ tokenValid: null,
904
+ repoWritePermission: null,
905
+ error: message,
906
+ };
907
+ }
908
+ const anonClient = new ForgeClient({ host });
909
+ const tokenPresent = client.hasToken();
910
+ const page = input?.page ?? 1;
911
+ const [paged, openIssueCount, repoPublic, tokenValid, repoWritePermission] = await Promise.all([
912
+ client.listIssues(repo, page),
913
+ client.openIssueCount(repo),
914
+ anonClient.repoIsPublic(repo),
915
+ client.tokenIsValid(),
916
+ client.repoWritePermission(repo),
917
+ ]);
918
+ if (!paged) {
919
+ logAt(guard.failureLogLevel(host, repo), "issue list failed", { repo, host });
920
+ const message =
921
+ remoteSource === "explicit"
922
+ ? `Issue list unavailable for ${host}/${repo}`
923
+ : "Issue list unavailable";
924
+ return {
925
+ repo,
926
+ host,
927
+ issues: [],
928
+ openIssueCount,
929
+ page,
930
+ hasMore: false,
931
+ derivedRemote,
932
+ remoteSource,
933
+ repoPublic,
934
+ tokenPresent,
935
+ tokenValid,
936
+ repoWritePermission,
937
+ error: message,
938
+ };
939
+ }
940
+ guard.noteSuccess(host, repo);
941
+ const issues: OpenIssuesResult["issues"] = rankIssues(paged.issues);
942
+ void liveScopesFromIssues(issues);
943
+ return {
944
+ repo,
945
+ host,
946
+ issues,
947
+ openIssueCount,
948
+ page,
949
+ hasMore: paged.hasMore,
950
+ derivedRemote,
951
+ remoteSource,
952
+ repoPublic,
953
+ tokenPresent,
954
+ tokenValid,
955
+ repoWritePermission,
956
+ };
957
+ },
958
+
959
+ async handleSearchIssues(input: SearchIssuesInput): Promise<SearchIssuesOutput> {
960
+ const query = typeof input?.query === "string" ? input.query.trim() : "";
961
+ if (!query) {
962
+ return { repo: null, host: null, issues: [], page: 1, hasMore: false, error: "Enter a search query" };
963
+ }
964
+ const resolved = await resolveRepo(input?.directory, input?.remoteUrl);
965
+ if (!resolved.ok) {
966
+ return { repo: null, host: null, issues: [], page: 1, hasMore: false, error: resolved.error };
967
+ }
968
+ const { host, repo } = resolved;
969
+ const client = await clientFor(host);
970
+ if (!(await probeForgeHost(client))) {
971
+ logAt(guard.skipLogLevel(host), "skipping non-forge remote", { repo, host });
972
+ return {
973
+ repo,
974
+ host,
975
+ issues: [],
976
+ page: 1,
977
+ hasMore: false,
978
+ error: `Selected forge ${host}/${repo} is unreachable or not a forge API host`,
979
+ };
980
+ }
981
+ const page = input?.page ?? 1;
982
+ const result = await client.searchIssues(repo, query, page);
983
+ if (!result) {
984
+ logAt(guard.failureLogLevel(host, repo), "issue search failed", { repo, host });
985
+ return { repo, host, issues: [], page, hasMore: false, error: `Search unavailable for ${host}/${repo}` };
986
+ }
987
+ guard.noteSuccess(host, repo);
988
+ return { repo, host, issues: result.issues, page, hasMore: result.hasMore };
989
+ },
990
+
991
+ async handleIssueDetail(input: IssueDetailInput): Promise<IssueDetailOutput> {
992
+ const fetchedAt = new Date().toISOString();
993
+ try {
994
+ const issueNumber = normalizeIssueNumber(input ?? {});
995
+ if (issueNumber == null) {
996
+ return {
997
+ repo: null,
998
+ issue: null,
999
+ fetchedAt,
1000
+ repoPublic: null,
1001
+ tokenPresent: false,
1002
+ tokenValid: null,
1003
+ repoWritePermission: null,
1004
+ error: "issueNumber is required",
1005
+ };
1006
+ }
1007
+ const resolved = await resolveRepo(input?.directory, input?.remoteUrl);
1008
+ if (!resolved.ok) {
1009
+ return {
1010
+ repo: null,
1011
+ issue: null,
1012
+ fetchedAt,
1013
+ repoPublic: null,
1014
+ tokenPresent: false,
1015
+ tokenValid: null,
1016
+ repoWritePermission: null,
1017
+ error: resolved.error,
1018
+ };
1019
+ }
1020
+ const { host, repo } = resolved;
1021
+ const client = await clientFor(host);
1022
+ const tokenPresent = client.hasToken();
1023
+ const [issue, repoPublic, tokenValid, repoWritePermission] = await Promise.all([
1024
+ fetchIssueDetail(repo, host, issueNumber),
1025
+ new ForgeClient({ host }).repoIsPublic(repo),
1026
+ client.tokenIsValid(),
1027
+ client.repoWritePermission(repo),
1028
+ ]);
1029
+ if (!issue) {
1030
+ return {
1031
+ repo,
1032
+ issue: null,
1033
+ fetchedAt,
1034
+ repoPublic,
1035
+ tokenPresent,
1036
+ tokenValid,
1037
+ repoWritePermission,
1038
+ error: `Issue #${issueNumber} not found in ${repo}`,
1039
+ };
1040
+ }
1041
+ return { repo, issue, fetchedAt, repoPublic, tokenPresent, tokenValid, repoWritePermission };
1042
+ } catch (error) {
1043
+ log.warn("issue detail failed", { error: String(error) });
1044
+ return {
1045
+ repo: null,
1046
+ issue: null,
1047
+ fetchedAt,
1048
+ repoPublic: null,
1049
+ tokenPresent: false,
1050
+ tokenValid: null,
1051
+ repoWritePermission: null,
1052
+ error: "Issue detail unavailable",
1053
+ };
1054
+ }
1055
+ },
1056
+
1057
+ async handleSetLabel(input: SetLabelInput): Promise<SetLabelOutput> {
1058
+ try {
1059
+ const issueNumber = normalizeIssueNumber(input ?? {});
1060
+ if (issueNumber == null) {
1061
+ return { number: 0, labels: [], error: "issueNumber is required" };
1062
+ }
1063
+ const resolved = await resolveRepo(input?.directory, input?.remoteUrl);
1064
+ if (!resolved.ok) {
1065
+ return {
1066
+ number: issueNumber,
1067
+ labels: [],
1068
+ error: resolved.error,
1069
+ };
1070
+ }
1071
+ const { host, repo } = resolved;
1072
+ const client = await clientFor(host);
1073
+ const detail = await client.getIssue(repo, host, issueNumber);
1074
+ if (!detail) {
1075
+ return {
1076
+ number: issueNumber,
1077
+ labels: [],
1078
+ error: `Issue #${issueNumber} not found in ${repo}`,
1079
+ };
1080
+ }
1081
+ const rawLabel = (input as { label?: unknown }).label;
1082
+ const labelError = validateSetLabel(
1083
+ typeof rawLabel === "string" ? rawLabel : "",
1084
+ detail.labels,
1085
+ );
1086
+ if (labelError) {
1087
+ return { number: issueNumber, labels: [], error: labelError };
1088
+ }
1089
+ const label = (rawLabel as string).trim();
1090
+ const scope = scopeOfLabel(label);
1091
+ const remove = scope
1092
+ ? detail.labels.filter(
1093
+ (existing) => scopeOfLabel(existing) === scope && existing !== label,
1094
+ )
1095
+ : [];
1096
+ const labels = await client.setLabels(repo, issueNumber, [label], remove);
1097
+ if (!labels) {
1098
+ log.warn("set-label failed", { repo, issueNumber, label });
1099
+ return { number: issueNumber, labels: [], error: "Could not update labels" };
1100
+ }
1101
+ return { number: issueNumber, labels };
1102
+ } catch (error) {
1103
+ log.warn("set-label failed", { error: String(error) });
1104
+ return { number: 0, labels: [], error: "Could not update labels" };
1105
+ }
1106
+ },
1107
+
1108
+ async handleAddComment(input: AddCommentInput): Promise<AddCommentOutput> {
1109
+ try {
1110
+ const issueNumber = normalizeIssueNumber(input ?? {});
1111
+ if (issueNumber == null) {
1112
+ return { number: 0, commentId: null, error: "issueNumber is required" };
1113
+ }
1114
+ const body =
1115
+ typeof (input as { body?: unknown }).body === "string"
1116
+ ? ((input as { body: string }).body as string).trim()
1117
+ : "";
1118
+ if (!body) {
1119
+ return { number: issueNumber, commentId: null, error: "Comment body must not be empty" };
1120
+ }
1121
+ if (body.length > 10000) {
1122
+ return { number: issueNumber, commentId: null, error: "Comment body is too long" };
1123
+ }
1124
+ const resolved = await resolveRepo(input?.directory, input?.remoteUrl);
1125
+ if (!resolved.ok) {
1126
+ return {
1127
+ number: issueNumber,
1128
+ commentId: null,
1129
+ error: resolved.error,
1130
+ };
1131
+ }
1132
+ const { host, repo } = resolved;
1133
+ const client = await clientFor(host);
1134
+ const commentId = await client.addComment(repo, issueNumber, body);
1135
+ if (commentId == null) {
1136
+ const detail = await client.getIssue(repo, host, issueNumber);
1137
+ if (!detail) {
1138
+ log.warn("add-comment failed", { repo, issueNumber });
1139
+ return { number: issueNumber, commentId: null, error: "Could not post comment" };
1140
+ }
1141
+ }
1142
+ return { number: issueNumber, commentId };
1143
+ } catch (error) {
1144
+ log.warn("add-comment failed", { error: String(error) });
1145
+ return { number: 0, commentId: null, error: "Could not post comment" };
1146
+ }
1147
+ },
1148
+
1149
+ async handleCreateIssue(input: CreateIssueInput): Promise<CreateIssueOutput> {
1150
+ try {
1151
+ const validationError = validateCreateIssueInput(input ?? {});
1152
+ if (validationError) {
1153
+ return { repo: null, host: null, number: null, error: validationError };
1154
+ }
1155
+ const resolved = await resolveRepo(input?.directory, input?.remoteUrl);
1156
+ if (!resolved.ok) {
1157
+ return { repo: null, host: null, number: null, error: resolved.error };
1158
+ }
1159
+ const { host, repo } = resolved;
1160
+ const title = (input.title as string).trim();
1161
+ const body = typeof input.body === "string" ? input.body.trim() : "";
1162
+ const labels = Array.isArray(input.labels)
1163
+ ? input.labels.map((label) => String(label).trim()).filter(Boolean)
1164
+ : [];
1165
+ const client = await clientFor(host);
1166
+ const number = await client.createIssue(repo, { title, body, labels });
1167
+ if (number == null) {
1168
+ log.warn("create-issue failed", { repo });
1169
+ return { repo, host, number: null, error: "Could not create issue" };
1170
+ }
1171
+ return { repo, host, number };
1172
+ } catch (error) {
1173
+ log.warn("create-issue failed", { error: String(error) });
1174
+ return { repo: null, host: null, number: null, error: "Could not create issue" };
1175
+ }
1176
+ },
1177
+
1178
+ async handleInstallLabels(input: InstallLabelsInput): Promise<InstallLabelsOutput> {
1179
+ const rawMode = (input as { mode?: unknown }).mode;
1180
+ const mode = INSTALL_LABEL_MODES.find((candidate) => candidate === rawMode);
1181
+ if (!mode) {
1182
+ return {
1183
+ host: null,
1184
+ repo: null,
1185
+ mode: null,
1186
+ created: [],
1187
+ skipped: [],
1188
+ removed: [],
1189
+ error: "An explicit install mode (merge or replace) is required",
1190
+ };
1191
+ }
1192
+ try {
1193
+ const resolved = await resolveRepo(input?.directory, input?.remoteUrl);
1194
+ if (!resolved.ok) {
1195
+ return { host: null, repo: null, mode, created: [], skipped: [], removed: [], error: resolved.error };
1196
+ }
1197
+ const { host, repo } = resolved;
1198
+ const result: InstallLabelsOutput = {
1199
+ host,
1200
+ repo,
1201
+ mode,
1202
+ created: [],
1203
+ skipped: [],
1204
+ removed: [],
1205
+ };
1206
+ const client = await clientFor(host);
1207
+ if (!client.hasToken()) {
1208
+ result.error = "A valid API token is required to install labels";
1209
+ return result;
1210
+ }
1211
+ const existing = await client.listLabels(repo);
1212
+ if (!existing) {
1213
+ result.error = `Could not read labels for ${host}/${repo}`;
1214
+ return result;
1215
+ }
1216
+ const plan = planLabelSetInstall(existing, mode);
1217
+ result.skipped = [...plan.skip];
1218
+ for (const label of plan.remove) {
1219
+ if (typeof label.id !== "number") continue;
1220
+ if (!(await client.deleteLabel(repo, label.id))) {
1221
+ result.error = `Could not remove label ${label.name}`;
1222
+ return result;
1223
+ }
1224
+ result.removed.push(label.name);
1225
+ }
1226
+ for (const definition of plan.create) {
1227
+ if (await client.createLabel(repo, definition)) {
1228
+ result.created.push(definition.name);
1229
+ continue;
1230
+ }
1231
+ const refreshed = await client.listLabels(repo);
1232
+ if (refreshed?.some((label) => label.name === definition.name)) {
1233
+ result.skipped.push(definition.name);
1234
+ continue;
1235
+ }
1236
+ result.error = `Could not create label ${definition.name}`;
1237
+ return result;
1238
+ }
1239
+ return result;
1240
+ } catch (error) {
1241
+ log.warn("install-labels failed", { error: String(error) });
1242
+ return { host: null, repo: null, mode, created: [], skipped: [], removed: [], error: "Could not install labels" };
1243
+ }
1244
+ },
1245
+ };
1246
+
1247
+ return handlers;
1248
+ }
1249
+
1250
+ const defaultTicketHandlers = createTicketHandlers();
1251
+
1252
+ export const handleOpenIssues = (input: OpenIssuesInput) => defaultTicketHandlers.handleOpenIssues(input);
1253
+ export const handleSearchIssues = (input: SearchIssuesInput) => defaultTicketHandlers.handleSearchIssues(input);
1254
+ export const handleForgeContext = (input: ForgeContextInput) => defaultTicketHandlers.handleForgeContext(input);
1255
+ export const handleIssueDetail = (input: IssueDetailInput) => defaultTicketHandlers.handleIssueDetail(input);
1256
+ export const handleSetLabel = (input: SetLabelInput) => defaultTicketHandlers.handleSetLabel(input);
1257
+ export const handleAddComment = (input: AddCommentInput) => defaultTicketHandlers.handleAddComment(input);
1258
+ export const handleCreateIssue = (input: CreateIssueInput) => defaultTicketHandlers.handleCreateIssue(input);
1259
+ export const handleInstallLabels = (input: InstallLabelsInput) => defaultTicketHandlers.handleInstallLabels(input);
1260
+
1261
+ /**
1262
+ * Registers all forge ticket RPC contracts on any plugin server context.
1263
+ * Handles open-issues, search-issues, context, issue-detail, set-label, add-comment, create-issue.
1264
+ */
1265
+ export function registerTicketHandlers(
1266
+ server: HandleableServerContext,
1267
+ options?: TicketHandlerOptions,
1268
+ ): TicketHandlers {
1269
+ const handlers = options ? createTicketHandlers(options) : defaultTicketHandlers;
1270
+
1271
+ server.handle(openIssuesContract, (input: OpenIssuesInput) => handlers.handleOpenIssues(input));
1272
+ server.handle(searchIssuesContract, (input: SearchIssuesInput) => handlers.handleSearchIssues(input));
1273
+ server.handle(forgeContextContract, (input: ForgeContextInput) => handlers.handleForgeContext(input));
1274
+ server.handle(forgeForgeContextContract, (input: ForgeContextInput) => handlers.handleForgeContext(input));
1275
+ server.handle(issueDetailContract, (input: IssueDetailInput) => handlers.handleIssueDetail(input));
1276
+ server.handle(setLabelContract, (input: SetLabelInput) => handlers.handleSetLabel(input));
1277
+ server.handle(addCommentContract, (input: AddCommentInput) => handlers.handleAddComment(input));
1278
+ server.handle(createIssueContract, (input: CreateIssueInput) => handlers.handleCreateIssue(input));
1279
+
1280
+ return handlers;
1281
+ }