@femtomc/mu-server 26.2.69 → 26.2.71
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/README.md +7 -3
- package/dist/api/activities.d.ts +2 -0
- package/dist/api/activities.js +160 -0
- package/dist/api/config.d.ts +2 -0
- package/dist/api/config.js +45 -0
- package/dist/api/control_plane.d.ts +2 -0
- package/dist/api/control_plane.js +28 -0
- package/dist/api/cron.d.ts +2 -0
- package/dist/api/cron.js +182 -0
- package/dist/api/events.js +77 -19
- package/dist/api/forum.js +52 -18
- package/dist/api/heartbeats.d.ts +2 -0
- package/dist/api/heartbeats.js +211 -0
- package/dist/api/identities.d.ts +2 -0
- package/dist/api/identities.js +103 -0
- package/dist/api/issues.js +120 -33
- package/dist/api/runs.d.ts +2 -0
- package/dist/api/runs.js +207 -0
- package/dist/cli.js +58 -3
- package/dist/config.d.ts +4 -21
- package/dist/config.js +24 -75
- package/dist/control_plane.d.ts +7 -114
- package/dist/control_plane.js +238 -654
- package/dist/control_plane_bootstrap_helpers.d.ts +16 -0
- package/dist/control_plane_bootstrap_helpers.js +85 -0
- package/dist/control_plane_contract.d.ts +176 -0
- package/dist/control_plane_contract.js +1 -0
- package/dist/control_plane_reload.d.ts +63 -0
- package/dist/control_plane_reload.js +525 -0
- package/dist/control_plane_run_outbox.d.ts +7 -0
- package/dist/control_plane_run_outbox.js +52 -0
- package/dist/control_plane_run_queue_coordinator.d.ts +48 -0
- package/dist/control_plane_run_queue_coordinator.js +327 -0
- package/dist/control_plane_telegram_generation.d.ts +27 -0
- package/dist/control_plane_telegram_generation.js +520 -0
- package/dist/control_plane_wake_delivery.d.ts +50 -0
- package/dist/control_plane_wake_delivery.js +123 -0
- package/dist/cron_request.d.ts +8 -0
- package/dist/cron_request.js +65 -0
- package/dist/index.d.ts +7 -2
- package/dist/index.js +4 -1
- package/dist/run_queue.d.ts +95 -0
- package/dist/run_queue.js +817 -0
- package/dist/run_supervisor.d.ts +20 -0
- package/dist/run_supervisor.js +25 -1
- package/dist/server.d.ts +12 -49
- package/dist/server.js +365 -2128
- package/dist/server_program_orchestration.d.ts +38 -0
- package/dist/server_program_orchestration.js +254 -0
- package/dist/server_routing.d.ts +31 -0
- package/dist/server_routing.js +230 -0
- package/dist/server_runtime.d.ts +30 -0
- package/dist/server_runtime.js +43 -0
- package/dist/server_types.d.ts +3 -0
- package/dist/server_types.js +16 -0
- package/dist/session_lifecycle.d.ts +11 -0
- package/dist/session_lifecycle.js +149 -0
- package/package.json +7 -6
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import { normalizeWakeMode } from "../server_types.js";
|
|
2
|
+
export async function heartbeatRoutes(request, url, deps, headers) {
|
|
3
|
+
const path = url.pathname;
|
|
4
|
+
if (path === "/api/heartbeats") {
|
|
5
|
+
if (request.method !== "GET") {
|
|
6
|
+
return Response.json({ error: "Method Not Allowed" }, { status: 405, headers });
|
|
7
|
+
}
|
|
8
|
+
const enabledRaw = url.searchParams.get("enabled")?.trim().toLowerCase();
|
|
9
|
+
const enabled = enabledRaw === "true" ? true : enabledRaw === "false" ? false : undefined;
|
|
10
|
+
const targetKindRaw = url.searchParams.get("target_kind")?.trim().toLowerCase();
|
|
11
|
+
const targetKind = targetKindRaw === "run" || targetKindRaw === "activity" ? targetKindRaw : undefined;
|
|
12
|
+
const limitRaw = url.searchParams.get("limit");
|
|
13
|
+
const limit = limitRaw && /^\d+$/.test(limitRaw) ? Math.max(1, Math.min(500, Number.parseInt(limitRaw, 10))) : undefined;
|
|
14
|
+
const programs = await deps.heartbeatPrograms.list({ enabled, targetKind, limit });
|
|
15
|
+
return Response.json({ count: programs.length, programs }, { headers });
|
|
16
|
+
}
|
|
17
|
+
if (path === "/api/heartbeats/create") {
|
|
18
|
+
if (request.method !== "POST") {
|
|
19
|
+
return Response.json({ error: "Method Not Allowed" }, { status: 405, headers });
|
|
20
|
+
}
|
|
21
|
+
let body;
|
|
22
|
+
try {
|
|
23
|
+
body = (await request.json());
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
return Response.json({ error: "invalid json body" }, { status: 400, headers });
|
|
27
|
+
}
|
|
28
|
+
const title = typeof body.title === "string" ? body.title.trim() : "";
|
|
29
|
+
if (!title) {
|
|
30
|
+
return Response.json({ error: "title is required" }, { status: 400, headers });
|
|
31
|
+
}
|
|
32
|
+
const targetKind = typeof body.target_kind === "string" ? body.target_kind.trim().toLowerCase() : "";
|
|
33
|
+
let target = null;
|
|
34
|
+
if (targetKind === "run") {
|
|
35
|
+
const jobId = typeof body.run_job_id === "string" ? body.run_job_id.trim() : "";
|
|
36
|
+
const rootIssueId = typeof body.run_root_issue_id === "string" ? body.run_root_issue_id.trim() : "";
|
|
37
|
+
if (!jobId && !rootIssueId) {
|
|
38
|
+
return Response.json({ error: "run target requires run_job_id or run_root_issue_id" }, { status: 400, headers });
|
|
39
|
+
}
|
|
40
|
+
target = {
|
|
41
|
+
kind: "run",
|
|
42
|
+
job_id: jobId || null,
|
|
43
|
+
root_issue_id: rootIssueId || null,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
else if (targetKind === "activity") {
|
|
47
|
+
const activityId = typeof body.activity_id === "string" ? body.activity_id.trim() : "";
|
|
48
|
+
if (!activityId) {
|
|
49
|
+
return Response.json({ error: "activity target requires activity_id" }, { status: 400, headers });
|
|
50
|
+
}
|
|
51
|
+
target = {
|
|
52
|
+
kind: "activity",
|
|
53
|
+
activity_id: activityId,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
else {
|
|
57
|
+
return Response.json({ error: "target_kind must be run or activity" }, { status: 400, headers });
|
|
58
|
+
}
|
|
59
|
+
const everyMs = typeof body.every_ms === "number" && Number.isFinite(body.every_ms)
|
|
60
|
+
? Math.max(0, Math.trunc(body.every_ms))
|
|
61
|
+
: undefined;
|
|
62
|
+
const reason = typeof body.reason === "string" ? body.reason.trim() : undefined;
|
|
63
|
+
const wakeMode = normalizeWakeMode(body.wake_mode);
|
|
64
|
+
const enabled = typeof body.enabled === "boolean" ? body.enabled : undefined;
|
|
65
|
+
try {
|
|
66
|
+
const program = await deps.heartbeatPrograms.create({
|
|
67
|
+
title,
|
|
68
|
+
target,
|
|
69
|
+
everyMs,
|
|
70
|
+
reason,
|
|
71
|
+
wakeMode,
|
|
72
|
+
enabled,
|
|
73
|
+
metadata: body.metadata && typeof body.metadata === "object" && !Array.isArray(body.metadata)
|
|
74
|
+
? body.metadata
|
|
75
|
+
: undefined,
|
|
76
|
+
});
|
|
77
|
+
return Response.json({ ok: true, program }, { status: 201, headers });
|
|
78
|
+
}
|
|
79
|
+
catch (err) {
|
|
80
|
+
return Response.json({ error: deps.describeError(err) }, { status: 400, headers });
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
if (path === "/api/heartbeats/update") {
|
|
84
|
+
if (request.method !== "POST") {
|
|
85
|
+
return Response.json({ error: "Method Not Allowed" }, { status: 405, headers });
|
|
86
|
+
}
|
|
87
|
+
let body;
|
|
88
|
+
try {
|
|
89
|
+
body = (await request.json());
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
return Response.json({ error: "invalid json body" }, { status: 400, headers });
|
|
93
|
+
}
|
|
94
|
+
const programId = typeof body.program_id === "string" ? body.program_id.trim() : "";
|
|
95
|
+
if (!programId) {
|
|
96
|
+
return Response.json({ error: "program_id is required" }, { status: 400, headers });
|
|
97
|
+
}
|
|
98
|
+
let target;
|
|
99
|
+
if (typeof body.target_kind === "string") {
|
|
100
|
+
const targetKind = body.target_kind.trim().toLowerCase();
|
|
101
|
+
if (targetKind === "run") {
|
|
102
|
+
const jobId = typeof body.run_job_id === "string" ? body.run_job_id.trim() : "";
|
|
103
|
+
const rootIssueId = typeof body.run_root_issue_id === "string" ? body.run_root_issue_id.trim() : "";
|
|
104
|
+
if (!jobId && !rootIssueId) {
|
|
105
|
+
return Response.json({ error: "run target requires run_job_id or run_root_issue_id" }, { status: 400, headers });
|
|
106
|
+
}
|
|
107
|
+
target = {
|
|
108
|
+
kind: "run",
|
|
109
|
+
job_id: jobId || null,
|
|
110
|
+
root_issue_id: rootIssueId || null,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
else if (targetKind === "activity") {
|
|
114
|
+
const activityId = typeof body.activity_id === "string" ? body.activity_id.trim() : "";
|
|
115
|
+
if (!activityId) {
|
|
116
|
+
return Response.json({ error: "activity target requires activity_id" }, { status: 400, headers });
|
|
117
|
+
}
|
|
118
|
+
target = {
|
|
119
|
+
kind: "activity",
|
|
120
|
+
activity_id: activityId,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
else {
|
|
124
|
+
return Response.json({ error: "target_kind must be run or activity" }, { status: 400, headers });
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
const wakeMode = Object.hasOwn(body, "wake_mode") ? normalizeWakeMode(body.wake_mode) : undefined;
|
|
128
|
+
try {
|
|
129
|
+
const result = await deps.heartbeatPrograms.update({
|
|
130
|
+
programId,
|
|
131
|
+
title: typeof body.title === "string" ? body.title : undefined,
|
|
132
|
+
target,
|
|
133
|
+
everyMs: typeof body.every_ms === "number" && Number.isFinite(body.every_ms)
|
|
134
|
+
? Math.max(0, Math.trunc(body.every_ms))
|
|
135
|
+
: undefined,
|
|
136
|
+
reason: typeof body.reason === "string" ? body.reason : undefined,
|
|
137
|
+
wakeMode,
|
|
138
|
+
enabled: typeof body.enabled === "boolean" ? body.enabled : undefined,
|
|
139
|
+
metadata: body.metadata && typeof body.metadata === "object" && !Array.isArray(body.metadata)
|
|
140
|
+
? body.metadata
|
|
141
|
+
: undefined,
|
|
142
|
+
});
|
|
143
|
+
if (result.ok) {
|
|
144
|
+
return Response.json(result, { headers });
|
|
145
|
+
}
|
|
146
|
+
return Response.json(result, { status: result.reason === "not_found" ? 404 : 400, headers });
|
|
147
|
+
}
|
|
148
|
+
catch (err) {
|
|
149
|
+
return Response.json({ error: deps.describeError(err) }, { status: 400, headers });
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
if (path === "/api/heartbeats/delete") {
|
|
153
|
+
if (request.method !== "POST") {
|
|
154
|
+
return Response.json({ error: "Method Not Allowed" }, { status: 405, headers });
|
|
155
|
+
}
|
|
156
|
+
let body;
|
|
157
|
+
try {
|
|
158
|
+
body = (await request.json());
|
|
159
|
+
}
|
|
160
|
+
catch {
|
|
161
|
+
return Response.json({ error: "invalid json body" }, { status: 400, headers });
|
|
162
|
+
}
|
|
163
|
+
const programId = typeof body.program_id === "string" ? body.program_id.trim() : "";
|
|
164
|
+
if (!programId) {
|
|
165
|
+
return Response.json({ error: "program_id is required" }, { status: 400, headers });
|
|
166
|
+
}
|
|
167
|
+
const result = await deps.heartbeatPrograms.remove(programId);
|
|
168
|
+
return Response.json(result, { status: result.ok ? 200 : result.reason === "not_found" ? 404 : 400, headers });
|
|
169
|
+
}
|
|
170
|
+
if (path === "/api/heartbeats/trigger") {
|
|
171
|
+
if (request.method !== "POST") {
|
|
172
|
+
return Response.json({ error: "Method Not Allowed" }, { status: 405, headers });
|
|
173
|
+
}
|
|
174
|
+
let body;
|
|
175
|
+
try {
|
|
176
|
+
body = (await request.json());
|
|
177
|
+
}
|
|
178
|
+
catch {
|
|
179
|
+
return Response.json({ error: "invalid json body" }, { status: 400, headers });
|
|
180
|
+
}
|
|
181
|
+
const result = await deps.heartbeatPrograms.trigger({
|
|
182
|
+
programId: typeof body.program_id === "string" ? body.program_id : null,
|
|
183
|
+
reason: typeof body.reason === "string" ? body.reason : null,
|
|
184
|
+
});
|
|
185
|
+
if (result.ok) {
|
|
186
|
+
return Response.json(result, { headers });
|
|
187
|
+
}
|
|
188
|
+
if (result.reason === "missing_target") {
|
|
189
|
+
return Response.json(result, { status: 400, headers });
|
|
190
|
+
}
|
|
191
|
+
if (result.reason === "not_found") {
|
|
192
|
+
return Response.json(result, { status: 404, headers });
|
|
193
|
+
}
|
|
194
|
+
return Response.json(result, { status: 409, headers });
|
|
195
|
+
}
|
|
196
|
+
if (path.startsWith("/api/heartbeats/")) {
|
|
197
|
+
if (request.method !== "GET") {
|
|
198
|
+
return Response.json({ error: "Method Not Allowed" }, { status: 405, headers });
|
|
199
|
+
}
|
|
200
|
+
const id = decodeURIComponent(path.slice("/api/heartbeats/".length)).trim();
|
|
201
|
+
if (!id) {
|
|
202
|
+
return Response.json({ error: "missing program id" }, { status: 400, headers });
|
|
203
|
+
}
|
|
204
|
+
const program = await deps.heartbeatPrograms.get(id);
|
|
205
|
+
if (!program) {
|
|
206
|
+
return Response.json({ error: "program not found" }, { status: 404, headers });
|
|
207
|
+
}
|
|
208
|
+
return Response.json(program, { headers });
|
|
209
|
+
}
|
|
210
|
+
return Response.json({ error: "Not Found" }, { status: 404, headers });
|
|
211
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { getControlPlanePaths, IdentityStore, ROLE_SCOPES, } from "@femtomc/mu-control-plane";
|
|
2
|
+
export async function identityRoutes(request, url, deps, headers) {
|
|
3
|
+
const path = url.pathname;
|
|
4
|
+
const cpPaths = getControlPlanePaths(deps.context.repoRoot);
|
|
5
|
+
const identityStore = new IdentityStore(cpPaths.identitiesPath);
|
|
6
|
+
await identityStore.load();
|
|
7
|
+
if (path === "/api/identities") {
|
|
8
|
+
if (request.method !== "GET") {
|
|
9
|
+
return Response.json({ error: "Method Not Allowed" }, { status: 405, headers });
|
|
10
|
+
}
|
|
11
|
+
const includeInactive = url.searchParams.get("include_inactive")?.trim().toLowerCase() === "true";
|
|
12
|
+
const bindings = identityStore.listBindings({ includeInactive });
|
|
13
|
+
return Response.json({ count: bindings.length, bindings }, { headers });
|
|
14
|
+
}
|
|
15
|
+
if (path === "/api/identities/link") {
|
|
16
|
+
if (request.method !== "POST") {
|
|
17
|
+
return Response.json({ error: "Method Not Allowed" }, { status: 405, headers });
|
|
18
|
+
}
|
|
19
|
+
let body;
|
|
20
|
+
try {
|
|
21
|
+
body = (await request.json());
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
return Response.json({ error: "invalid json body" }, { status: 400, headers });
|
|
25
|
+
}
|
|
26
|
+
const channel = typeof body.channel === "string" ? body.channel.trim() : "";
|
|
27
|
+
if (!channel || (channel !== "slack" && channel !== "discord" && channel !== "telegram")) {
|
|
28
|
+
return Response.json({ error: "channel is required (slack, discord, telegram)" }, { status: 400, headers });
|
|
29
|
+
}
|
|
30
|
+
const actorId = typeof body.actor_id === "string" ? body.actor_id.trim() : "";
|
|
31
|
+
if (!actorId) {
|
|
32
|
+
return Response.json({ error: "actor_id is required" }, { status: 400, headers });
|
|
33
|
+
}
|
|
34
|
+
const tenantId = typeof body.tenant_id === "string" ? body.tenant_id.trim() : "";
|
|
35
|
+
if (!tenantId) {
|
|
36
|
+
return Response.json({ error: "tenant_id is required" }, { status: 400, headers });
|
|
37
|
+
}
|
|
38
|
+
const roleKey = typeof body.role === "string" ? body.role.trim() : "operator";
|
|
39
|
+
const roleScopes = ROLE_SCOPES[roleKey];
|
|
40
|
+
if (!roleScopes) {
|
|
41
|
+
return Response.json({ error: `invalid role: ${roleKey} (operator, contributor, viewer)` }, { status: 400, headers });
|
|
42
|
+
}
|
|
43
|
+
const bindingId = typeof body.binding_id === "string" && body.binding_id.trim().length > 0
|
|
44
|
+
? body.binding_id.trim()
|
|
45
|
+
: `bind-${crypto.randomUUID()}`;
|
|
46
|
+
const operatorId = typeof body.operator_id === "string" && body.operator_id.trim().length > 0
|
|
47
|
+
? body.operator_id.trim()
|
|
48
|
+
: "default";
|
|
49
|
+
const decision = await identityStore.link({
|
|
50
|
+
bindingId,
|
|
51
|
+
operatorId,
|
|
52
|
+
channel: channel,
|
|
53
|
+
channelTenantId: tenantId,
|
|
54
|
+
channelActorId: actorId,
|
|
55
|
+
scopes: [...roleScopes],
|
|
56
|
+
});
|
|
57
|
+
switch (decision.kind) {
|
|
58
|
+
case "linked":
|
|
59
|
+
return Response.json({ ok: true, kind: "linked", binding: decision.binding }, { status: 201, headers });
|
|
60
|
+
case "binding_exists":
|
|
61
|
+
return Response.json({ ok: false, kind: "binding_exists", binding: decision.binding }, { status: 409, headers });
|
|
62
|
+
case "principal_already_linked":
|
|
63
|
+
return Response.json({ ok: false, kind: "principal_already_linked", binding: decision.binding }, { status: 409, headers });
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
if (path === "/api/identities/unlink") {
|
|
67
|
+
if (request.method !== "POST") {
|
|
68
|
+
return Response.json({ error: "Method Not Allowed" }, { status: 405, headers });
|
|
69
|
+
}
|
|
70
|
+
let body;
|
|
71
|
+
try {
|
|
72
|
+
body = (await request.json());
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
return Response.json({ error: "invalid json body" }, { status: 400, headers });
|
|
76
|
+
}
|
|
77
|
+
const bindingId = typeof body.binding_id === "string" ? body.binding_id.trim() : "";
|
|
78
|
+
if (!bindingId) {
|
|
79
|
+
return Response.json({ error: "binding_id is required" }, { status: 400, headers });
|
|
80
|
+
}
|
|
81
|
+
const actorBindingId = typeof body.actor_binding_id === "string" ? body.actor_binding_id.trim() : "";
|
|
82
|
+
if (!actorBindingId) {
|
|
83
|
+
return Response.json({ error: "actor_binding_id is required" }, { status: 400, headers });
|
|
84
|
+
}
|
|
85
|
+
const reason = typeof body.reason === "string" ? body.reason.trim() : null;
|
|
86
|
+
const decision = await identityStore.unlinkSelf({
|
|
87
|
+
bindingId,
|
|
88
|
+
actorBindingId,
|
|
89
|
+
reason: reason || null,
|
|
90
|
+
});
|
|
91
|
+
switch (decision.kind) {
|
|
92
|
+
case "unlinked":
|
|
93
|
+
return Response.json({ ok: true, kind: "unlinked", binding: decision.binding }, { headers });
|
|
94
|
+
case "not_found":
|
|
95
|
+
return Response.json({ ok: false, kind: "not_found" }, { status: 404, headers });
|
|
96
|
+
case "invalid_actor":
|
|
97
|
+
return Response.json({ ok: false, kind: "invalid_actor" }, { status: 403, headers });
|
|
98
|
+
case "already_inactive":
|
|
99
|
+
return Response.json({ ok: false, kind: "already_inactive", binding: decision.binding }, { status: 409, headers });
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return Response.json({ error: "Not Found" }, { status: 404, headers });
|
|
103
|
+
}
|
package/dist/api/issues.js
CHANGED
|
@@ -1,3 +1,58 @@
|
|
|
1
|
+
import { DEFAULT_ISSUE_QUERY_LIMIT, ISSUE_STATUS_VALUES, IssueStoreNotFoundError, IssueStoreValidationError, normalizeIssueContainsFilter, normalizeIssueQueryLimit, } from "@femtomc/mu-issue";
|
|
2
|
+
const ISSUE_STATUS_SET = new Set(ISSUE_STATUS_VALUES);
|
|
3
|
+
function normalizeIssueId(value) {
|
|
4
|
+
try {
|
|
5
|
+
return decodeURIComponent(value).trim();
|
|
6
|
+
}
|
|
7
|
+
catch (cause) {
|
|
8
|
+
throw new IssueStoreValidationError("invalid issue id encoding", { cause });
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
function normalizeIssueStatusFilter(value) {
|
|
12
|
+
if (value == null) {
|
|
13
|
+
return undefined;
|
|
14
|
+
}
|
|
15
|
+
const normalized = value.trim();
|
|
16
|
+
if (normalized.length === 0) {
|
|
17
|
+
return undefined;
|
|
18
|
+
}
|
|
19
|
+
if (!ISSUE_STATUS_SET.has(normalized)) {
|
|
20
|
+
throw new IssueStoreValidationError(`invalid issue status filter: ${normalized}`);
|
|
21
|
+
}
|
|
22
|
+
return normalized;
|
|
23
|
+
}
|
|
24
|
+
async function readJsonBody(request) {
|
|
25
|
+
let body;
|
|
26
|
+
try {
|
|
27
|
+
body = await request.json();
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
throw new IssueStoreValidationError("invalid json body");
|
|
31
|
+
}
|
|
32
|
+
if (!body || typeof body !== "object" || Array.isArray(body)) {
|
|
33
|
+
throw new IssueStoreValidationError("json body must be an object");
|
|
34
|
+
}
|
|
35
|
+
return body;
|
|
36
|
+
}
|
|
37
|
+
function errorResponse(status, message) {
|
|
38
|
+
return new Response(JSON.stringify({ error: message }), {
|
|
39
|
+
status,
|
|
40
|
+
headers: { "Content-Type": "application/json" },
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
function mapIssueRouteError(error) {
|
|
44
|
+
if (error instanceof IssueStoreNotFoundError) {
|
|
45
|
+
return errorResponse(404, error.message);
|
|
46
|
+
}
|
|
47
|
+
if (error instanceof IssueStoreValidationError) {
|
|
48
|
+
return errorResponse(400, error.message);
|
|
49
|
+
}
|
|
50
|
+
if (error instanceof Error && error.name === "ZodError") {
|
|
51
|
+
return errorResponse(400, error.message);
|
|
52
|
+
}
|
|
53
|
+
console.error("Issue API error:", error);
|
|
54
|
+
return errorResponse(500, error instanceof Error ? error.message : "Internal server error");
|
|
55
|
+
}
|
|
1
56
|
export async function issueRoutes(request, context) {
|
|
2
57
|
const url = new URL(request.url);
|
|
3
58
|
const path = url.pathname.replace("/api/issues", "") || "/";
|
|
@@ -5,37 +60,63 @@ export async function issueRoutes(request, context) {
|
|
|
5
60
|
try {
|
|
6
61
|
// List issues - GET /api/issues
|
|
7
62
|
if (path === "/" && method === "GET") {
|
|
8
|
-
const status = url.searchParams.get("status");
|
|
9
|
-
const tag = url.searchParams.get("tag");
|
|
10
|
-
const
|
|
11
|
-
|
|
12
|
-
|
|
63
|
+
const status = normalizeIssueStatusFilter(url.searchParams.get("status"));
|
|
64
|
+
const tag = url.searchParams.get("tag")?.trim() || undefined;
|
|
65
|
+
const contains = normalizeIssueContainsFilter(url.searchParams.get("contains"));
|
|
66
|
+
const limit = normalizeIssueQueryLimit(url.searchParams.get("limit"), {
|
|
67
|
+
defaultLimit: DEFAULT_ISSUE_QUERY_LIMIT,
|
|
13
68
|
});
|
|
69
|
+
const issues = await context.issueStore.list({ status, tag, contains, limit: limit ?? undefined });
|
|
14
70
|
return Response.json(issues);
|
|
15
71
|
}
|
|
16
72
|
// Get ready issues - GET /api/issues/ready
|
|
17
73
|
if (path === "/ready" && method === "GET") {
|
|
18
|
-
const root = url.searchParams.get("root");
|
|
19
|
-
const
|
|
74
|
+
const root = url.searchParams.get("root")?.trim() || undefined;
|
|
75
|
+
const contains = normalizeIssueContainsFilter(url.searchParams.get("contains"));
|
|
76
|
+
const limit = normalizeIssueQueryLimit(url.searchParams.get("limit"), {
|
|
77
|
+
defaultLimit: DEFAULT_ISSUE_QUERY_LIMIT,
|
|
78
|
+
});
|
|
79
|
+
const issues = await context.issueStore.ready(root, { contains, limit: limit ?? undefined });
|
|
20
80
|
return Response.json(issues);
|
|
21
81
|
}
|
|
22
82
|
// Get single issue - GET /api/issues/:id
|
|
23
83
|
if (path.startsWith("/") && method === "GET") {
|
|
24
|
-
const id = path.slice(1);
|
|
25
|
-
if (id) {
|
|
26
|
-
|
|
27
|
-
if (!issue) {
|
|
28
|
-
return new Response("Issue not found", { status: 404 });
|
|
29
|
-
}
|
|
30
|
-
return Response.json(issue);
|
|
84
|
+
const id = normalizeIssueId(path.slice(1));
|
|
85
|
+
if (id.length === 0) {
|
|
86
|
+
return errorResponse(400, "issue id is required");
|
|
31
87
|
}
|
|
88
|
+
const issue = await context.issueStore.get(id);
|
|
89
|
+
if (!issue) {
|
|
90
|
+
return errorResponse(404, "issue not found");
|
|
91
|
+
}
|
|
92
|
+
return Response.json(issue);
|
|
32
93
|
}
|
|
33
94
|
// Create issue - POST /api/issues
|
|
34
95
|
if (path === "/" && method === "POST") {
|
|
35
|
-
const body =
|
|
36
|
-
const
|
|
96
|
+
const body = await readJsonBody(request);
|
|
97
|
+
const title = typeof body.title === "string" ? body.title.trim() : "";
|
|
37
98
|
if (!title) {
|
|
38
|
-
return
|
|
99
|
+
return errorResponse(400, "title is required");
|
|
100
|
+
}
|
|
101
|
+
const issueBody = body.body == null ? undefined : typeof body.body === "string" ? body.body : undefined;
|
|
102
|
+
if (body.body != null && issueBody == null) {
|
|
103
|
+
return errorResponse(400, "body must be a string when provided");
|
|
104
|
+
}
|
|
105
|
+
let tags;
|
|
106
|
+
if (body.tags != null) {
|
|
107
|
+
if (!Array.isArray(body.tags) || !body.tags.every((tag) => typeof tag === "string")) {
|
|
108
|
+
return errorResponse(400, "tags must be a string[] when provided");
|
|
109
|
+
}
|
|
110
|
+
tags = body.tags.map((tag) => tag.trim());
|
|
111
|
+
}
|
|
112
|
+
let priority;
|
|
113
|
+
if (body.priority != null) {
|
|
114
|
+
if (typeof body.priority !== "number" ||
|
|
115
|
+
!Number.isFinite(body.priority) ||
|
|
116
|
+
!Number.isInteger(body.priority)) {
|
|
117
|
+
return errorResponse(400, "priority must be an integer when provided");
|
|
118
|
+
}
|
|
119
|
+
priority = body.priority;
|
|
39
120
|
}
|
|
40
121
|
const issue = await context.issueStore.create(title, {
|
|
41
122
|
body: issueBody,
|
|
@@ -46,41 +127,47 @@ export async function issueRoutes(request, context) {
|
|
|
46
127
|
}
|
|
47
128
|
// Update issue - PATCH /api/issues/:id
|
|
48
129
|
if (path.startsWith("/") && method === "PATCH") {
|
|
49
|
-
const id = path.slice(1);
|
|
50
|
-
if (id) {
|
|
51
|
-
|
|
52
|
-
const issue = await context.issueStore.update(id, body);
|
|
53
|
-
return Response.json(issue);
|
|
130
|
+
const id = normalizeIssueId(path.slice(1));
|
|
131
|
+
if (id.length === 0) {
|
|
132
|
+
return errorResponse(400, "issue id is required");
|
|
54
133
|
}
|
|
134
|
+
const body = await readJsonBody(request);
|
|
135
|
+
const issue = await context.issueStore.update(id, body);
|
|
136
|
+
return Response.json(issue);
|
|
55
137
|
}
|
|
56
138
|
// Close issue - POST /api/issues/:id/close
|
|
57
139
|
if (path.endsWith("/close") && method === "POST") {
|
|
58
|
-
const id = path.slice(1, -
|
|
59
|
-
|
|
60
|
-
|
|
140
|
+
const id = normalizeIssueId(path.slice(1, -"/close".length));
|
|
141
|
+
if (id.length === 0) {
|
|
142
|
+
return errorResponse(400, "issue id is required");
|
|
143
|
+
}
|
|
144
|
+
const body = await readJsonBody(request);
|
|
145
|
+
const outcome = typeof body.outcome === "string" ? body.outcome.trim() : "";
|
|
61
146
|
if (!outcome) {
|
|
62
|
-
return
|
|
147
|
+
return errorResponse(400, "outcome is required");
|
|
63
148
|
}
|
|
64
149
|
const issue = await context.issueStore.close(id, outcome);
|
|
65
150
|
return Response.json(issue);
|
|
66
151
|
}
|
|
67
152
|
// Claim issue - POST /api/issues/:id/claim
|
|
68
153
|
if (path.endsWith("/claim") && method === "POST") {
|
|
69
|
-
const id = path.slice(1, -
|
|
154
|
+
const id = normalizeIssueId(path.slice(1, -"/claim".length));
|
|
155
|
+
if (id.length === 0) {
|
|
156
|
+
return errorResponse(400, "issue id is required");
|
|
157
|
+
}
|
|
70
158
|
const success = await context.issueStore.claim(id);
|
|
71
159
|
if (!success) {
|
|
72
|
-
return
|
|
160
|
+
return errorResponse(409, "failed to claim issue");
|
|
73
161
|
}
|
|
74
162
|
const issue = await context.issueStore.get(id);
|
|
163
|
+
if (!issue) {
|
|
164
|
+
return errorResponse(404, "issue not found");
|
|
165
|
+
}
|
|
75
166
|
return Response.json(issue);
|
|
76
167
|
}
|
|
77
168
|
return new Response("Not Found", { status: 404 });
|
|
78
169
|
}
|
|
79
170
|
catch (error) {
|
|
80
|
-
|
|
81
|
-
return new Response(JSON.stringify({ error: error instanceof Error ? error.message : "Internal server error" }), {
|
|
82
|
-
status: 500,
|
|
83
|
-
headers: { "Content-Type": "application/json" },
|
|
84
|
-
});
|
|
171
|
+
return mapIssueRouteError(error);
|
|
85
172
|
}
|
|
86
173
|
}
|