@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,60 @@
1
+ import { importFooter } from '../imports/destination.js';
2
+ import { createGitSource } from './git.js';
3
+ import { identifierOf, newTaskFile, parseTaskName, serialiseTaskFile } from './taskfile.js';
4
+ /** How often a create is retried when another machine takes the number it was about to use. */
5
+ const MAX_ATTEMPTS = 5;
6
+ /** A time as the files carry it: ISO 8601 in UTC, to the second. */
7
+ const stamp = (date) => date.toISOString().replace(/\.\d{3}Z$/, 'Z');
8
+ /** One more than the highest task number among `names`; 1 when none of them is a task file. */
9
+ const nextNumber = (names) => Math.max(0, ...names.map((name) => parseTaskName(name)?.number ?? 0)) + 1;
10
+ /**
11
+ * Writes a new task file with the next free number and pushes it under `Add <identifier>: <title>.`.
12
+ * The commit message has to name the number before the transaction runs, so the number is read
13
+ * first and checked again on the synced tree inside the transaction: when another machine took it
14
+ * in between, nothing is written and the next free number is tried, so two machines never end up
15
+ * with one number and every commit names the file it adds.
16
+ */
17
+ export async function createTask(source, input, now = () => new Date()) {
18
+ if (input.title.trim() === '')
19
+ throw new Error('A task needs a title.');
20
+ const store = await source.store();
21
+ const created = stamp(now());
22
+ for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt += 1) {
23
+ const number = nextNumber((await source.readAll()).map((file) => file.name));
24
+ const file = newTaskFile(number, { ...input, created });
25
+ const identifier = identifierOf(source.prefix, number);
26
+ const pushed = await store.transact(`Add ${identifier}: ${file.title}.`, (tree) => nextNumber(tree.list(source.directory)) === number ? { [`${source.directory}/${file.name}`]: serialiseTaskFile(file) } : null);
27
+ if (pushed)
28
+ return { identifier, name: file.name };
29
+ }
30
+ throw new Error(`Gave up after ${MAX_ATTEMPTS} attempts: the next task number in ${source.directory} kept being taken.`);
31
+ }
32
+ /**
33
+ * A git source as the place imported tasks are copied into. A task file has no field for an
34
+ * external id, so the description ends with a line naming the origin, and that line is what later
35
+ * imports match. The source is opened here rather than passed in: the store behind it is one per
36
+ * repository and branch, so the destination shares the clone with the runner's source.
37
+ */
38
+ export function gitDestination(settings, context, deps = {}) {
39
+ const source = createGitSource(settings, context, deps);
40
+ const now = deps.now ?? (() => new Date());
41
+ return {
42
+ trackerName: 'Git branch',
43
+ async alreadyImported(_source, tasks) {
44
+ const files = await source.readAll();
45
+ const found = new Map();
46
+ for (const task of tasks) {
47
+ const footer = importFooter(task);
48
+ const file = files.find((candidate) => candidate.description?.endsWith(footer));
49
+ if (file)
50
+ found.set(task.id, identifierOf(source.prefix, file.number));
51
+ }
52
+ return found;
53
+ },
54
+ async create(_source, task) {
55
+ const text = task.description?.trim();
56
+ const created = await createTask(source, { title: task.title, description: text ? `${text}\n\n${importFooter(task)}` : importFooter(task), priority: task.priority }, now);
57
+ return { identifier: created.identifier };
58
+ },
59
+ };
60
+ }
@@ -0,0 +1,430 @@
1
+ import os from 'node:os';
2
+ import path from 'node:path';
3
+ import { openTaskStore } from '../git/taskstore.js';
4
+ import { defaultExec } from '../git/worktree.js';
5
+ import { branchNameOf } from './branch.js';
6
+ import { decideLease, nextLease, stamp } from './lease.js';
7
+ import { appendLog, identifierOf, parseLease, parseTaskFile, parseTaskName, serialiseLease, serialiseTaskFile, TASK_STATES, withMeta, } from './taskfile.js';
8
+ export const DEFAULT_BRANCH = 'foreman';
9
+ export const DEFAULT_DIRECTORY = '.foreman/tasks';
10
+ export const DEFAULT_PREFIX = 'FM';
11
+ /** How old the sync behind a read may be: the runner's three fetches of one poll share one sync. */
12
+ const MAX_VIEW_AGE_MS = 5_000;
13
+ /** The lease file, in the task directory; not a task name, so the task listing skips it. */
14
+ const LEASE_FILE = '.lease';
15
+ const NO_REPO_PATH = 'The Repository setting is needed until the project\'s repository path is set.';
16
+ const NO_ORIGIN = 'This project has no "origin" remote; set Repository.';
17
+ const GITHUB_REMOTE = /^(?:https:\/\/github\.com\/|git@github\.com:)([^/]+)\/([^/]+?)(?:\.git)?\/?$/;
18
+ /** The tracker state names as people see them. */
19
+ const STATE_NAMES = {
20
+ todo: 'todo',
21
+ in_progress: 'in progress',
22
+ in_review: 'in review',
23
+ pending_approval: 'pending approval',
24
+ done: 'done',
25
+ };
26
+ const settingsOf = (settings) => ({
27
+ repository: (settings.repository ?? '').trim(),
28
+ branch: (settings.branch ?? '').trim() || DEFAULT_BRANCH,
29
+ directory: (settings.directory ?? '').trim().replace(/^\.\/+|\/+$/g, '') || DEFAULT_DIRECTORY,
30
+ prefix: (settings.prefix ?? '').trim() || DEFAULT_PREFIX,
31
+ });
32
+ /** Where a person can read a task file: its page on GitHub for a GitHub remote, otherwise its path in the clone. */
33
+ export function taskUrlOf(repository, branch, directory, name, cloneDir) {
34
+ const match = GITHUB_REMOTE.exec(repository);
35
+ return match ? `https://github.com/${match[1]}/${match[2]}/blob/${branch}/${directory}/${name}` : path.join(cloneDir, directory, name);
36
+ }
37
+ /** The clock and the name of this machine, as injected or from the system. */
38
+ const clockOf = (deps) => ({
39
+ now: deps.now ?? (() => new Date()),
40
+ hostname: deps.hostname ?? (() => os.hostname()),
41
+ });
42
+ /** The lease as people see it: its holder and expiry while it stands, `mine` for this machine's own; null when missing or lapsed. */
43
+ const standingLease = (lease, host, at) => {
44
+ const decision = decideLease(lease, host, at);
45
+ return lease !== null && decision.kind !== 'take' ? { holder: lease.holder, until: lease.until, mine: decision.kind === 'hold' } : null;
46
+ };
47
+ /** What the settings check says about the lease: who polls the project, or nobody when it is missing or lapsed. */
48
+ const polledBy = (lease, host, at) => {
49
+ const standing = standingLease(lease, host, at);
50
+ return standing ? `Polled by ${standing.holder} until ${standing.until}.` : 'Not polled by any machine.';
51
+ };
52
+ const errorMessage = (error) => (error instanceof Error ? error.message : String(error));
53
+ /** What git says when a push had no usable credentials. */
54
+ const AUTH_FAILURE = /could not read Username|Authentication failed|Permission denied|publickey|403|terminal prompts disabled/i;
55
+ /** The fix for a push refused for want of credentials, by the kind of remote; nothing for a local path. */
56
+ const pushHint = (repository) => /^https?:\/\//i.test(repository)
57
+ ? 'For an HTTPS remote, run `gh auth setup-git` or set up a credential helper for the account foreman runs under.'
58
+ : /^(ssh:\/\/|[^/:]+@)/.test(repository)
59
+ ? 'For an SSH remote, load the key into an agent foreman can reach, or use a key without a passphrase.'
60
+ : '';
61
+ /** The message for a push to `repository` that was refused, with the fix when credentials are the cause. */
62
+ export const describePushFailure = (repository, error) => {
63
+ const message = errorMessage(error);
64
+ const hint = AUTH_FAILURE.test(message) ? pushHint(repository) : '';
65
+ // Git's last line rarely ends in a full stop; the hint is a sentence of its own.
66
+ const joined = hint ? `${message}${/[.!?]$/.test(message) ? '' : '.'} ${hint}` : message;
67
+ return `Foreman cannot push to ${repository}: ${joined}`;
68
+ };
69
+ /** The `review` line of a file as progress, or null when it is not a JSON object with the two required fields. */
70
+ function parseProgress(line) {
71
+ let value;
72
+ try {
73
+ value = JSON.parse(line);
74
+ }
75
+ catch {
76
+ return null;
77
+ }
78
+ if (typeof value !== 'object' || value === null || Array.isArray(value))
79
+ return null;
80
+ const { handledUntil, rounds } = value;
81
+ if (typeof handledUntil !== 'string' || typeof rounds !== 'number')
82
+ return null;
83
+ return value;
84
+ }
85
+ export function createGitSource(settings, context, deps = {}) {
86
+ const { repository: configured, branch, directory, prefix } = settingsOf(settings);
87
+ const { now, hostname } = clockOf(deps);
88
+ const exec = deps.exec ?? defaultExec;
89
+ const pathOf = (name) => `${directory}/${name}`;
90
+ const identifier = (number) => identifierOf(prefix, number);
91
+ /** The project's `origin` remote, for a blank repository setting. */
92
+ const originOf = async () => {
93
+ if (context.repoPath === '')
94
+ throw new Error(NO_REPO_PATH);
95
+ let url;
96
+ try {
97
+ url = (await exec('git', ['-C', context.repoPath, 'remote', 'get-url', 'origin'])).stdout.trim();
98
+ }
99
+ catch {
100
+ throw new Error(NO_ORIGIN);
101
+ }
102
+ if (url === '')
103
+ throw new Error(NO_ORIGIN);
104
+ return url;
105
+ };
106
+ // Resolved once; a failure is not kept, so a remote added later is picked up by the next call.
107
+ let resolving = null;
108
+ const resolved = () => {
109
+ resolving ??= (async () => {
110
+ const repository = configured || (await originOf());
111
+ const store = openTaskStore({ repository, branch, dataDir: context.dataDir, ...(deps.exec ? { exec: deps.exec } : {}) });
112
+ return { repository, store };
113
+ })().catch((error) => {
114
+ resolving = null;
115
+ throw error;
116
+ });
117
+ return resolving;
118
+ };
119
+ /** The task files of a tree, by number. Two files with one number are an error, as is a file that does not parse. */
120
+ const filesIn = (tree) => {
121
+ const files = [];
122
+ const seen = new Map();
123
+ for (const name of tree.list(directory)) {
124
+ if (!parseTaskName(name))
125
+ continue;
126
+ const file = parseTaskFile(name, tree.read(pathOf(name)) ?? '');
127
+ const other = seen.get(file.number);
128
+ if (other !== undefined)
129
+ throw new Error(`Duplicate task number ${file.number} in ${directory}: ${other} and ${name}`);
130
+ seen.set(file.number, name);
131
+ files.push(file);
132
+ }
133
+ return files.sort((a, b) => a.number - b.number);
134
+ };
135
+ const fileOf = (files, id) => {
136
+ const file = files.find((candidate) => candidate.number === Number(id));
137
+ if (!file)
138
+ throw new Error(`Task ${id} was not found in ${directory}`);
139
+ return file;
140
+ };
141
+ const readAll = async (maxAgeMs = MAX_VIEW_AGE_MS) => {
142
+ const { store } = await resolved();
143
+ return filesIn(await store.view(maxAgeMs));
144
+ };
145
+ const toTask = ({ repository, store }, file) => {
146
+ const key = identifier(file.number);
147
+ return {
148
+ id: String(file.number),
149
+ identifier: key,
150
+ title: file.title,
151
+ description: file.description,
152
+ url: taskUrlOf(repository, branch, directory, file.name, store.dir),
153
+ priority: file.priority,
154
+ createdAt: file.meta.created ?? '',
155
+ branchName: file.meta.branch ?? branchNameOf(key, file.title),
156
+ };
157
+ };
158
+ const released = { claimed_by: null, claimed_at: null };
159
+ const leasePath = pathOf(LEASE_FILE);
160
+ const leaseIn = (tree) => parseLease(tree.read(leasePath));
161
+ /** Whether the decision has the lease file written: a take, or a hold that is due for renewal. */
162
+ const writesLease = (decision) => decision.kind === 'take' || (decision.kind === 'hold' && decision.renew);
163
+ /**
164
+ * Clears the claims `previous` left when it stopped renewing its lease, in one transaction: each
165
+ * of its tasks loses the claim and gets a log entry saying so, and keeps its state, which leaves
166
+ * the task where a locally failed run would (ADR 0001). Nothing is pushed when it left none.
167
+ */
168
+ const recoverClaims = async (previous) => {
169
+ const { store } = await resolved();
170
+ const note = `Foreman on ${previous} stopped renewing its lease while it held this task; ${hostname()} cleared the claim. The state was left as it was.`;
171
+ await store.transact(`Clear claims left by ${previous}.`, (tree) => {
172
+ const changes = {};
173
+ for (const file of filesIn(tree)) {
174
+ if (file.meta.claimed_by !== previous)
175
+ continue;
176
+ changes[pathOf(file.name)] = serialiseTaskFile(appendLog(withMeta(file, released), stamp(now()), note));
177
+ }
178
+ return Object.keys(changes).length === 0 ? null : changes;
179
+ });
180
+ };
181
+ /**
182
+ * Decides on the lease as the synced view has it and, when it is to be taken or renewed, writes
183
+ * it in a transaction that decides again on the tree as it is on the remote, so that a lease
184
+ * another machine took in between is left to it. The fetches of one poll share the view's sync,
185
+ * so the first of them takes or renews and the rest find the lease as it left it. A take from
186
+ * another machine then clears that machine's claims. Returns what holds once that is done.
187
+ */
188
+ const ensureLease = async () => {
189
+ const host = hostname();
190
+ const { repository, store } = await resolved();
191
+ let decision = decideLease(leaseIn(await store.view(MAX_VIEW_AGE_MS)), host, now());
192
+ if (!writesLease(decision))
193
+ return decision;
194
+ try {
195
+ await store.transact(`Lease ${directory} to ${host}.`, (tree) => {
196
+ const at = now();
197
+ const current = leaseIn(tree);
198
+ decision = decideLease(current, host, at);
199
+ return writesLease(decision) ? { [leasePath]: serialiseLease(nextLease(current, host, at)) } : null;
200
+ });
201
+ }
202
+ catch (error) {
203
+ // The first push of a machine without credentials fails here, and the runner shows it as the project's error.
204
+ if (AUTH_FAILURE.test(errorMessage(error)))
205
+ throw new Error(describePushFailure(repository, error), { cause: error });
206
+ throw error;
207
+ }
208
+ if (decision.kind === 'take' && decision.from !== null && decision.from !== host)
209
+ await recoverClaims(decision.from);
210
+ return decision;
211
+ };
212
+ /** Whether this machine polls the project: it holds the lease, or has just taken it. */
213
+ const polling = async () => (await ensureLease()).kind !== 'wait';
214
+ const inState = async (state) => {
215
+ if (!(await polling()))
216
+ return [];
217
+ const current = await resolved();
218
+ return (await readAll()).filter((file) => file.state === state).map((file) => toTask(current, file));
219
+ };
220
+ /**
221
+ * Changes one task file in a transaction. `apply` runs on the file as it is on the remote after
222
+ * the sync, so its precondition holds when the push lands; it returns null for nothing to write.
223
+ */
224
+ const write = async (id, message, apply) => {
225
+ const { store } = await resolved();
226
+ await store.transact(message, (tree) => {
227
+ const next = apply(fileOf(filesIn(tree), id));
228
+ return next ? { [pathOf(next.name)]: serialiseTaskFile(next) } : null;
229
+ });
230
+ };
231
+ const lease = async () => {
232
+ const { store } = await resolved();
233
+ return leaseIn(await store.view(MAX_VIEW_AGE_MS));
234
+ };
235
+ const release = async () => {
236
+ // Nothing was taken through a store that was never opened, and a stop is no reason to clone one.
237
+ if (resolving === null)
238
+ return;
239
+ const host = hostname();
240
+ const { store } = await resolved();
241
+ await store.transact(`Release the lease on ${directory} from ${host}.`, (tree) => {
242
+ const current = leaseIn(tree);
243
+ // Only this machine's unlapsed lease is handed back: another machine's is left to it, and a lapsed one is free already.
244
+ return current !== null && decideLease(current, host, now()).kind === 'hold' ? { [leasePath]: serialiseLease({ ...current, until: stamp(now()) }) } : null;
245
+ });
246
+ };
247
+ /** A frontmatter value as the console shows it: a key that is missing or left blank is none. */
248
+ const metaOf = (file, key) => file.meta[key] || null;
249
+ const claimOf = (file) => `claimed by ${file.meta.claimed_by} since ${file.meta.claimed_at ?? 'an unknown time'}`;
250
+ /** Throws unless this machine holds the file's claim. */
251
+ const requireClaim = (file) => {
252
+ const holder = file.meta.claimed_by;
253
+ if (!holder)
254
+ throw new Error(`${identifier(file.number)} is not claimed`);
255
+ if (holder !== hostname())
256
+ throw new Error(`${identifier(file.number)} is ${claimOf(file)}, not by ${hostname()}`);
257
+ };
258
+ return {
259
+ directory,
260
+ prefix,
261
+ repository: async () => (await resolved()).repository,
262
+ store: async () => (await resolved()).store,
263
+ readAll,
264
+ lease,
265
+ release,
266
+ async inspect() {
267
+ // The files and the lease come from one sync: `readAll` syncs the view and `lease` reads it while it is fresh.
268
+ const { repository, store } = await resolved();
269
+ const files = await readAll();
270
+ return {
271
+ repository,
272
+ branch,
273
+ directory,
274
+ lease: standingLease(await lease(), hostname(), now()),
275
+ tasks: files.map((file) => ({
276
+ id: String(file.number),
277
+ identifier: identifier(file.number),
278
+ title: file.title,
279
+ state: file.state,
280
+ priority: file.priority,
281
+ claimedBy: metaOf(file, 'claimed_by'),
282
+ claimedAt: metaOf(file, 'claimed_at'),
283
+ pr: metaOf(file, 'pr'),
284
+ branch: metaOf(file, 'branch'),
285
+ url: taskUrlOf(repository, branch, directory, file.name, store.dir),
286
+ })),
287
+ };
288
+ },
289
+ async fetchCandidates() {
290
+ if (!(await polling()))
291
+ return [];
292
+ const current = await resolved();
293
+ return (await readAll())
294
+ .filter((file) => file.state === 'todo' && !file.meta.claimed_by)
295
+ .sort((a, b) => (a.priority || 5) - (b.priority || 5) || a.number - b.number)
296
+ .map((file) => toTask(current, file));
297
+ },
298
+ async claim(task) {
299
+ const host = hostname();
300
+ await write(task.id, `Claim ${identifier(Number(task.id))} on ${host}.`, (file) => {
301
+ const key = identifier(file.number);
302
+ const claim = { claimed_by: host, claimed_at: stamp(now()) };
303
+ if (file.meta.claimed_by)
304
+ throw new Error(`${key} is ${file.state}, ${claimOf(file)}`);
305
+ if (file.state === 'todo') {
306
+ // The branch is fixed at claim time so that a later change of the title does not move it.
307
+ return withMeta(file, { state: 'in_progress', ...claim, branch: file.meta.branch ?? branchNameOf(key, file.title) });
308
+ }
309
+ // A review round: the task stays where it is while the agent works on the feedback.
310
+ if (file.state === 'in_review' || file.state === 'pending_approval')
311
+ return withMeta(file, claim);
312
+ throw new Error(`${key} is ${file.state}`);
313
+ });
314
+ },
315
+ async complete(task) {
316
+ const key = identifier(Number(task.id));
317
+ // The commit message names the move; the move itself is decided on the synced file inside the transaction.
318
+ const moving = fileOf(await readAll(), task.id).state === 'in_progress';
319
+ await write(task.id, moving ? `Move ${key} to in review.` : `Release ${key}.`, (file) => {
320
+ requireClaim(file);
321
+ return withMeta(file, file.state === 'in_progress' ? { state: 'in_review', in_review_since: stamp(now()), ...released } : released);
322
+ });
323
+ },
324
+ fetchInReview: () => inState('in_review'),
325
+ fetchAwaitingApproval: () => inState('pending_approval'),
326
+ async approve(task) {
327
+ await write(task.id, `Move ${identifier(Number(task.id))} to pending approval.`, (file) => withMeta(file, { state: 'pending_approval', ...released }));
328
+ },
329
+ async finish(task) {
330
+ await write(task.id, `Move ${identifier(Number(task.id))} to done.`, (file) => withMeta(file, { state: 'done', ...released }));
331
+ },
332
+ async comment(task, body) {
333
+ await write(task.id, `Log on ${identifier(Number(task.id))}.`, (file) => appendLog(file, stamp(now()), body));
334
+ },
335
+ describe: () => ({
336
+ waiting: STATE_NAMES.todo,
337
+ claimed: STATE_NAMES.in_progress,
338
+ done: STATE_NAMES.in_review,
339
+ approval: STATE_NAMES.pending_approval,
340
+ finished: STATE_NAMES.done,
341
+ }),
342
+ async loadProgress(task) {
343
+ const file = fileOf(await readAll(), task.id);
344
+ const line = file.meta.review;
345
+ if (line !== undefined && line !== '') {
346
+ const progress = parseProgress(line);
347
+ return progress ? { ...progress, issueId: task.id } : null;
348
+ }
349
+ // Without a record, feedback older than the move to review was dealt with by whichever machine moved it.
350
+ const since = file.meta.in_review_since;
351
+ return since ? { issueId: task.id, handledUntil: since, rounds: 0 } : null;
352
+ },
353
+ async saveProgress(progress) {
354
+ const line = JSON.stringify(progress);
355
+ await write(progress.issueId, `Record review progress of ${identifier(Number(progress.issueId))}.`, (file) => file.meta.review === line ? null : withMeta(file, { review: line }));
356
+ },
357
+ };
358
+ }
359
+ /**
360
+ * Tries the settings: the repository resolves, the branch is on the remote or is created there as
361
+ * one empty commit, the clone syncs, a non-interactive push would be accepted, and every task file
362
+ * parses. Nothing but a missing branch is written, the lease included: the check reports who holds
363
+ * it without taking it.
364
+ */
365
+ export async function checkGitSource(settings, context, deps = {}) {
366
+ const { branch, directory } = settingsOf(settings);
367
+ const { now, hostname } = clockOf(deps);
368
+ const source = createGitSource(settings, context, deps);
369
+ try {
370
+ const repository = await source.repository();
371
+ const store = await source.store();
372
+ const cannotPush = (error) => ({ ok: false, message: describePushFailure(repository, error) });
373
+ let created = false;
374
+ if ((await store.remoteBranch()) === null) {
375
+ try {
376
+ created = (await store.createBranch('Start the foreman task branch.')) === 'created';
377
+ }
378
+ catch (error) {
379
+ return cannotPush(error);
380
+ }
381
+ }
382
+ await store.sync();
383
+ try {
384
+ await store.canPush();
385
+ }
386
+ catch (error) {
387
+ return cannotPush(error);
388
+ }
389
+ const files = await source.readAll();
390
+ const counts = TASK_STATES.map((state) => `${files.filter((file) => file.state === state).length} ${STATE_NAMES[state]}`);
391
+ const total = files.length === 1 ? '1 task' : `${files.length} tasks`;
392
+ const polled = polledBy(await source.lease(), hostname(), now());
393
+ const made = created ? `Created branch "${branch}" in ${repository}. ` : '';
394
+ return { ok: true, message: `${made}Pushes work. ${total} in ${directory} on ${branch}: ${counts.join(', ')}. ${polled}` };
395
+ }
396
+ catch (error) {
397
+ return { ok: false, message: errorMessage(error) };
398
+ }
399
+ }
400
+ export const gitSourceType = {
401
+ id: 'git',
402
+ name: 'Git branch',
403
+ fields: [
404
+ {
405
+ key: 'repository',
406
+ label: 'Repository',
407
+ type: 'text',
408
+ required: false,
409
+ help: 'URL or path of the repository that holds the task files. Leave blank to use this project\'s own "origin" remote.',
410
+ },
411
+ {
412
+ key: 'branch',
413
+ label: 'Branch',
414
+ type: 'text',
415
+ required: true,
416
+ default: DEFAULT_BRANCH,
417
+ help: 'Created on the remote by Check source when it does not exist yet. Mind the spelling: a misspelt name becomes a new branch.',
418
+ },
419
+ { key: 'directory', label: 'Directory', type: 'text', required: true, default: DEFAULT_DIRECTORY },
420
+ { key: 'prefix', label: 'Identifier prefix', type: 'text', required: true, default: DEFAULT_PREFIX },
421
+ ],
422
+ create(settings, context) {
423
+ return createGitSource(settings, context);
424
+ },
425
+ check: (settings, context) => checkGitSource(settings, context),
426
+ summarise(settings) {
427
+ const { repository, branch, directory } = settingsOf(settings);
428
+ return `${repository || 'this repository'} · ${branch} · ${directory}`;
429
+ },
430
+ };
@@ -0,0 +1,156 @@
1
+ import { importFooter } from '../imports/destination.js';
2
+ import { createKaneoClient } from '../kaneo/client.js';
3
+ import { createIssueApi } from '../kaneo/issues.js';
4
+ import { fetchWorkspaces } from '../kaneo/workspace.js';
5
+ import { workspaceOptions } from './options.js';
6
+ /** Only tasks carrying this label are picked up, unless the project's label setting says otherwise. */
7
+ export const DEFAULT_LABEL = 'foreman';
8
+ /** An explicitly empty label means "any task". */
9
+ const labelOf = (settings) => (settings.label ?? DEFAULT_LABEL).trim();
10
+ const names = (settings) => ({
11
+ target: settings.targetState ?? 'To Do',
12
+ inProgress: settings.inProgressState ?? 'In Progress',
13
+ success: settings.successState ?? 'In Review',
14
+ approval: settings.approvalState ?? 'Pending approval',
15
+ done: settings.doneState ?? 'Done',
16
+ });
17
+ /** The Kaneo API for a project's settings. */
18
+ export const createKaneoApi = (settings) => createIssueApi(createKaneoClient(settings.baseUrl ?? '', settings.apiKey ?? ''), {
19
+ baseUrl: settings.baseUrl ?? '',
20
+ projectId: settings.projectId ?? '',
21
+ });
22
+ /**
23
+ * A Kaneo project as a task source. Columns are looked up by name on every call, so a renamed or
24
+ * missing column is reported at the next poll rather than at some later claim. A task's status is the
25
+ * slug of its column, so each move resolves the name to a slug first.
26
+ */
27
+ export function createKaneoSource(settings, api) {
28
+ const columnNames = names(settings);
29
+ const label = labelOf(settings) || undefined;
30
+ const slugOf = async (name) => (await api.findColumnSlugs([name]))[name];
31
+ return {
32
+ async fetchCandidates() {
33
+ await api.findColumnSlugs([columnNames.target, columnNames.inProgress, columnNames.success]);
34
+ return api.fetchInState(columnNames.target, label);
35
+ },
36
+ async claim(task) {
37
+ await api.moveTask(task.id, await slugOf(columnNames.inProgress));
38
+ },
39
+ async complete(task) {
40
+ await api.moveTask(task.id, await slugOf(columnNames.success));
41
+ },
42
+ fetchInReview: () => api.fetchInState(columnNames.success, label),
43
+ fetchAwaitingApproval: () => api.fetchInState(columnNames.approval, label),
44
+ async approve(task) {
45
+ await api.moveTask(task.id, await slugOf(columnNames.approval));
46
+ },
47
+ async finish(task) {
48
+ await api.moveTask(task.id, await slugOf(columnNames.done));
49
+ },
50
+ async comment(task, body) {
51
+ await api.addComment(task.id, body);
52
+ },
53
+ describe: () => ({
54
+ waiting: columnNames.target,
55
+ claimed: columnNames.inProgress,
56
+ done: columnNames.success,
57
+ approval: columnNames.approval,
58
+ finished: columnNames.done,
59
+ }),
60
+ };
61
+ }
62
+ const stateField = (key, label, def, help) => ({
63
+ key,
64
+ label,
65
+ type: 'text',
66
+ required: true,
67
+ default: def,
68
+ help,
69
+ lookup: { dependsOn: 'projectId' },
70
+ });
71
+ export const kaneoSourceType = {
72
+ id: 'kaneo',
73
+ name: 'Kaneo',
74
+ fields: [
75
+ {
76
+ key: 'baseUrl',
77
+ label: 'Kaneo address',
78
+ type: 'text',
79
+ required: true,
80
+ help: 'Where Kaneo is served, e.g. http://localhost:5173. The API is expected under /api.',
81
+ },
82
+ { key: 'apiKey', label: 'API key', type: 'secret', required: true, help: 'A Kaneo API key (Settings, API keys).' },
83
+ { key: 'projectId', label: 'Project', type: 'text', required: true, help: 'The project tasks are taken from.', lookup: {} },
84
+ stateField('targetState', 'Target column', 'To Do', 'Tasks are picked up from here.'),
85
+ stateField('inProgressState', 'In-progress column', 'In Progress', 'Set on claim; kept on failure.'),
86
+ stateField('successState', 'Review column', 'In Review', 'Set once the pull request is open. Foreman watches these for review feedback.'),
87
+ stateField('approvalState', 'Approval column', 'Pending approval', 'Set once the review is positive: a person now approves and merges. Kaneo does not have this column by default: create it in the project.'),
88
+ stateField('doneState', 'Done column', 'Done', 'Set once the pull request is merged.'),
89
+ {
90
+ key: 'label',
91
+ label: 'Label',
92
+ type: 'text',
93
+ required: false,
94
+ default: DEFAULT_LABEL,
95
+ help: 'Only tasks with this label are picked up. Empty picks up every task in the target column.',
96
+ lookup: { dependsOn: 'projectId' },
97
+ },
98
+ ],
99
+ create(settings) {
100
+ return createKaneoSource(settings, createKaneoApi(settings));
101
+ },
102
+ async check(settings) {
103
+ try {
104
+ if (!(settings.baseUrl ?? '').trim())
105
+ throw new Error('The Kaneo address is not set.');
106
+ const api = createKaneoApi(settings);
107
+ const n = names(settings);
108
+ await api.findColumnSlugs([n.target, n.inProgress, n.success, n.approval, n.done]);
109
+ const label = labelOf(settings);
110
+ const waiting = await api.fetchInState(n.target, label || undefined);
111
+ const count = waiting.length === 1 ? '1 task is' : `${waiting.length} tasks are`;
112
+ const labelled = label ? ` with the label "${label}"` : '';
113
+ return { ok: true, message: `Connected. All five columns exist, and ${count} waiting in "${n.target}"${labelled}.` };
114
+ }
115
+ catch (error) {
116
+ return { ok: false, message: error instanceof Error ? error.message : String(error) };
117
+ }
118
+ },
119
+ async lookup(settings) {
120
+ return workspaceOptions(await fetchWorkspaces(createKaneoClient(settings.baseUrl ?? '', settings.apiKey ?? '')));
121
+ },
122
+ summarise: (settings) => `Kaneo · ${names(settings).target} → ${names(settings).inProgress} → ${names(settings).success}${labelOf(settings) ? ` · label "${labelOf(settings)}"` : ' · any label'}`,
123
+ };
124
+ /**
125
+ * A Kaneo project as the place imported tasks are copied into. Kaneo has no external id on a task, so
126
+ * the description ends with a line naming the origin URL, and that line is what later imports match.
127
+ */
128
+ export function kaneoDestination(api, settings) {
129
+ const column = names(settings).target;
130
+ const label = labelOf(settings) || undefined;
131
+ return {
132
+ trackerName: 'Kaneo',
133
+ async alreadyImported(_source, tasks) {
134
+ const held = await api.importedFrom();
135
+ const found = new Map();
136
+ for (const task of tasks) {
137
+ const identifier = held.get(task.url);
138
+ if (identifier)
139
+ found.set(task.id, identifier);
140
+ }
141
+ return found;
142
+ },
143
+ async create(_source, task) {
144
+ const text = task.description?.trim();
145
+ const created = await api.createTask({
146
+ title: task.title,
147
+ description: text ? `${text}\n\n${importFooter(task)}` : importFooter(task),
148
+ priority: task.priority,
149
+ columnName: column,
150
+ labelName: label,
151
+ link: { url: task.url, title: `Imported from ${task.identifier}` },
152
+ });
153
+ return { identifier: created.identifier };
154
+ },
155
+ };
156
+ }