@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.
- package/LICENSE +21 -0
- package/README.md +109 -0
- package/client/active-forge.ts +47 -0
- package/client/board-alert.tsx +236 -0
- package/client/foreign-link.tsx +33 -0
- package/client/hook-queue-panel.tsx +412 -0
- package/client/issues-pill.tsx +1976 -0
- package/client/label-chip.tsx +116 -0
- package/client/linkifier.tsx +280 -0
- package/client/pill-label.ts +183 -0
- package/client/vendor/paseo-plugin-helper/command-center.ts +43 -0
- package/client/vendor/paseo-plugin-helper/components/AboutSection.tsx +493 -0
- package/client/vendor/paseo-plugin-helper/components/AttentionBeacon.tsx +250 -0
- package/client/vendor/paseo-plugin-helper/components/Badge.tsx +156 -0
- package/client/vendor/paseo-plugin-helper/components/Button.tsx +178 -0
- package/client/vendor/paseo-plugin-helper/components/Card.tsx +225 -0
- package/client/vendor/paseo-plugin-helper/components/CodeBlock.tsx +196 -0
- package/client/vendor/paseo-plugin-helper/components/Collapsible.tsx +277 -0
- package/client/vendor/paseo-plugin-helper/components/CommandBox.tsx +172 -0
- package/client/vendor/paseo-plugin-helper/components/CopyButton.tsx +180 -0
- package/client/vendor/paseo-plugin-helper/components/DataTable.tsx +200 -0
- package/client/vendor/paseo-plugin-helper/components/EmptyState.tsx +97 -0
- package/client/vendor/paseo-plugin-helper/components/HighlightedText.tsx +70 -0
- package/client/vendor/paseo-plugin-helper/components/InlineButton.tsx +73 -0
- package/client/vendor/paseo-plugin-helper/components/KeyValue.tsx +446 -0
- package/client/vendor/paseo-plugin-helper/components/MetricGauge.tsx +247 -0
- package/client/vendor/paseo-plugin-helper/components/ProgressBar.tsx +117 -0
- package/client/vendor/paseo-plugin-helper/components/Responsive.tsx +53 -0
- package/client/vendor/paseo-plugin-helper/components/SearchInput.tsx +118 -0
- package/client/vendor/paseo-plugin-helper/components/SectionHeader.tsx +80 -0
- package/client/vendor/paseo-plugin-helper/components/Select.tsx +215 -0
- package/client/vendor/paseo-plugin-helper/components/StatusDot.tsx +80 -0
- package/client/vendor/paseo-plugin-helper/components/Tabs.tsx +319 -0
- package/client/vendor/paseo-plugin-helper/components/TextInput.tsx +150 -0
- package/client/vendor/paseo-plugin-helper/components/Toggle.tsx +163 -0
- package/client/vendor/paseo-plugin-helper/components/TruncatedText.tsx +157 -0
- package/client/vendor/paseo-plugin-helper/components/index.ts +25 -0
- package/client/vendor/paseo-plugin-helper/custom-pills.tsx +224 -0
- package/client/vendor/paseo-plugin-helper/forge-icon.tsx +79 -0
- package/client/vendor/paseo-plugin-helper/host.ts +277 -0
- package/client/vendor/paseo-plugin-helper/icon.tsx +39 -0
- package/client/vendor/paseo-plugin-helper/index.ts +28 -0
- package/client/vendor/paseo-plugin-helper/layout/ActionBar.tsx +49 -0
- package/client/vendor/paseo-plugin-helper/layout/FormRow.tsx +103 -0
- package/client/vendor/paseo-plugin-helper/layout/Grid.tsx +65 -0
- package/client/vendor/paseo-plugin-helper/layout/ModalBody.tsx +378 -0
- package/client/vendor/paseo-plugin-helper/layout/ModalContent.tsx +49 -0
- package/client/vendor/paseo-plugin-helper/layout/Row.tsx +39 -0
- package/client/vendor/paseo-plugin-helper/layout/Stack.tsx +39 -0
- package/client/vendor/paseo-plugin-helper/layout/index.ts +7 -0
- package/client/vendor/paseo-plugin-helper/panel.tsx +81 -0
- package/client/vendor/paseo-plugin-helper/pill.tsx +884 -0
- package/client/vendor/paseo-plugin-helper/query-refresh.ts +79 -0
- package/client/vendor/paseo-plugin-helper/query.ts +66 -0
- package/client/vendor/paseo-plugin-helper/settings-screen.tsx +372 -0
- package/client/vendor/paseo-plugin-helper/settings.ts +181 -0
- package/client/vendor/paseo-plugin-helper/shared-settings.ts +46 -0
- package/client/vendor/paseo-plugin-helper/snapshot.ts +68 -0
- package/client/vendor/paseo-plugin-helper/surface.tsx +80 -0
- package/client/vendor/paseo-plugin-helper/theme/color-utils.ts +118 -0
- package/client/vendor/paseo-plugin-helper/theme/flair.ts +76 -0
- package/client/vendor/paseo-plugin-helper/theme/host-variables.ts +121 -0
- package/client/vendor/paseo-plugin-helper/theme/index.ts +7 -0
- package/client/vendor/paseo-plugin-helper/theme/provider.tsx +214 -0
- package/client/vendor/paseo-plugin-helper/theme/responsive.ts +213 -0
- package/client/vendor/paseo-plugin-helper/theme/tokens.ts +161 -0
- package/client/vendor/paseo-plugin-helper/theme/useResponsive.ts +57 -0
- package/client/vendor/paseo-plugin-helper/utils/clipboard.ts +149 -0
- package/client/vendor/paseo-plugin-helper/utils/haptics.ts +34 -0
- package/client/webhook-card.tsx +198 -0
- package/docs/specs/forge-workflow-gui.md +820 -0
- package/docs/workflow.md +339 -0
- package/examples/README.md +108 -0
- package/examples/hook-service/README.md +66 -0
- package/examples/hook-service/forge-hook.service +28 -0
- package/examples/hook-service/hook-server.mjs +265 -0
- package/examples/hook-service/hook.env.example +21 -0
- package/examples/labels/README.md +64 -0
- package/examples/labels/label-base.yaml +117 -0
- package/examples/skills/coding-agent/SKILL.md +262 -0
- package/examples/skills/coding-agent-fgjx/SKILL.md +271 -0
- package/examples/skills/orchestrator/SKILL.md +133 -0
- package/examples/skills/orchestrator-fgjx/SKILL.md +139 -0
- package/examples/tools/README.md +68 -0
- package/examples/tools/fgjx +464 -0
- package/package.json +38 -0
- package/paseo-plugin.json +4 -0
- package/server/forge-client.ts +484 -0
- package/server/forge-guard.ts +70 -0
- package/server/git-origin.ts +70 -0
- package/server/hook-queue.ts +127 -0
- package/server/issues.ts +542 -0
- package/server/settings.ts +52 -0
- package/server/vendor/paseo-plugin-helper/agent.ts +85 -0
- package/server/vendor/paseo-plugin-helper/custom-pills.ts +344 -0
- package/server/vendor/paseo-plugin-helper/index.ts +18 -0
- package/server/vendor/paseo-plugin-helper/jsonc.ts +78 -0
- package/server/vendor/paseo-plugin-helper/logger.ts +210 -0
- package/server/vendor/paseo-plugin-helper/mcp-config.ts +367 -0
- package/server/vendor/paseo-plugin-helper/mcp-injection.ts +85 -0
- package/server/vendor/paseo-plugin-helper/network.ts +91 -0
- package/server/vendor/paseo-plugin-helper/plugins.ts +160 -0
- package/server/vendor/paseo-plugin-helper/process.ts +186 -0
- package/server/vendor/paseo-plugin-helper/redact.ts +86 -0
- package/server/vendor/paseo-plugin-helper/rpc-guard.ts +77 -0
- package/server/vendor/paseo-plugin-helper/settings.ts +97 -0
- package/server/vendor/paseo-plugin-helper/shared-settings.ts +243 -0
- package/server/vendor/paseo-plugin-helper/storage.ts +244 -0
- package/server/vendor/paseo-plugin-helper/system.ts +128 -0
- package/server/vendor/paseo-plugin-helper/task.ts +116 -0
- package/server/vendor/paseo-plugin-helper/version.ts +153 -0
- package/server/vendor/paseo-plugin-helper/workspace-beacon.ts +418 -0
- package/shared/hook-queue.ts +140 -0
- package/shared/issues.ts +1750 -0
- package/shared/vendor/paseo-plugin-helper/README.md +11 -0
- package/shared/vendor/paseo-plugin-helper/async.ts +35 -0
- package/shared/vendor/paseo-plugin-helper/custom-pills.ts +169 -0
- package/shared/vendor/paseo-plugin-helper/forge.ts +110 -0
- package/shared/vendor/paseo-plugin-helper/formatters.ts +271 -0
- package/shared/vendor/paseo-plugin-helper/highlight.ts +184 -0
- package/shared/vendor/paseo-plugin-helper/index.ts +10 -0
- package/shared/vendor/paseo-plugin-helper/rpc.ts +72 -0
- package/shared/vendor/paseo-plugin-helper/settings.ts +138 -0
- package/shared/vendor/paseo-plugin-helper/suite-settings.ts +17 -0
- package/shared/vendor/paseo-plugin-helper/suppressed.ts +31 -0
- package/shared/vendor/paseo-plugin-helper/types.ts +36 -0
- package/shared/version.ts +2 -0
- package/shared/webhook.ts +223 -0
|
@@ -0,0 +1,484 @@
|
|
|
1
|
+
import { ForgeIssueSchema, forgeCapabilityFromRepo, type ForgeIssue, type ForgeLabel } from "../shared/issues.js";
|
|
2
|
+
|
|
3
|
+
export interface ForgeClientOptions {
|
|
4
|
+
host: string;
|
|
5
|
+
token?: string;
|
|
6
|
+
timeoutMs?: number;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/** The identity probe sits on the 30s poll path, so it gets a tighter bound. */
|
|
10
|
+
const FORGE_PROBE_TIMEOUT_MS = 5000;
|
|
11
|
+
|
|
12
|
+
interface ApiLabel {
|
|
13
|
+
name?: unknown;
|
|
14
|
+
color?: unknown;
|
|
15
|
+
description?: unknown;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Map the Gitea-family label objects on an issue to `{ name, color,
|
|
20
|
+
* description? }`. Bare string entries (older shapes) degrade to name-only.
|
|
21
|
+
* The API returns `color` as hex without `#`; it is passed through untouched.
|
|
22
|
+
*/
|
|
23
|
+
function labelList(value: unknown): ForgeLabel[] {
|
|
24
|
+
if (!Array.isArray(value)) return [];
|
|
25
|
+
const labels: ForgeLabel[] = [];
|
|
26
|
+
for (const entry of value as unknown[]) {
|
|
27
|
+
if (entry && typeof entry === "object") {
|
|
28
|
+
const record = entry as ApiLabel;
|
|
29
|
+
if (typeof record.name !== "string" || !record.name) continue;
|
|
30
|
+
labels.push({
|
|
31
|
+
name: record.name,
|
|
32
|
+
...(typeof record.color === "string" && record.color ? { color: record.color } : {}),
|
|
33
|
+
...(typeof record.description === "string" && record.description
|
|
34
|
+
? { description: record.description }
|
|
35
|
+
: {}),
|
|
36
|
+
});
|
|
37
|
+
} else if (typeof entry === "string" && entry) {
|
|
38
|
+
labels.push({ name: entry });
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return labels;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Label-name view, derived from the full label objects. */
|
|
45
|
+
function labelNames(value: unknown): string[] {
|
|
46
|
+
return labelList(value).map((label) => label.name);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function asText(value: unknown, fallback = ""): string {
|
|
50
|
+
return typeof value === "string" ? value : fallback;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function asLogin(value: unknown): string {
|
|
54
|
+
if (value && typeof value === "object") {
|
|
55
|
+
const login = (value as Record<string, unknown>).login;
|
|
56
|
+
if (typeof login === "string" && login) return login;
|
|
57
|
+
}
|
|
58
|
+
return "unknown";
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Parse Gitea-family issue rows. `openOnly` keeps the open snapshot contract of
|
|
63
|
+
* `listIssues`; search drops the filter so closed issues are included.
|
|
64
|
+
*/
|
|
65
|
+
function toIssues(rows: unknown[], openOnly: boolean): ForgeIssue[] {
|
|
66
|
+
const issues: ForgeIssue[] = [];
|
|
67
|
+
for (const row of rows) {
|
|
68
|
+
if (!row || typeof row !== "object") continue;
|
|
69
|
+
const record = row as Record<string, unknown>;
|
|
70
|
+
const candidate = {
|
|
71
|
+
number: record.number,
|
|
72
|
+
title: record.title,
|
|
73
|
+
state: record.state,
|
|
74
|
+
labels: labelNames(record.labels),
|
|
75
|
+
labelDetails: labelList(record.labels),
|
|
76
|
+
updatedAt: typeof record.updated_at === "string" ? record.updated_at : undefined,
|
|
77
|
+
};
|
|
78
|
+
const parsed = ForgeIssueSchema.safeParse(candidate);
|
|
79
|
+
if (parsed.success && (!openOnly || parsed.data.state === "open")) issues.push(parsed.data);
|
|
80
|
+
}
|
|
81
|
+
return issues;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export interface ForgejoLabel {
|
|
85
|
+
id: number;
|
|
86
|
+
name: string;
|
|
87
|
+
color?: string;
|
|
88
|
+
exclusive?: boolean;
|
|
89
|
+
description?: string;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export interface ForgejoComment {
|
|
93
|
+
id: number;
|
|
94
|
+
author: string;
|
|
95
|
+
createdAt: string;
|
|
96
|
+
updatedAt: string;
|
|
97
|
+
body: string;
|
|
98
|
+
url: string;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function toLabel(entry: unknown): ForgejoLabel | null {
|
|
102
|
+
if (!entry || typeof entry !== "object") return null;
|
|
103
|
+
const record = entry as Record<string, unknown>;
|
|
104
|
+
if (typeof record.name !== "string" || !record.name) return null;
|
|
105
|
+
return {
|
|
106
|
+
id: typeof record.id === "number" ? record.id : 0,
|
|
107
|
+
name: record.name,
|
|
108
|
+
color: typeof record.color === "string" ? record.color : undefined,
|
|
109
|
+
exclusive: typeof record.exclusive === "boolean" ? record.exclusive : undefined,
|
|
110
|
+
description: typeof record.description === "string" ? record.description : undefined,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export interface ForgejoIssueDetail {
|
|
115
|
+
number: number;
|
|
116
|
+
title: string;
|
|
117
|
+
state: string;
|
|
118
|
+
labels: string[];
|
|
119
|
+
labelDetails: ForgeLabel[];
|
|
120
|
+
body: string;
|
|
121
|
+
author: string;
|
|
122
|
+
createdAt: string;
|
|
123
|
+
updatedAt: string;
|
|
124
|
+
webUrl: string;
|
|
125
|
+
comments: ForgejoComment[];
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function toComment(entry: unknown): ForgejoComment | null {
|
|
129
|
+
if (!entry || typeof entry !== "object") return null;
|
|
130
|
+
const record = entry as Record<string, unknown>;
|
|
131
|
+
if (typeof record.id !== "number") return null;
|
|
132
|
+
const body = asText(record.body);
|
|
133
|
+
return {
|
|
134
|
+
id: record.id,
|
|
135
|
+
author: asLogin(record.user),
|
|
136
|
+
createdAt: asText(record.created_at),
|
|
137
|
+
updatedAt: asText(record.updated_at, asText(record.created_at)),
|
|
138
|
+
body,
|
|
139
|
+
url: asText(record.html_url),
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function toDetail(repo: string, host: string, payload: unknown): ForgejoIssueDetail | null {
|
|
144
|
+
if (!payload || typeof payload !== "object") return null;
|
|
145
|
+
const root = payload as Record<string, unknown>;
|
|
146
|
+
const rawIssue = (root.issue ?? root) as Record<string, unknown>;
|
|
147
|
+
if (!rawIssue || typeof rawIssue !== "object" || typeof rawIssue.number !== "number") {
|
|
148
|
+
return null;
|
|
149
|
+
}
|
|
150
|
+
const rawComments = Array.isArray(root.comments) ? root.comments : [];
|
|
151
|
+
const comments: ForgejoComment[] = [];
|
|
152
|
+
for (const entry of rawComments) {
|
|
153
|
+
const comment = toComment(entry);
|
|
154
|
+
if (comment) comments.push(comment);
|
|
155
|
+
}
|
|
156
|
+
return {
|
|
157
|
+
number: rawIssue.number,
|
|
158
|
+
title: asText(rawIssue.title, `Issue #${rawIssue.number}`),
|
|
159
|
+
state: asText(rawIssue.state, "open"),
|
|
160
|
+
labels: labelNames(rawIssue.labels),
|
|
161
|
+
labelDetails: labelList(rawIssue.labels),
|
|
162
|
+
body: asText(rawIssue.body),
|
|
163
|
+
author: asLogin(rawIssue.user),
|
|
164
|
+
createdAt: asText(rawIssue.created_at),
|
|
165
|
+
updatedAt: asText(rawIssue.updated_at),
|
|
166
|
+
webUrl:
|
|
167
|
+
asText(rawIssue.html_url) ||
|
|
168
|
+
`https://${host}/${repo}/issues/${rawIssue.number}`,
|
|
169
|
+
comments,
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export class ForgeClient {
|
|
174
|
+
readonly host: string;
|
|
175
|
+
private readonly baseUrl: string;
|
|
176
|
+
private readonly token?: string;
|
|
177
|
+
private readonly timeoutMs: number;
|
|
178
|
+
|
|
179
|
+
constructor(options: ForgeClientOptions) {
|
|
180
|
+
this.host = options.host;
|
|
181
|
+
this.baseUrl = `https://${options.host}/api/v1`;
|
|
182
|
+
this.token = options.token?.trim() ? options.token.trim() : undefined;
|
|
183
|
+
this.timeoutMs = options.timeoutMs ?? 15000;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Status-aware variant of {@link request}. A 403 from Forgejo means "token is
|
|
188
|
+
* valid but lacks the scope this endpoint needs" (e.g. `/user` demands
|
|
189
|
+
* `read:user`), which is materially different from a 401 "token is bad"
|
|
190
|
+
* (xpufx-org/paseo#212). Callers that must tell those apart use this instead
|
|
191
|
+
* of the null-collapsing `request`.
|
|
192
|
+
*/
|
|
193
|
+
private async requestWithStatus(
|
|
194
|
+
path: string,
|
|
195
|
+
init?: RequestInit,
|
|
196
|
+
timeoutMs = this.timeoutMs,
|
|
197
|
+
): Promise<{ status: number; payload: unknown | null; ok: boolean }> {
|
|
198
|
+
const controller = new AbortController();
|
|
199
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
200
|
+
try {
|
|
201
|
+
const headers: Record<string, string> = {
|
|
202
|
+
Accept: "application/json",
|
|
203
|
+
...(init?.headers as Record<string, string> | undefined),
|
|
204
|
+
};
|
|
205
|
+
if (this.token) headers.Authorization = `token ${this.token}`;
|
|
206
|
+
if (init?.body !== undefined && !headers["Content-Type"]) {
|
|
207
|
+
headers["Content-Type"] = "application/json";
|
|
208
|
+
}
|
|
209
|
+
const res = await fetch(`${this.baseUrl}${path}`, {
|
|
210
|
+
...init,
|
|
211
|
+
headers,
|
|
212
|
+
signal: controller.signal,
|
|
213
|
+
});
|
|
214
|
+
if (!res.ok) return { status: res.status, payload: null, ok: false };
|
|
215
|
+
if (res.status === 204) return { status: res.status, payload: {}, ok: true };
|
|
216
|
+
const text = await res.text();
|
|
217
|
+
if (!text) return { status: res.status, payload: {}, ok: true };
|
|
218
|
+
try {
|
|
219
|
+
return { status: res.status, payload: JSON.parse(text) as unknown, ok: true };
|
|
220
|
+
} catch {
|
|
221
|
+
return { status: res.status, payload: null, ok: false };
|
|
222
|
+
}
|
|
223
|
+
} catch {
|
|
224
|
+
// status 0: transport/abort failure, not an HTTP verdict.
|
|
225
|
+
return { status: 0, payload: null, ok: false };
|
|
226
|
+
} finally {
|
|
227
|
+
clearTimeout(timer);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
private async request(
|
|
232
|
+
path: string,
|
|
233
|
+
init?: RequestInit,
|
|
234
|
+
timeoutMs = this.timeoutMs,
|
|
235
|
+
): Promise<unknown | null> {
|
|
236
|
+
const { ok, payload } = await this.requestWithStatus(path, init, timeoutMs);
|
|
237
|
+
return ok ? payload : null;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Anonymous repo probe: true when the repo answers without credentials
|
|
242
|
+
* (public), false when it 404/403s anonymously (private or missing),
|
|
243
|
+
* null on network failure.
|
|
244
|
+
*/
|
|
245
|
+
async repoIsPublic(repo: string): Promise<boolean | null> {
|
|
246
|
+
const controller = new AbortController();
|
|
247
|
+
const timer = setTimeout(() => controller.abort(), this.timeoutMs);
|
|
248
|
+
try {
|
|
249
|
+
const res = await fetch(`${this.baseUrl}/repos/${repo}`, {
|
|
250
|
+
headers: { Accept: "application/json" },
|
|
251
|
+
signal: controller.signal,
|
|
252
|
+
});
|
|
253
|
+
if (res.ok) return true;
|
|
254
|
+
if (res.status === 404 || res.status === 403) return false;
|
|
255
|
+
return null;
|
|
256
|
+
} catch {
|
|
257
|
+
return null;
|
|
258
|
+
} finally {
|
|
259
|
+
clearTimeout(timer);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Forgejo/Gitea API identity probe (issue #114): only the Gitea family
|
|
265
|
+
* answers `/api/v1/version` with a `version` string. Runs with the
|
|
266
|
+
* configured token and fails closed, so a non-forge host (github.com, ...)
|
|
267
|
+
* is never issued a list or search call.
|
|
268
|
+
*/
|
|
269
|
+
async isForgeHost(): Promise<boolean> {
|
|
270
|
+
const payload = await this.request("/version", undefined, FORGE_PROBE_TIMEOUT_MS);
|
|
271
|
+
if (!payload || typeof payload !== "object") return false;
|
|
272
|
+
return typeof (payload as Record<string, unknown>).version === "string";
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** Whether a token is configured for this client's host. */
|
|
276
|
+
hasToken(): boolean {
|
|
277
|
+
return Boolean(this.token);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Token validity probe: null when no token is configured.
|
|
282
|
+
*
|
|
283
|
+
* `/user` requires the `read:user` scope, so a fine-grained token limited to
|
|
284
|
+
* issue scopes gets a **403** even though it is perfectly valid. Treat 403 as
|
|
285
|
+
* valid-but-narrow and only a 401 (or an unauthenticated rejection) as
|
|
286
|
+
* invalid; a transport failure stays null so callers do not cache a wrong
|
|
287
|
+
* verdict (xpufx-org/paseo#212).
|
|
288
|
+
*/
|
|
289
|
+
async tokenIsValid(): Promise<boolean | null> {
|
|
290
|
+
if (!this.token) return null;
|
|
291
|
+
const { status, ok } = await this.requestWithStatus("/user");
|
|
292
|
+
if (ok) return true;
|
|
293
|
+
if (status === 403) return true; // valid token, missing read:user
|
|
294
|
+
if (status === 0) return null; // transport failure: unknown, not invalid
|
|
295
|
+
return false;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Write capability for the configured token against one repo (issue #193),
|
|
300
|
+
* derived from the repo response's permission object (Forgejo/GitHub
|
|
301
|
+
* `permissions`, GitLab `access_level`). Null when no token is configured or
|
|
302
|
+
* the host returned no recognizable permissions, so `deriveForgeAccess`
|
|
303
|
+
* keeps treating bare validity as the capability.
|
|
304
|
+
*
|
|
305
|
+
* A fine-grained token without `read:repository` gets a 403 here even for a
|
|
306
|
+
* public repo. That is a scope gap, not a read-only verdict, so retry
|
|
307
|
+
* anonymously before giving up (xpufx-org/paseo#212).
|
|
308
|
+
*/
|
|
309
|
+
async repoWritePermission(repo: string): Promise<boolean | null> {
|
|
310
|
+
if (!this.token) return null;
|
|
311
|
+
const { ok, payload } = await this.requestWithStatus(`/repos/${repo}`);
|
|
312
|
+
if (ok) return forgeCapabilityFromRepo(payload);
|
|
313
|
+
const anonymous = await this.anonymousRepo(repo);
|
|
314
|
+
if (anonymous === null) return null;
|
|
315
|
+
return forgeCapabilityFromRepo(anonymous);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
async openIssueCount(repo: string): Promise<number | null> {
|
|
319
|
+
const count = (payload: unknown): number | null => {
|
|
320
|
+
if (!payload || typeof payload !== "object") return null;
|
|
321
|
+
const value = (payload as Record<string, unknown>).open_issues_count;
|
|
322
|
+
return typeof value === "number" && Number.isInteger(value) && value >= 0 ? value : null;
|
|
323
|
+
};
|
|
324
|
+
const authed = await this.requestWithStatus(`/repos/${repo}`);
|
|
325
|
+
if (authed.ok) return count(authed.payload);
|
|
326
|
+
// Scope-limited token (403) on a public repo: the anonymous read still
|
|
327
|
+
// carries open_issues_count (xpufx-org/paseo#212).
|
|
328
|
+
return count(await this.anonymousRepo(repo));
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/** Unauthenticated repo read; null on any non-ok/invalid response. */
|
|
332
|
+
private async anonymousRepo(repo: string): Promise<unknown | null> {
|
|
333
|
+
const controller = new AbortController();
|
|
334
|
+
const timer = setTimeout(() => controller.abort(), this.timeoutMs);
|
|
335
|
+
try {
|
|
336
|
+
const res = await fetch(`${this.baseUrl}/repos/${repo}`, {
|
|
337
|
+
headers: { Accept: "application/json" },
|
|
338
|
+
signal: controller.signal,
|
|
339
|
+
});
|
|
340
|
+
if (!res.ok) return null;
|
|
341
|
+
const text = await res.text();
|
|
342
|
+
if (!text) return null;
|
|
343
|
+
try {
|
|
344
|
+
return JSON.parse(text) as unknown;
|
|
345
|
+
} catch {
|
|
346
|
+
return null;
|
|
347
|
+
}
|
|
348
|
+
} catch {
|
|
349
|
+
return null;
|
|
350
|
+
} finally {
|
|
351
|
+
clearTimeout(timer);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
async listIssues(repo: string, page = 1, limit = 50): Promise<{ issues: ForgeIssue[]; hasMore: boolean } | null> {
|
|
356
|
+
const payload = await this.request(
|
|
357
|
+
`/repos/${repo}/issues?state=open&type=issues&limit=${limit}&page=${page}`,
|
|
358
|
+
);
|
|
359
|
+
if (!Array.isArray(payload)) return null;
|
|
360
|
+
return { issues: toIssues(payload, true), hasMore: payload.length >= limit };
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* Live keyword search over open and closed issues (never pull requests).
|
|
365
|
+
* The Gitea/Forgejo issues endpoint's `q` matches title, body, and comments.
|
|
366
|
+
* Returns null on a transport/API failure so the handler can surface an error.
|
|
367
|
+
*/
|
|
368
|
+
async searchIssues(
|
|
369
|
+
repo: string,
|
|
370
|
+
query: string,
|
|
371
|
+
page = 1,
|
|
372
|
+
limit = 50,
|
|
373
|
+
): Promise<{ issues: ForgeIssue[]; hasMore: boolean } | null> {
|
|
374
|
+
const payload = await this.request(
|
|
375
|
+
`/repos/${repo}/issues?state=all&type=issues&q=${encodeURIComponent(query)}&limit=${limit}&page=${page}`,
|
|
376
|
+
);
|
|
377
|
+
if (!Array.isArray(payload)) return null;
|
|
378
|
+
return { issues: toIssues(payload, false), hasMore: payload.length >= limit };
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
async listComments(repo: string, issueNumber: number): Promise<ForgejoComment[] | null> {
|
|
382
|
+
const payload = await this.request(
|
|
383
|
+
`/repos/${repo}/issues/${issueNumber}/comments`,
|
|
384
|
+
);
|
|
385
|
+
if (!Array.isArray(payload)) return null;
|
|
386
|
+
const comments: ForgejoComment[] = [];
|
|
387
|
+
for (const entry of payload) {
|
|
388
|
+
const comment = toComment(entry);
|
|
389
|
+
if (comment) comments.push(comment);
|
|
390
|
+
}
|
|
391
|
+
return comments;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
async getIssue(repo: string, host: string, issueNumber: number): Promise<ForgejoIssueDetail | null> {
|
|
395
|
+
const payload = await this.request(`/repos/${repo}/issues/${issueNumber}`);
|
|
396
|
+
const detail = toDetail(repo, host, payload);
|
|
397
|
+
if (!detail) return null;
|
|
398
|
+
const comments = await this.listComments(repo, issueNumber);
|
|
399
|
+
if (comments) detail.comments = comments;
|
|
400
|
+
return detail;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/** All labels defined on the repo, following the Gitea-family page window. */
|
|
404
|
+
async listLabels(repo: string): Promise<ForgejoLabel[] | null> {
|
|
405
|
+
const labels: ForgejoLabel[] = [];
|
|
406
|
+
for (let page = 1; page <= 10; page += 1) {
|
|
407
|
+
const payload = await this.request(`/repos/${repo}/labels?limit=100&page=${page}`);
|
|
408
|
+
if (!Array.isArray(payload)) return null;
|
|
409
|
+
for (const entry of payload) {
|
|
410
|
+
const label = toLabel(entry);
|
|
411
|
+
if (label) labels.push(label);
|
|
412
|
+
}
|
|
413
|
+
if (payload.length < 100) break;
|
|
414
|
+
}
|
|
415
|
+
return labels;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/** Create one repo label; false when the API rejects it (e.g. 409 duplicate). */
|
|
419
|
+
async createLabel(
|
|
420
|
+
repo: string,
|
|
421
|
+
label: { name: string; color: string; exclusive: boolean; description: string },
|
|
422
|
+
): Promise<boolean> {
|
|
423
|
+
const created = await this.request(`/repos/${repo}/labels`, {
|
|
424
|
+
method: "POST",
|
|
425
|
+
body: JSON.stringify(label),
|
|
426
|
+
});
|
|
427
|
+
return created !== null;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/** Delete one repo label by id; false when the API rejects the delete. */
|
|
431
|
+
async deleteLabel(repo: string, id: number): Promise<boolean> {
|
|
432
|
+
const removed = await this.request(`/repos/${repo}/labels/${id}`, { method: "DELETE" });
|
|
433
|
+
return removed !== null;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
async setLabels(
|
|
437
|
+
repo: string,
|
|
438
|
+
issueNumber: number,
|
|
439
|
+
add: string[],
|
|
440
|
+
remove: string[],
|
|
441
|
+
): Promise<string[] | null> {
|
|
442
|
+
const current = await this.request(`/repos/${repo}/issues/${issueNumber}`);
|
|
443
|
+
if (!current || typeof current !== "object") return null;
|
|
444
|
+
const existing = labelNames((current as Record<string, unknown>).labels);
|
|
445
|
+
const next = existing.filter((label) => !remove.includes(label));
|
|
446
|
+
for (const label of add) {
|
|
447
|
+
if (!next.includes(label)) next.push(label);
|
|
448
|
+
}
|
|
449
|
+
const updated = await this.request(`/repos/${repo}/issues/${issueNumber}`, {
|
|
450
|
+
method: "PATCH",
|
|
451
|
+
body: JSON.stringify({ labels: next }),
|
|
452
|
+
});
|
|
453
|
+
if (!updated || typeof updated !== "object") return null;
|
|
454
|
+
return labelNames((updated as Record<string, unknown>).labels);
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
async addComment(repo: string, issueNumber: number, body: string): Promise<number | null> {
|
|
458
|
+
const created = await this.request(`/repos/${repo}/issues/${issueNumber}/comments`, {
|
|
459
|
+
method: "POST",
|
|
460
|
+
body: JSON.stringify({ body }),
|
|
461
|
+
});
|
|
462
|
+
if (!created || typeof created !== "object") return null;
|
|
463
|
+
const id = (created as Record<string, unknown>).id;
|
|
464
|
+
return typeof id === "number" ? id : null;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
/** Create one issue; returns its number, or null when the API rejects it. */
|
|
468
|
+
async createIssue(
|
|
469
|
+
repo: string,
|
|
470
|
+
issue: { title: string; body?: string; labels?: string[] },
|
|
471
|
+
): Promise<number | null> {
|
|
472
|
+
const created = await this.request(`/repos/${repo}/issues`, {
|
|
473
|
+
method: "POST",
|
|
474
|
+
body: JSON.stringify({
|
|
475
|
+
title: issue.title,
|
|
476
|
+
...(issue.body ? { body: issue.body } : {}),
|
|
477
|
+
...(issue.labels && issue.labels.length ? { labels: issue.labels } : {}),
|
|
478
|
+
}),
|
|
479
|
+
});
|
|
480
|
+
if (!created || typeof created !== "object") return null;
|
|
481
|
+
const number = (created as Record<string, unknown>).number;
|
|
482
|
+
return typeof number === "number" ? number : null;
|
|
483
|
+
}
|
|
484
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Poll-path guard for the forges plugin (issue #114). Holds in-memory state
|
|
3
|
+
* only — no timers, no network, no logging — so the caller stays in charge of
|
|
4
|
+
* emitting at most one WARN/INFO per host while a non-forge remote or a
|
|
5
|
+
* flapping repo is polled every cycle.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* How long a negative host probe is trusted. A real forge that is briefly
|
|
10
|
+
* unreachable (daemon start, network blip) must not be written off for the
|
|
11
|
+
* lifetime of the process, so the miss expires and is re-probed.
|
|
12
|
+
*/
|
|
13
|
+
export const NEGATIVE_PROBE_TTL_MS = 300_000;
|
|
14
|
+
|
|
15
|
+
export type GuardLogLevel = "info" | "debug" | "warn";
|
|
16
|
+
|
|
17
|
+
interface ProbeEntry {
|
|
18
|
+
speaksForgeApi: boolean;
|
|
19
|
+
at: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export class ForgeGuard {
|
|
23
|
+
private readonly probes = new Map<string, ProbeEntry>();
|
|
24
|
+
private readonly quietLogged = new Set<string>();
|
|
25
|
+
private readonly failures = new Map<string, number>();
|
|
26
|
+
private readonly now: () => number;
|
|
27
|
+
private readonly negativeProbeTtlMs: number;
|
|
28
|
+
|
|
29
|
+
constructor(
|
|
30
|
+
options: { now?: () => number; negativeProbeTtlMs?: number } = {},
|
|
31
|
+
) {
|
|
32
|
+
this.now = options.now ?? Date.now;
|
|
33
|
+
this.negativeProbeTtlMs = options.negativeProbeTtlMs ?? NEGATIVE_PROBE_TTL_MS;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Cached verdict: a host that speaks the forge API stays cached, a negative
|
|
38
|
+
* verdict expires. Null means the caller must probe.
|
|
39
|
+
*/
|
|
40
|
+
cachedProbe(host: string): boolean | null {
|
|
41
|
+
const entry = this.probes.get(host);
|
|
42
|
+
if (!entry) return null;
|
|
43
|
+
if (entry.speaksForgeApi) return true;
|
|
44
|
+
return this.now() - entry.at < this.negativeProbeTtlMs ? false : null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
recordProbe(host: string, speaksForgeApi: boolean): void {
|
|
48
|
+
this.probes.set(host, { speaksForgeApi, at: this.now() });
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Skip-log level for a non-forge host: info the first time, debug after. */
|
|
52
|
+
skipLogLevel(host: string): GuardLogLevel {
|
|
53
|
+
if (this.quietLogged.has(host)) return "debug";
|
|
54
|
+
this.quietLogged.add(host);
|
|
55
|
+
return "info";
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** List-failure level: warn the first time per repo, debug on repeats. */
|
|
59
|
+
failureLogLevel(host: string, repo: string): GuardLogLevel {
|
|
60
|
+
const key = `${host}/${repo}`;
|
|
61
|
+
const count = (this.failures.get(key) ?? 0) + 1;
|
|
62
|
+
this.failures.set(key, count);
|
|
63
|
+
return count <= 1 ? "warn" : "debug";
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** A successful list clears the backoff so the next failure warns again. */
|
|
67
|
+
noteSuccess(host: string, repo: string): void {
|
|
68
|
+
this.failures.delete(`${host}/${repo}`);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { readFile, stat } from "node:fs/promises";
|
|
2
|
+
import { isAbsolute, join, resolve } from "node:path";
|
|
3
|
+
|
|
4
|
+
const ORIGIN_SECTION = /\[remote\s+"origin"\][^\[]*?url\s*=\s*(.+)/;
|
|
5
|
+
const GITDIR_POINTER = /^\s*gitdir:\s*(.+?)\s*$/m;
|
|
6
|
+
|
|
7
|
+
/** `[remote "origin"]` URL from git config text, or null when absent. */
|
|
8
|
+
export function parseOriginUrl(config: string | null | undefined): string | null {
|
|
9
|
+
if (!config) return null;
|
|
10
|
+
const match = ORIGIN_SECTION.exec(config);
|
|
11
|
+
const url = match?.[1]?.trim();
|
|
12
|
+
return url || null;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** `gitdir:` target from a linked-worktree `.git` file, or null. */
|
|
16
|
+
export function parseGitDirPointer(contents: string | null | undefined): string | null {
|
|
17
|
+
if (!contents) return null;
|
|
18
|
+
const match = GITDIR_POINTER.exec(contents);
|
|
19
|
+
return match?.[1]?.trim() || null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async function readText(filePath: string): Promise<string | null> {
|
|
23
|
+
try {
|
|
24
|
+
return await readFile(filePath, "utf8");
|
|
25
|
+
} catch {
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function isFile(filePath: string): Promise<boolean> {
|
|
31
|
+
try {
|
|
32
|
+
return (await stat(filePath)).isFile();
|
|
33
|
+
} catch {
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Resolve a gitdir/commondir value relative to the directory that held it. */
|
|
39
|
+
function resolveGitPath(value: string, base: string): string {
|
|
40
|
+
return isAbsolute(value) ? value : resolve(base, value);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Git directory that holds the shared config for a checkout.
|
|
45
|
+
* A normal checkout's `.git` is a directory. A linked worktree's `.git` is a
|
|
46
|
+
* file `gitdir: <path>`; that gitdir carries a `commondir` pointing at the main
|
|
47
|
+
* repo's git dir. Submodule-style gitdirs have no `commondir`, so the gitdir
|
|
48
|
+
* itself is used. Null when there is no usable git pointer.
|
|
49
|
+
*/
|
|
50
|
+
async function commonGitDir(directory: string): Promise<string | null> {
|
|
51
|
+
const dotGit = join(directory, ".git");
|
|
52
|
+
if (!(await isFile(dotGit))) return dotGit;
|
|
53
|
+
const pointer = parseGitDirPointer(await readText(dotGit));
|
|
54
|
+
if (!pointer) return null;
|
|
55
|
+
const gitDir = resolveGitPath(pointer, directory);
|
|
56
|
+
const commondir = (await readText(join(gitDir, "commondir")))?.trim();
|
|
57
|
+
if (commondir) return resolveGitPath(commondir, gitDir);
|
|
58
|
+
return gitDir;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Read the workspace's git origin URL without shelling out. Handles both a
|
|
63
|
+
* normal `.git` directory and a linked-worktree `.git` file. Never throws:
|
|
64
|
+
* an unreadable or non-git directory yields null.
|
|
65
|
+
*/
|
|
66
|
+
export async function gitOriginForDirectory(directory: string): Promise<string | null> {
|
|
67
|
+
const gitDir = await commonGitDir(directory);
|
|
68
|
+
if (!gitDir) return null;
|
|
69
|
+
return parseOriginUrl(await readText(join(gitDir, "config")));
|
|
70
|
+
}
|