@dfsramos/foreman 0.2.0 → 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.
- package/CHANGELOG.md +5 -0
- package/README.md +1 -1
- package/dist/server/tasks.js +20 -3
- package/dist/sources/git-import.js +26 -1
- package/dist/sources/git.js +1 -0
- package/dist/sources/taskfile.js +20 -0
- package/package.json +1 -1
- package/public/style.css +1 -1
- package/public/tasks.js +41 -15
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,11 @@ All notable changes to foreman are recorded here. The format follows [Keep a Cha
|
|
|
4
4
|
|
|
5
5
|
## [Unreleased]
|
|
6
6
|
|
|
7
|
+
## [0.3.0] - 2026-10-26
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
- The Tasks tab can edit a task already on the branch: each row has an **Edit** button that loads its title, description and priority into the form and saves them as one commit (`Edit FM-12: <title>.`). The state, claim and log are kept, and the file keeps its name, so the identifier and the agent's branch do not change.
|
|
11
|
+
|
|
7
12
|
## [0.2.0] - 2026-10-26
|
|
8
13
|
|
|
9
14
|
The first public release, installed with `npm install -g @dfsramos/foreman`.
|
package/README.md
CHANGED
|
@@ -211,7 +211,7 @@ Foreman: picked up. Claude Code is working on it.
|
|
|
211
211
|
|
|
212
212
|
**Using `main` instead.** Branch can be set to `main`, or any other existing branch, and the task files then live in that branch's tree; but every claim, comment, lease renewal and state move is then a commit on `main`, and every workflow with `on: push` and no branch filter runs on each of them. The orphan branch carries no workflow files, so GitHub Actions does not run on it; a CI system that triggers on every branch of the repository does, and needs a branch filter.
|
|
213
213
|
|
|
214
|
-
**Tasks tab.** The console's **Tasks** tab lists a Git branch project's tasks grouped by state, shows which machine holds the project's lease, and has a form that creates a task file on the branch (one commit, `Add FM-13: <title>.`). It is the way to add a task from a machine that has no worktree on the task branch.
|
|
214
|
+
**Tasks tab.** The console's **Tasks** tab lists a Git branch project's tasks grouped by state, shows which machine holds the project's lease, and has a form that creates a task file on the branch (one commit, `Add FM-13: <title>.`) and an **Edit** button on each row that changes a task's title, description and priority (one commit, `Edit FM-13: <title>.`; the file keeps its name, state, claim and log). It is the way to add or change a task from a machine that has no worktree on the task branch.
|
|
215
215
|
|
|
216
216
|
## Importing from Linear
|
|
217
217
|
|
package/dist/server/tasks.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { createTask } from '../sources/git-import.js';
|
|
1
|
+
import { createTask, updateTask } from '../sources/git-import.js';
|
|
2
2
|
import { createGitSource } from '../sources/git.js';
|
|
3
3
|
const MAX_TITLE_LENGTH = 200;
|
|
4
4
|
const PRIORITY_RANGE = { min: 0, max: 4 };
|
|
@@ -7,7 +7,7 @@ const messageOf = (error) => (error instanceof Error ? error.message : String(er
|
|
|
7
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
8
|
const gitProjectTasks = (dataDir) => (project) => {
|
|
9
9
|
const source = createGitSource(project.sourceSettings, { repoPath: project.repoPath, dataDir });
|
|
10
|
-
return { inspect: () => source.inspect(), create: (input) => createTask(source, input) };
|
|
10
|
+
return { inspect: () => source.inspect(), create: (input) => createTask(source, input), update: (id, input) => updateTask(source, id, input) };
|
|
11
11
|
};
|
|
12
12
|
/** A whole number in `range`, from a number or a string of digits as the console's select sends it; null otherwise. */
|
|
13
13
|
const wholeNumber = (value, range) => {
|
|
@@ -38,7 +38,7 @@ function parseNewTask(input) {
|
|
|
38
38
|
return { errors };
|
|
39
39
|
return { task: { title, description: description === '' ? null : description, priority: priority } };
|
|
40
40
|
}
|
|
41
|
-
/** Routes that list a git-source project's task files with its lease, and add a task file
|
|
41
|
+
/** Routes that list a git-source project's task files with its lease, and add or edit a task file on its branch. */
|
|
42
42
|
export function registerTaskRoutes(app, { db, dataDir, projectTasks = gitProjectTasks(dataDir) }) {
|
|
43
43
|
/** The project's tasks, or why the request cannot have them. */
|
|
44
44
|
const tasksOf = (id) => {
|
|
@@ -74,4 +74,21 @@ export function registerTaskRoutes(app, { db, dataDir, projectTasks = gitProject
|
|
|
74
74
|
return reply.code(502).send({ error: messageOf(error) });
|
|
75
75
|
}
|
|
76
76
|
});
|
|
77
|
+
app.patch('/api/projects/:id/tasks/:taskId', async (request, reply) => {
|
|
78
|
+
const target = tasksOf(request.params.id);
|
|
79
|
+
if ('error' in target)
|
|
80
|
+
return reply.code(target.status).send({ error: target.error });
|
|
81
|
+
if (!/^\d+$/.test(request.params.taskId))
|
|
82
|
+
return reply.code(404).send({ error: 'Task not found' });
|
|
83
|
+
const parsed = parseNewTask(request.body);
|
|
84
|
+
if ('errors' in parsed)
|
|
85
|
+
return reply.code(400).send({ errors: parsed.errors });
|
|
86
|
+
try {
|
|
87
|
+
return await target.tasks.update(request.params.taskId, parsed.task);
|
|
88
|
+
}
|
|
89
|
+
catch (error) {
|
|
90
|
+
const message = messageOf(error);
|
|
91
|
+
return reply.code(/was not found in/.test(message) ? 404 : 502).send({ error: message });
|
|
92
|
+
}
|
|
93
|
+
});
|
|
77
94
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { importFooter } from '../imports/destination.js';
|
|
2
2
|
import { createGitSource } from './git.js';
|
|
3
|
-
import { identifierOf, newTaskFile, parseTaskName, serialiseTaskFile } from './taskfile.js';
|
|
3
|
+
import { identifierOf, newTaskFile, parseTaskFile, parseTaskName, serialiseTaskFile, withContent } from './taskfile.js';
|
|
4
4
|
/** How often a create is retried when another machine takes the number it was about to use. */
|
|
5
5
|
const MAX_ATTEMPTS = 5;
|
|
6
6
|
/** A time as the files carry it: ISO 8601 in UTC, to the second. */
|
|
@@ -29,6 +29,31 @@ export async function createTask(source, input, now = () => new Date()) {
|
|
|
29
29
|
}
|
|
30
30
|
throw new Error(`Gave up after ${MAX_ATTEMPTS} attempts: the next task number in ${source.directory} kept being taken.`);
|
|
31
31
|
}
|
|
32
|
+
/**
|
|
33
|
+
* Rewrites the title, description and priority of task `id` and pushes it under `Edit <identifier>: <title>.`.
|
|
34
|
+
* The file is read again on the synced tree inside the transaction, so a state change or a claim
|
|
35
|
+
* another machine pushed in between is kept and only the three fields are replaced. Throws when the
|
|
36
|
+
* task is not on the branch.
|
|
37
|
+
*/
|
|
38
|
+
export async function updateTask(source, id, input) {
|
|
39
|
+
if (input.title.trim() === '')
|
|
40
|
+
throw new Error('A task needs a title.');
|
|
41
|
+
const store = await source.store();
|
|
42
|
+
const number = Number(id);
|
|
43
|
+
const identifier = identifierOf(source.prefix, number);
|
|
44
|
+
const name = (await source.readAll()).find((file) => file.number === number)?.name;
|
|
45
|
+
if (!name)
|
|
46
|
+
throw new Error(`Task ${identifier} was not found in ${source.directory}`);
|
|
47
|
+
const path = `${source.directory}/${name}`;
|
|
48
|
+
const title = input.title.replace(/\s+/g, ' ').trim();
|
|
49
|
+
await store.transact(`Edit ${identifier}: ${title}.`, (tree) => {
|
|
50
|
+
const text = tree.read(path);
|
|
51
|
+
if (text === null)
|
|
52
|
+
throw new Error(`Task ${identifier} was not found in ${source.directory}`);
|
|
53
|
+
return { [path]: serialiseTaskFile(withContent(parseTaskFile(name, text), input)) };
|
|
54
|
+
});
|
|
55
|
+
return { identifier, name };
|
|
56
|
+
}
|
|
32
57
|
/**
|
|
33
58
|
* A git source as the place imported tasks are copied into. A task file has no field for an
|
|
34
59
|
* external id, so the description ends with a line naming the origin, and that line is what later
|
package/dist/sources/git.js
CHANGED
|
@@ -276,6 +276,7 @@ export function createGitSource(settings, context, deps = {}) {
|
|
|
276
276
|
id: String(file.number),
|
|
277
277
|
identifier: identifier(file.number),
|
|
278
278
|
title: file.title,
|
|
279
|
+
description: file.description,
|
|
279
280
|
state: file.state,
|
|
280
281
|
priority: file.priority,
|
|
281
282
|
claimedBy: metaOf(file, 'claimed_by'),
|
package/dist/sources/taskfile.js
CHANGED
|
@@ -150,6 +150,26 @@ export function appendLog(file, at, text) {
|
|
|
150
150
|
const body = `${opened}\n\n### ${at}\n\n${text.trim()}\n`;
|
|
151
151
|
return { ...file, body, ...derive(body, file.slug) };
|
|
152
152
|
}
|
|
153
|
+
/**
|
|
154
|
+
* The file with a new title, description and priority. Only the title line and the description
|
|
155
|
+
* under it are rewritten: whatever precedes the title line, the `## Log` section and every other
|
|
156
|
+
* frontmatter key stay as they were. The file name is kept, so the identifier and the link to the
|
|
157
|
+
* file do not move when the title does.
|
|
158
|
+
*/
|
|
159
|
+
export function withContent(file, input) {
|
|
160
|
+
const title = input.title.replace(/\s+/g, ' ').trim();
|
|
161
|
+
if (title === '')
|
|
162
|
+
throw new TaskFileError(file.name, 'a task needs a title');
|
|
163
|
+
const description = input.description?.trim() ?? '';
|
|
164
|
+
const lines = linesOf(file.body);
|
|
165
|
+
const { titleAt, logAt } = layoutOf(lines);
|
|
166
|
+
const before = titleAt === -1 ? [] : lines.slice(0, titleAt);
|
|
167
|
+
const after = logAt === lines.length ? [] : ['', ...lines.slice(logAt)];
|
|
168
|
+
const written = description === '' ? [`# ${title}`] : [`# ${title}`, '', ...description.split(/\r?\n/)];
|
|
169
|
+
// A body that began with the frontmatter's closing line keeps its leading blank line.
|
|
170
|
+
const body = [...(titleAt === -1 ? [''] : []), ...before, ...written, ...after, ...(after.length === 0 ? [''] : [])].join('\n');
|
|
171
|
+
return { ...withMeta(file, { priority: String(input.priority) }), body, ...derive(body, file.slug) };
|
|
172
|
+
}
|
|
153
173
|
/** A fresh `todo` file for a task, as the import and the console create them. */
|
|
154
174
|
export function newTaskFile(number, input) {
|
|
155
175
|
const title = input.title.replace(/\s+/g, ' ').trim();
|
package/package.json
CHANGED
package/public/style.css
CHANGED
|
@@ -1586,7 +1586,7 @@ select.input {
|
|
|
1586
1586
|
.task-head,
|
|
1587
1587
|
.task-row {
|
|
1588
1588
|
display: grid;
|
|
1589
|
-
grid-template-columns: 84px minmax(0, 1.6fr) 96px minmax(0, 1fr) 120px;
|
|
1589
|
+
grid-template-columns: 84px minmax(0, 1.6fr) 96px minmax(0, 1fr) 120px 56px;
|
|
1590
1590
|
gap: 12px;
|
|
1591
1591
|
align-items: center;
|
|
1592
1592
|
padding: 0 16px;
|
package/public/tasks.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// The Tasks tab: a git-source project's task files grouped by state, the machine that holds the
|
|
2
|
-
// project's lease, and a form that adds a task as a new file on the branch. Everything is read
|
|
2
|
+
// project's lease, and a form that adds a task as a new file on the branch or edits one already there. Everything is read
|
|
3
3
|
// from the branch as it is on the remote, from any machine and lease or not: only polling is
|
|
4
4
|
// gated, so this is where a person sees which machine holds a project and which holds each task.
|
|
5
5
|
// All text from the files is inserted as text nodes.
|
|
@@ -16,7 +16,8 @@ const STATES = [
|
|
|
16
16
|
['done', 'Done'],
|
|
17
17
|
];
|
|
18
18
|
|
|
19
|
-
|
|
19
|
+
/** `editing` is the id of the task the form changes, or null when it adds a new one. */
|
|
20
|
+
const emptyForm = () => ({ editing: null, title: '', description: '', priority: '0', errors: {}, busy: false });
|
|
20
21
|
|
|
21
22
|
const view = {
|
|
22
23
|
projectId: null,
|
|
@@ -61,6 +62,8 @@ export function tasksTab(ctx) {
|
|
|
61
62
|
view.data = null;
|
|
62
63
|
view.error = null;
|
|
63
64
|
view.notice = null;
|
|
65
|
+
// An edit belongs to the task of the project it was started on; a draft of a new task carries over.
|
|
66
|
+
if (view.form.editing) view.form = emptyForm();
|
|
64
67
|
view.form.errors = {};
|
|
65
68
|
}
|
|
66
69
|
|
|
@@ -150,7 +153,7 @@ export function tasksTab(ctx) {
|
|
|
150
153
|
h('summary', null, h('span', { class: 'task-state' }, name), h('span', { class: 'task-count' }, String(tasks.length))),
|
|
151
154
|
tasks.length === 0
|
|
152
155
|
? h('div', { class: 'task-empty' }, 'None.')
|
|
153
|
-
: [h('div', { class: 'task-head' }, ...['Task', 'Title', 'Priority', 'Claimed by', 'Pull request'].map((label) => h('span', null, label))), tasks.map(row)],
|
|
156
|
+
: [h('div', { class: 'task-head' }, ...['Task', 'Title', 'Priority', 'Claimed by', 'Pull request', ''].map((label) => h('span', null, label))), tasks.map(row)],
|
|
154
157
|
);
|
|
155
158
|
// Folding is remembered across refreshes, not across visits to the tab.
|
|
156
159
|
details.addEventListener('toggle', () => {
|
|
@@ -169,9 +172,19 @@ export function tasksTab(ctx) {
|
|
|
169
172
|
h('span', null, PRIORITY_NAMES[task.priority] ?? '—'),
|
|
170
173
|
task.claimedBy ? h('span', { class: 'ellipsis' }, task.claimedBy, h('span', { class: 'faint' }, ` since ${fmtDateTime(task.claimedAt)}`)) : h('span', { class: 'faint' }, '—'),
|
|
171
174
|
task.pr ? externalLink(task.pr, prLabel(task.pr)) : h('span', { class: 'faint' }, '—'),
|
|
175
|
+
button('Edit', { onclick: () => edit(task) }),
|
|
172
176
|
);
|
|
173
177
|
}
|
|
174
178
|
|
|
179
|
+
/** Loads a task into the form; the form takes the place of the "new task" one until it is saved or cancelled. */
|
|
180
|
+
function edit(task) {
|
|
181
|
+
view.form = { ...emptyForm(), editing: task.id, title: task.title, description: task.description ?? '', priority: String(task.priority) };
|
|
182
|
+
view.notice = null;
|
|
183
|
+
ctx.rerender();
|
|
184
|
+
document.getElementById('task-title')?.scrollIntoView({ block: 'center' });
|
|
185
|
+
document.getElementById('task-title')?.focus();
|
|
186
|
+
}
|
|
187
|
+
|
|
175
188
|
/* ---------- new task ---------- */
|
|
176
189
|
|
|
177
190
|
function formCard() {
|
|
@@ -208,45 +221,58 @@ export function tasksTab(ctx) {
|
|
|
208
221
|
form.errors = {};
|
|
209
222
|
view.notice = null;
|
|
210
223
|
ctx.rerender();
|
|
211
|
-
const
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
224
|
+
const editing = form.editing;
|
|
225
|
+
const body = { title: form.title, description: form.description, priority: Number(form.priority) };
|
|
226
|
+
const response = editing
|
|
227
|
+
? await api('PATCH', `/api/projects/${view.projectId}/tasks/${editing}`, body)
|
|
228
|
+
: await api('POST', `/api/projects/${view.projectId}/tasks`, body);
|
|
216
229
|
if (view.form !== form) return;
|
|
217
230
|
form.busy = false;
|
|
218
231
|
if (response.ok) {
|
|
219
|
-
view.notice =
|
|
232
|
+
view.notice = editing
|
|
233
|
+
? { tone: 'ok', title: `Saved ${response.data.identifier}`, text: `${response.data.name} is updated on the branch.` }
|
|
234
|
+
: { tone: 'ok', title: `Added ${response.data.identifier}`, text: `${response.data.name} is on the branch, waiting as todo.` };
|
|
220
235
|
view.form = emptyForm();
|
|
221
236
|
load();
|
|
222
237
|
} else if (response.data?.errors) {
|
|
223
238
|
form.errors = response.data.errors;
|
|
224
|
-
if (form.errors._) view.notice = { tone: 'fail', title: 'Not added', text: form.errors._ };
|
|
239
|
+
if (form.errors._) view.notice = { tone: 'fail', title: editing ? 'Not saved' : 'Not added', text: form.errors._ };
|
|
225
240
|
} else {
|
|
226
|
-
view.notice = { tone: 'fail', title: 'Not added', text: response.data?.error ?? `Request failed (${response.status})` };
|
|
241
|
+
view.notice = { tone: 'fail', title: editing ? 'Not saved' : 'Not added', text: response.data?.error ?? `Request failed (${response.status})` };
|
|
227
242
|
}
|
|
228
243
|
ctx.rerender();
|
|
229
244
|
}
|
|
230
245
|
|
|
246
|
+
function cancel() {
|
|
247
|
+
view.form = emptyForm();
|
|
248
|
+
ctx.rerender();
|
|
249
|
+
}
|
|
250
|
+
|
|
231
251
|
showNotice();
|
|
232
252
|
return h(
|
|
233
253
|
'section',
|
|
234
254
|
{ class: 'card' },
|
|
235
|
-
h(
|
|
255
|
+
h(
|
|
256
|
+
'div',
|
|
257
|
+
{ class: 'card-head' },
|
|
258
|
+
h('h2', null, form.editing ? 'Edit task' : 'New task'),
|
|
259
|
+
h('span', { class: 'sub' }, form.editing ? 'Rewrites the title, description and priority; state and log stay' : 'One todo file, pushed as one commit'),
|
|
260
|
+
),
|
|
236
261
|
noticeLine,
|
|
237
262
|
h(
|
|
238
263
|
'div',
|
|
239
264
|
{ class: 'fields' },
|
|
240
|
-
field('Title', 'title', 'task-title', title, 'Names the file: <number>-<slug of the title>.md.'),
|
|
265
|
+
field('Title', 'title', 'task-title', title, form.editing ? 'The file name keeps the old title.' : 'Names the file: <number>-<slug of the title>.md.'),
|
|
241
266
|
field('Priority', 'priority', 'task-priority', priority, 'Urgent tasks are picked up first.'),
|
|
242
267
|
field('Description', 'description', 'task-description', description, 'Markdown. What the agent is told to do.', true),
|
|
243
268
|
),
|
|
244
269
|
h(
|
|
245
270
|
'div',
|
|
246
271
|
{ class: 'save-bar' },
|
|
247
|
-
h('span', { class: 'note' }, 'Needs no lease: any machine can add a task.'),
|
|
272
|
+
h('span', { class: 'note' }, form.editing ? 'The file keeps its name, so the identifier and branch do not change.' : 'Needs no lease: any machine can add a task.'),
|
|
248
273
|
h('span', { class: 'spacer' }),
|
|
249
|
-
|
|
274
|
+
form.editing ? button('Cancel', { disabled: form.busy, onclick: cancel }) : null,
|
|
275
|
+
button(form.busy ? (form.editing ? 'Saving…' : 'Adding…') : form.editing ? 'Save changes' : 'Add task', { variant: 'primary', disabled: form.busy || !view.data, onclick: () => void submit() }),
|
|
250
276
|
),
|
|
251
277
|
);
|
|
252
278
|
}
|