ework-web 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 +170 -0
- package/bin/ework-web.js +2 -0
- package/package.json +61 -0
- package/src/attachments.ts +54 -0
- package/src/auth.ts +218 -0
- package/src/build.ts +17 -0
- package/src/config.ts +178 -0
- package/src/db.ts +106 -0
- package/src/fileview.ts +617 -0
- package/src/giteaApi.ts +333 -0
- package/src/index.ts +1310 -0
- package/src/logger.ts +44 -0
- package/src/opencode.ts +340 -0
- package/src/ratelimit.ts +35 -0
- package/src/reactions.ts +31 -0
- package/src/render/components.ts +66 -0
- package/src/render/layout.ts +240 -0
- package/src/render/markdown.ts +106 -0
- package/src/schema.sql +178 -0
- package/src/static/app.js +571 -0
- package/src/static/favicon.svg +6 -0
- package/src/static/file.js +103 -0
- package/src/static/session.js +380 -0
- package/src/static/tts.js +101 -0
- package/src/store.ts +1020 -0
- package/src/translate.ts +166 -0
- package/src/views/adminTokens.ts +122 -0
- package/src/views/home.ts +109 -0
- package/src/views/issueList.ts +89 -0
- package/src/views/issueNew.ts +35 -0
- package/src/views/issueThread.ts +179 -0
- package/src/views/issues.ts +66 -0
- package/src/views/projectMembers.ts +146 -0
- package/src/views/projectUpstreams.ts +145 -0
- package/src/views/sessionLog.ts +709 -0
- package/src/views/settings.ts +67 -0
- package/src/views/tokens.ts +146 -0
- package/src/views/ttsBackends.ts +98 -0
- package/src/views/users.ts +163 -0
- package/src/views/webhooks.ts +166 -0
- package/src/webhooks.ts +634 -0
package/src/giteaApi.ts
ADDED
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
// Gitea-compatible REST shim under /api/v1/*.
|
|
2
|
+
//
|
|
3
|
+
// Rationale: ework reuses Gitea's
|
|
4
|
+
// GiteaClient which speaks Gitea's REST protocol verbatim. Rather than
|
|
5
|
+
// rewrite the daemon to talk ework's native /api/* shape (and re-translate
|
|
6
|
+
// all the response fields), we expose Gitea-shaped endpoints so the daemon
|
|
7
|
+
// can be repointed at ework just by changing GITEA_URL. The webhook payload
|
|
8
|
+
// builders in webhooks.ts are reused here so consumers see ONE consistent
|
|
9
|
+
// Gitea-compatible contract across push (webhook) and pull (REST).
|
|
10
|
+
//
|
|
11
|
+
// Coverage matches Gitea's actual surface (10 endpoints identified in the
|
|
12
|
+
// bg audit) plus a few cheap stubs (/version, /user, /repos/:o/:r) that
|
|
13
|
+
// Gitea clients commonly hit during connection bootstrap.
|
|
14
|
+
|
|
15
|
+
import { rawDB } from "./db";
|
|
16
|
+
import {
|
|
17
|
+
StoreError,
|
|
18
|
+
getProject,
|
|
19
|
+
getIssue,
|
|
20
|
+
getIssueById,
|
|
21
|
+
getComment,
|
|
22
|
+
postComment,
|
|
23
|
+
editComment,
|
|
24
|
+
deleteComment,
|
|
25
|
+
editIssue,
|
|
26
|
+
createIssue,
|
|
27
|
+
countComments,
|
|
28
|
+
listCommentsForIssue,
|
|
29
|
+
listAllIssues,
|
|
30
|
+
addReaction,
|
|
31
|
+
removeReaction,
|
|
32
|
+
listReactionsFor,
|
|
33
|
+
canWriteProject,
|
|
34
|
+
type UserRow,
|
|
35
|
+
} from "./store";
|
|
36
|
+
import {
|
|
37
|
+
buildUser,
|
|
38
|
+
buildIssuePayload,
|
|
39
|
+
buildCommentPayload,
|
|
40
|
+
buildRepository,
|
|
41
|
+
emitIssueEvent,
|
|
42
|
+
emitCommentEvent,
|
|
43
|
+
} from "./webhooks";
|
|
44
|
+
|
|
45
|
+
const ROUTES = {
|
|
46
|
+
version: /^\/api\/v1\/version$/,
|
|
47
|
+
currentUser: /^\/api\/v1\/user$/,
|
|
48
|
+
// Owner/repo char class must match store.ts project-create validator
|
|
49
|
+
// (`/^[A-Za-z0-9_.-]+$/`). Loosening here without loosening there (or vice
|
|
50
|
+
// versa) creates a routing black hole where valid projects 404 in the shim.
|
|
51
|
+
repoShow: /^\/api\/v1\/repos\/([A-Za-z0-9._-]+)\/([A-Za-z0-9._-]+)$/,
|
|
52
|
+
issuesCollection: /^\/api\/v1\/repos\/([A-Za-z0-9._-]+)\/([A-Za-z0-9._-]+)\/issues$/,
|
|
53
|
+
issueSearch: /^\/api\/v1\/repos\/issues\/search$/,
|
|
54
|
+
issueShow: /^\/api\/v1\/repos\/([A-Za-z0-9._-]+)\/([A-Za-z0-9._-]+)\/issues\/(\d+)$/,
|
|
55
|
+
issueComments: /^\/api\/v1\/repos\/([A-Za-z0-9._-]+)\/([A-Za-z0-9._-]+)\/issues\/(\d+)\/comments$/,
|
|
56
|
+
issueReactions: /^\/api\/v1\/repos\/([A-Za-z0-9._-]+)\/([A-Za-z0-9._-]+)\/issues\/(\d+)\/reactions$/,
|
|
57
|
+
commentShow: /^\/api\/v1\/repos\/([A-Za-z0-9._-]+)\/([A-Za-z0-9._-]+)\/issues\/comments\/(\d+)$/,
|
|
58
|
+
commentReactions: /^\/api\/v1\/repos\/([A-Za-z0-9._-]+)\/([A-Za-z0-9._-]+)\/issues\/comments\/(\d+)\/reactions$/,
|
|
59
|
+
} as const;
|
|
60
|
+
|
|
61
|
+
export interface GiteaApiResult {
|
|
62
|
+
status: number;
|
|
63
|
+
body: unknown;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function giteaError(status: number, message: string): GiteaApiResult {
|
|
67
|
+
return { status, body: { message, url: "/api/v1/swagger" } };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async function readJson(req: Request): Promise<Record<string, unknown>> {
|
|
71
|
+
try {
|
|
72
|
+
const txt = await req.text();
|
|
73
|
+
if (!txt) return {};
|
|
74
|
+
const parsed = JSON.parse(txt);
|
|
75
|
+
return parsed && typeof parsed === "object" ? (parsed as Record<string, unknown>) : {};
|
|
76
|
+
} catch {
|
|
77
|
+
throw new StoreError(400, "invalid JSON body");
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function asString(v: unknown): string | undefined {
|
|
82
|
+
return typeof v === "string" ? v : undefined;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function asIssueState(v: unknown): "open" | "closed" | undefined {
|
|
86
|
+
return v === "open" || v === "closed" ? v : undefined;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function asContent(v: unknown): string | undefined {
|
|
90
|
+
if (typeof v !== "string") return undefined;
|
|
91
|
+
const trimmed = v.trim();
|
|
92
|
+
if (!trimmed) return undefined;
|
|
93
|
+
if (trimmed.length > 32) throw new StoreError(400, "reaction content 过长(≤32)");
|
|
94
|
+
return trimmed;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export async function handleGiteaApi(
|
|
98
|
+
req: Request,
|
|
99
|
+
url: URL,
|
|
100
|
+
ctx: { user: UserRow | null }
|
|
101
|
+
): Promise<GiteaApiResult | null> {
|
|
102
|
+
const path = url.pathname;
|
|
103
|
+
if (!path.startsWith("/api/v1/")) return null;
|
|
104
|
+
const origin = new URL(req.url).origin;
|
|
105
|
+
const user = ctx.user;
|
|
106
|
+
if (!user) return giteaError(401, "requires authentication");
|
|
107
|
+
|
|
108
|
+
if (ROUTES.version.test(path) && req.method === "GET") {
|
|
109
|
+
return { status: 200, body: { version: "1.22.0" } };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (ROUTES.currentUser.test(path) && req.method === "GET") {
|
|
113
|
+
return { status: 200, body: buildUser(user.login, origin) };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// issueSearch must be matched BEFORE repoShow: the path /repos/issues/search
|
|
117
|
+
// would otherwise match repoShow's owner=issues, repo=search capture groups.
|
|
118
|
+
let m = path.match(ROUTES.issueSearch);
|
|
119
|
+
if (m && req.method === "GET") {
|
|
120
|
+
const q = url.searchParams.get("q") ?? "";
|
|
121
|
+
const state = (url.searchParams.get("state") ?? "open") as "open" | "closed" | "all";
|
|
122
|
+
const limitRaw = Number(url.searchParams.get("limit") ?? 50);
|
|
123
|
+
const limit = Number.isFinite(limitRaw) && limitRaw > 0 ? Math.min(limitRaw, 200) : 50;
|
|
124
|
+
try {
|
|
125
|
+
const rows = listAllIssues({ q, state, limit });
|
|
126
|
+
return {
|
|
127
|
+
status: 200,
|
|
128
|
+
body: rows.map((row) => {
|
|
129
|
+
const project = getProject(row.project_owner, row.project_name);
|
|
130
|
+
if (!project) return null;
|
|
131
|
+
return {
|
|
132
|
+
...buildIssuePayload(row, project, row.comment_count ?? 0, origin),
|
|
133
|
+
repository: buildRepository(project, origin),
|
|
134
|
+
};
|
|
135
|
+
}).filter((x) => x !== null),
|
|
136
|
+
};
|
|
137
|
+
} catch (e) {
|
|
138
|
+
return giteaError(e instanceof StoreError ? e.status : 500, e instanceof Error ? e.message : "error");
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
m = path.match(ROUTES.repoShow);
|
|
143
|
+
if (m && req.method === "GET") {
|
|
144
|
+
const [, owner, repo] = m;
|
|
145
|
+
if (!(owner && repo)) return giteaError(404, "not found");
|
|
146
|
+
const project = getProject(owner, repo);
|
|
147
|
+
if (!project) return giteaError(404, "repository not found");
|
|
148
|
+
return { status: 200, body: buildRepository(project, origin) };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
m = path.match(ROUTES.issuesCollection);
|
|
152
|
+
if (m && req.method === "POST") {
|
|
153
|
+
const [, owner, repo] = m;
|
|
154
|
+
if (!(owner && repo)) return giteaError(404, "not found");
|
|
155
|
+
try {
|
|
156
|
+
const project = getProject(owner, repo);
|
|
157
|
+
if (!project) return giteaError(404, "repository not found");
|
|
158
|
+
if (!canWriteProject(project.id, user)) return giteaError(403, "requires writer role");
|
|
159
|
+
const body = await readJson(req);
|
|
160
|
+
const title = asString(body.title);
|
|
161
|
+
if (!title) return giteaError(400, "title required");
|
|
162
|
+
const text = asString(body.body) ?? "";
|
|
163
|
+
const created = createIssue(project.id, title, text, user.login);
|
|
164
|
+
emitIssueEvent(project.id, created.id, "opened", origin);
|
|
165
|
+
return { status: 201, body: buildIssuePayload(created, project, 0, origin) };
|
|
166
|
+
} catch (e) {
|
|
167
|
+
return giteaError(e instanceof StoreError ? e.status : 500, e instanceof Error ? e.message : "error");
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
m = path.match(ROUTES.issueShow);
|
|
172
|
+
if (m) {
|
|
173
|
+
const [, owner, repo, numStr] = m;
|
|
174
|
+
if (!(owner && repo && numStr)) return giteaError(404, "not found");
|
|
175
|
+
const number = Number(numStr);
|
|
176
|
+
try {
|
|
177
|
+
const project = getProject(owner, repo);
|
|
178
|
+
if (!project) return giteaError(404, "repository not found");
|
|
179
|
+
const issue = getIssue(project.id, number);
|
|
180
|
+
if (!issue) return giteaError(404, "issue not found");
|
|
181
|
+
|
|
182
|
+
if (req.method === "GET") {
|
|
183
|
+
return { status: 200, body: buildIssuePayload(issue, project, countComments(issue.id), origin) };
|
|
184
|
+
}
|
|
185
|
+
if (req.method === "PATCH") {
|
|
186
|
+
if (!canWriteProject(project.id, user)) return giteaError(403, "requires writer role");
|
|
187
|
+
const body = await readJson(req);
|
|
188
|
+
const before = issue.state;
|
|
189
|
+
editIssue(issue.id, {
|
|
190
|
+
title: asString(body.title),
|
|
191
|
+
body: asString(body.body),
|
|
192
|
+
state: asIssueState(body.state),
|
|
193
|
+
});
|
|
194
|
+
const after = getIssueById(issue.id);
|
|
195
|
+
if (!after) return giteaError(404, "issue vanished mid-edit");
|
|
196
|
+
if (before === "open" && after.state === "closed") {
|
|
197
|
+
emitIssueEvent(project.id, after.id, "closed", origin);
|
|
198
|
+
} else if (before === "closed" && after.state === "open") {
|
|
199
|
+
emitIssueEvent(project.id, after.id, "reopened", origin);
|
|
200
|
+
}
|
|
201
|
+
return { status: 200, body: buildIssuePayload(after, project, countComments(after.id), origin) };
|
|
202
|
+
}
|
|
203
|
+
return giteaError(405, `method ${req.method} not allowed`);
|
|
204
|
+
} catch (e) {
|
|
205
|
+
return giteaError(e instanceof StoreError ? e.status : 500, e instanceof Error ? e.message : "error");
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
m = path.match(ROUTES.issueComments);
|
|
210
|
+
if (m) {
|
|
211
|
+
const [, owner, repo, numStr] = m;
|
|
212
|
+
if (!(owner && repo && numStr)) return giteaError(404, "not found");
|
|
213
|
+
const number = Number(numStr);
|
|
214
|
+
try {
|
|
215
|
+
const project = getProject(owner, repo);
|
|
216
|
+
if (!project) return giteaError(404, "repository not found");
|
|
217
|
+
const issue = getIssue(project.id, number);
|
|
218
|
+
if (!issue) return giteaError(404, "issue not found");
|
|
219
|
+
|
|
220
|
+
if (req.method === "GET") {
|
|
221
|
+
const comments = listCommentsForIssue(issue.id);
|
|
222
|
+
return {
|
|
223
|
+
status: 200,
|
|
224
|
+
body: comments.map((c) => buildCommentPayload(issue, c, project, origin)),
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
if (req.method === "POST") {
|
|
228
|
+
if (!canWriteProject(project.id, user)) return giteaError(403, "requires writer role");
|
|
229
|
+
const body = await readJson(req);
|
|
230
|
+
const text = asString(body.body);
|
|
231
|
+
if (text === undefined) return giteaError(400, "body required");
|
|
232
|
+
const created = postComment(issue.id, text, user.login);
|
|
233
|
+
emitCommentEvent(project.id, issue.id, created.id, origin);
|
|
234
|
+
return { status: 201, body: buildCommentPayload(issue, created, project, origin) };
|
|
235
|
+
}
|
|
236
|
+
return giteaError(405, `method ${req.method} not allowed`);
|
|
237
|
+
} catch (e) {
|
|
238
|
+
return giteaError(e instanceof StoreError ? e.status : 500, e instanceof Error ? e.message : "error");
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// /issues/:n/reactions — stub. Some daemons use these for the 🔄 picked-up marker
|
|
243
|
+
// on issues. ework has no issue-reactions table today (only comment reactions).
|
|
244
|
+
// Returning empty list keeps the daemon's main loop (comment + close) running
|
|
245
|
+
// without crashing. Add a real issue_reactions table in Phase 4.x if the
|
|
246
|
+
// picked-up marker turns out to matter operationally.
|
|
247
|
+
m = path.match(ROUTES.issueReactions);
|
|
248
|
+
if (m && (req.method === "POST" || req.method === "DELETE" || req.method === "GET")) {
|
|
249
|
+
return { status: 200, body: [] };
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
m = path.match(ROUTES.commentShow);
|
|
253
|
+
if (m) {
|
|
254
|
+
const [, owner, repo, cidStr] = m;
|
|
255
|
+
if (!(owner && repo && cidStr)) return giteaError(404, "not found");
|
|
256
|
+
const cid = Number(cidStr);
|
|
257
|
+
try {
|
|
258
|
+
const project = getProject(owner, repo);
|
|
259
|
+
if (!project) return giteaError(404, "repository not found");
|
|
260
|
+
|
|
261
|
+
if (req.method === "GET") {
|
|
262
|
+
const comment = getComment(cid);
|
|
263
|
+
if (!comment) return giteaError(404, "comment not found");
|
|
264
|
+
const issue = getIssueById(comment.issue_id);
|
|
265
|
+
if (!issue) return giteaError(404, "issue not found");
|
|
266
|
+
return { status: 200, body: buildCommentPayload(issue, comment, project, origin) };
|
|
267
|
+
}
|
|
268
|
+
if (req.method === "PATCH") {
|
|
269
|
+
if (!canWriteProject(project.id, user)) return giteaError(403, "requires writer role");
|
|
270
|
+
const body = await readJson(req);
|
|
271
|
+
const text = asString(body.body);
|
|
272
|
+
if (text === undefined) return giteaError(400, "body required");
|
|
273
|
+
const updated = editComment(cid, text);
|
|
274
|
+
const issue = getIssueById(updated.issue_id);
|
|
275
|
+
if (!issue) return giteaError(404, "issue not found");
|
|
276
|
+
return { status: 200, body: buildCommentPayload(issue, updated, project, origin) };
|
|
277
|
+
}
|
|
278
|
+
if (req.method === "DELETE") {
|
|
279
|
+
if (!canWriteProject(project.id, user)) return giteaError(403, "requires writer role");
|
|
280
|
+
deleteComment(cid);
|
|
281
|
+
return { status: 204, body: null };
|
|
282
|
+
}
|
|
283
|
+
return giteaError(405, `method ${req.method} not allowed`);
|
|
284
|
+
} catch (e) {
|
|
285
|
+
return giteaError(e instanceof StoreError ? e.status : 500, e instanceof Error ? e.message : "error");
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
m = path.match(ROUTES.commentReactions);
|
|
290
|
+
if (m) {
|
|
291
|
+
const [, owner, repo, cidStr] = m;
|
|
292
|
+
if (!(owner && repo && cidStr)) return giteaError(404, "not found");
|
|
293
|
+
const cid = Number(cidStr);
|
|
294
|
+
try {
|
|
295
|
+
const project = getProject(owner, repo);
|
|
296
|
+
if (!project) return giteaError(404, "repository not found");
|
|
297
|
+
|
|
298
|
+
if (req.method === "GET") {
|
|
299
|
+
return { status: 200, body: reactionsList(cid, origin) };
|
|
300
|
+
}
|
|
301
|
+
if (req.method === "POST" || req.method === "DELETE") {
|
|
302
|
+
if (!canWriteProject(project.id, user)) return giteaError(403, "requires writer role");
|
|
303
|
+
const body = await readJson(req);
|
|
304
|
+
const content = asContent(body.content);
|
|
305
|
+
if (content === undefined) return giteaError(400, "content required");
|
|
306
|
+
if (req.method === "POST") addReaction(cid, user.login, content);
|
|
307
|
+
else removeReaction(cid, user.login, content);
|
|
308
|
+
return { status: 200, body: reactionsList(cid, origin) };
|
|
309
|
+
}
|
|
310
|
+
return giteaError(405, `method ${req.method} not allowed`);
|
|
311
|
+
} catch (e) {
|
|
312
|
+
return giteaError(e instanceof StoreError ? e.status : 500, e instanceof Error ? e.message : "error");
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
return giteaError(404, "endpoint not implemented in ework shim");
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// Gitea returns a per-user list, not the aggregated counts ework's store
|
|
320
|
+
// keeps. The "reaction" key is Gitea's wire name; we also emit "content"
|
|
321
|
+
// for self-consistency with ework's schema.
|
|
322
|
+
function reactionsList(commentId: number, origin: string): { user: ReturnType<typeof buildUser>; reaction: string; content: string }[] {
|
|
323
|
+
const aggs = listReactionsFor([commentId]);
|
|
324
|
+
if (aggs.length === 0) return [];
|
|
325
|
+
const rows = rawDB()
|
|
326
|
+
.query("SELECT user_login, content FROM reactions WHERE comment_id = ? ORDER BY rowid")
|
|
327
|
+
.all(commentId) as { user_login: string; content: string }[];
|
|
328
|
+
return rows.map((r) => ({
|
|
329
|
+
user: buildUser(r.user_login, origin),
|
|
330
|
+
reaction: r.content,
|
|
331
|
+
content: r.content,
|
|
332
|
+
}));
|
|
333
|
+
}
|