@webappwiz/arbor 0.0.30 → 0.0.32

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 (120) hide show
  1. package/README.md +2 -2
  2. package/add.d.ts +23 -0
  3. package/age.d.ts +2 -0
  4. package/arbor.d.ts +11 -0
  5. package/attachments.d.ts +36 -0
  6. package/claim.d.ts +11 -0
  7. package/config.d.ts +55 -0
  8. package/config.js +7 -0
  9. package/dev/assets.d.ts +11 -0
  10. package/dev.d.ts +48 -0
  11. package/escalate.d.ts +28 -0
  12. package/exit.d.ts +42 -0
  13. package/git.d.ts +82 -0
  14. package/index.d.ts +2 -0
  15. package/index.js +4763 -0
  16. package/journal.d.ts +29 -0
  17. package/list.d.ts +18 -0
  18. package/load-config.d.ts +15 -0
  19. package/log.d.ts +14 -0
  20. package/merge.d.ts +24 -0
  21. package/package.json +15 -32
  22. package/path.d.ts +13 -0
  23. package/plan.d.ts +83 -0
  24. package/remove.d.ts +19 -0
  25. package/repository.d.ts +25 -0
  26. package/retry.d.ts +17 -0
  27. package/shell.d.ts +17 -0
  28. package/show.d.ts +53 -0
  29. package/snapshot.d.ts +36 -0
  30. package/table.d.ts +5 -0
  31. package/todo.d.ts +202 -0
  32. package/todos.d.ts +75 -0
  33. package/wait.d.ts +22 -0
  34. package/worktree-service.d.ts +51 -0
  35. package/worktree.d.ts +95 -0
  36. package/AGENTS.md +0 -7
  37. package/add.test.ts +0 -125
  38. package/add.ts +0 -192
  39. package/age.ts +0 -11
  40. package/arbor.test.ts +0 -145
  41. package/arbor.ts +0 -429
  42. package/assets.d.ts +0 -9
  43. package/attachments.ts +0 -87
  44. package/build.ts +0 -98
  45. package/claim.test.ts +0 -89
  46. package/claim.ts +0 -73
  47. package/components.json +0 -21
  48. package/config.ts +0 -58
  49. package/dev/api.ts +0 -120
  50. package/dev/app.test.tsx +0 -645
  51. package/dev/app.tsx +0 -80
  52. package/dev/assets.ts +0 -16
  53. package/dev/build/main.txt +0 -68
  54. package/dev/build/shell.txt +0 -21
  55. package/dev/build/styles.txt +0 -2263
  56. package/dev/components/ui/badge.tsx +0 -51
  57. package/dev/components/ui/button.tsx +0 -57
  58. package/dev/components/ui/dialog.tsx +0 -154
  59. package/dev/components/ui/empty.tsx +0 -100
  60. package/dev/components/ui/input-group.tsx +0 -155
  61. package/dev/components/ui/input.tsx +0 -19
  62. package/dev/components/ui/progress.tsx +0 -80
  63. package/dev/components/ui/textarea.tsx +0 -17
  64. package/dev/components/ui/toast.tsx +0 -227
  65. package/dev/feed.ts +0 -69
  66. package/dev/files.tsx +0 -191
  67. package/dev/index.html +0 -21
  68. package/dev/lib/utils.ts +0 -7
  69. package/dev/main.tsx +0 -22
  70. package/dev/markdown.test.tsx +0 -133
  71. package/dev/markdown.tsx +0 -124
  72. package/dev/mentions.tsx +0 -319
  73. package/dev/shadcn.css +0 -649
  74. package/dev/styles.css +0 -107
  75. package/dev/tags.tsx +0 -44
  76. package/dev/tasks.tsx +0 -151
  77. package/dev/todos.tsx +0 -759
  78. package/dev.test.ts +0 -439
  79. package/dev.ts +0 -386
  80. package/e2e.test.ts +0 -256
  81. package/escalate.test.ts +0 -76
  82. package/escalate.ts +0 -112
  83. package/exit.test.ts +0 -71
  84. package/exit.ts +0 -74
  85. package/git.ts +0 -294
  86. package/index.ts +0 -9
  87. package/journal.ts +0 -89
  88. package/list.test.ts +0 -82
  89. package/list.ts +0 -137
  90. package/load-config.test.ts +0 -121
  91. package/load-config.ts +0 -62
  92. package/log.test.ts +0 -65
  93. package/log.ts +0 -40
  94. package/merge.test.ts +0 -332
  95. package/merge.ts +0 -283
  96. package/path.test.ts +0 -35
  97. package/path.ts +0 -38
  98. package/plan.test.ts +0 -318
  99. package/plan.ts +0 -302
  100. package/progress.test.ts +0 -54
  101. package/progress.ts +0 -31
  102. package/remove.test.ts +0 -68
  103. package/remove.ts +0 -88
  104. package/repository.ts +0 -83
  105. package/retry.test.ts +0 -43
  106. package/retry.ts +0 -48
  107. package/shell.ts +0 -44
  108. package/show.test.ts +0 -91
  109. package/show.ts +0 -151
  110. package/snapshot.test.ts +0 -33
  111. package/snapshot.ts +0 -84
  112. package/table.ts +0 -20
  113. package/testing.ts +0 -229
  114. package/todo.test.ts +0 -490
  115. package/todo.ts +0 -834
  116. package/wait.test.ts +0 -61
  117. package/wait.ts +0 -77
  118. package/worktree-service.test.ts +0 -158
  119. package/worktree-service.ts +0 -193
  120. package/worktree.ts +0 -239
package/todo.ts DELETED
@@ -1,834 +0,0 @@
1
- import { basename } from "node:path";
2
- import { type IdProvider, UuidProvider } from "webappwiz/id";
3
- import { color, type Logger } from "webappwiz/log";
4
- import { type Fs, type Lock, NodeFs, type Ps } from "webappwiz/system";
5
- import { age } from "./age";
6
- import { type Attachment, Attachments, readFiles } from "./attachments";
7
- import { fail } from "./exit";
8
- import { table } from "./table";
9
-
10
- /** What a todo is on disk: one file apiece, so two agents never rewrite one. */
11
- export interface TodoState {
12
- id: number;
13
- /** What is left to do, in a line. */
14
- subject: string;
15
- /** Whatever more there is to say about it, or empty when the line is enough. */
16
- text: string;
17
- /** Where it stands in the list, 1 at the top: the one to pick up first. */
18
- position: number;
19
- /** The task it came up in, or null when a person added it from the main tree. */
20
- from: string | null;
21
- createdAt: string;
22
- /** The task working on it now, or null while it waits to be picked up. */
23
- takenBy: string | null;
24
- /** Absolute paths of the files attached, in `todos/<id>/`. */
25
- files: string[];
26
- /** What it belongs to, sorted: the areas or goals it adds up to with others. */
27
- tags: string[];
28
- }
29
-
30
- /** A tag in use, with how many todos have it. */
31
- export interface TagState {
32
- tag: string;
33
- todos: number;
34
- }
35
-
36
- /** A lowercase word or a few joined by hyphens, so `Dark Mode` and `dark-mode` cannot both exist. */
37
- const TAG = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
38
-
39
- /** `tags` deduped and sorted, refusing any not written the one way. */
40
- export function tagList(tags: string[]): string[] {
41
- const bad = tags.find((tag) => !TAG.test(tag));
42
- if (bad !== undefined) {
43
- fail(
44
- "usage",
45
- `'${bad}' is not a tag: use one lowercase word, or a few joined by hyphens, like uploads or dark-mode`,
46
- { tag: bad },
47
- );
48
- }
49
- return [...new Set(tags)].sort();
50
- }
51
-
52
- /** A todo's words, place or files changed: new ones added, some of the old kept. */
53
- export interface TodoChange {
54
- /** A new line; the old one stays when this is absent. */
55
- subject?: string;
56
- /** New detail, empty for none; the old stays when this is absent. */
57
- text?: string;
58
- /** Where to move it in the list; it stays put when this is absent. */
59
- position?: number;
60
- /** Files to add. */
61
- files?: Attachment[];
62
- /** The paths of the files it has now to keep; absent keeps them all. */
63
- keep?: string[];
64
- /** Its tags from now on; the old stay when this is absent. */
65
- tags?: string[];
66
- }
67
-
68
- /** What a new todo has besides its subject, all of it optional. */
69
- export interface TodoNew {
70
- /** Whatever more there is to say about it. */
71
- text?: string;
72
- files?: Attachment[];
73
- /** Where it goes in the list, pushing those from there down; the bottom by default. */
74
- position?: number;
75
- tags?: string[];
76
- }
77
-
78
- /**
79
- * Work deferred for later: what an agent notes when something outside its
80
- * task comes up, so it can move on instead of growing the task. Kept under the
81
- * shared `.git`, so every worktree sees a new one at once, with nothing to
82
- * commit and nothing to merge.
83
- */
84
- export class Todo {
85
- constructor(
86
- private readonly todos: Todos,
87
- readonly state: TodoState,
88
- ) {}
89
-
90
- get id(): number {
91
- return this.state.id;
92
- }
93
-
94
- get subject(): string {
95
- return this.state.subject;
96
- }
97
-
98
- get text(): string {
99
- return this.state.text;
100
- }
101
-
102
- get position(): number {
103
- return this.state.position;
104
- }
105
-
106
- get from(): string | null {
107
- return this.state.from;
108
- }
109
-
110
- get takenBy(): string | null {
111
- return this.state.takenBy;
112
- }
113
-
114
- get createdAt(): Date {
115
- return new Date(this.state.createdAt);
116
- }
117
-
118
- /** How long it has waited, in milliseconds. */
119
- get waited(): number {
120
- return Date.now() - this.createdAt.getTime();
121
- }
122
-
123
- /** Marks it as the work of `task`, refusing one another task already has. */
124
- take(task: string): Promise<Todo> {
125
- return this.todos.revise(this.id, (state) => {
126
- if (state.takenBy !== null && state.takenBy !== task) {
127
- fail(
128
- "exists",
129
- `todo ${this.id} is already taken by '${state.takenBy}': pick another from \`arbor todo list\``,
130
- { todo: this.id, takenBy: state.takenBy },
131
- );
132
- }
133
- return { ...state, takenBy: task };
134
- });
135
- }
136
-
137
- /** Puts it back on the list, as when the task that took it is removed. */
138
- release(): Promise<Todo> {
139
- return this.todos.revise(this.id, (state) => ({
140
- ...state,
141
- takenBy: null,
142
- }));
143
- }
144
-
145
- get files(): string[] {
146
- return this.state.files;
147
- }
148
-
149
- get tags(): string[] {
150
- return this.state.tags;
151
- }
152
-
153
- get attachments(): Attachments {
154
- return this.todos.attachments(this.id);
155
- }
156
-
157
- /**
158
- * Says what is left to do in other words, with other files, or higher or
159
- * lower in the list, keeping its id and history.
160
- */
161
- async update({
162
- subject,
163
- text,
164
- position,
165
- files = [],
166
- keep,
167
- tags,
168
- }: TodoChange): Promise<Todo> {
169
- const words = wording(subject ?? this.subject, text ?? this.text);
170
- if (words.subject === "") {
171
- fail("usage", "a todo needs a subject: say what is left to do", {
172
- todo: this.id,
173
- });
174
- }
175
- const tagged = tags === undefined ? this.tags : tagList(tags);
176
- const kept =
177
- keep === undefined
178
- ? this.files
179
- : this.files.filter((path) => keep.includes(path));
180
- await this.attachments.remove(
181
- this.files.filter((path) => !kept.includes(path)),
182
- );
183
- const added = await this.attachments.store(files);
184
- const updated = await this.todos.revise(this.id, (state) => ({
185
- ...state,
186
- ...words,
187
- files: [...kept, ...added],
188
- tags: tagged,
189
- }));
190
- return position === undefined
191
- ? updated
192
- : this.todos.move(this.id, position);
193
- }
194
-
195
- async remove(): Promise<void> {
196
- await this.todos.delete(this.id);
197
- await this.attachments.clear();
198
- }
199
- }
200
-
201
- /** What `Todos` is stored through; the real filesystem by default. */
202
- export interface TodosOptions {
203
- fs?: Fs;
204
- /** Names stored files apart; a test counts. */
205
- ids?: IdProvider;
206
- }
207
-
208
- /** Every todo in the repo, one JSON file each under `.git/arbor/todos`. */
209
- export class Todos {
210
- private readonly fs: Fs;
211
- private readonly ids: IdProvider;
212
-
213
- constructor(
214
- readonly dir: string,
215
- /**
216
- * Held while numbering a new todo, so two agents never share an id, and
217
- * while writing one, so a move renumbering the rest never loses to a
218
- * write of a position it just changed.
219
- */
220
- private readonly lock: Lock,
221
- opts: TodosOptions = {},
222
- ) {
223
- this.fs = opts.fs ?? new NodeFs();
224
- this.ids = opts.ids ?? new UuidProvider();
225
- }
226
-
227
- /** Where a todo's files live, beside its record. */
228
- attachments(id: number): Attachments {
229
- return new Attachments(`${this.dir}/${id}`, this.fs, this.ids);
230
- }
231
-
232
- /** Whether `path` is a file some todo holds, and not a way out of here. */
233
- owns(path: string): boolean {
234
- return new Attachments(this.dir, this.fs).owns(path);
235
- }
236
-
237
- async add(
238
- subject: string,
239
- from: string | null,
240
- { text = "", files = [], position, tags = [] }: TodoNew = {},
241
- ): Promise<Todo> {
242
- const words = wording(subject, text);
243
- if (words.subject === "") {
244
- fail("usage", "a todo needs a subject: say what is left to do", {});
245
- }
246
- const tagged = tagList(tags);
247
- await this.fs.mkdir(this.dir);
248
- return this.locked(async () => {
249
- // Numbered past every id handed out before, removed ones included, so
250
- // a number never comes back meaning something else.
251
- const id = (await this.lastId()) + 1;
252
- await this.fs.write(`${this.dir}/last`, String(id));
253
- const todo = await this.save({
254
- id,
255
- ...words,
256
- position: 0,
257
- from,
258
- createdAt: new Date().toISOString(),
259
- takenBy: null,
260
- files: await this.attachments(id).store(files),
261
- tags: tagged,
262
- });
263
- return this.place(todo.state, position);
264
- });
265
- }
266
-
267
- /**
268
- * Top of the list first. A file that will not parse is skipped rather than
269
- * fatal, and positions are counted again from 1 as they are read, so a
270
- * gap or a tie left by a crash, or a todo saved before they had any (it
271
- * goes below those that do, oldest first), never shows.
272
- */
273
- async all(): Promise<Todo[]> {
274
- return (await this.stored()).map(
275
- (state, i) => new Todo(this, { ...state, position: i + 1 }),
276
- );
277
- }
278
-
279
- async find(id: number): Promise<Todo> {
280
- return (await this.all()).find((todo) => todo.id === id) ?? missing(id);
281
- }
282
-
283
- /** Every tag a todo on the list has, by name, with how many have it. */
284
- async tags(): Promise<TagState[]> {
285
- const counts = new Map<string, number>();
286
- for (const todo of await this.all()) {
287
- for (const tag of todo.tags) {
288
- counts.set(tag, (counts.get(tag) ?? 0) + 1);
289
- }
290
- }
291
- return [...counts]
292
- .map(([tag, todos]) => ({ tag, todos }))
293
- .sort((left, right) => left.tag.localeCompare(right.tag));
294
- }
295
-
296
- /** The todos `task` has taken; what a merge or remove settles. */
297
- async takenBy(task: string): Promise<Todo[]> {
298
- return (await this.all()).filter((todo) => todo.takenBy === task);
299
- }
300
-
301
- /**
302
- * Puts a todo at `position`, 1 at the top, past the bottom meaning the
303
- * bottom, and numbers the rest around it.
304
- */
305
- async move(id: number, position: number): Promise<Todo> {
306
- return this.locked(async () => this.place(await this.state(id), position));
307
- }
308
-
309
- /**
310
- * @internal Rewrites one todo as it stands on disk now, under the lock, so
311
- * a change to its words or who has it never undoes a move made meanwhile.
312
- */
313
- async revise(
314
- id: number,
315
- change: (state: TodoState) => TodoState,
316
- ): Promise<Todo> {
317
- return this.locked(async () => this.save(change(await this.state(id))));
318
- }
319
-
320
- /** @internal Writes one todo. Rename makes the swap atomic for readers. */
321
- async save(state: TodoState): Promise<Todo> {
322
- const path = this.path(state.id);
323
- const tmp = `${path}.${crypto.randomUUID()}.tmp`;
324
- await this.fs.write(tmp, `${JSON.stringify(state, null, "\t")}\n`);
325
- await this.fs.rename(tmp, path);
326
- return new Todo(this, state);
327
- }
328
-
329
- /** @internal Removes one, moving those below it up to close the gap. */
330
- async delete(id: number): Promise<void> {
331
- await this.locked(async () => {
332
- await this.fs.rm(this.path(id), { force: true });
333
- await this.renumber(await this.stored());
334
- });
335
- }
336
-
337
- private async locked<T>(run: () => Promise<T>): Promise<T> {
338
- await this.lock.acquire();
339
- try {
340
- return await run();
341
- } finally {
342
- await this.lock.release();
343
- }
344
- }
345
-
346
- /** One todo as it stands on disk; call it under the lock. */
347
- private async state(id: number): Promise<TodoState> {
348
- const state = await this.read(this.path(id));
349
- return state ?? missing(id);
350
- }
351
-
352
- /**
353
- * Slots `state` in at `position` among the rest and saves whichever moved.
354
- * Call it under the lock.
355
- */
356
- private async place(
357
- state: TodoState,
358
- position: number | undefined,
359
- ): Promise<Todo> {
360
- if (
361
- position !== undefined &&
362
- (!Number.isInteger(position) || position <= 0)
363
- ) {
364
- fail("usage", `'${position}' is not a position: 1 is the top`, {
365
- todo: state.id,
366
- position,
367
- });
368
- }
369
- const rest = (await this.stored()).filter((other) => other.id !== state.id);
370
- const at = Math.min((position ?? Infinity) - 1, rest.length);
371
- const order = [...rest.slice(0, at), state, ...rest.slice(at)];
372
- await this.renumber(order);
373
- return this.find(state.id);
374
- }
375
-
376
- /** Every todo in list order, with the positions it has on disk. */
377
- private async stored(): Promise<TodoState[]> {
378
- const entries = await this.fs.readdir(this.dir).catch(() => []);
379
- const states: TodoState[] = [];
380
- for (const entry of entries.filter((entry) => entry.endsWith(".json"))) {
381
- const state = await this.read(`${this.dir}/${entry}`);
382
- if (state) {
383
- states.push(state);
384
- }
385
- }
386
- return states.sort(
387
- (left, right) =>
388
- (left.position || Infinity) - (right.position || Infinity) ||
389
- left.id - right.id,
390
- );
391
- }
392
-
393
- /** Saves each todo in `order` whose position on disk is not its place there. */
394
- private async renumber(order: TodoState[]): Promise<void> {
395
- for (const [i, state] of order.entries()) {
396
- if (state.position !== i + 1) {
397
- await this.save({ ...state, position: i + 1 });
398
- }
399
- }
400
- }
401
-
402
- private path(id: number): string {
403
- return `${this.dir}/${id}.json`;
404
- }
405
-
406
- private async lastId(): Promise<number> {
407
- const last = Number(await this.fs.read(`${this.dir}/last`).catch(() => 0));
408
- const ids = (await this.all()).map((todo) => todo.id);
409
- return Math.max(Number.isInteger(last) ? last : 0, ...ids);
410
- }
411
-
412
- private async read(path: string): Promise<TodoState | null> {
413
- const raw = await this.fs.read(path).catch(() => null);
414
- if (raw === null) {
415
- return null;
416
- }
417
- try {
418
- // One saved before todos took files has none, rather than no list;
419
- // one saved before they had subjects has its first line as one; and
420
- // one saved before they had positions has 0 until `all` places it;
421
- // one saved before tags has none.
422
- const state = JSON.parse(raw) as Omit<
423
- TodoState,
424
- "files" | "subject" | "position" | "tags"
425
- > & {
426
- files?: string[];
427
- tags?: string[];
428
- subject?: string;
429
- position?: number;
430
- };
431
- return {
432
- ...state,
433
- ...(state.subject === undefined
434
- ? wording(state.text, "")
435
- : { subject: state.subject, text: state.text }),
436
- position: state.position ?? 0,
437
- files: state.files ?? [],
438
- tags: state.tags ?? [],
439
- };
440
- } catch {
441
- return null;
442
- }
443
- }
444
- }
445
-
446
- function missing(id: number): never {
447
- fail("not_found", `no todo ${id}: run \`arbor todo list\``, { todo: id });
448
- }
449
-
450
- /**
451
- * A subject and its detail, trimmed. A subject running past one line keeps
452
- * only the first, the rest leading the detail, so the list stays one line a
453
- * todo however it was written.
454
- */
455
- function wording(
456
- subject: string,
457
- text: string,
458
- ): { subject: string; text: string } {
459
- const [first = "", ...rest] = subject.trim().split("\n");
460
- return {
461
- subject: first.trim(),
462
- text: [rest.join("\n").trim(), text.trim()].filter(Boolean).join("\n\n"),
463
- };
464
- }
465
-
466
- /** What a finished task should be followed by, and what should just go. */
467
- export interface Recommendation {
468
- /** The todo to pick up next, or null when there is nothing fresh left. */
469
- next: Todo | null;
470
- /** Todos left waiting so long they probably no longer apply. */
471
- stale: Todo[];
472
- }
473
-
474
- /**
475
- * Which todo to take up after `task`: one that came up in it first, since
476
- * whoever just finished it knows that context best, then one sharing a tag
477
- * in `settled`, those of the todos it finished, the same area of work, then the open one highest
478
- * on the list, which is how whoever keeps the list says what matters most.
479
- * Todos past `staleness` are never recommended, only offered for removal.
480
- */
481
- export async function recommend(
482
- todos: Todos,
483
- task: string | null,
484
- staleness: number,
485
- settled: string[] = [],
486
- ): Promise<Recommendation> {
487
- const rank = (todo: Todo) =>
488
- todo.from === task
489
- ? 2
490
- : todo.tags.some((tag) => settled.includes(tag))
491
- ? 1
492
- : 0;
493
- const open = (await todos.all())
494
- .filter((todo) => todo.takenBy === null)
495
- // Stable, so each group keeps its place in the list.
496
- .sort((left, right) => rank(right) - rank(left));
497
- return {
498
- next: open.find((todo) => todo.waited <= staleness) ?? null,
499
- stale: open.filter((todo) => todo.waited > staleness),
500
- };
501
- }
502
-
503
- /** The lines `merge` ends with, or none when there is nothing to say. */
504
- export function recommendation({ next, stale }: Recommendation): string[] {
505
- const lines: string[] = [];
506
- if (next) {
507
- lines.push(
508
- "",
509
- `${color.bold("next todo")} ${next.id}: ${next.subject}`,
510
- ` ${from(next)}, waiting ${age(next.state.createdAt)}`,
511
- ` start it: arbor add <task> --todo ${next.id}`,
512
- );
513
- }
514
- if (stale.length > 0) {
515
- lines.push(
516
- "",
517
- color.yellow("stale todos, remove unless they still apply:"),
518
- );
519
- for (const todo of stale) {
520
- lines.push(
521
- ` ${todo.id}: ${todo.subject} (${age(todo.state.createdAt)}) arbor todo remove ${todo.id}`,
522
- );
523
- }
524
- }
525
- return lines;
526
- }
527
-
528
- function from(todo: Todo): string {
529
- return todo.from === null ? "added by hand" : `from ${todo.from}`;
530
- }
531
-
532
- export interface TodoListOptions {
533
- /** Print the todos as JSON instead of a table. */
534
- json?: boolean;
535
- /** Only those no task has taken: the ones free to pick up. */
536
- open?: boolean;
537
- /** Only those with this tag. */
538
- tag?: string;
539
- }
540
-
541
- export interface TodoFileOptions {
542
- /** Files to attach, relative to the current directory or absolute. */
543
- files?: string[];
544
- }
545
-
546
- export interface TodoAddOptions extends TodoFileOptions {
547
- /** Whatever more there is to say than the subject. */
548
- text?: string;
549
- /** Where it goes in the list, 1 at the top; the bottom by default. */
550
- position?: number;
551
- tags?: string[];
552
- }
553
-
554
- export async function todoAdd(
555
- deps: { todos: Todos; log: Logger; fs: Fs; ps: Ps },
556
- subject: string,
557
- from: string | null,
558
- { text, files = [], position, tags }: TodoAddOptions = {},
559
- ): Promise<Todo> {
560
- const todo = await deps.todos.add(subject, from, {
561
- text,
562
- files: await readFiles(deps, files),
563
- position,
564
- tags,
565
- });
566
- deps.log.info(
567
- [
568
- `${color.green("added")} todo ${todo.id} at position ${todo.position}`,
569
- ...todo.files.map((path) => ` ${path}`),
570
- ` start it: arbor add <task> --todo ${todo.id}`,
571
- ].join("\n"),
572
- );
573
- return todo;
574
- }
575
-
576
- export async function todoList(
577
- { todos, log }: { todos: Todos; log: Logger },
578
- { json = false, open = false, tag }: TodoListOptions = {},
579
- ): Promise<void> {
580
- const every = await todos.all();
581
- const listed = every.filter(
582
- (todo) =>
583
- (!open || todo.takenBy === null) &&
584
- (tag === undefined || todo.tags.includes(tag)),
585
- );
586
- if (json) {
587
- log.info(
588
- JSON.stringify(
589
- listed.map((todo) => todo.state),
590
- null,
591
- "\t",
592
- ),
593
- );
594
- return;
595
- }
596
- if (every.length === 0) {
597
- log.info("no todos: `arbor todo add <subject>` defers work for later");
598
- return;
599
- }
600
- if (listed.length === 0) {
601
- log.info(
602
- tag === undefined
603
- ? "no open todos: every one is taken, see `arbor todo list`"
604
- : `no ${open ? "open " : ""}todos tagged ${tag}: see \`arbor todo tags\``,
605
- );
606
- return;
607
- }
608
- log.info(
609
- table(
610
- ["POS", "ID", "SUBJECT", "TAGS", "FROM", "AGE", "TAKEN BY", "FILES"],
611
- listed.map((todo) => [
612
- String(todo.position),
613
- String(todo.id),
614
- todo.subject,
615
- todo.tags.join(","),
616
- todo.from ?? "",
617
- age(todo.state.createdAt),
618
- todo.takenBy ?? "",
619
- todo.files.length === 0 ? "" : String(todo.files.length),
620
- ]),
621
- ),
622
- );
623
- }
624
-
625
- export interface TodoShowOptions {
626
- /** Print the todo as JSON instead of prose. */
627
- json?: boolean;
628
- }
629
-
630
- /** One todo in full: what `todo list` shows of it, its files, and its detail. */
631
- export async function todoShow(
632
- { todos, log }: { todos: Todos; log: Logger },
633
- id: number,
634
- { json = false }: TodoShowOptions = {},
635
- ): Promise<void> {
636
- const todo = await todos.find(id);
637
- if (json) {
638
- log.info(JSON.stringify(todo.state, null, "\t"));
639
- return;
640
- }
641
- const lines = [
642
- `${color.bold(`todo ${todo.id}`)} ${todo.subject}`,
643
- ` position: ${todo.position}`,
644
- ` from: ${todo.from ?? "added by hand"}`,
645
- ` taken by: ${todo.takenBy ?? "nobody yet"}`,
646
- ...(todo.tags.length === 0 ? [] : [` tags: ${todo.tags.join(", ")}`]),
647
- ` age: ${age(todo.state.createdAt)}`,
648
- ...todo.files.map((path) => ` file: ${path}`),
649
- ];
650
- if (todo.text !== "") {
651
- lines.push("", todo.text);
652
- }
653
- log.info(lines.join("\n"));
654
- }
655
-
656
- export interface TodoUpdateOptions extends TodoFileOptions {
657
- /** A new line; the old one stays when this is absent or empty. */
658
- subject?: string;
659
- /** New detail; the old stays when this is absent or empty. */
660
- text?: string;
661
- /** Where to move it in the list, 1 at the top; it stays put when absent. */
662
- position?: number;
663
- /** Attached files to drop, by path or by the name they were stored under. */
664
- removeFiles?: string[];
665
- /** Tags to add. */
666
- tags?: string[];
667
- /** Tags to drop. */
668
- removeTags?: string[];
669
- }
670
-
671
- export async function todoUpdate(
672
- deps: { todos: Todos; log: Logger; fs: Fs; ps: Ps },
673
- id: number,
674
- {
675
- subject,
676
- text,
677
- position,
678
- files = [],
679
- removeFiles = [],
680
- tags = [],
681
- removeTags = [],
682
- }: TodoUpdateOptions = {},
683
- ): Promise<Todo> {
684
- const todo = await deps.todos.find(id);
685
- const unknownTag = removeTags.find((tag) => !todo.tags.includes(tag));
686
- if (unknownTag !== undefined) {
687
- fail(
688
- "not_found",
689
- `todo ${id} has no tag '${unknownTag}': nothing was changed`,
690
- { todo: id, tag: unknownTag },
691
- );
692
- }
693
- const matches = (path: string, named: string) =>
694
- path === named || basename(path) === named;
695
- const unknown = removeFiles.find(
696
- (named) => !todo.files.some((path) => matches(path, named)),
697
- );
698
- if (unknown !== undefined) {
699
- fail(
700
- "not_found",
701
- `todo ${id} has no file '${unknown}': nothing was changed`,
702
- { todo: id, file: unknown },
703
- );
704
- }
705
- const dropped = todo.files.filter((path) =>
706
- removeFiles.some((named) => matches(path, named)),
707
- );
708
- const updated = await todo.update({
709
- subject: subject || undefined,
710
- text: text || undefined,
711
- position,
712
- files: await readFiles(deps, files),
713
- keep: todo.files.filter((path) => !dropped.includes(path)),
714
- tags: [...todo.tags, ...tags].filter((tag) => !removeTags.includes(tag)),
715
- });
716
- deps.log.info(
717
- [
718
- `${color.green("updated")} todo ${id} at position ${updated.position}: ${updated.subject}`,
719
- ...updated.files.map((path) => ` ${path}`),
720
- ].join("\n"),
721
- );
722
- return updated;
723
- }
724
-
725
- /**
726
- * Takes up todos for `task` after it started, as when one turns out to be
727
- * part of the work. Every id is checked before any is taken, so one that is
728
- * gone or another task's refuses them all.
729
- */
730
- export async function todoTake(
731
- { todos, log }: { todos: Todos; log: Logger },
732
- ids: number[],
733
- task: string | null,
734
- ): Promise<Todo[]> {
735
- if (task === null) {
736
- fail(
737
- "usage",
738
- "take todos from the worktree of the task they belong to, or start one with `arbor add <task> --todo <id>`",
739
- { todos: ids },
740
- );
741
- }
742
- const found = await findAll(todos, ids);
743
- for (const todo of found) {
744
- if (todo.takenBy !== null && todo.takenBy !== task) {
745
- await todo.take(task); // refuses, naming the task that has it
746
- }
747
- }
748
- const taken = await Promise.all(found.map((todo) => todo.take(task)));
749
- log.info(
750
- [
751
- ...taken.map(
752
- (todo) => `${color.green("took")} todo ${todo.id}: ${todo.subject}`,
753
- ),
754
- ` add ${taken.length === 1 ? "it" : "them"} to the Goal in ARBOR.md; merging removes ${taken.length === 1 ? "it" : "them"}`,
755
- ].join("\n"),
756
- );
757
- return taken;
758
- }
759
-
760
- /**
761
- * Puts todos back on the list, as when a task is landing without finishing
762
- * them: say what is left with `arbor todo update` first. From a worktree it
763
- * gives back only its own task's todos.
764
- */
765
- export async function todoRelease(
766
- { todos, log }: { todos: Todos; log: Logger },
767
- ids: number[],
768
- task: string | null,
769
- ): Promise<Todo[]> {
770
- const found = await findAll(todos, ids);
771
- const other = found.find(
772
- (todo) => task !== null && todo.takenBy !== null && todo.takenBy !== task,
773
- );
774
- if (other) {
775
- fail(
776
- "exists",
777
- `todo ${other.id} is taken by '${other.takenBy}', not '${task}': nothing was released`,
778
- { todo: other.id, takenBy: other.takenBy },
779
- );
780
- }
781
- const released = await Promise.all(found.map((todo) => todo.release()));
782
- log.info(
783
- released
784
- .map(
785
- (todo) => `${color.green("released")} todo ${todo.id}: ${todo.subject}`,
786
- )
787
- .join("\n"),
788
- );
789
- return released;
790
- }
791
-
792
- /** Every todo in `ids`, once each, refusing the lot if one is missing. */
793
- async function findAll(todos: Todos, ids: number[]): Promise<Todo[]> {
794
- if (ids.length === 0) {
795
- fail("usage", "name at least one todo id: run `arbor todo list`", {});
796
- }
797
- return Promise.all([...new Set(ids)].map((id) => todos.find(id)));
798
- }
799
-
800
- export async function todoRemove(
801
- { todos, log }: { todos: Todos; log: Logger },
802
- id: number,
803
- ): Promise<void> {
804
- const todo = await todos.find(id);
805
- await todo.remove();
806
- log.info(`${color.green("removed")} todo ${id}: ${todo.subject}`);
807
- }
808
-
809
- export interface TodoTagsOptions {
810
- /** Print the tags as JSON instead of a table. */
811
- json?: boolean;
812
- }
813
-
814
- /** Every tag in use, with how many todos have it: the names to reuse first. */
815
- export async function todoTags(
816
- { todos, log }: { todos: Todos; log: Logger },
817
- { json = false }: TodoTagsOptions = {},
818
- ): Promise<void> {
819
- const tags = await todos.tags();
820
- if (json) {
821
- log.info(JSON.stringify(tags, null, "\t"));
822
- return;
823
- }
824
- if (tags.length === 0) {
825
- log.info("no tags: `arbor todo add <subject> --tag <tag>` adds one");
826
- return;
827
- }
828
- log.info(
829
- table(
830
- ["TAG", "TODOS"],
831
- tags.map(({ tag, todos }) => [tag, String(todos)]),
832
- ),
833
- );
834
- }