@nickmeriano/task 0.7.0 → 0.8.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/README.md +12 -7
- package/dist/check.d.ts +42 -0
- package/dist/check.d.ts.map +1 -0
- package/dist/check.js +364 -0
- package/dist/check.js.map +1 -0
- package/dist/check.test.d.ts +9 -0
- package/dist/check.test.d.ts.map +1 -0
- package/dist/check.test.js +209 -0
- package/dist/check.test.js.map +1 -0
- package/dist/claim.d.ts +56 -3
- package/dist/claim.d.ts.map +1 -1
- package/dist/claim.js +164 -9
- package/dist/claim.js.map +1 -1
- package/dist/claim.test.js +168 -14
- package/dist/claim.test.js.map +1 -1
- package/dist/cli.js +504 -93
- package/dist/cli.js.map +1 -1
- package/dist/file-store.d.ts +41 -15
- package/dist/file-store.d.ts.map +1 -1
- package/dist/file-store.js +197 -99
- package/dist/file-store.js.map +1 -1
- package/dist/index.d.ts +5 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -3
- package/dist/index.js.map +1 -1
- package/dist/overview.d.ts +43 -0
- package/dist/overview.d.ts.map +1 -0
- package/dist/overview.js +53 -0
- package/dist/overview.js.map +1 -0
- package/dist/overview.test.d.ts +8 -0
- package/dist/overview.test.d.ts.map +1 -0
- package/dist/overview.test.js +47 -0
- package/dist/overview.test.js.map +1 -0
- package/dist/promote.test.d.ts +14 -0
- package/dist/promote.test.d.ts.map +1 -0
- package/dist/promote.test.js +106 -0
- package/dist/promote.test.js.map +1 -0
- package/dist/search.d.ts +32 -0
- package/dist/search.d.ts.map +1 -0
- package/dist/search.js +66 -0
- package/dist/search.js.map +1 -0
- package/dist/search.test.d.ts +2 -0
- package/dist/search.test.d.ts.map +1 -0
- package/dist/search.test.js +53 -0
- package/dist/search.test.js.map +1 -0
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +10 -2
- package/dist/server.js.map +1 -1
- package/dist/store.d.ts +16 -50
- package/dist/store.d.ts.map +1 -1
- package/dist/store.js +0 -368
- package/dist/store.js.map +1 -1
- package/dist/store.test.d.ts +1 -2
- package/dist/store.test.d.ts.map +1 -1
- package/dist/store.test.js +77 -48
- package/dist/store.test.js.map +1 -1
- package/dist/ticket-doc.d.ts +55 -2
- package/dist/ticket-doc.d.ts.map +1 -1
- package/dist/ticket-doc.js +177 -7
- package/dist/ticket-doc.js.map +1 -1
- package/dist/types.d.ts +67 -14
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/package.json +1 -1
- package/skill/SKILL.md +97 -31
- package/src/check.test.ts +271 -0
- package/src/check.ts +454 -0
- package/src/claim.test.ts +202 -14
- package/src/claim.ts +194 -10
- package/src/cli.ts +510 -89
- package/src/file-store.ts +217 -111
- package/src/index.ts +4 -2
- package/src/overview.test.ts +51 -0
- package/src/overview.ts +90 -0
- package/src/promote.test.ts +131 -0
- package/src/search.test.ts +64 -0
- package/src/search.ts +91 -0
- package/src/server.ts +11 -2
- package/src/store.test.ts +89 -57
- package/src/store.ts +18 -431
- package/src/ticket-doc.ts +210 -10
- package/src/types.ts +71 -14
- package/ui/dist/assets/{index-oJzomUDL.js → index-BJmOsOdR.js} +76 -76
- package/ui/dist/assets/index-BoqQlqSU.css +1 -0
- package/ui/dist/index.html +2 -2
- package/ui/dist/assets/index-CXW8uT5f.css +0 -1
package/src/claim.test.ts
CHANGED
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
import assert from "node:assert/strict"
|
|
16
16
|
import { test } from "node:test"
|
|
17
17
|
import { execFile, spawnSync } from "node:child_process"
|
|
18
|
-
import { mkdtempSync, rmSync, writeFileSync } from "node:fs"
|
|
18
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"
|
|
19
19
|
import { readFileSync } from "node:fs"
|
|
20
20
|
import { tmpdir } from "node:os"
|
|
21
21
|
import { join } from "node:path"
|
|
@@ -69,7 +69,7 @@ interface Fixture {
|
|
|
69
69
|
* 2 "Ready two" todo
|
|
70
70
|
* 3 "Blocked" todo, blocked by 1
|
|
71
71
|
* 4 "For a person" todo, needs-human
|
|
72
|
-
* 5 "
|
|
72
|
+
* 5 "Still parked" backlog
|
|
73
73
|
* 6 "Jumped queue" todo, moved to the top of the column after creation —
|
|
74
74
|
* claimable order must be [6, 1, 2], proving position
|
|
75
75
|
* order beats number order.
|
|
@@ -90,11 +90,11 @@ function fixture(claimPrefix?: string): Fixture {
|
|
|
90
90
|
config.claimPrefix = claimPrefix
|
|
91
91
|
writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`)
|
|
92
92
|
}
|
|
93
|
-
store.create({ title: "Ready one" })
|
|
94
|
-
store.create({ title: "Ready two" })
|
|
95
|
-
store.create({ title: "Blocked", blockedBy: [1] })
|
|
96
|
-
store.create({ title: "For a person", needsHuman: true })
|
|
97
|
-
store.create({ title: "
|
|
93
|
+
store.create({ title: "Ready one", status: "todo" })
|
|
94
|
+
store.create({ title: "Ready two", status: "todo" })
|
|
95
|
+
store.create({ title: "Blocked", status: "todo", blockedBy: [1] })
|
|
96
|
+
store.create({ title: "For a person", status: "todo", needsHuman: true })
|
|
97
|
+
store.create({ title: "Still parked", status: "backlog" })
|
|
98
98
|
const jumper = store.create({ title: "Jumped queue", status: "backlog" })
|
|
99
99
|
// A status move without an explicit slot lands on top of the new column.
|
|
100
100
|
store.update(jumper.number, { status: "todo" })
|
|
@@ -151,12 +151,16 @@ test("unclaimable tickets exit 2, before anything touches origin", async () => {
|
|
|
151
151
|
assert.equal((await cli(dir, "claim", "99")).code, 2) // missing
|
|
152
152
|
assert.match((await cli(dir, "claim", "3")).stderr, /blocked by CLM-1/)
|
|
153
153
|
|
|
154
|
-
// A dirty tree refuses too — claiming switches branches.
|
|
155
|
-
|
|
154
|
+
// A dirty tree refuses too — claiming switches branches. Only *tracked*
|
|
155
|
+
// modifications count: untracked files ride a branch switch untouched
|
|
156
|
+
// (release deliberately leaves one — the reason comment).
|
|
157
|
+
const gitignore = join(dir, ".task", ".gitignore")
|
|
158
|
+
writeFileSync(gitignore, `${readFileSync(gitignore, "utf8")}\nwip\n`)
|
|
156
159
|
const dirty = await cli(dir, "claim", "2")
|
|
157
160
|
assert.equal(dirty.code, 2)
|
|
158
161
|
assert.match(dirty.stderr, /working tree is dirty/)
|
|
159
|
-
|
|
162
|
+
sh(dir, "git", "checkout", "--", ".task/.gitignore")
|
|
163
|
+
writeFileSync(join(dir, "scratch.txt"), "untracked is fine")
|
|
160
164
|
assert.equal((await cli(dir, "claim", "2")).code, 0)
|
|
161
165
|
|
|
162
166
|
// Nothing above created a stray claim branch for the failed ids.
|
|
@@ -189,7 +193,7 @@ test("release deletes the branch everywhere and reopens the claim", async () =>
|
|
|
189
193
|
assert.equal((await cli(dir, "claim", "2")).code, 0)
|
|
190
194
|
assert.equal((await cli(dir, "claim", "2")).code, 1) // locally claimed too
|
|
191
195
|
|
|
192
|
-
const released = await cli(dir, "claim", "--release", "2")
|
|
196
|
+
const released = await cli(dir, "claim", "--release", "2", "--comment", "trying another shape")
|
|
193
197
|
assert.equal(released.code, 0, released.stderr)
|
|
194
198
|
assert.match(released.stdout, /released CLM-2/)
|
|
195
199
|
|
|
@@ -203,7 +207,7 @@ test("release deletes the branch everywhere and reopens the claim", async () =>
|
|
|
203
207
|
assert.equal((await cli(dir, "claim", "2")).code, 0)
|
|
204
208
|
|
|
205
209
|
// Releasing an unclaimed ticket is a quiet no-op, not an error.
|
|
206
|
-
const noop = await cli(dir, "claim", "--release", "1")
|
|
210
|
+
const noop = await cli(dir, "claim", "--release", "1", "--comment", "noop")
|
|
207
211
|
assert.equal(noop.code, 0)
|
|
208
212
|
assert.match(noop.stdout, /wasn't claimed/)
|
|
209
213
|
})
|
|
@@ -226,7 +230,7 @@ test("a configured claimPrefix moves the whole namespace", async () => {
|
|
|
226
230
|
const { tasks } = JSON.parse(listed.stdout) as { tasks: { number: number }[] }
|
|
227
231
|
assert.deepEqual(tasks.map((t) => t.number), [6, 2])
|
|
228
232
|
assert.equal((await cli(other, "claim", "1")).code, 1)
|
|
229
|
-
assert.equal((await cli(dir, "claim", "--release", "1")).code, 0)
|
|
233
|
+
assert.equal((await cli(dir, "claim", "--release", "1", "--comment", "handing it back")).code, 0)
|
|
230
234
|
assert.equal(sh(dir, "git", "ls-remote", "--heads", "origin", "claude/task/*"), "")
|
|
231
235
|
|
|
232
236
|
// A missing trailing slash is forgiven; a malformed value fails loudly.
|
|
@@ -238,5 +242,189 @@ test("a configured claimPrefix moves the whole namespace", async () => {
|
|
|
238
242
|
sh(bad, "git", "commit", "--quiet", "-am", "bad prefix")
|
|
239
243
|
const invalid = await cli(bad, "claim", "2")
|
|
240
244
|
assert.equal(invalid.code, 2)
|
|
241
|
-
assert.match(invalid.stderr, /invalid
|
|
245
|
+
assert.match(invalid.stderr, /invalid claims\.branchPrefix/)
|
|
246
|
+
})
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* A monorepo with two boards for the multi-board selection paths (TAS-27):
|
|
250
|
+
* a root board (ROO, one ready ticket) and a nested one under pkg/ (NES,
|
|
251
|
+
* one ready ticket). The root config carries the new `claims` block and,
|
|
252
|
+
* optionally, the top-level `boards` list — NES deliberately ahead of ROO,
|
|
253
|
+
* so priority provably follows list order, not nearness.
|
|
254
|
+
*/
|
|
255
|
+
function multiFixture(options: { boards?: string[]; maxOpenCount?: number } = {}): Fixture {
|
|
256
|
+
const home = tempDir()
|
|
257
|
+
const bare = join(home, "origin.git")
|
|
258
|
+
sh(home, "git", "init", "--quiet", "--bare", "-b", "main", bare)
|
|
259
|
+
|
|
260
|
+
const seed = join(home, "seed")
|
|
261
|
+
sh(home, "git", "clone", "--quiet", bare, seed)
|
|
262
|
+
sh(seed, "git", "config", "user.name", "Test")
|
|
263
|
+
sh(seed, "git", "config", "user.email", "test@example.com")
|
|
264
|
+
|
|
265
|
+
const root = initProject(seed, { name: "root board", prefix: "ROO" })
|
|
266
|
+
const configPath = join(seed, ".task", "config.json")
|
|
267
|
+
const config = JSON.parse(readFileSync(configPath, "utf8")) as Record<string, unknown>
|
|
268
|
+
if (options.boards) config.boards = options.boards
|
|
269
|
+
config.claims = { branchPrefix: "task/claim/", maxOpenCount: options.maxOpenCount }
|
|
270
|
+
writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`)
|
|
271
|
+
root.create({ title: "Root ready", status: "todo" })
|
|
272
|
+
|
|
273
|
+
mkdirSync(join(seed, "pkg"), { recursive: true })
|
|
274
|
+
const nested = initProject(join(seed, "pkg"), { name: "nested board", prefix: "NES" })
|
|
275
|
+
nested.create({ title: "Nested ready", status: "todo" })
|
|
276
|
+
|
|
277
|
+
sh(seed, "git", "add", "-A")
|
|
278
|
+
sh(seed, "git", "commit", "--quiet", "-m", "boards")
|
|
279
|
+
sh(seed, "git", "push", "--quiet", "-u", "origin", "main")
|
|
280
|
+
|
|
281
|
+
let clones = 0
|
|
282
|
+
const clone = (): string => {
|
|
283
|
+
const dir = join(home, `clone-${++clones}`)
|
|
284
|
+
sh(home, "git", "clone", "--quiet", bare, dir)
|
|
285
|
+
sh(dir, "git", "config", "user.name", "Test")
|
|
286
|
+
sh(dir, "git", "config", "user.email", "test@example.com")
|
|
287
|
+
return dir
|
|
288
|
+
}
|
|
289
|
+
return { bare, clone }
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
test("claim --next drains the queue in position order and exits 1 when empty", async () => {
|
|
293
|
+
const { clone } = fixture()
|
|
294
|
+
const dir = clone()
|
|
295
|
+
|
|
296
|
+
const order: number[] = []
|
|
297
|
+
for (;;) {
|
|
298
|
+
const result = await cli(dir, "claim", "--next", "--json")
|
|
299
|
+
if (result.code === 1) {
|
|
300
|
+
assert.equal(JSON.parse(result.stdout).task, null)
|
|
301
|
+
break
|
|
302
|
+
}
|
|
303
|
+
assert.equal(result.code, 0, result.stderr)
|
|
304
|
+
const { task, branch } = JSON.parse(result.stdout) as { task: { number: number }; branch: string }
|
|
305
|
+
order.push(task.number)
|
|
306
|
+
assert.equal(sh(dir, "git", "rev-parse", "--abbrev-ref", "HEAD"), branch)
|
|
307
|
+
}
|
|
308
|
+
// The jumper leads (position order), then creation order; blocked,
|
|
309
|
+
// needs-human and backlog tickets never surface.
|
|
310
|
+
assert.deepEqual(order, [6, 1, 2])
|
|
311
|
+
})
|
|
312
|
+
|
|
313
|
+
test("two concurrent claim --next calls land on different tickets", async () => {
|
|
314
|
+
const { clone } = fixture()
|
|
315
|
+
const a = clone()
|
|
316
|
+
const b = clone()
|
|
317
|
+
|
|
318
|
+
const [ra, rb] = await Promise.all([
|
|
319
|
+
cli(a, "claim", "--next", "--json"),
|
|
320
|
+
cli(b, "claim", "--next", "--json"),
|
|
321
|
+
])
|
|
322
|
+
assert.equal(ra.code, 0, ra.stderr)
|
|
323
|
+
assert.equal(rb.code, 0, rb.stderr)
|
|
324
|
+
const ids = [ra, rb].map((r) => (JSON.parse(r.stdout) as { task: { id: string } }).task.id)
|
|
325
|
+
assert.notEqual(ids[0], ids[1], `both claimed ${ids[0]}`)
|
|
326
|
+
})
|
|
327
|
+
|
|
328
|
+
test("--next walks the root boards list in priority order; --board overrides it", async () => {
|
|
329
|
+
const { clone } = multiFixture({ boards: ["NES", "ROO"] })
|
|
330
|
+
const a = clone()
|
|
331
|
+
|
|
332
|
+
// list --claimable reads the same scope, in the same order.
|
|
333
|
+
const listed = await cli(a, "list", "--claimable", "--json")
|
|
334
|
+
assert.equal(listed.code, 0, listed.stderr)
|
|
335
|
+
const { tasks } = JSON.parse(listed.stdout) as { tasks: { id: string }[] }
|
|
336
|
+
assert.deepEqual(tasks.map((t) => t.id), ["NES-1", "ROO-1"])
|
|
337
|
+
|
|
338
|
+
// The nested board wins despite the root board being nearest.
|
|
339
|
+
const next = await cli(a, "claim", "--next", "--json")
|
|
340
|
+
assert.equal(next.code, 0, next.stderr)
|
|
341
|
+
const claimed = JSON.parse(next.stdout) as { task: { id: string }; branch: string }
|
|
342
|
+
assert.equal(claimed.task.id, "NES-1")
|
|
343
|
+
assert.equal(claimed.branch, "task/claim/nes-1")
|
|
344
|
+
|
|
345
|
+
// An explicit --board list is the whole scope, in the given order.
|
|
346
|
+
const b = clone()
|
|
347
|
+
const overridden = await cli(b, "claim", "--next", "--board", "ROO", "--json")
|
|
348
|
+
assert.equal(overridden.code, 0, overridden.stderr)
|
|
349
|
+
assert.equal((JSON.parse(overridden.stdout) as { task: { id: string } }).task.id, "ROO-1")
|
|
350
|
+
|
|
351
|
+
const unknown = await cli(b, "claim", "--next", "--board", "NOPE")
|
|
352
|
+
assert.equal(unknown.code, 2)
|
|
353
|
+
assert.match(unknown.stderr, /no board with prefix NOPE/)
|
|
354
|
+
})
|
|
355
|
+
|
|
356
|
+
test("without a boards list, --next sees the nearest board only", async () => {
|
|
357
|
+
const { clone } = multiFixture()
|
|
358
|
+
const dir = clone()
|
|
359
|
+
|
|
360
|
+
const next = await cli(dir, "claim", "--next", "--json")
|
|
361
|
+
assert.equal(next.code, 0, next.stderr)
|
|
362
|
+
assert.equal((JSON.parse(next.stdout) as { task: { id: string } }).task.id, "ROO-1")
|
|
363
|
+
|
|
364
|
+
// ROO's queue is drained and NES never entered the scope.
|
|
365
|
+
const empty = await cli(dir, "claim", "--next", "--json")
|
|
366
|
+
assert.equal(empty.code, 1)
|
|
367
|
+
})
|
|
368
|
+
|
|
369
|
+
test("maxOpenCount caps every claim repo-wide; --force is the escape", async () => {
|
|
370
|
+
const { clone } = multiFixture({ boards: ["NES", "ROO"], maxOpenCount: 1 })
|
|
371
|
+
const a = clone()
|
|
372
|
+
assert.equal((await cli(a, "claim", "--next", "--json")).code, 0)
|
|
373
|
+
|
|
374
|
+
// At the cap, --next and explicit-id claims both refuse with exit 2 —
|
|
375
|
+
// the fix is to finish or release a claim, not to pick another ticket.
|
|
376
|
+
const b = clone()
|
|
377
|
+
const capped = await cli(b, "claim", "--next")
|
|
378
|
+
assert.equal(capped.code, 2)
|
|
379
|
+
assert.match(capped.stderr, /maxOpenCount cap \(1\)/)
|
|
380
|
+
const explicit = await cli(b, "claim", "ROO-1")
|
|
381
|
+
assert.equal(explicit.code, 2)
|
|
382
|
+
assert.match(explicit.stderr, /maxOpenCount cap \(1\)/)
|
|
383
|
+
|
|
384
|
+
const forced = await cli(b, "claim", "--next", "--force", "--json")
|
|
385
|
+
assert.equal(forced.code, 0, forced.stderr)
|
|
386
|
+
assert.equal((JSON.parse(forced.stdout) as { task: { id: string } }).task.id, "ROO-1")
|
|
387
|
+
})
|
|
388
|
+
|
|
389
|
+
test("release requires a reason and leaves it as a comment on the ticket", async () => {
|
|
390
|
+
const { clone } = fixture()
|
|
391
|
+
const dir = clone()
|
|
392
|
+
assert.equal((await cli(dir, "claim", "1")).code, 0)
|
|
393
|
+
|
|
394
|
+
// No comment, and an empty one, are both refused before anything happens.
|
|
395
|
+
const missing = await cli(dir, "claim", "--release", "1")
|
|
396
|
+
assert.equal(missing.code, 1)
|
|
397
|
+
assert.match(missing.stderr, /requires --comment/)
|
|
398
|
+
const empty = await cli(dir, "claim", "--release", "1", "--comment", "")
|
|
399
|
+
assert.equal(empty.code, 1)
|
|
400
|
+
assert.match(empty.stderr, /requires --comment/)
|
|
401
|
+
assert.match(
|
|
402
|
+
sh(dir, "git", "ls-remote", "--heads", "origin", "task/claim/clm-1"),
|
|
403
|
+
/clm-1/,
|
|
404
|
+
)
|
|
405
|
+
|
|
406
|
+
const released = await cli(
|
|
407
|
+
dir, "claim", "--release", "1",
|
|
408
|
+
"--comment", "the migration kept flaking — needs a fixture, not a retry",
|
|
409
|
+
"--author", "claude",
|
|
410
|
+
)
|
|
411
|
+
assert.equal(released.code, 0, released.stderr)
|
|
412
|
+
|
|
413
|
+
// The reason survives the branch: an ordinary attributed comment, sitting
|
|
414
|
+
// uncommitted in the working tree on the default branch.
|
|
415
|
+
const shown = await cli(dir, "show", "1", "--json")
|
|
416
|
+
const { comments } = JSON.parse(shown.stdout) as {
|
|
417
|
+
comments: { author: string; body: string }[]
|
|
418
|
+
}
|
|
419
|
+
assert.equal(comments.length, 1)
|
|
420
|
+
assert.equal(comments[0].author, "claude")
|
|
421
|
+
assert.match(comments[0].body, /kept flaking/)
|
|
422
|
+
assert.match(sh(dir, "git", "status", "--porcelain"), /tickets\/1\/comments/)
|
|
423
|
+
|
|
424
|
+
// Releasing an unclaimed ticket stays a no-op — no second comment.
|
|
425
|
+
const noop = await cli(dir, "claim", "--release", "1", "--comment", "again")
|
|
426
|
+
assert.equal(noop.code, 0)
|
|
427
|
+
assert.match(noop.stdout, /wasn't claimed/)
|
|
428
|
+
const after = await cli(dir, "show", "1", "--json")
|
|
429
|
+
assert.equal((JSON.parse(after.stdout) as { comments: unknown[] }).comments.length, 1)
|
|
242
430
|
})
|
package/src/claim.ts
CHANGED
|
@@ -12,8 +12,14 @@
|
|
|
12
12
|
|
|
13
13
|
import { spawnSync } from "node:child_process"
|
|
14
14
|
import { join } from "node:path"
|
|
15
|
-
import { FileStore, TICKETS_DIR } from "./file-store.ts"
|
|
16
|
-
import
|
|
15
|
+
import { FileStore, TICKETS_DIR, openBoard } from "./file-store.ts"
|
|
16
|
+
import {
|
|
17
|
+
boardConfig,
|
|
18
|
+
findBoardsByPrefix,
|
|
19
|
+
findRoot,
|
|
20
|
+
findScopeRoot,
|
|
21
|
+
type Store,
|
|
22
|
+
} from "./store.ts"
|
|
17
23
|
import type { ProjectConfig, Task } from "./types.ts"
|
|
18
24
|
|
|
19
25
|
/**
|
|
@@ -31,22 +37,48 @@ export const DEFAULT_CLAIM_PREFIX = "task/claim/"
|
|
|
31
37
|
const CLAIM_PREFIX_SHAPE = /^([A-Za-z0-9._-]+\/)+$/
|
|
32
38
|
|
|
33
39
|
/**
|
|
34
|
-
* The board's claim namespace
|
|
40
|
+
* The board's configured claim namespace before validation or defaulting —
|
|
41
|
+
* `claims.branchPrefix`, falling back to the pre-0.8 top-level `claimPrefix`
|
|
42
|
+
* (still read for one release so a version bump can't strand in-flight
|
|
43
|
+
* claims; `task check` nags, `--fix` migrates). Exported for `task check`,
|
|
44
|
+
* which wants the raw value to lint without the throw below.
|
|
45
|
+
*/
|
|
46
|
+
export function configuredBranchPrefix(config: ProjectConfig): string | undefined {
|
|
47
|
+
return config.claims?.branchPrefix ?? config.claimPrefix
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The board's claim namespace: configured branch prefix (a trailing slash is
|
|
35
52
|
* implied) or the default. Validated here because it becomes a git ref and an
|
|
36
53
|
* ls-remote glob — a malformed value must fail the claim, not corrupt it.
|
|
37
54
|
*/
|
|
38
55
|
export function claimNamespace(config: ProjectConfig): string {
|
|
39
|
-
const raw = config
|
|
56
|
+
const raw = configuredBranchPrefix(config) ?? DEFAULT_CLAIM_PREFIX
|
|
40
57
|
const prefix = raw.endsWith("/") ? raw : `${raw}/`
|
|
41
58
|
if (!CLAIM_PREFIX_SHAPE.test(prefix) || prefix.includes("..") || /(^|\/)\./.test(prefix)) {
|
|
42
59
|
throw new ClaimError(
|
|
43
|
-
`invalid
|
|
60
|
+
`invalid claims.branchPrefix in .task/config.json: ${JSON.stringify(raw)} — use slash-separated segments like "task/claim/" or "claude/task/"`,
|
|
44
61
|
"invalid",
|
|
45
62
|
)
|
|
46
63
|
}
|
|
47
64
|
return prefix
|
|
48
65
|
}
|
|
49
66
|
|
|
67
|
+
/**
|
|
68
|
+
* The repo-wide cap on open claims: `claims.maxOpenCount` read from the
|
|
69
|
+
* *root* board's config (the outermost `.task/` above `root`), never the
|
|
70
|
+
* nearest one — a WIP cap each board could set for itself is no cap at all.
|
|
71
|
+
* Absent, unparseable or nonsensical values all mean "no cap".
|
|
72
|
+
*/
|
|
73
|
+
export function openClaimCap(root: string): number | undefined {
|
|
74
|
+
try {
|
|
75
|
+
const cap = boardConfig(findScopeRoot(root)).claims?.maxOpenCount
|
|
76
|
+
return typeof cap === "number" && Number.isInteger(cap) && cap > 0 ? cap : undefined
|
|
77
|
+
} catch {
|
|
78
|
+
return undefined
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
50
82
|
/** { prefix: "TAS" }, 21 → "task/claim/tas-21" (or under the configured namespace). */
|
|
51
83
|
export function claimBranch(config: ProjectConfig, number: number): string {
|
|
52
84
|
return `${claimNamespace(config)}${config.prefix.toLowerCase()}-${number}`
|
|
@@ -151,6 +183,29 @@ export interface ClaimResult {
|
|
|
151
183
|
base: string
|
|
152
184
|
}
|
|
153
185
|
|
|
186
|
+
export interface ClaimOptions {
|
|
187
|
+
/** Claim past the root board's `claims.maxOpenCount` cap. */
|
|
188
|
+
force?: boolean
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* The WIP-cap gate every claim passes through: while `maxOpenCount` claim
|
|
193
|
+
* branches exist on origin, claiming more is refused (exit 2 — the fix is to
|
|
194
|
+
* finish or release one, not to pick a different ticket). Existence on origin
|
|
195
|
+
* is the definition of "open": a claim branch dies at merge or release, so a
|
|
196
|
+
* lingering merged branch counts — deliberate pressure to clean it up. The
|
|
197
|
+
* cap applies to humans and agents alike; `--force` is the escape.
|
|
198
|
+
*/
|
|
199
|
+
function assertUnderCap(openClaims: Set<string>, root: string, namespace: string): void {
|
|
200
|
+
const cap = openClaimCap(root)
|
|
201
|
+
if (cap !== undefined && openClaims.size >= cap) {
|
|
202
|
+
throw new ClaimError(
|
|
203
|
+
`${openClaims.size} claim branch${openClaims.size === 1 ? "" : "es"} already open under ${namespace} — at the root board's maxOpenCount cap (${cap}); finish or \`task claim --release\` one, or pass --force`,
|
|
204
|
+
"invalid",
|
|
205
|
+
)
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
154
209
|
/**
|
|
155
210
|
* Claim `number`: validate, branch off origin's default branch, flip the
|
|
156
211
|
* ticket to in_progress as the branch's first commit, and push. The push
|
|
@@ -160,9 +215,14 @@ export interface ClaimResult {
|
|
|
160
215
|
* someone pre-created without a claim commit can't be hijacked by a plain
|
|
161
216
|
* fast-forward. Leaves the winner checked out on the claim branch.
|
|
162
217
|
*/
|
|
163
|
-
export function claim(store: Store, number: number): ClaimResult {
|
|
218
|
+
export function claim(store: Store, number: number, options: ClaimOptions = {}): ClaimResult {
|
|
219
|
+
// Unreachable since openBoard refuses pre-0.6 boards, kept as the narrowing
|
|
220
|
+
// that lets the rest of this function assume text-canonical semantics.
|
|
164
221
|
if (!(store instanceof FileStore)) {
|
|
165
|
-
throw new ClaimError(
|
|
222
|
+
throw new ClaimError(
|
|
223
|
+
"claiming needs a text-format board — run `npx @nickmeriano/task@0.6 migrate` once",
|
|
224
|
+
"invalid",
|
|
225
|
+
)
|
|
166
226
|
}
|
|
167
227
|
const cwd = store.root
|
|
168
228
|
const branch = claimBranch(store.config, number)
|
|
@@ -171,7 +231,11 @@ export function claim(store: Store, number: number): ClaimResult {
|
|
|
171
231
|
// The local-branch check comes before ticket validation on purpose: on the
|
|
172
232
|
// claim branch itself the ticket reads in_progress, and "already claimed"
|
|
173
233
|
// (exit 1, move on) is the truthful answer there — not a validation failure.
|
|
174
|
-
|
|
234
|
+
// -uno everywhere a claim reads the tree: only *tracked* modifications block
|
|
235
|
+
// a branch switch. Untracked files ride along untouched — and the release
|
|
236
|
+
// flow deliberately leaves one (the reason comment), so counting them would
|
|
237
|
+
// wedge the release-then-claim-next loop this exists for.
|
|
238
|
+
const tree = git(cwd, "status", "--porcelain", "-uno")
|
|
175
239
|
if (tree.status !== 0) {
|
|
176
240
|
throw new ClaimError(`not a git repository: ${cwd}`, "invalid")
|
|
177
241
|
}
|
|
@@ -191,9 +255,15 @@ export function claim(store: Store, number: number): ClaimResult {
|
|
|
191
255
|
}
|
|
192
256
|
|
|
193
257
|
gitMust(cwd, "fetch", "--quiet", "origin")
|
|
194
|
-
|
|
258
|
+
// One ls-remote answers both gates: is this ticket taken, and is the repo
|
|
259
|
+
// at its WIP cap. "Already claimed" wins — that's exit 1 (pick the next
|
|
260
|
+
// ticket), where the cap is exit 2 (fix something).
|
|
261
|
+
const namespace = claimNamespace(store.config)
|
|
262
|
+
const openClaims = remoteClaims(cwd, namespace)
|
|
263
|
+
if (openClaims.has(branch)) {
|
|
195
264
|
throw new ClaimError(`${id} is already claimed — ${branch} exists on origin`, "claimed")
|
|
196
265
|
}
|
|
266
|
+
if (!options.force) assertUnderCap(openClaims, cwd, namespace)
|
|
197
267
|
|
|
198
268
|
const base = defaultBase(cwd)
|
|
199
269
|
// So a failed claim can put the checkout back exactly where it was —
|
|
@@ -263,7 +333,7 @@ export function release(store: Store, number: number): ReleaseResult {
|
|
|
263
333
|
const onBranch =
|
|
264
334
|
git(cwd, "symbolic-ref", "--quiet", "--short", "HEAD").stdout === branch
|
|
265
335
|
if (onBranch) {
|
|
266
|
-
const tree = gitMust(cwd, "status", "--porcelain")
|
|
336
|
+
const tree = gitMust(cwd, "status", "--porcelain", "-uno")
|
|
267
337
|
if (tree !== "") {
|
|
268
338
|
throw new ClaimError(
|
|
269
339
|
`working tree on ${branch} is dirty — commit elsewhere or discard before releasing`,
|
|
@@ -300,3 +370,117 @@ export function claimableTasks(store: Store): Task[] {
|
|
|
300
370
|
.filter((t) => openBlockers(store, t).length === 0)
|
|
301
371
|
.filter((t) => !claimed.has(claimBranch(store.config, t.number)))
|
|
302
372
|
}
|
|
373
|
+
|
|
374
|
+
export interface PromoteResult {
|
|
375
|
+
task: Task
|
|
376
|
+
/** Claim-time gates still in the way — promoted, but not claimable yet. */
|
|
377
|
+
holds: string[]
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* `task promote` — the human "build this" call, as a verb: `backlog` → `todo`.
|
|
382
|
+
* No quality gate: planning happens at claim time (the worker's plan tick),
|
|
383
|
+
* so a one-line backlog ticket is promotable. Blocked and needs-human are
|
|
384
|
+
* claim-time facts that `claim`/`--claimable` already enforce live, so
|
|
385
|
+
* promoting a blocked ticket is legitimate sequencing ("build this once
|
|
386
|
+
* NIC-11 lands") — reported as a hold, never refused. Writes the ticket like
|
|
387
|
+
* `update` does; committing is the caller's job.
|
|
388
|
+
*/
|
|
389
|
+
export function promote(store: Store, number: number): PromoteResult {
|
|
390
|
+
const task = store.get(number)
|
|
391
|
+
const id = store.displayId(number)
|
|
392
|
+
if (!task) throw new ClaimError(`no such task: ${id}`, "invalid")
|
|
393
|
+
if (task.status !== "backlog") {
|
|
394
|
+
throw new ClaimError(
|
|
395
|
+
`${id} is ${task.status} — only backlog tickets can be promoted`,
|
|
396
|
+
"invalid",
|
|
397
|
+
)
|
|
398
|
+
}
|
|
399
|
+
const updated = store.update(number, { status: "todo" })
|
|
400
|
+
const holds: string[] = []
|
|
401
|
+
if (updated.needsHuman) holds.push("flagged needs-human")
|
|
402
|
+
const blockers = openBlockers(store, updated)
|
|
403
|
+
if (blockers.length) holds.push(`blocked by ${blockers.join(", ")}`)
|
|
404
|
+
return { task: updated, holds }
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/**
|
|
408
|
+
* The boards `claim --next` and `list --claimable` select over, in priority
|
|
409
|
+
* order. Resolution: an explicit `--board` list > the root board's top-level
|
|
410
|
+
* `boards` key > the nearest board alone. The list is *selection scope*, not
|
|
411
|
+
* permission — `task claim <id>` works on any board regardless — and it's
|
|
412
|
+
* read from the repo's root board only, so a vendored board can't opt itself
|
|
413
|
+
* into a repo's automation by shipping a config.
|
|
414
|
+
*/
|
|
415
|
+
export function selectionBoards(cwd: string, prefixes?: string[]): Store[] {
|
|
416
|
+
const scope = findScopeRoot(cwd)
|
|
417
|
+
let wanted = prefixes?.length ? prefixes : undefined
|
|
418
|
+
if (!wanted) {
|
|
419
|
+
try {
|
|
420
|
+
const boards = boardConfig(scope).boards
|
|
421
|
+
if (Array.isArray(boards) && boards.length > 0) wanted = boards.map(String)
|
|
422
|
+
} catch {
|
|
423
|
+
// No readable root board — same answer as no boards list.
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
if (!wanted) {
|
|
427
|
+
const nearest = findRoot(cwd)
|
|
428
|
+
if (!nearest) {
|
|
429
|
+
throw new ClaimError(
|
|
430
|
+
"no .task directory found in this directory or any parent — run `task init` first",
|
|
431
|
+
"invalid",
|
|
432
|
+
)
|
|
433
|
+
}
|
|
434
|
+
return [openBoard(nearest)]
|
|
435
|
+
}
|
|
436
|
+
return wanted.map((prefix) => {
|
|
437
|
+
const matches = findBoardsByPrefix(scope, prefix)
|
|
438
|
+
if (matches.length === 1) return openBoard(matches[0].root)
|
|
439
|
+
throw new ClaimError(
|
|
440
|
+
matches.length === 0
|
|
441
|
+
? `no board with prefix ${prefix.toUpperCase()} in this repo — check --board and the root board's "boards" list`
|
|
442
|
+
: `prefix ${prefix.toUpperCase()} is ambiguous — boards at: ${matches.map((m) => m.id).join(", ")}`,
|
|
443
|
+
"invalid",
|
|
444
|
+
)
|
|
445
|
+
})
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/**
|
|
449
|
+
* `task claim --next`: walk the selection scope in priority order and claim
|
|
450
|
+
* the first ticket that sticks. Losing a race for one ticket (another worker
|
|
451
|
+
* pushed its branch first) means *try the next*, so every ClaimError inside
|
|
452
|
+
* the loop is a skip — which is why the repo-level preconditions (dirty tree,
|
|
453
|
+
* malformed prefix, WIP cap) are asserted up front, where they still mean
|
|
454
|
+
* "stop and fix something" (exit 2) instead of draining the queue to a bogus
|
|
455
|
+
* "nothing claimable". Returns null when the queue really is empty (exit 1).
|
|
456
|
+
*/
|
|
457
|
+
export function claimNext(stores: Store[], options: ClaimOptions = {}): ClaimResult | null {
|
|
458
|
+
const first = stores[0]
|
|
459
|
+
const tree = git(first.root, "status", "--porcelain", "-uno")
|
|
460
|
+
if (tree.status !== 0) {
|
|
461
|
+
throw new ClaimError(`not a git repository: ${first.root}`, "invalid")
|
|
462
|
+
}
|
|
463
|
+
if (tree.stdout !== "") {
|
|
464
|
+
throw new ClaimError(
|
|
465
|
+
"working tree is dirty — commit or stash before claiming, the claim switches branches",
|
|
466
|
+
"invalid",
|
|
467
|
+
)
|
|
468
|
+
}
|
|
469
|
+
for (const store of stores) claimNamespace(store.config)
|
|
470
|
+
if (!options.force) {
|
|
471
|
+
const namespace = claimNamespace(first.config)
|
|
472
|
+
assertUnderCap(remoteClaims(first.root, namespace), first.root, namespace)
|
|
473
|
+
}
|
|
474
|
+
for (const store of stores) {
|
|
475
|
+
for (const task of claimableTasks(store)) {
|
|
476
|
+
try {
|
|
477
|
+
return claim(store, task.number, options)
|
|
478
|
+
} catch (error) {
|
|
479
|
+
// Claimed out from under us, or changed since listing — next in line.
|
|
480
|
+
if (error instanceof ClaimError) continue
|
|
481
|
+
throw error
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
return null
|
|
486
|
+
}
|