@dfsramos/foreman 0.0.0-stage → 0.2.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 (59) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/LICENSE +21 -0
  3. package/README.md +385 -2
  4. package/dist/agent/adapters/agy.js +96 -0
  5. package/dist/agent/adapters/claude.js +152 -0
  6. package/dist/agent/adapters/codex.js +107 -0
  7. package/dist/agent/adapters/opencode.js +91 -0
  8. package/dist/agent/config.js +169 -0
  9. package/dist/agent/process.js +230 -0
  10. package/dist/agent/prompt.js +184 -0
  11. package/dist/agent/registry.js +57 -0
  12. package/dist/agent/types.js +12 -0
  13. package/dist/cli-args.js +70 -0
  14. package/dist/cli.js +184 -0
  15. package/dist/config.js +102 -0
  16. package/dist/daemon.js +109 -0
  17. package/dist/db.js +467 -0
  18. package/dist/git/taskstore.js +224 -0
  19. package/dist/git/worktree.js +248 -0
  20. package/dist/imports/connections.js +57 -0
  21. package/dist/imports/destination.js +2 -0
  22. package/dist/imports/linear.js +40 -0
  23. package/dist/imports/registry.js +4 -0
  24. package/dist/imports/types.js +1 -0
  25. package/dist/kaneo/client.js +47 -0
  26. package/dist/kaneo/issues.js +138 -0
  27. package/dist/kaneo/workspace.js +24 -0
  28. package/dist/linear/browse.js +55 -0
  29. package/dist/linear/client.js +48 -0
  30. package/dist/linear/issues.js +99 -0
  31. package/dist/linear/workspace.js +30 -0
  32. package/dist/paths.js +12 -0
  33. package/dist/pidfile.js +35 -0
  34. package/dist/projects.js +211 -0
  35. package/dist/review/decide.js +186 -0
  36. package/dist/review/loop.js +114 -0
  37. package/dist/runner.js +1281 -0
  38. package/dist/server/app.js +344 -0
  39. package/dist/server/imports.js +182 -0
  40. package/dist/server/tasks.js +77 -0
  41. package/dist/sources/branch.js +17 -0
  42. package/dist/sources/git-import.js +60 -0
  43. package/dist/sources/git.js +430 -0
  44. package/dist/sources/kaneo.js +156 -0
  45. package/dist/sources/lease.js +38 -0
  46. package/dist/sources/linear.js +186 -0
  47. package/dist/sources/options.js +26 -0
  48. package/dist/sources/registry.js +6 -0
  49. package/dist/sources/taskfile.js +190 -0
  50. package/dist/sources/types.js +1 -0
  51. package/package.json +63 -4
  52. package/public/agents.js +435 -0
  53. package/public/app.js +1200 -0
  54. package/public/imports.js +481 -0
  55. package/public/index.html +19 -0
  56. package/public/projects.js +481 -0
  57. package/public/style.css +1614 -0
  58. package/public/tasks.js +253 -0
  59. package/public/ui.js +165 -0
@@ -0,0 +1,344 @@
1
+ import path from 'node:path';
2
+ import fastifyStatic from '@fastify/static';
3
+ import Fastify from 'fastify';
4
+ import { loadAgentsConfig, saveAgentsConfig } from '../agent/config.js';
5
+ import { ADAPTERS } from '../agent/registry.js';
6
+ import { RUN_STATUSES } from '../db.js';
7
+ import { DEFAULT_IGNORED_AUTHORS, DEFAULT_REVIEW_ROUNDS, maskProject, PRIORITIES, REVIEW_MODES, validateProject } from '../projects.js';
8
+ import { SOURCE_TYPES } from '../sources/registry.js';
9
+ import { registerImportRoutes } from './imports.js';
10
+ import { registerTaskRoutes } from './tasks.js';
11
+ const PUBLIC_DIR = path.join(import.meta.dirname, '../../public');
12
+ const KEEP_ALIVE_MS = 25_000;
13
+ const LOOPBACK_HOSTS = new Set(['localhost', '127.0.0.1', '[::1]', '::1']);
14
+ const DEFAULT_PAGE_SIZE = 50;
15
+ const MAX_PAGE_SIZE = 200;
16
+ function parseInteger(value, fallback) {
17
+ if (value === undefined || value === '')
18
+ return fallback;
19
+ return /^\d+$/.test(value) ? Number(value) : null;
20
+ }
21
+ const idParam = (value) => (/^\d+$/.test(value) ? Number(value) : NaN);
22
+ export function buildApp({ runner, db, env, availability, adapters = ADAPTERS, sourceTypes = SOURCE_TYPES, importTypes, projectTasks }) {
23
+ const app = Fastify({ logger: false });
24
+ const streams = new Set();
25
+ const statusView = () => {
26
+ const status = runner.getStatus();
27
+ const names = new Map(db.listProjects().map((project) => [project.id, project]));
28
+ return {
29
+ state: status.state,
30
+ projects: status.projects.map((project) => ({
31
+ ...project,
32
+ name: names.get(project.id)?.name ?? `Project ${project.id}`,
33
+ priority: names.get(project.id)?.priority ?? 'normal',
34
+ })),
35
+ activeRunIds: status.activeRunIds,
36
+ activeRuns: status.activeRunIds.flatMap((id) => db.getRun(id) ?? []),
37
+ };
38
+ };
39
+ const projectsView = () => ({
40
+ projects: db.listProjects().map((project) => ({ ...maskProject(project, sourceTypes), status: runner.projectStatus(project.id) })),
41
+ sources: sourceTypes.map((type) => ({ id: type.id, name: type.name, fields: type.fields })),
42
+ priorities: PRIORITIES,
43
+ reviewModes: REVIEW_MODES,
44
+ defaults: {
45
+ pollIntervalSeconds: 60,
46
+ agentTimeoutMinutes: 30,
47
+ worktreeRoot: path.join(env.dataDir, 'worktrees'),
48
+ reviewMaxRounds: DEFAULT_REVIEW_ROUNDS,
49
+ reviewIgnoreAuthors: DEFAULT_IGNORED_AUTHORS,
50
+ reviewMode: 'none',
51
+ waitForCi: true,
52
+ },
53
+ });
54
+ const agentsView = () => {
55
+ const config = loadAgentsConfig(db, env, adapters);
56
+ const usage = runner.getUsage();
57
+ const stats = db.agentStats();
58
+ const active = new Map();
59
+ for (const id of runner.getStatus().activeRunIds) {
60
+ const agent = db.getRun(id)?.agent;
61
+ if (agent)
62
+ active.set(agent, (active.get(agent) ?? 0) + 1);
63
+ }
64
+ return {
65
+ order: config.order,
66
+ agents: config.order.flatMap((id) => {
67
+ const adapter = adapters.find((candidate) => candidate.id === id);
68
+ const entry = config.agents[id];
69
+ if (!adapter || !entry)
70
+ return [];
71
+ const probe = availability.get(id);
72
+ return [
73
+ {
74
+ id,
75
+ name: adapter.name,
76
+ verified: adapter.verified,
77
+ notes: adapter.notes ?? [],
78
+ defaultBin: adapter.defaultBin,
79
+ fields: adapter.fields,
80
+ available: probe?.available ?? false,
81
+ version: probe?.version ?? null,
82
+ error: probe?.error ?? null,
83
+ enabled: entry.enabled,
84
+ maxConcurrent: entry.maxConcurrent,
85
+ maxUsagePercent: entry.maxUsagePercent ?? null,
86
+ allowOverage: entry.allowOverage ?? false,
87
+ reportsUsage: adapter.reportsUsage === true,
88
+ usage: usage[id] ?? null,
89
+ paused: runner.pauseReason({ id, maxUsagePercent: entry.maxUsagePercent ?? null, allowOverage: entry.allowOverage ?? false }),
90
+ signedOut: runner.signedOut(id),
91
+ stats: stats[id] ?? null,
92
+ bin: entry.bin ?? '',
93
+ settings: entry.settings,
94
+ active: active.get(id) ?? 0,
95
+ },
96
+ ];
97
+ }),
98
+ };
99
+ };
100
+ const validationOptions = (existing) => ({
101
+ existing,
102
+ others: db.listProjects().filter((project) => project.id !== existing?.id),
103
+ dataDir: env.dataDir,
104
+ agentIds: adapters.map((adapter) => adapter.id),
105
+ sourceTypes,
106
+ });
107
+ /** Tries a project's source settings against its tracker. A check that throws is a failed check, not a failed request. */
108
+ const checkSource = async (project) => {
109
+ const type = sourceTypes.find((candidate) => candidate.id === project.sourceType);
110
+ if (!type)
111
+ return { ok: false, message: `${project.sourceType} is not a known source` };
112
+ try {
113
+ return await type.check(project.sourceSettings, { repoPath: project.repoPath, dataDir: env.dataDir });
114
+ }
115
+ catch (error) {
116
+ return { ok: false, message: error instanceof Error ? error.message : String(error) };
117
+ }
118
+ };
119
+ // There is no authentication, so the browser is the main threat: another site must not be able to
120
+ // reach this API through DNS rebinding (Host check) or cross-site requests (Origin check).
121
+ const exposed = !LOOPBACK_HOSTS.has(env.host);
122
+ app.addHook('onRequest', async (request, reply) => {
123
+ const host = request.headers.host ?? '';
124
+ const hostname = host.replace(/:\d+$/, '').toLowerCase();
125
+ if (!exposed && !LOOPBACK_HOSTS.has(hostname)) {
126
+ return reply.code(403).send({ error: 'Requests must be addressed to a loopback host' });
127
+ }
128
+ const origin = request.headers.origin;
129
+ if (origin !== undefined && request.method !== 'GET' && request.method !== 'HEAD') {
130
+ if (!URL.canParse(origin) || new URL(origin).host !== host) {
131
+ return reply.code(403).send({ error: 'Cross-origin requests are not allowed' });
132
+ }
133
+ }
134
+ });
135
+ /* ---------- runner ---------- */
136
+ app.get('/api/status', async () => statusView());
137
+ app.post('/api/runner/start', async (_request, reply) => {
138
+ if (db.listProjects().length === 0)
139
+ return reply.code(409).send({ error: 'There are no projects to start' });
140
+ if (runner.startAll() === 0)
141
+ return reply.code(409).send({ error: 'Every project is already started' });
142
+ return statusView();
143
+ });
144
+ app.post('/api/runner/stop', async (_request, reply) => {
145
+ if (runner.stopAll() === 0)
146
+ return reply.code(409).send({ error: 'No project is started' });
147
+ return statusView();
148
+ });
149
+ /* ---------- projects ---------- */
150
+ app.get('/api/projects', async () => projectsView());
151
+ app.post('/api/projects', async (request, reply) => {
152
+ const result = validateProject(request.body, validationOptions());
153
+ if ('errors' in result)
154
+ return reply.code(400).send({ errors: result.errors });
155
+ db.createProject(result.project);
156
+ // The project is stored whatever the check finds; the check's message says what to fix.
157
+ const check = await checkSource(result.project);
158
+ return reply.code(201).send({ ...projectsView(), check });
159
+ });
160
+ app.put('/api/projects/:id', async (request, reply) => {
161
+ const existing = db.getProject(idParam(request.params.id));
162
+ if (!existing)
163
+ return reply.code(404).send({ error: 'Project not found' });
164
+ const result = validateProject(request.body, validationOptions(existing));
165
+ if ('errors' in result)
166
+ return reply.code(400).send({ errors: result.errors });
167
+ // Moving a project to another repository under running agents would strand their worktrees.
168
+ if (result.project.repoPath !== existing.repoPath && runner.projectStatus(existing.id).activeRunIds.length > 0) {
169
+ return reply.code(409).send({ error: 'The repository cannot be changed while the project has runs in progress' });
170
+ }
171
+ db.updateProject(existing.id, result.project);
172
+ const check = await checkSource(result.project);
173
+ return { ...projectsView(), check };
174
+ });
175
+ app.delete('/api/projects/:id', async (request, reply) => {
176
+ const existing = db.getProject(idParam(request.params.id));
177
+ if (!existing)
178
+ return reply.code(404).send({ error: 'Project not found' });
179
+ if (!runner.forgetProject(existing.id)) {
180
+ return reply.code(409).send({ error: 'Stop the project and wait for its runs to finish before deleting it' });
181
+ }
182
+ db.deleteProject(existing.id);
183
+ return projectsView();
184
+ });
185
+ app.post('/api/projects/:id/start', async (request, reply) => {
186
+ const id = idParam(request.params.id);
187
+ if (!db.getProject(id))
188
+ return reply.code(404).send({ error: 'Project not found' });
189
+ if (!runner.startProject(id))
190
+ return reply.code(409).send({ error: 'The project is already started or still stopping' });
191
+ return statusView();
192
+ });
193
+ app.post('/api/projects/:id/stop', async (request, reply) => {
194
+ const id = idParam(request.params.id);
195
+ if (!db.getProject(id))
196
+ return reply.code(404).send({ error: 'Project not found' });
197
+ if (!runner.stopProject(id))
198
+ return reply.code(409).send({ error: 'The project is not started' });
199
+ return statusView();
200
+ });
201
+ /** Tries source settings against the tracker. For an existing project, secrets left empty use the stored ones. */
202
+ app.post('/api/projects/check', async (request, reply) => {
203
+ const existing = typeof request.body?.id === 'number' ? db.getProject(request.body.id) : undefined;
204
+ const result = validateProject(request.body, { ...validationOptions(existing), sourceOnly: true });
205
+ if ('errors' in result) {
206
+ const sourceErrors = Object.fromEntries(Object.entries(result.errors).filter(([key]) => key.startsWith('source') || key === '_'));
207
+ if (Object.keys(sourceErrors).length > 0)
208
+ return reply.code(400).send({ errors: sourceErrors });
209
+ }
210
+ if (!('project' in result))
211
+ return reply.code(400).send({ errors: { sourceType: 'is not a known source' } });
212
+ return checkSource(result.project);
213
+ });
214
+ /**
215
+ * Lists what a source's credentials can see, so the form can offer choices instead of asking for ids.
216
+ * Only the secrets need to be filled in; for an existing project, secrets left empty use the stored ones.
217
+ */
218
+ app.post('/api/projects/lookup', async (request, reply) => {
219
+ const type = sourceTypes.find((candidate) => candidate.id === request.body?.sourceType);
220
+ if (!type)
221
+ return reply.code(400).send({ errors: { sourceType: 'is not a known source' } });
222
+ if (!type.lookup)
223
+ return { ok: false, message: `${type.name} cannot list its options; enter the values by hand.` };
224
+ const existing = typeof request.body.id === 'number' ? db.getProject(request.body.id) : undefined;
225
+ const settings = {};
226
+ const errors = {};
227
+ for (const field of type.fields) {
228
+ const raw = request.body.sourceSettings?.[field.key];
229
+ let value = typeof raw === 'string' ? raw.trim() : '';
230
+ if (value === '' && field.type === 'secret' && existing?.sourceType === type.id)
231
+ value = existing.sourceSettings[field.key] ?? '';
232
+ if (value === '' && field.type === 'secret' && field.required)
233
+ errors[`sourceSettings.${field.key}`] = 'is required';
234
+ settings[field.key] = value;
235
+ }
236
+ if (Object.keys(errors).length > 0)
237
+ return reply.code(400).send({ errors });
238
+ try {
239
+ return { ok: true, options: await type.lookup(settings) };
240
+ }
241
+ catch (error) {
242
+ return { ok: false, message: error instanceof Error ? error.message : String(error) };
243
+ }
244
+ });
245
+ /* ---------- imports ---------- */
246
+ registerImportRoutes(app, { db, dataDir: env.dataDir, importTypes });
247
+ /* ---------- tasks ---------- */
248
+ registerTaskRoutes(app, { db, dataDir: env.dataDir, projectTasks });
249
+ /* ---------- runs ---------- */
250
+ app.get('/api/runs', async (request, reply) => {
251
+ const { status } = request.query;
252
+ if (status !== undefined && status !== '' && !RUN_STATUSES.includes(status)) {
253
+ return reply.code(400).send({ error: `Unknown status "${status}"` });
254
+ }
255
+ const limit = parseInteger(request.query.limit, DEFAULT_PAGE_SIZE);
256
+ const offset = parseInteger(request.query.offset, 0);
257
+ const projectId = parseInteger(request.query.project, -1);
258
+ if (limit === null || offset === null || projectId === null || limit < 1) {
259
+ return reply.code(400).send({ error: 'limit, offset and project must be non-negative whole numbers' });
260
+ }
261
+ const page = db.listRuns({
262
+ status: status ? status : undefined,
263
+ projectId: projectId >= 0 ? projectId : undefined,
264
+ limit: Math.min(limit, MAX_PAGE_SIZE),
265
+ offset,
266
+ });
267
+ // counts always cover every run, whatever the filter, so the UI can label its filters.
268
+ return { ...page, counts: db.countRuns() };
269
+ });
270
+ app.get('/api/runs/:id', async (request, reply) => {
271
+ const run = db.getRun(idParam(request.params.id));
272
+ if (!run)
273
+ return reply.code(404).send({ error: 'Run not found' });
274
+ return run;
275
+ });
276
+ app.get('/api/runs/:id/events', async (request, reply) => {
277
+ const run = db.getRun(idParam(request.params.id));
278
+ if (!run)
279
+ return reply.code(404).send({ error: 'Run not found' });
280
+ const after = parseInteger(request.query.after, 0);
281
+ if (after === null)
282
+ return reply.code(400).send({ error: 'after must be a non-negative whole number' });
283
+ return { events: db.listEvents(run.id, after) };
284
+ });
285
+ app.post('/api/runs/:id/abort', async (request, reply) => {
286
+ if (!runner.abort(idParam(request.params.id)))
287
+ return reply.code(409).send({ error: 'That run is not in progress' });
288
+ return statusView();
289
+ });
290
+ /* ---------- agents ---------- */
291
+ app.get('/api/agents', async () => agentsView());
292
+ app.put('/api/agents', async (request, reply) => {
293
+ const result = saveAgentsConfig(db, request.body, adapters);
294
+ if ('errors' in result)
295
+ return reply.code(400).send({ errors: result.errors });
296
+ // A changed binary path changes what is installed.
297
+ await availability.refresh(result.config);
298
+ return agentsView();
299
+ });
300
+ app.post('/api/agents/refresh', async () => {
301
+ await availability.refresh(loadAgentsConfig(db, env, adapters));
302
+ return agentsView();
303
+ });
304
+ /* ---------- live updates ---------- */
305
+ app.get('/api/stream', (request, reply) => {
306
+ reply.hijack();
307
+ const response = reply.raw;
308
+ response.writeHead(200, {
309
+ 'Content-Type': 'text/event-stream',
310
+ 'Cache-Control': 'no-cache',
311
+ Connection: 'keep-alive',
312
+ });
313
+ const send = (event, data) => {
314
+ response.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
315
+ };
316
+ const onStatus = () => send('status', statusView());
317
+ const onRun = (run) => send('run', run);
318
+ const onLog = (event) => send('log', event);
319
+ // Usage changes what the agents view shows (percentages, pauses), so the whole view is resent.
320
+ const onUsage = () => send('agents', agentsView());
321
+ runner.on('status', onStatus);
322
+ runner.on('run', onRun);
323
+ runner.on('log', onLog);
324
+ runner.on('usage', onUsage);
325
+ const keepAlive = setInterval(() => response.write(': keep-alive\n\n'), KEEP_ALIVE_MS);
326
+ streams.add(response);
327
+ onStatus();
328
+ request.raw.on('close', () => {
329
+ clearInterval(keepAlive);
330
+ runner.off('status', onStatus);
331
+ runner.off('run', onRun);
332
+ runner.off('log', onLog);
333
+ runner.off('usage', onUsage);
334
+ streams.delete(response);
335
+ });
336
+ });
337
+ // Open event streams would otherwise keep the server from closing.
338
+ app.addHook('preClose', async () => {
339
+ for (const response of streams)
340
+ response.end();
341
+ });
342
+ app.register(fastifyStatic, { root: PUBLIC_DIR });
343
+ return app;
344
+ }
@@ -0,0 +1,182 @@
1
+ import { maskConnection, resolveSettings, validateConnection } from '../imports/connections.js';
2
+ import { IMPORT_TYPES } from '../imports/registry.js';
3
+ import { gitDestination as createGitDestination } from '../sources/git-import.js';
4
+ import { createKaneoApi, kaneoDestination } from '../sources/kaneo.js';
5
+ const idParam = (value) => (typeof value === 'string' && /^\d+$/.test(value) ? Number(value) : NaN);
6
+ /** A filter from the request body, or what is wrong with it. */
7
+ function parseFilter(raw) {
8
+ if (typeof raw !== 'object' || raw === null)
9
+ return { error: 'filter is required' };
10
+ const body = raw;
11
+ if (typeof body.teamId !== 'string' || body.teamId === '')
12
+ return { error: 'filter.teamId is required' };
13
+ if (body.projectId !== undefined && body.projectId !== null && typeof body.projectId !== 'string')
14
+ return { error: 'filter.projectId must be text' };
15
+ if (body.states !== undefined && (!Array.isArray(body.states) || body.states.some((state) => typeof state !== 'string'))) {
16
+ return { error: 'filter.states must be a list of state names' };
17
+ }
18
+ return {
19
+ filter: {
20
+ teamId: body.teamId,
21
+ ...(typeof body.projectId === 'string' && body.projectId !== '' ? { projectId: body.projectId } : {}),
22
+ ...(Array.isArray(body.states) && body.states.length > 0 ? { states: body.states } : {}),
23
+ },
24
+ };
25
+ }
26
+ const messageOf = (error) => (error instanceof Error ? error.message : String(error));
27
+ /** Routes for saved import connections, listing what they can see, and copying tasks into the tracker a project takes its work from. */
28
+ export function registerImportRoutes(app, { db, dataDir, importTypes = IMPORT_TYPES, kaneoApi = createKaneoApi, gitDestination = createGitDestination }) {
29
+ /** What a project takes imports through, by the tracker it takes its work from. */
30
+ const destinations = {
31
+ kaneo: (project) => kaneoDestination(kaneoApi(project.sourceSettings), project.sourceSettings),
32
+ git: (project) => gitDestination(project.sourceSettings, { repoPath: project.repoPath, dataDir }),
33
+ };
34
+ const view = () => ({
35
+ connections: db.listImportConnections().map((connection) => maskConnection(connection, importTypes)),
36
+ types: importTypes.map((type) => ({ id: type.id, name: type.name, fields: type.fields })),
37
+ });
38
+ const typeOf = (id) => importTypes.find((candidate) => candidate.id === id);
39
+ app.get('/api/imports', async () => view());
40
+ app.post('/api/imports', async (request, reply) => {
41
+ const result = validateConnection(request.body, { others: db.listImportConnections(), types: importTypes });
42
+ if ('errors' in result)
43
+ return reply.code(400).send({ errors: result.errors });
44
+ db.createImportConnection(result.connection);
45
+ return reply.code(201).send(view());
46
+ });
47
+ app.put('/api/imports/:id', async (request, reply) => {
48
+ const existing = db.getImportConnection(idParam(request.params.id));
49
+ if (!existing)
50
+ return reply.code(404).send({ error: 'Import connection not found' });
51
+ const result = validateConnection(request.body, {
52
+ existing,
53
+ others: db.listImportConnections().filter((connection) => connection.id !== existing.id),
54
+ types: importTypes,
55
+ });
56
+ if ('errors' in result)
57
+ return reply.code(400).send({ errors: result.errors });
58
+ db.updateImportConnection(existing.id, result.connection);
59
+ return view();
60
+ });
61
+ app.delete('/api/imports/:id', async (request, reply) => {
62
+ const existing = db.getImportConnection(idParam(request.params.id));
63
+ if (!existing)
64
+ return reply.code(404).send({ error: 'Import connection not found' });
65
+ db.deleteImportConnection(existing.id);
66
+ return view();
67
+ });
68
+ /** Settings typed into the form, with an existing connection's stored secrets filled in. */
69
+ const formSettings = (body) => {
70
+ const type = typeOf(body?.type ?? '');
71
+ if (!type)
72
+ return null;
73
+ const existing = typeof body?.id === 'number' ? db.getImportConnection(body.id) : undefined;
74
+ return { type, settings: resolveSettings(type, body?.settings, existing) };
75
+ };
76
+ app.post('/api/imports/check', async (request, reply) => {
77
+ const form = formSettings(request.body);
78
+ if (!form)
79
+ return reply.code(400).send({ errors: { type: 'is not a known import source' } });
80
+ return form.type.check(form.settings);
81
+ });
82
+ app.post('/api/imports/lookup', async (request, reply) => {
83
+ const form = formSettings(request.body);
84
+ if (!form)
85
+ return reply.code(400).send({ errors: { type: 'is not a known import source' } });
86
+ try {
87
+ return { ok: true, options: await form.type.lookup(form.settings) };
88
+ }
89
+ catch (error) {
90
+ return { ok: false, message: messageOf(error) };
91
+ }
92
+ });
93
+ const importTarget = (raw) => {
94
+ if (raw === undefined || raw === null)
95
+ return { project: undefined };
96
+ const project = typeof raw === 'number' ? db.getProject(raw) : undefined;
97
+ if (!project)
98
+ return { status: 404, error: 'Target project not found' };
99
+ const destination = destinations[project.sourceType]?.(project);
100
+ if (!destination)
101
+ return { status: 400, error: 'The target project does not take its work from a tracker that takes imports' };
102
+ return { project, destination };
103
+ };
104
+ app.post('/api/imports/:id/tasks', async (request, reply) => {
105
+ const connection = db.getImportConnection(idParam(request.params.id));
106
+ const type = connection && typeOf(connection.type);
107
+ if (!connection || !type)
108
+ return reply.code(404).send({ error: 'Import connection not found' });
109
+ const parsed = parseFilter(request.body?.filter);
110
+ if ('error' in parsed)
111
+ return reply.code(400).send({ error: parsed.error });
112
+ const target = importTarget(request.body?.targetProjectId);
113
+ if ('error' in target)
114
+ return reply.code(target.status).send({ error: target.error });
115
+ try {
116
+ const tasks = await type.list(connection.settings, parsed.filter);
117
+ const imported = target.destination ? await target.destination.alreadyImported(type.id, tasks) : new Map();
118
+ return { tasks: tasks.map((task) => ({ ...task, imported: imported.get(task.id) ?? null })) };
119
+ }
120
+ catch (error) {
121
+ return reply.code(502).send({ error: messageOf(error) });
122
+ }
123
+ });
124
+ app.post('/api/imports/:id/import', async (request, reply) => {
125
+ const connection = db.getImportConnection(idParam(request.params.id));
126
+ const type = connection && typeOf(connection.type);
127
+ if (!connection || !type)
128
+ return reply.code(404).send({ error: 'Import connection not found' });
129
+ const parsed = parseFilter(request.body?.filter);
130
+ if ('error' in parsed)
131
+ return reply.code(400).send({ error: parsed.error });
132
+ const { taskIds } = request.body ?? {};
133
+ if (!Array.isArray(taskIds) || taskIds.length === 0 || taskIds.some((id) => typeof id !== 'string')) {
134
+ return reply.code(400).send({ error: 'taskIds must be a non-empty list of task ids' });
135
+ }
136
+ const target = importTarget(request.body?.targetProjectId);
137
+ if ('error' in target)
138
+ return reply.code(target.status).send({ error: target.error });
139
+ if (!target.destination)
140
+ return reply.code(400).send({ error: 'A target project is required' });
141
+ // The tasks are read again from the origin: what gets copied is never taken from the browser.
142
+ let listed;
143
+ try {
144
+ listed = await type.list(connection.settings, parsed.filter);
145
+ }
146
+ catch (error) {
147
+ return reply.code(502).send({ error: messageOf(error) });
148
+ }
149
+ const { destination } = target;
150
+ const imported = [];
151
+ const skipped = [];
152
+ const failed = [];
153
+ let already;
154
+ try {
155
+ already = await destination.alreadyImported(type.id, listed);
156
+ }
157
+ catch (error) {
158
+ return reply.code(502).send({ error: messageOf(error) });
159
+ }
160
+ // One at a time: trackers rate-limit per key (Kaneo 100 a window), and a failure is reported for its task alone.
161
+ for (const id of [...new Set(taskIds)]) {
162
+ const task = listed.find((candidate) => candidate.id === id);
163
+ if (!task) {
164
+ failed.push({ id, error: 'is not in the list for this filter' });
165
+ continue;
166
+ }
167
+ const held = already.get(id);
168
+ if (held) {
169
+ skipped.push({ id, targetIdentifier: held });
170
+ continue;
171
+ }
172
+ try {
173
+ const created = await destination.create(type.id, task);
174
+ imported.push({ id, identifier: task.identifier, targetIdentifier: created.identifier });
175
+ }
176
+ catch (error) {
177
+ failed.push({ id, identifier: task.identifier, error: messageOf(error) });
178
+ }
179
+ }
180
+ return { imported, skipped, failed };
181
+ });
182
+ }
@@ -0,0 +1,77 @@
1
+ import { createTask } from '../sources/git-import.js';
2
+ import { createGitSource } from '../sources/git.js';
3
+ const MAX_TITLE_LENGTH = 200;
4
+ const PRIORITY_RANGE = { min: 0, max: 4 };
5
+ const idParam = (value) => (typeof value === 'string' && /^\d+$/.test(value) ? Number(value) : NaN);
6
+ const messageOf = (error) => (error instanceof Error ? error.message : String(error));
7
+ /** A project's git source, opened for one request; the store behind it is one per repository and branch, shared with the runner's. */
8
+ const gitProjectTasks = (dataDir) => (project) => {
9
+ const source = createGitSource(project.sourceSettings, { repoPath: project.repoPath, dataDir });
10
+ return { inspect: () => source.inspect(), create: (input) => createTask(source, input) };
11
+ };
12
+ /** A whole number in `range`, from a number or a string of digits as the console's select sends it; null otherwise. */
13
+ const wholeNumber = (value, range) => {
14
+ const n = typeof value === 'string' && /^\d+$/.test(value.trim()) ? Number(value) : value;
15
+ return typeof n === 'number' && Number.isInteger(n) && n >= range.min && n <= range.max ? n : null;
16
+ };
17
+ /** A new task from the request body, or what is wrong with it field by field, in the shape `validateProject` uses. */
18
+ function parseNewTask(input) {
19
+ if (typeof input !== 'object' || input === null || Array.isArray(input)) {
20
+ return { errors: { _: 'Request body must be a JSON object' } };
21
+ }
22
+ const body = input;
23
+ const errors = {};
24
+ const title = typeof body.title === 'string' ? body.title.trim() : '';
25
+ if (body.title !== undefined && typeof body.title !== 'string')
26
+ errors.title = 'must be text';
27
+ else if (title === '')
28
+ errors.title = 'is required';
29
+ else if (title.length > MAX_TITLE_LENGTH)
30
+ errors.title = `must be at most ${MAX_TITLE_LENGTH} characters`;
31
+ const description = typeof body.description === 'string' ? body.description.trim() : '';
32
+ if (body.description !== undefined && body.description !== null && typeof body.description !== 'string')
33
+ errors.description = 'must be text';
34
+ const priority = wholeNumber(body.priority ?? PRIORITY_RANGE.min, PRIORITY_RANGE);
35
+ if (priority === null)
36
+ errors.priority = `must be a whole number between ${PRIORITY_RANGE.min} and ${PRIORITY_RANGE.max}`;
37
+ if (Object.keys(errors).length > 0)
38
+ return { errors };
39
+ return { task: { title, description: description === '' ? null : description, priority: priority } };
40
+ }
41
+ /** Routes that list a git-source project's task files with its lease, and add a task file to its branch. */
42
+ export function registerTaskRoutes(app, { db, dataDir, projectTasks = gitProjectTasks(dataDir) }) {
43
+ /** The project's tasks, or why the request cannot have them. */
44
+ const tasksOf = (id) => {
45
+ const project = db.getProject(idParam(id));
46
+ if (!project)
47
+ return { status: 404, error: 'Project not found' };
48
+ if (project.sourceType !== 'git')
49
+ return { status: 400, error: 'Not a git-source project' };
50
+ return { tasks: projectTasks(project) };
51
+ };
52
+ app.get('/api/projects/:id/tasks', async (request, reply) => {
53
+ const target = tasksOf(request.params.id);
54
+ if ('error' in target)
55
+ return reply.code(target.status).send({ error: target.error });
56
+ try {
57
+ return await target.tasks.inspect();
58
+ }
59
+ catch (error) {
60
+ return reply.code(502).send({ error: messageOf(error) });
61
+ }
62
+ });
63
+ app.post('/api/projects/:id/tasks', async (request, reply) => {
64
+ const target = tasksOf(request.params.id);
65
+ if ('error' in target)
66
+ return reply.code(target.status).send({ error: target.error });
67
+ const parsed = parseNewTask(request.body);
68
+ if ('errors' in parsed)
69
+ return reply.code(400).send({ errors: parsed.errors });
70
+ try {
71
+ return reply.code(201).send(await target.tasks.create(parsed.task));
72
+ }
73
+ catch (error) {
74
+ return reply.code(502).send({ error: messageOf(error) });
75
+ }
76
+ });
77
+ }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Text as a file or branch name part: lowercase, runs of anything but letters and digits as one
3
+ * dash, no dash at either end, at most 50 characters. Empty when the text has nothing to keep.
4
+ */
5
+ export function slug(text) {
6
+ return text
7
+ .toLowerCase()
8
+ .replace(/[^a-z0-9]+/g, '-')
9
+ .replace(/^-+|-+$/g, '')
10
+ .slice(0, 50)
11
+ .replace(/-+$/, '');
12
+ }
13
+ /** For trackers with no branch name of their own: one made from the task's identifier and title. */
14
+ export function branchNameOf(identifier, title) {
15
+ const part = slug(title);
16
+ return part ? `${identifier.toLowerCase()}-${part}` : identifier.toLowerCase();
17
+ }