@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,127 @@
1
+ import type { PluginHandlerContext } from "@getpaseo/plugin/server";
2
+ import {
3
+ hookStatusContract,
4
+ hookQueuesContract,
5
+ hookPauseContract,
6
+ hookResumeContract,
7
+ hookDrainContract,
8
+ type HookStatusOutput,
9
+ type HookQueuesOutput,
10
+ type HookPauseOutput,
11
+ type HookResumeOutput,
12
+ type HookDrainOutput,
13
+ } from "../shared/hook-queue.js";
14
+
15
+ const DEFAULT_HOOK_URL = process.env.FORGE_HOOK_URL || "http://127.0.0.1:8099";
16
+
17
+ export function resolveHookUrl(provided?: string): string {
18
+ if (provided && typeof provided === "string" && provided.trim()) {
19
+ return provided.trim().replace(/\/+$/, "");
20
+ }
21
+ return DEFAULT_HOOK_URL.replace(/\/+$/, "");
22
+ }
23
+
24
+ export async function handleHookStatus(
25
+ input: { hookUrl?: string },
26
+ _context?: PluginHandlerContext,
27
+ ): Promise<HookStatusOutput> {
28
+ const url = `${resolveHookUrl(input.hookUrl)}/status`;
29
+ try {
30
+ const res = await fetch(url, { method: "GET", signal: AbortSignal.timeout(4000) });
31
+ if (!res.ok) {
32
+ return { ok: false, totalQueued: 0, repoCount: 0, paused: [], error: `HTTP ${res.status}: ${res.statusText}` };
33
+ }
34
+ const data = (await res.json()) as HookStatusOutput;
35
+ return { ...data, ok: true, paused: data.paused ?? [], totalQueued: data.totalQueued ?? 0, repoCount: data.repoCount ?? 0 };
36
+ } catch (err: unknown) {
37
+ const msg = err instanceof Error ? err.message : String(err);
38
+ return { ok: false, totalQueued: 0, repoCount: 0, paused: [], error: `Unreachable: ${msg}` };
39
+ }
40
+ }
41
+
42
+ export async function handleHookQueues(
43
+ input: { hookUrl?: string },
44
+ _context?: PluginHandlerContext,
45
+ ): Promise<HookQueuesOutput> {
46
+ const url = `${resolveHookUrl(input.hookUrl)}/queues`;
47
+ try {
48
+ const res = await fetch(url, { method: "GET", signal: AbortSignal.timeout(5000) });
49
+ if (!res.ok) {
50
+ return { ok: false, paused: [], queues: [], error: `HTTP ${res.status}: ${res.statusText}` };
51
+ }
52
+ const data = (await res.json()) as HookQueuesOutput;
53
+ return { ...data, ok: true, paused: data.paused ?? [], queues: data.queues ?? [] };
54
+ } catch (err: unknown) {
55
+ const msg = err instanceof Error ? err.message : String(err);
56
+ return { ok: false, paused: [], queues: [], error: `Unreachable: ${msg}` };
57
+ }
58
+ }
59
+
60
+ export async function handleHookPause(
61
+ input: { hookUrl?: string; repo?: string },
62
+ _context?: PluginHandlerContext,
63
+ ): Promise<HookPauseOutput> {
64
+ const url = `${resolveHookUrl(input.hookUrl)}/queue/pause`;
65
+ try {
66
+ const res = await fetch(url, {
67
+ method: "POST",
68
+ headers: { "Content-Type": "application/json" },
69
+ body: JSON.stringify({ repo: input.repo || "all" }),
70
+ signal: AbortSignal.timeout(4000),
71
+ });
72
+ if (!res.ok) {
73
+ return { ok: false, allPaused: [], error: `HTTP ${res.status}: ${res.statusText}` };
74
+ }
75
+ const data = (await res.json()) as HookPauseOutput;
76
+ return { ...data, ok: true, allPaused: data.allPaused ?? [] };
77
+ } catch (err: unknown) {
78
+ const msg = err instanceof Error ? err.message : String(err);
79
+ return { ok: false, allPaused: [], error: `Failed: ${msg}` };
80
+ }
81
+ }
82
+
83
+ export async function handleHookResume(
84
+ input: { hookUrl?: string; repo?: string },
85
+ _context?: PluginHandlerContext,
86
+ ): Promise<HookResumeOutput> {
87
+ const url = `${resolveHookUrl(input.hookUrl)}/queue/resume`;
88
+ try {
89
+ const res = await fetch(url, {
90
+ method: "POST",
91
+ headers: { "Content-Type": "application/json" },
92
+ body: JSON.stringify({ repo: input.repo || "all" }),
93
+ signal: AbortSignal.timeout(4000),
94
+ });
95
+ if (!res.ok) {
96
+ return { ok: false, allPaused: [], error: `HTTP ${res.status}: ${res.statusText}` };
97
+ }
98
+ const data = (await res.json()) as HookResumeOutput;
99
+ return { ...data, ok: true, allPaused: data.allPaused ?? [] };
100
+ } catch (err: unknown) {
101
+ const msg = err instanceof Error ? err.message : String(err);
102
+ return { ok: false, allPaused: [], error: `Failed: ${msg}` };
103
+ }
104
+ }
105
+
106
+ export async function handleHookDrain(
107
+ input: { hookUrl?: string; repo?: string },
108
+ _context?: PluginHandlerContext,
109
+ ): Promise<HookDrainOutput> {
110
+ const url = `${resolveHookUrl(input.hookUrl)}/queue/drain`;
111
+ try {
112
+ const res = await fetch(url, {
113
+ method: "POST",
114
+ headers: { "Content-Type": "application/json" },
115
+ body: JSON.stringify({ repo: input.repo }),
116
+ signal: AbortSignal.timeout(4000),
117
+ });
118
+ if (!res.ok) {
119
+ return { ok: false, error: `HTTP ${res.status}: ${res.statusText}` };
120
+ }
121
+ const data = (await res.json()) as HookDrainOutput;
122
+ return { ...data, ok: true };
123
+ } catch (err: unknown) {
124
+ const msg = err instanceof Error ? err.message : String(err);
125
+ return { ok: false, error: `Failed: ${msg}` };
126
+ }
127
+ }
@@ -0,0 +1,542 @@
1
+ import { createPluginLogger } from "paseo-plugin-helper/server";
2
+ import {
3
+ INSTALL_LABEL_MODES,
4
+ IssueDetailSchema,
5
+ PASEO_LABEL_SCOPES,
6
+ liveScopesFromIssues,
7
+ liveScopesFromLabels,
8
+ normalizeIssueNumber,
9
+ parseAgentEnvelope,
10
+ parseForgeRemote,
11
+ planLabelSetInstall,
12
+ rankIssues,
13
+ resolveForgeTarget,
14
+ scopeOfLabel,
15
+ type AddCommentInput,
16
+ type AddCommentOutput,
17
+ type CreateIssueInput,
18
+ type CreateIssueOutput,
19
+ type ForgeContextInput,
20
+ type ForgeContextOutput,
21
+ type InstallLabelsInput,
22
+ type InstallLabelsOutput,
23
+ type IssueDetail,
24
+ type IssueDetailInput,
25
+ type IssueDetailOutput,
26
+ type OpenIssuesInput,
27
+ type OpenIssuesOutput,
28
+ type SearchIssuesInput,
29
+ type SearchIssuesOutput,
30
+ type SetLabelInput,
31
+ type SetLabelOutput,
32
+ validateCreateIssueInput,
33
+ } from "../shared/issues.js";
34
+ import type { RpcOutput } from "paseo-plugin-helper/shared";
35
+ import { ForgeClient, type ForgejoIssueDetail } from "./forge-client.js";
36
+ import { ForgeGuard, type GuardLogLevel } from "./forge-guard.js";
37
+ import { gitOriginForDirectory } from "./git-origin.js";
38
+
39
+ const log = createPluginLogger("forges");
40
+
41
+ type OpenIssuesResult = RpcOutput<typeof openIssuesContract>;
42
+ import { openIssuesContract } from "../shared/issues.js";
43
+
44
+ const guard = new ForgeGuard();
45
+
46
+ /** Emit at the level the poll guard chose, so repeat noise stays at debug. */
47
+ function logAt(level: GuardLogLevel, message: string, context: Record<string, unknown>): void {
48
+ if (level === "warn") log.warn(message, context);
49
+ else if (level === "info") log.info(message, context);
50
+ else log.debug(message, context);
51
+ }
52
+
53
+ /**
54
+ * Confirm the resolved host answers as Forgejo/Gitea before any issue call,
55
+ * with the configured token, and cache the verdict (issue #114). Non-forge
56
+ * remotes resolve to a quiet not-a-forge state instead of a per-poll WARN.
57
+ */
58
+ async function probeForgeHost(client: ForgeClient): Promise<boolean> {
59
+ const cached = guard.cachedProbe(client.host);
60
+ if (cached !== null) return cached;
61
+ const speaks = await client.isForgeHost();
62
+ guard.recordProbe(client.host, speaks);
63
+ return speaks;
64
+ }
65
+
66
+ import { storedForgeSelection, tokenForHost } from "./settings.js";
67
+
68
+ type ResolvedRepo =
69
+ | { ok: true; host: string; repo: string; derivedRemote: string | null; remoteSource: "explicit" | "derived" }
70
+ | { ok: false; derivedRemote: string | null; error: string };
71
+
72
+ /**
73
+ * Single remote resolution for the whole plugin. The explicit forge target
74
+ * (per-request override, else the workspace's active forge selection, else the
75
+ * legacy single remote) wins absolutely: when present it is used as-is and
76
+ * never falls back to git derivation, so an invalid target fails loudly. Git
77
+ * origin is only consulted when no explicit target is set, or to supply the
78
+ * host for a bare owner/repo.
79
+ */
80
+ async function resolveRepo(
81
+ directory?: string,
82
+ explicitRemote?: string,
83
+ ): Promise<ResolvedRepo> {
84
+ const stored = await storedForgeSelection(directory);
85
+ const explicit = explicitRemote?.trim() ? explicitRemote.trim() : stored?.trim();
86
+ const remoteUrl = directory ? await gitOriginForDirectory(directory) : null;
87
+ const resolved = resolveForgeTarget(explicit, remoteUrl);
88
+ if (!resolved.ok) {
89
+ return { ok: false, derivedRemote: remoteUrl, error: resolved.error };
90
+ }
91
+ return {
92
+ ok: true,
93
+ host: resolved.host,
94
+ repo: resolved.repo,
95
+ derivedRemote: remoteUrl,
96
+ remoteSource: resolved.source,
97
+ };
98
+ }
99
+
100
+ async function clientFor(host: string): Promise<ForgeClient> {
101
+ return new ForgeClient({ host, token: await tokenForHost(host) });
102
+ }
103
+
104
+ /**
105
+ * Git-origin forge coordinates for a workspace, exposed independently of the
106
+ * issues query so the settings form can resolve a host-keyed token even while
107
+ * issues are loading or unavailable (regression #152). Never throws: an
108
+ * unreadable directory yields empty coordinates.
109
+ */
110
+ export async function handleForgeContext(
111
+ input: ForgeContextInput,
112
+ ): Promise<ForgeContextOutput> {
113
+ const directory =
114
+ typeof input?.directory === "string" && input.directory.trim()
115
+ ? input.directory.trim()
116
+ : null;
117
+ if (!directory) {
118
+ return { directory: null, derivedRemote: null, derivedHost: null, derivedRepo: null };
119
+ }
120
+ const derivedRemote = await gitOriginForDirectory(directory);
121
+ const parsed = parseForgeRemote(derivedRemote);
122
+ return {
123
+ directory,
124
+ derivedRemote,
125
+ derivedHost: parsed?.host ?? null,
126
+ derivedRepo: parsed ? `${parsed.owner}/${parsed.repo}` : null,
127
+ };
128
+ }
129
+
130
+ /**
131
+ * List open forge issues for the repo backing a workspace directory.
132
+ * Resolves owner/repo from the directory's git origin remote, fetches via
133
+ * the embedded API client, and never throws: failures surface as an
134
+ * error field so the pill renders a placeholder instead of breaking.
135
+ */
136
+ export async function handleOpenIssues(input: OpenIssuesInput): Promise<OpenIssuesOutput> {
137
+ const resolved = await resolveRepo(input?.directory, input?.remoteUrl);
138
+ if (!resolved.ok) {
139
+ return {
140
+ repo: null,
141
+ host: null,
142
+ issues: [],
143
+ openIssueCount: null,
144
+ derivedRemote: resolved.derivedRemote,
145
+ remoteSource: null,
146
+ repoPublic: null,
147
+ tokenPresent: false,
148
+ tokenValid: null,
149
+ repoWritePermission: null,
150
+ page: 1,
151
+ hasMore: false,
152
+ error: resolved.error,
153
+ };
154
+ }
155
+ const { host, repo, derivedRemote, remoteSource } = resolved;
156
+ const client = await clientFor(host);
157
+ if (!(await probeForgeHost(client))) {
158
+ logAt(guard.skipLogLevel(host), "skipping non-forge remote", { repo, host });
159
+ const message = remoteSource === "explicit"
160
+ ? `Selected forge ${host}/${repo} is unreachable or not a forge API host`
161
+ : "Not a forge repo for this workspace";
162
+ return { repo, host, issues: [], openIssueCount: null, page: 1, hasMore: false, derivedRemote, remoteSource, repoPublic: null, tokenPresent: false, tokenValid: null, repoWritePermission: null, error: message };
163
+ }
164
+ const anonClient = new ForgeClient({ host });
165
+ const tokenPresent = client.hasToken();
166
+ const page = input?.page ?? 1;
167
+ const [paged, openIssueCount, repoPublic, tokenValid, repoWritePermission] = await Promise.all([
168
+ client.listIssues(repo, page),
169
+ client.openIssueCount(repo),
170
+ anonClient.repoIsPublic(repo),
171
+ client.tokenIsValid(),
172
+ client.repoWritePermission(repo),
173
+ ]);
174
+ if (!paged) {
175
+ logAt(guard.failureLogLevel(host, repo), "issue list failed", { repo, host });
176
+ const message = remoteSource === "explicit"
177
+ ? `Issue list unavailable for ${host}/${repo}`
178
+ : "Issue list unavailable";
179
+ return { repo, host, issues: [], openIssueCount, page, hasMore: false, derivedRemote, remoteSource, repoPublic, tokenPresent, tokenValid, repoWritePermission, error: message };
180
+ }
181
+ guard.noteSuccess(host, repo);
182
+ const issues: OpenIssuesResult["issues"] = rankIssues(paged.issues);
183
+ void liveScopesFromIssues(issues);
184
+ return { repo, host, issues, openIssueCount, page, hasMore: paged.hasMore, derivedRemote, remoteSource, repoPublic, tokenPresent, tokenValid, repoWritePermission };
185
+ }
186
+
187
+ // ---------------------------------------------------------------------------
188
+ // Live remote issue search (issue #139). Same remote resolution and auth as
189
+ // `handleOpenIssues` — the token is attached daemon-side — but the query goes
190
+ // to the issues API's `q` parameter with `state=all`, so closed issues match.
191
+ // Never throws: failures come back as an `error` field for the client to show
192
+ // while it keeps rendering its instant client-side filter.
193
+ // ---------------------------------------------------------------------------
194
+
195
+ export async function handleSearchIssues(input: SearchIssuesInput): Promise<SearchIssuesOutput> {
196
+ const query = typeof input?.query === "string" ? input.query.trim() : "";
197
+ if (!query) {
198
+ return { repo: null, host: null, issues: [], page: 1, hasMore: false, error: "Enter a search query" };
199
+ }
200
+ const resolved = await resolveRepo(input?.directory, input?.remoteUrl);
201
+ if (!resolved.ok) {
202
+ return { repo: null, host: null, issues: [], page: 1, hasMore: false, error: resolved.error };
203
+ }
204
+ const { host, repo } = resolved;
205
+ const client = await clientFor(host);
206
+ if (!(await probeForgeHost(client))) {
207
+ logAt(guard.skipLogLevel(host), "skipping non-forge remote", { repo, host });
208
+ return {
209
+ repo,
210
+ host,
211
+ issues: [],
212
+ page: 1,
213
+ hasMore: false,
214
+ error: `Selected forge ${host}/${repo} is unreachable or not a forge API host`,
215
+ };
216
+ }
217
+ const page = input?.page ?? 1;
218
+ const result = await client.searchIssues(repo, query, page);
219
+ if (!result) {
220
+ logAt(guard.failureLogLevel(host, repo), "issue search failed", { repo, host });
221
+ return { repo, host, issues: [], page, hasMore: false, error: `Search unavailable for ${host}/${repo}` };
222
+ }
223
+ guard.noteSuccess(host, repo);
224
+ return { repo, host, issues: result.issues, page, hasMore: result.hasMore };
225
+ }
226
+
227
+ // ---------------------------------------------------------------------------
228
+ // Issue detail / label / comment handlers.
229
+ // Auth is daemon-side: the token lives in plugin settings and is only ever
230
+ // attached to fetch() calls here, never sent to the client.
231
+ // ---------------------------------------------------------------------------
232
+
233
+ function toIssueDetail(detail: ForgejoIssueDetail): IssueDetail | null {
234
+ const comments: IssueDetail["comments"] = detail.comments.map((comment) => ({
235
+ id: comment.id,
236
+ author: comment.author,
237
+ createdAt: comment.createdAt,
238
+ updatedAt: comment.updatedAt,
239
+ body: comment.body,
240
+ url: comment.url,
241
+ envelope: parseAgentEnvelope(comment.id, comment.body),
242
+ }));
243
+ const candidate = {
244
+ ...detail,
245
+ comments,
246
+ envelopes: comments
247
+ .map((comment) => comment.envelope)
248
+ .filter((envelope): envelope is NonNullable<typeof envelope> => envelope !== null),
249
+ };
250
+ const parsed = IssueDetailSchema.safeParse(candidate);
251
+ return parsed.success ? parsed.data : null;
252
+ }
253
+
254
+ async function fetchIssueDetail(
255
+ repo: string,
256
+ host: string,
257
+ issueNumber: number,
258
+ ): Promise<IssueDetail | null> {
259
+ const client = await clientFor(host);
260
+ const detail = await client.getIssue(repo, host, issueNumber);
261
+ return detail ? toIssueDetail(detail) : null;
262
+ }
263
+
264
+ /** Validate a label against the board's live scopes (plus known fallback scopes). */
265
+ function validateSetLabel(label: string, boardLabels: string[]): string | null {
266
+ const trimmed = label.trim();
267
+ if (!trimmed) return "Label must not be empty";
268
+ if (trimmed.length > 100) return "Label is too long";
269
+ if (/[\s,;]/.test(trimmed)) return `Unsupported label: ${trimmed}`;
270
+ const scope = scopeOfLabel(trimmed);
271
+ if (scope) {
272
+ const live = new Set(liveScopesFromLabels(boardLabels));
273
+ const fallback = new Set<string>(PASEO_LABEL_SCOPES);
274
+ if (!live.has(scope) && !fallback.has(scope)) {
275
+ return `Unknown label scope: ${trimmed}`;
276
+ }
277
+ }
278
+ return null;
279
+ }
280
+
281
+ export async function handleIssueDetail(
282
+ input: IssueDetailInput,
283
+ ): Promise<IssueDetailOutput> {
284
+ const fetchedAt = new Date().toISOString();
285
+ try {
286
+ const issueNumber = normalizeIssueNumber(input ?? {});
287
+ if (issueNumber == null) {
288
+ return { repo: null, issue: null, fetchedAt, repoPublic: null, tokenPresent: false, tokenValid: null, repoWritePermission: null, error: "issueNumber is required" };
289
+ }
290
+ const resolved = await resolveRepo(input?.directory, input?.remoteUrl);
291
+ if (!resolved.ok) {
292
+ return {
293
+ repo: null,
294
+ issue: null,
295
+ fetchedAt,
296
+ repoPublic: null,
297
+ tokenPresent: false,
298
+ tokenValid: null,
299
+ repoWritePermission: null,
300
+ error: resolved.error,
301
+ };
302
+ }
303
+ const { host, repo } = resolved;
304
+ const client = await clientFor(host);
305
+ const tokenPresent = client.hasToken();
306
+ const [issue, repoPublic, tokenValid, repoWritePermission] = await Promise.all([
307
+ fetchIssueDetail(repo, host, issueNumber),
308
+ new ForgeClient({ host }).repoIsPublic(repo),
309
+ client.tokenIsValid(),
310
+ client.repoWritePermission(repo),
311
+ ]);
312
+ if (!issue) {
313
+ return {
314
+ repo,
315
+ issue: null,
316
+ fetchedAt,
317
+ repoPublic,
318
+ tokenPresent,
319
+ tokenValid,
320
+ repoWritePermission,
321
+ error: `Issue #${issueNumber} not found in ${repo}`,
322
+ };
323
+ }
324
+ return { repo, issue, fetchedAt, repoPublic, tokenPresent, tokenValid, repoWritePermission };
325
+ } catch (error) {
326
+ log.warn("issue detail failed", { error: String(error) });
327
+ return { repo: null, issue: null, fetchedAt, repoPublic: null, tokenPresent: false, tokenValid: null, repoWritePermission: null, error: "Issue detail unavailable" };
328
+ }
329
+ }
330
+
331
+ export async function handleSetLabel(input: SetLabelInput): Promise<SetLabelOutput> {
332
+ try {
333
+ const issueNumber = normalizeIssueNumber(input ?? {});
334
+ if (issueNumber == null) {
335
+ return { number: 0, labels: [], error: "issueNumber is required" };
336
+ }
337
+ const resolved = await resolveRepo(input?.directory, input?.remoteUrl);
338
+ if (!resolved.ok) {
339
+ return {
340
+ number: issueNumber,
341
+ labels: [],
342
+ error: resolved.error,
343
+ };
344
+ }
345
+ const { host, repo } = resolved;
346
+ const client = await clientFor(host);
347
+ const detail = await client.getIssue(repo, host, issueNumber);
348
+ if (!detail) {
349
+ return {
350
+ number: issueNumber,
351
+ labels: [],
352
+ error: `Issue #${issueNumber} not found in ${repo}`,
353
+ };
354
+ }
355
+ const rawLabel = (input as { label?: unknown }).label;
356
+ const labelError = validateSetLabel(
357
+ typeof rawLabel === "string" ? rawLabel : "",
358
+ detail.labels,
359
+ );
360
+ if (labelError) {
361
+ return { number: issueNumber, labels: [], error: labelError };
362
+ }
363
+ const label = (rawLabel as string).trim();
364
+ const scope = scopeOfLabel(label);
365
+ const remove = scope
366
+ ? detail.labels.filter(
367
+ (existing) => scopeOfLabel(existing) === scope && existing !== label,
368
+ )
369
+ : [];
370
+ const labels = await client.setLabels(repo, issueNumber, [label], remove);
371
+ if (!labels) {
372
+ log.warn("set-label failed", { repo, issueNumber, label });
373
+ return { number: issueNumber, labels: [], error: "Could not update labels" };
374
+ }
375
+ return { number: issueNumber, labels };
376
+ } catch (error) {
377
+ log.warn("set-label failed", { error: String(error) });
378
+ return { number: 0, labels: [], error: "Could not update labels" };
379
+ }
380
+ }
381
+
382
+ export async function handleAddComment(
383
+ input: AddCommentInput,
384
+ ): Promise<AddCommentOutput> {
385
+ try {
386
+ const issueNumber = normalizeIssueNumber(input ?? {});
387
+ if (issueNumber == null) {
388
+ return { number: 0, commentId: null, error: "issueNumber is required" };
389
+ }
390
+ const body = typeof (input as { body?: unknown }).body === "string"
391
+ ? ((input as { body: string }).body as string).trim()
392
+ : "";
393
+ if (!body) {
394
+ return { number: issueNumber, commentId: null, error: "Comment body must not be empty" };
395
+ }
396
+ if (body.length > 10000) {
397
+ return { number: issueNumber, commentId: null, error: "Comment body is too long" };
398
+ }
399
+ const resolved = await resolveRepo(input?.directory, input?.remoteUrl);
400
+ if (!resolved.ok) {
401
+ return {
402
+ number: issueNumber,
403
+ commentId: null,
404
+ error: resolved.error,
405
+ };
406
+ }
407
+ const { host, repo } = resolved;
408
+ // Operator steering only: no agent envelope is appended (spec §5.4).
409
+ const client = await clientFor(host);
410
+ const commentId = await client.addComment(repo, issueNumber, body);
411
+ if (commentId == null) {
412
+ const detail = await client.getIssue(repo, host, issueNumber);
413
+ if (!detail) {
414
+ log.warn("add-comment failed", { repo, issueNumber });
415
+ return { number: issueNumber, commentId: null, error: "Could not post comment" };
416
+ }
417
+ }
418
+ return { number: issueNumber, commentId };
419
+ } catch (error) {
420
+ log.warn("add-comment failed", { error: String(error) });
421
+ return { number: 0, commentId: null, error: "Could not post comment" };
422
+ }
423
+ }
424
+
425
+ // ---------------------------------------------------------------------------
426
+ // Create issue (issue #200). Same remote resolution and daemon-side token as
427
+ // the other write verbs; the client only ever supplies the payload. Validation
428
+ // mirrors the composer, and an under-scoped/rejected token surfaces as an
429
+ // error rather than a silent no-op.
430
+ // ---------------------------------------------------------------------------
431
+
432
+ export async function handleCreateIssue(
433
+ input: CreateIssueInput,
434
+ ): Promise<CreateIssueOutput> {
435
+ try {
436
+ const validationError = validateCreateIssueInput(input ?? {});
437
+ if (validationError) {
438
+ return { repo: null, host: null, number: null, error: validationError };
439
+ }
440
+ const resolved = await resolveRepo(input?.directory, input?.remoteUrl);
441
+ if (!resolved.ok) {
442
+ return { repo: null, host: null, number: null, error: resolved.error };
443
+ }
444
+ const { host, repo } = resolved;
445
+ const title = (input.title as string).trim();
446
+ const body = typeof input.body === "string" ? input.body.trim() : "";
447
+ const labels = Array.isArray(input.labels)
448
+ ? input.labels.map((label) => String(label).trim()).filter(Boolean)
449
+ : [];
450
+ const client = await clientFor(host);
451
+ const number = await client.createIssue(repo, { title, body, labels });
452
+ if (number == null) {
453
+ log.warn("create-issue failed", { repo });
454
+ return { repo, host, number: null, error: "Could not create issue" };
455
+ }
456
+ return { repo, host, number };
457
+ } catch (error) {
458
+ log.warn("create-issue failed", { error: String(error) });
459
+ return { repo: null, host: null, number: null, error: "Could not create issue" };
460
+ }
461
+ }
462
+
463
+ // ---------------------------------------------------------------------------
464
+ // Optional label-set install (decision #121.3). A user-invoked action, never
465
+ // automatic: the client sends an explicit `mode`, the server resolves the
466
+ // configured forge and only ever writes repo labels for that target. `merge`
467
+ // adds our missing labels; `replace` additionally removes the target's labels
468
+ // that share a scope with our taxonomy. Unrelated boards are never touched
469
+ // silently, and a missing token fails closed.
470
+ // ---------------------------------------------------------------------------
471
+
472
+ export async function handleInstallLabels(
473
+ input: InstallLabelsInput,
474
+ ): Promise<InstallLabelsOutput> {
475
+ const rawMode = (input as { mode?: unknown }).mode;
476
+ const mode = INSTALL_LABEL_MODES.find((candidate) => candidate === rawMode);
477
+ if (!mode) {
478
+ return {
479
+ host: null,
480
+ repo: null,
481
+ mode: null,
482
+ created: [],
483
+ skipped: [],
484
+ removed: [],
485
+ error: "An explicit install mode (merge or replace) is required",
486
+ };
487
+ }
488
+ try {
489
+ const resolved = await resolveRepo(input?.directory, input?.remoteUrl);
490
+ if (!resolved.ok) {
491
+ return { host: null, repo: null, mode, created: [], skipped: [], removed: [], error: resolved.error };
492
+ }
493
+ const { host, repo } = resolved;
494
+ const result: InstallLabelsOutput = {
495
+ host,
496
+ repo,
497
+ mode,
498
+ created: [],
499
+ skipped: [],
500
+ removed: [],
501
+ };
502
+ const client = await clientFor(host);
503
+ if (!client.hasToken()) {
504
+ result.error = "A valid API token is required to install labels";
505
+ return result;
506
+ }
507
+ const existing = await client.listLabels(repo);
508
+ if (!existing) {
509
+ result.error = `Could not read labels for ${host}/${repo}`;
510
+ return result;
511
+ }
512
+ const plan = planLabelSetInstall(existing, mode);
513
+ result.skipped = [...plan.skip];
514
+ for (const label of plan.remove) {
515
+ if (typeof label.id !== "number") continue;
516
+ if (!(await client.deleteLabel(repo, label.id))) {
517
+ result.error = `Could not remove label ${label.name}`;
518
+ return result;
519
+ }
520
+ result.removed.push(label.name);
521
+ }
522
+ for (const definition of plan.create) {
523
+ if (await client.createLabel(repo, definition)) {
524
+ result.created.push(definition.name);
525
+ continue;
526
+ }
527
+ // A concurrent install (or a page-window miss) returns 409 for a label
528
+ // that now exists; treat that as skipped rather than a hard failure.
529
+ const refreshed = await client.listLabels(repo);
530
+ if (refreshed?.some((label) => label.name === definition.name)) {
531
+ result.skipped.push(definition.name);
532
+ continue;
533
+ }
534
+ result.error = `Could not create label ${definition.name}`;
535
+ return result;
536
+ }
537
+ return result;
538
+ } catch (error) {
539
+ log.warn("install-labels failed", { error: String(error) });
540
+ return { host: null, repo: null, mode, created: [], skipped: [], removed: [], error: "Could not install labels" };
541
+ }
542
+ }