@dfsramos/foreman 0.0.0-stage → 0.3.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 +23 -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 +94 -0
  41. package/dist/sources/branch.js +17 -0
  42. package/dist/sources/git-import.js +85 -0
  43. package/dist/sources/git.js +431 -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 +210 -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 +279 -0
  59. package/public/ui.js +165 -0
@@ -0,0 +1,481 @@
1
+ // The Import tab: saved read-only connections to trackers (Linear), a filtered list of what they
2
+ // hold, and a button that copies the ticked tasks into the tracker a project takes its work from
3
+ // (a Kaneo project or a git branch). Nothing is ever written to the tracker the tasks come from.
4
+ // All text from the tracker is inserted as text nodes.
5
+
6
+ import { api, banner, button, externalLink, fill, fmtDateTime, h } from './ui.js';
7
+
8
+ const PRIORITY_NAMES = ['No priority', 'Urgent', 'High', 'Medium', 'Low'];
9
+ /** States ticked by default when a team is chosen: work that has not started. */
10
+ const DEFAULT_STATES = /^(backlog|todo|to do|triage|unstarted)$/i;
11
+
12
+ const view = {
13
+ data: null, // the `/api/imports` response
14
+ loading: false,
15
+ form: null, // a connection being added or edited
16
+ connectionId: null,
17
+ targetProjectId: null,
18
+ options: null, // what the connection can see: teamId, projectId, states
19
+ lookup: { busy: false, message: null },
20
+ filter: { teamId: '', projectId: '', states: [] },
21
+ list: { busy: false, error: null, tasks: null },
22
+ selected: new Set(),
23
+ expanded: new Set(),
24
+ result: null,
25
+ importing: false,
26
+ };
27
+
28
+ /** Closes the form and forgets the loaded list; called when the user leaves the tab. */
29
+ export function resetImports() {
30
+ view.form = null;
31
+ view.connectionId = null;
32
+ view.options = null;
33
+ view.list = { busy: false, error: null, tasks: null };
34
+ view.selected = new Set();
35
+ view.expanded = new Set();
36
+ view.result = null;
37
+ view.lookup = { busy: false, message: null };
38
+ }
39
+
40
+ const pending = (task) => task.imported === null;
41
+
42
+ /** The trackers a project can take imports through, by source type. */
43
+ const TRACKERS = { kaneo: 'Kaneo', git: 'Git branch' };
44
+
45
+ export function importsTab(ctx) {
46
+ const targets = ctx.projects.filter((project) => project.sourceType in TRACKERS);
47
+
48
+ if (!view.data) {
49
+ if (!view.loading) {
50
+ view.loading = true;
51
+ void api('GET', '/api/imports').then((response) => {
52
+ view.loading = false;
53
+ if (response.ok) view.data = response.data;
54
+ else view.data = { connections: [], types: [], error: response.data?.error ?? `Could not load (${response.status})` };
55
+ ctx.rerender();
56
+ });
57
+ }
58
+ return h('div', { class: 'config' }, h('span', { class: 'muted' }, 'Loading…'));
59
+ }
60
+ const { data } = view;
61
+ const connection = data.connections.find((candidate) => candidate.id === view.connectionId) ?? null;
62
+ const target = targets.find((project) => project.id === view.targetProjectId) ?? null;
63
+ /** Where the chosen project keeps its tasks, or a neutral wording before one is chosen. */
64
+ const trackerName = target ? TRACKERS[target.sourceType] : 'the task tracker';
65
+
66
+ return h('div', { class: 'config' }, view.form ? formCard() : [intro(), connectionsCard(), data.connections.length > 0 ? importCard() : null]);
67
+
68
+ /* ---------- connections ---------- */
69
+
70
+ function intro() {
71
+ return banner(
72
+ 'run',
73
+ null,
74
+ 'Import tasks from another tracker into the place foreman picks work up from: a Kaneo project or a git branch. Foreman only reads from the other tracker: it never adds labels, comments or changes states there. An import is a one-time copy.',
75
+ null,
76
+ true,
77
+ );
78
+ }
79
+
80
+ function connectionsCard() {
81
+ return h(
82
+ 'section',
83
+ { class: 'card' },
84
+ h('div', { class: 'card-head' }, h('h2', null, 'Connections'), h('span', { class: 'sub' }, 'Read-only logins'), h('span', { class: 'spacer' }), button('Add a connection', { onclick: () => openForm(null) })),
85
+ data.connections.length === 0
86
+ ? h('div', { class: 'list-empty' }, 'No connections yet. Add one to see what you can import.')
87
+ : data.connections.map((item) =>
88
+ h(
89
+ 'div',
90
+ { class: 'import-conn' },
91
+ h('strong', null, item.name),
92
+ h('span', { class: 'muted' }, item.summary),
93
+ h('span', { class: 'spacer' }),
94
+ button('Edit', { onclick: () => openForm(item) }),
95
+ button('Delete', { variant: 'danger', onclick: () => remove(item) }),
96
+ ),
97
+ ),
98
+ );
99
+ }
100
+
101
+ function openForm(item) {
102
+ const type = data.types.find((candidate) => candidate.id === (item?.type ?? data.types[0]?.id));
103
+ view.form = {
104
+ id: item?.id ?? null,
105
+ title: item ? `Edit ${item.name}` : 'Add a connection',
106
+ type: type?.id ?? '',
107
+ name: item?.name ?? '',
108
+ // Secrets start empty: an empty secret on an existing connection means "keep the stored one".
109
+ settings: Object.fromEntries((type?.fields ?? []).map((field) => [field.key, field.type === 'secret' ? '' : (item?.settings[field.key] ?? '')])),
110
+ secretsSet: Object.fromEntries((type?.fields ?? []).filter((field) => field.type === 'secret').map((field) => [field.key, item?.settings[field.key]?.set === true])),
111
+ errors: {},
112
+ check: null,
113
+ };
114
+ ctx.rerender();
115
+ }
116
+
117
+ async function remove(item) {
118
+ if (!window.confirm(`Delete the connection "${item.name}"? Tasks already imported stay where they were copied to. Nothing is changed in the other tracker.`)) return;
119
+ const response = await api('DELETE', `/api/imports/${item.id}`);
120
+ if (!response.ok) return window.alert(response.data?.error ?? `Delete failed (${response.status})`);
121
+ view.data = response.data;
122
+ if (view.connectionId === item.id) resetImports();
123
+ ctx.rerender();
124
+ }
125
+
126
+ function formCard() {
127
+ const { form } = view;
128
+ const type = data.types.find((candidate) => candidate.id === form.type);
129
+ const payload = () => ({ ...(form.id === null ? {} : { id: form.id }), name: form.name, type: form.type, settings: form.settings });
130
+ const statusLine = h('div', { class: 'usage-line' });
131
+
132
+ const field = (label, key, id, control, help) => {
133
+ const error = form.errors[key];
134
+ return h(
135
+ 'div',
136
+ { class: `field ${error ? 'error' : ''}` },
137
+ h('div', { class: 'label-row' }, h('label', { for: id }, label)),
138
+ control,
139
+ h('div', { class: 'help' }, error ? `⚠ ${error}` : (help ?? '')),
140
+ );
141
+ };
142
+ const input = (id, value, onInput, { secret = false, placeholder } = {}) => {
143
+ const control = h('input', { id, class: 'input', type: secret ? 'password' : 'text', autocomplete: secret ? 'new-password' : 'off', spellcheck: 'false', placeholder, value });
144
+ control.addEventListener('input', () => {
145
+ onInput(control.value);
146
+ form.check = null;
147
+ showStatus();
148
+ });
149
+ return control;
150
+ };
151
+
152
+ function showStatus() {
153
+ const messages = [];
154
+ if (form.check) messages.push(h('span', { class: form.check.ok ? 'text-ok' : 'text-fail' }, form.check.ok ? '✓ ' : '⚠ ', form.check.message));
155
+ if (form.errors._) messages.push(h('span', { class: 'text-fail' }, `⚠ ${form.errors._}`));
156
+ fill(statusLine, messages);
157
+ statusLine.hidden = messages.length === 0;
158
+ }
159
+
160
+ async function check(event) {
161
+ event.currentTarget.disabled = true;
162
+ form.check = null;
163
+ const response = await api('POST', '/api/imports/check', payload());
164
+ if (view.form !== form) return;
165
+ form.check = response.ok ? response.data : { ok: false, message: response.data?.error ?? `Check failed (${response.status})` };
166
+ ctx.rerender();
167
+ }
168
+
169
+ async function save() {
170
+ const response = form.id === null ? await api('POST', '/api/imports', payload()) : await api('PUT', `/api/imports/${form.id}`, payload());
171
+ if (view.form !== form) return;
172
+ if (response.ok) {
173
+ view.data = response.data;
174
+ view.form = null;
175
+ } else {
176
+ form.errors = response.data?.errors ?? { _: response.data?.error ?? `Save failed (${response.status})` };
177
+ }
178
+ ctx.rerender();
179
+ }
180
+
181
+ showStatus();
182
+ return [
183
+ h('h1', { class: 'title-l' }, form.title),
184
+ h(
185
+ 'section',
186
+ { class: 'card' },
187
+ h('div', { class: 'card-head' }, h('h2', null, `Connection · ${type?.name ?? form.type}`), h('span', { class: 'sub' }, 'Used only to read')),
188
+ h(
189
+ 'div',
190
+ { class: 'fields' },
191
+ field('Name', 'name', 'import-name', input('import-name', form.name, (value) => (form.name = value))),
192
+ (type?.fields ?? []).map((spec) =>
193
+ field(
194
+ spec.label,
195
+ `settings.${spec.key}`,
196
+ `import-${spec.key}`,
197
+ input(`import-${spec.key}`, form.settings[spec.key] ?? '', (value) => (form.settings[spec.key] = value), {
198
+ secret: spec.type === 'secret',
199
+ placeholder: spec.type === 'secret' && form.secretsSet[spec.key] ? '•••••••• (unchanged)' : undefined,
200
+ }),
201
+ spec.type === 'secret' && form.secretsSet[spec.key] ? `${spec.help ?? ''} A key is set; leave empty to keep it.`.trim() : spec.help,
202
+ ),
203
+ ),
204
+ ),
205
+ statusLine,
206
+ ),
207
+ h(
208
+ 'div',
209
+ { class: 'save-bar' },
210
+ h('span', { class: 'note' }, 'The key is stored on this machine and never shown again.'),
211
+ h('span', { class: 'spacer' }),
212
+ button('Cancel', { onclick: () => ((view.form = null), ctx.rerender()) }),
213
+ button('Check', { onclick: check, title: 'Try the key against the tracker without saving' }),
214
+ button(form.id === null ? 'Add connection' : 'Save changes', { variant: 'primary', onclick: save }),
215
+ ),
216
+ ];
217
+ }
218
+
219
+ /* ---------- import ---------- */
220
+
221
+ function select(id, label, choices, current, onChange, help) {
222
+ const control = h(
223
+ 'select',
224
+ { id, class: 'input' },
225
+ choices.map(([value, name]) => h('option', { value, selected: current === value }, name)),
226
+ );
227
+ control.addEventListener('change', () => onChange(control.value));
228
+ return h('div', { class: 'field' }, h('div', { class: 'label-row' }, h('label', { for: id }, label)), control, help ? h('div', { class: 'help' }, help) : null);
229
+ }
230
+
231
+ function forTeam(key) {
232
+ return (view.options?.[key] ?? []).filter((option) => !option.for || option.for.includes(view.filter.teamId));
233
+ }
234
+
235
+ async function loadOptions() {
236
+ const current = view.connectionId;
237
+ view.lookup = { busy: true, message: null };
238
+ ctx.rerender();
239
+ const type = view.data.connections.find((item) => item.id === current)?.type;
240
+ const response = await api('POST', '/api/imports/lookup', { id: current, type, settings: {} });
241
+ if (view.connectionId !== current) return;
242
+ if (response.ok && response.data.ok) {
243
+ view.options = response.data.options;
244
+ view.lookup = { busy: false, message: null };
245
+ } else {
246
+ view.options = null;
247
+ view.lookup = { busy: false, message: response.data?.message ?? response.data?.error ?? `Could not load (${response.status})` };
248
+ }
249
+ ctx.rerender();
250
+ }
251
+
252
+ function chooseTeam(teamId) {
253
+ view.filter = { teamId, projectId: '', states: [] };
254
+ view.filter.states = forTeam('states')
255
+ .filter((option) => DEFAULT_STATES.test(option.value))
256
+ .map((option) => option.value);
257
+ view.list = { busy: false, error: null, tasks: null };
258
+ view.selected = new Set();
259
+ view.result = null;
260
+ ctx.rerender();
261
+ }
262
+
263
+ async function loadTasks() {
264
+ view.list = { busy: true, error: null, tasks: null };
265
+ view.selected = new Set();
266
+ view.expanded = new Set();
267
+ ctx.rerender();
268
+ const current = view.connectionId;
269
+ const response = await api('POST', `/api/imports/${current}/tasks`, { filter: filterBody(), targetProjectId: view.targetProjectId });
270
+ if (view.connectionId !== current) return;
271
+ view.list = response.ok
272
+ ? { busy: false, error: null, tasks: response.data.tasks }
273
+ : { busy: false, error: response.data?.error ?? `Could not load (${response.status})`, tasks: null };
274
+ ctx.rerender();
275
+ }
276
+
277
+ function filterBody() {
278
+ return {
279
+ teamId: view.filter.teamId,
280
+ ...(view.filter.projectId ? { projectId: view.filter.projectId } : {}),
281
+ ...(view.filter.states.length > 0 ? { states: view.filter.states } : {}),
282
+ };
283
+ }
284
+
285
+ async function runImport() {
286
+ view.importing = true;
287
+ ctx.rerender();
288
+ const response = await api('POST', `/api/imports/${view.connectionId}/import`, {
289
+ filter: filterBody(),
290
+ targetProjectId: view.targetProjectId,
291
+ taskIds: [...view.selected],
292
+ });
293
+ view.importing = false;
294
+ view.result = response.ok ? response.data : { error: response.data?.error ?? `Import failed (${response.status})` };
295
+ await loadTasks();
296
+ }
297
+
298
+ function importCard() {
299
+ const ready = connection && target && view.filter.teamId;
300
+ return h(
301
+ 'section',
302
+ { class: 'card' },
303
+ h('div', { class: 'card-head' }, h('h2', null, 'Import'), h('span', { class: 'sub' }, 'Choose what to copy into a project')),
304
+ h(
305
+ 'div',
306
+ { class: 'fields' },
307
+ select(
308
+ 'import-connection',
309
+ 'Import from',
310
+ [['', 'Choose…'], ...data.connections.map((item) => [String(item.id), item.name])],
311
+ view.connectionId === null ? '' : String(view.connectionId),
312
+ (value) => {
313
+ resetImports();
314
+ view.connectionId = value === '' ? null : Number(value);
315
+ view.filter = { teamId: '', projectId: '', states: [] };
316
+ if (view.connectionId !== null) void loadOptions();
317
+ else ctx.rerender();
318
+ },
319
+ ),
320
+ select(
321
+ 'import-target',
322
+ 'Copy into',
323
+ [['', targets.length === 0 ? 'No project takes its work from Kaneo or a git branch yet' : 'Choose…'], ...targets.map((project) => [String(project.id), `${project.name} (${TRACKERS[project.sourceType]})`])],
324
+ view.targetProjectId === null ? '' : String(view.targetProjectId),
325
+ (value) => {
326
+ view.targetProjectId = value === '' ? null : Number(value);
327
+ view.list = { busy: false, error: null, tasks: null };
328
+ ctx.rerender();
329
+ },
330
+ 'A foreman project that takes its work from Kaneo or a git branch. Imported tasks land where it picks work up: the Kaneo target column with its label, or a new todo file on the task branch.',
331
+ ),
332
+ view.options
333
+ ? [
334
+ select(
335
+ 'import-team',
336
+ 'Team',
337
+ [['', 'Choose…'], ...view.options.teamId.map((option) => [option.value, option.label])],
338
+ view.filter.teamId,
339
+ chooseTeam,
340
+ ),
341
+ select(
342
+ 'import-project',
343
+ 'Project',
344
+ [['', 'Any project'], ...forTeam('projectId').map((option) => [option.value, option.label])],
345
+ view.filter.projectId,
346
+ (value) => {
347
+ view.filter.projectId = value;
348
+ view.list = { busy: false, error: null, tasks: null };
349
+ ctx.rerender();
350
+ },
351
+ ),
352
+ view.filter.teamId ? stateChecks() : null,
353
+ ]
354
+ : null,
355
+ ),
356
+ h(
357
+ 'div',
358
+ { class: 'lookup-row' },
359
+ view.lookup.busy ? h('span', { class: 'muted' }, 'Loading…') : null,
360
+ view.lookup.message ? h('span', { class: 'text-fail' }, '⚠ ', view.lookup.message) : null,
361
+ button(view.list.busy ? 'Loading…' : 'Show tasks', { variant: 'primary', disabled: !ready || view.list.busy, onclick: () => void loadTasks() }),
362
+ !target && connection ? h('span', { class: 'faint' }, 'Choose the project to copy into.') : null,
363
+ ),
364
+ resultView(),
365
+ taskList(),
366
+ );
367
+ }
368
+
369
+ function stateChecks() {
370
+ const states = forTeam('states');
371
+ return h(
372
+ 'div',
373
+ { class: 'field wide' },
374
+ h('div', { class: 'label-row' }, h('span', { class: 'label' }, 'States')),
375
+ h(
376
+ 'div',
377
+ { class: 'checks' },
378
+ states.map((option) => {
379
+ const box = h('input', { type: 'checkbox', id: `import-state-${option.value}`, checked: view.filter.states.includes(option.value) });
380
+ box.addEventListener('change', () => {
381
+ view.filter.states = box.checked ? [...view.filter.states, option.value] : view.filter.states.filter((name) => name !== option.value);
382
+ view.list = { busy: false, error: null, tasks: null };
383
+ ctx.rerender();
384
+ });
385
+ return h('label', { class: 'switch', for: box.id }, box, option.label);
386
+ }),
387
+ ),
388
+ h('div', { class: 'help' }, 'None ticked lists every state.'),
389
+ );
390
+ }
391
+
392
+ function resultView() {
393
+ const result = view.result;
394
+ if (!result) return null;
395
+ if (result.error) return banner('fail', 'Import failed', result.error, null, true);
396
+ const parts = [];
397
+ if (result.imported.length > 0) parts.push(`${result.imported.length} imported: ${result.imported.map((item) => `${item.identifier} → ${item.targetIdentifier}`).join(', ')}`);
398
+ if (result.skipped.length > 0) parts.push(`${result.skipped.length} already in ${trackerName}`);
399
+ const tone = result.failed.length > 0 ? 'warn' : 'ok';
400
+ return [
401
+ banner(tone, result.failed.length > 0 ? 'Imported with problems' : 'Imported', parts.join(' · ') || 'Nothing to import.', null, true),
402
+ result.failed.length > 0 ? h('div', { class: 'import-failures' }, result.failed.map((item) => h('div', null, h('span', { class: 'mono' }, item.identifier ?? item.id), ' ', item.error))) : null,
403
+ ];
404
+ }
405
+
406
+ function taskList() {
407
+ const { busy, error, tasks } = view.list;
408
+ if (busy) return h('div', { class: 'list-empty' }, 'Loading tasks…');
409
+ if (error) return h('div', { class: 'list-empty text-fail' }, `⚠ ${error}`);
410
+ if (!tasks) return null;
411
+ if (tasks.length === 0) return h('div', { class: 'list-empty' }, 'No tasks match these filters.');
412
+
413
+ const open = tasks.filter(pending);
414
+ const allBox = h('input', { type: 'checkbox', 'aria-label': 'Select all', checked: open.length > 0 && open.every((task) => view.selected.has(task.id)), disabled: open.length === 0 });
415
+ allBox.addEventListener('change', () => {
416
+ view.selected = allBox.checked ? new Set(open.map((task) => task.id)) : new Set();
417
+ ctx.rerender();
418
+ });
419
+ const chosen = view.selected.size;
420
+ return [
421
+ h('div', { class: 'import-head' }, allBox, ...['Task', 'Title', 'Priority', 'Status', 'Labels'].map((label) => h('span', null, label))),
422
+ tasks.map(taskRows),
423
+ h(
424
+ 'div',
425
+ { class: 'save-bar' },
426
+ h('span', { class: 'note' }, `${tasks.length} ${tasks.length === 1 ? 'task' : 'tasks'} · ${tasks.length - open.length} already imported · ${chosen} selected`),
427
+ h('span', { class: 'spacer' }),
428
+ button(view.importing ? 'Importing…' : `Import ${chosen} ${chosen === 1 ? 'task' : 'tasks'} into ${target?.name ?? 'a project'}`, {
429
+ variant: 'primary',
430
+ disabled: chosen === 0 || !target || view.importing,
431
+ onclick: () => void runImport(),
432
+ }),
433
+ ),
434
+ ];
435
+ }
436
+
437
+ function taskRows(task) {
438
+ const box = h('input', { type: 'checkbox', 'aria-label': `Select ${task.identifier}`, checked: view.selected.has(task.id), disabled: !pending(task) });
439
+ box.addEventListener('change', () => {
440
+ if (box.checked) view.selected.add(task.id);
441
+ else view.selected.delete(task.id);
442
+ ctx.rerender();
443
+ });
444
+ const isOpen = view.expanded.has(task.id);
445
+ const toggle = h(
446
+ 'button',
447
+ {
448
+ class: 'import-title',
449
+ type: 'button',
450
+ 'aria-expanded': String(isOpen),
451
+ onclick: () => {
452
+ if (isOpen) view.expanded.delete(task.id);
453
+ else view.expanded.add(task.id);
454
+ ctx.rerender();
455
+ },
456
+ },
457
+ h('span', { class: 'ellipsis' }, task.title),
458
+ task.imported ? h('span', { class: 'badge tone-ok' }, `In ${trackerName} · ${task.imported}`) : null,
459
+ );
460
+ return [
461
+ h(
462
+ 'div',
463
+ { class: `import-row ${task.imported ? 'done' : ''}` },
464
+ box,
465
+ h('span', { class: 'mono' }, task.identifier),
466
+ toggle,
467
+ h('span', null, PRIORITY_NAMES[task.priority] ?? '—'),
468
+ h('span', null, task.status),
469
+ h('span', { class: 'ellipsis muted' }, task.labels.join(', ') || '—'),
470
+ ),
471
+ isOpen
472
+ ? h(
473
+ 'div',
474
+ { class: 'import-detail' },
475
+ h('div', { class: 'import-meta' }, externalLink(task.url, `Open ${task.identifier}`), h('span', { class: 'faint' }, `Created ${fmtDateTime(task.createdAt)}`), h('span', { class: 'faint' }, task.assignee ? `Assigned to ${task.assignee}` : 'Unassigned')),
476
+ h('pre', { class: 'import-desc' }, task.description?.trim() || 'No description.'),
477
+ )
478
+ : null,
479
+ ];
480
+ }
481
+ }
@@ -0,0 +1,19 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <meta name="color-scheme" content="light dark" />
7
+ <title>Foreman</title>
8
+ <link rel="stylesheet" href="style.css" />
9
+ </head>
10
+ <body>
11
+ <header id="header" class="header"></header>
12
+ <div id="banner"></div>
13
+ <nav id="tabs" class="tabs" aria-label="Sections"></nav>
14
+ <main id="main" class="main"></main>
15
+ <dialog id="dialog" class="dialog"></dialog>
16
+ <noscript>Foreman's console needs JavaScript.</noscript>
17
+ <script type="module" src="app.js"></script>
18
+ </body>
19
+ </html>