@nickmeriano/task 0.7.1 → 0.9.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 +73 -25
- package/dist/asks.test.d.ts +17 -0
- package/dist/asks.test.d.ts.map +1 -0
- package/dist/asks.test.js +278 -0
- package/dist/asks.test.js.map +1 -0
- package/dist/check.d.ts +43 -0
- package/dist/check.d.ts.map +1 -0
- package/dist/check.js +403 -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 +248 -0
- package/dist/check.test.js.map +1 -0
- package/dist/claim-io.d.ts +73 -0
- package/dist/claim-io.d.ts.map +1 -0
- package/dist/claim-io.js +344 -0
- package/dist/claim-io.js.map +1 -0
- package/dist/claim.d.ts +61 -9
- package/dist/claim.d.ts.map +1 -1
- package/dist/claim.js +197 -67
- package/dist/claim.js.map +1 -1
- package/dist/claim.test.d.ts +2 -2
- package/dist/claim.test.js +235 -64
- package/dist/claim.test.js.map +1 -1
- package/dist/cli.js +724 -136
- package/dist/cli.js.map +1 -1
- package/dist/file-store.d.ts +110 -38
- package/dist/file-store.d.ts.map +1 -1
- package/dist/file-store.js +514 -238
- package/dist/file-store.js.map +1 -1
- package/dist/git-serve.d.ts +183 -0
- package/dist/git-serve.d.ts.map +1 -0
- package/dist/git-serve.js +503 -0
- package/dist/git-serve.js.map +1 -0
- package/dist/git-serve.test.d.ts +16 -0
- package/dist/git-serve.test.d.ts.map +1 -0
- package/dist/git-serve.test.js +183 -0
- package/dist/git-serve.test.js.map +1 -0
- package/dist/git.d.ts +65 -0
- package/dist/git.d.ts.map +1 -0
- package/dist/git.js +114 -0
- package/dist/git.js.map +1 -0
- package/dist/id.d.ts +39 -0
- package/dist/id.d.ts.map +1 -0
- package/dist/id.js +67 -0
- package/dist/id.js.map +1 -0
- package/dist/inbox.d.ts +41 -0
- package/dist/inbox.d.ts.map +1 -0
- package/dist/inbox.js +56 -0
- package/dist/inbox.js.map +1 -0
- 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 +52 -0
- package/dist/overview.d.ts.map +1 -0
- package/dist/overview.js +61 -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 +48 -0
- package/dist/overview.test.js.map +1 -0
- package/dist/promote.test.d.ts +15 -0
- package/dist/promote.test.d.ts.map +1 -0
- package/dist/promote.test.js +104 -0
- package/dist/promote.test.js.map +1 -0
- package/dist/publish.d.ts +2 -17
- package/dist/publish.d.ts.map +1 -1
- package/dist/publish.js +4 -49
- package/dist/publish.js.map +1 -1
- package/dist/search.d.ts +34 -0
- package/dist/search.d.ts.map +1 -0
- package/dist/search.js +79 -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 +228 -23
- package/dist/server.js.map +1 -1
- package/dist/store.d.ts +43 -63
- 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 +148 -106
- package/dist/store.test.js.map +1 -1
- package/dist/ticket-doc.d.ts +74 -5
- package/dist/ticket-doc.d.ts.map +1 -1
- package/dist/ticket-doc.js +229 -15
- package/dist/ticket-doc.js.map +1 -1
- package/dist/types.d.ts +115 -28
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/package.json +1 -1
- package/skill/SKILL.md +153 -40
- package/src/asks.test.ts +355 -0
- package/src/check.test.ts +328 -0
- package/src/check.ts +497 -0
- package/src/claim-io.ts +401 -0
- package/src/claim.test.ts +301 -71
- package/src/claim.ts +238 -81
- package/src/cli.ts +740 -131
- package/src/file-store.ts +572 -254
- package/src/git-serve.test.ts +240 -0
- package/src/git-serve.ts +595 -0
- package/src/git.ts +141 -0
- package/src/id.ts +68 -0
- package/src/inbox.ts +77 -0
- package/src/index.ts +4 -2
- package/src/overview.test.ts +52 -0
- package/src/overview.ts +105 -0
- package/src/promote.test.ts +143 -0
- package/src/publish.ts +6 -53
- package/src/search.test.ts +64 -0
- package/src/search.ts +105 -0
- package/src/server.ts +232 -21
- package/src/store.test.ts +166 -116
- package/src/store.ts +46 -444
- package/src/ticket-doc.ts +284 -21
- package/src/types.ts +120 -28
- package/ui/dist/assets/index-BjsorZOU.js +229 -0
- package/ui/dist/assets/index-CoKCUYic.css +1 -0
- package/ui/dist/index.html +2 -2
- package/ui/dist/assets/index-COunM-QN.css +0 -1
- package/ui/dist/assets/index-D4homvrQ.js +0 -229
package/src/claim.test.ts
CHANGED
|
@@ -6,8 +6,8 @@
|
|
|
6
6
|
* 1. Two concurrent `task claim <same-id>`: exactly one exits 0, the other
|
|
7
7
|
* exits 1 — git's atomic ref creation is the lock.
|
|
8
8
|
* 2. Validation failures exit 2 before anything touches git.
|
|
9
|
-
* 3. `task list --claimable` is the queue: position order, minus blocked
|
|
10
|
-
*
|
|
9
|
+
* 3. `task list --claimable` is the queue: position order, minus blocked
|
|
10
|
+
* and already-claimed tickets — open asks never exclude a ticket.
|
|
11
11
|
* 4. `task claim --release` deletes the branch both sides and the ticket is
|
|
12
12
|
* claimable again.
|
|
13
13
|
*/
|
|
@@ -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"
|
|
@@ -61,20 +61,22 @@ function cli(cwd: string, ...args: string[]): Promise<CliResult> {
|
|
|
61
61
|
interface Fixture {
|
|
62
62
|
bare: string
|
|
63
63
|
clone: () => string
|
|
64
|
+
/** Keys of the seeded tickets, by role. */
|
|
65
|
+
keys: { one: string; two: string; blocked: string; person: string; parked: string; jumper: string }
|
|
64
66
|
}
|
|
65
67
|
|
|
66
68
|
/**
|
|
67
69
|
* A bare origin holding a board with the claimable shapes:
|
|
68
|
-
*
|
|
69
|
-
*
|
|
70
|
-
*
|
|
71
|
-
*
|
|
72
|
-
*
|
|
73
|
-
*
|
|
74
|
-
*
|
|
75
|
-
*
|
|
70
|
+
* one "Ready one" todo
|
|
71
|
+
* two "Ready two" todo
|
|
72
|
+
* blocked "Blocked" todo, blocked by `one`
|
|
73
|
+
* person "For a person" todo, carrying an open ask
|
|
74
|
+
* parked "Still parked" backlog
|
|
75
|
+
* jumper "Jumped queue" todo, moved to the top of the column after
|
|
76
|
+
* creation — claimable order must be [jumper, one, two], proving
|
|
77
|
+
* position order beats creation order.
|
|
76
78
|
*/
|
|
77
|
-
function fixture(
|
|
79
|
+
function fixture(branchPrefix?: string): Fixture {
|
|
78
80
|
const home = tempDir()
|
|
79
81
|
const bare = join(home, "origin.git")
|
|
80
82
|
sh(home, "git", "init", "--quiet", "--bare", "-b", "main", bare)
|
|
@@ -84,20 +86,29 @@ function fixture(claimPrefix?: string): Fixture {
|
|
|
84
86
|
sh(seed, "git", "config", "user.name", "Test")
|
|
85
87
|
sh(seed, "git", "config", "user.email", "test@example.com")
|
|
86
88
|
const store = initProject(seed, { name: "claim board", prefix: "CLM" })
|
|
87
|
-
if (
|
|
89
|
+
if (branchPrefix !== undefined) {
|
|
88
90
|
const configPath = join(seed, ".task", "config.json")
|
|
89
91
|
const config = JSON.parse(readFileSync(configPath, "utf8")) as Record<string, unknown>
|
|
90
|
-
config.
|
|
92
|
+
config.claims = { branchPrefix }
|
|
91
93
|
writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`)
|
|
92
94
|
}
|
|
93
|
-
store.create({ title: "Ready one" })
|
|
94
|
-
store.create({ title: "Ready two" })
|
|
95
|
-
store.create({ title: "Blocked", blockedBy: [
|
|
96
|
-
store.create({ title: "For a person",
|
|
97
|
-
store.
|
|
95
|
+
const one = store.create({ title: "Ready one", status: "todo" })
|
|
96
|
+
const two = store.create({ title: "Ready two", status: "todo" })
|
|
97
|
+
const blocked = store.create({ title: "Blocked", status: "todo", blockedBy: [one.key] })
|
|
98
|
+
const person = store.create({ title: "For a person", status: "todo" })
|
|
99
|
+
store.addAsk(person.key, "Approve the vendor contract", "claude")
|
|
100
|
+
const parked = store.create({ title: "Still parked", status: "backlog" })
|
|
98
101
|
const jumper = store.create({ title: "Jumped queue", status: "backlog" })
|
|
99
102
|
// A status move without an explicit slot lands on top of the new column.
|
|
100
|
-
store.update(jumper.
|
|
103
|
+
store.update(jumper.key, { status: "todo" })
|
|
104
|
+
const keys = {
|
|
105
|
+
one: one.key,
|
|
106
|
+
two: two.key,
|
|
107
|
+
blocked: blocked.key,
|
|
108
|
+
person: person.key,
|
|
109
|
+
parked: parked.key,
|
|
110
|
+
jumper: jumper.key,
|
|
111
|
+
}
|
|
101
112
|
sh(seed, "git", "add", "-A")
|
|
102
113
|
sh(seed, "git", "commit", "--quiet", "-m", "board")
|
|
103
114
|
sh(seed, "git", "push", "--quiet", "-u", "origin", "main")
|
|
@@ -110,15 +121,15 @@ function fixture(claimPrefix?: string): Fixture {
|
|
|
110
121
|
sh(dir, "git", "config", "user.email", "test@example.com")
|
|
111
122
|
return dir
|
|
112
123
|
}
|
|
113
|
-
return { bare, clone }
|
|
124
|
+
return { bare, clone, keys }
|
|
114
125
|
}
|
|
115
126
|
|
|
116
127
|
test("two concurrent claims of one ticket: exactly one wins", async () => {
|
|
117
|
-
const { bare, clone } = fixture()
|
|
128
|
+
const { bare, clone, keys } = fixture()
|
|
118
129
|
const a = clone()
|
|
119
130
|
const b = clone()
|
|
120
131
|
|
|
121
|
-
const [ra, rb] = await Promise.all([cli(a, "claim",
|
|
132
|
+
const [ra, rb] = await Promise.all([cli(a, "claim", keys.one), cli(b, "claim", keys.one)])
|
|
122
133
|
assert.deepEqual([ra.code, rb.code].sort(), [0, 1], `a: ${ra.stderr}\nb: ${rb.stderr}`)
|
|
123
134
|
|
|
124
135
|
const winner = ra.code === 0 ? a : b
|
|
@@ -126,117 +137,336 @@ test("two concurrent claims of one ticket: exactly one wins", async () => {
|
|
|
126
137
|
assert.match(loser.stderr, /already claimed|rejected/)
|
|
127
138
|
|
|
128
139
|
// The winner is left on the claim branch, flip committed and pushed.
|
|
129
|
-
|
|
140
|
+
const branch = `task/claim/clm-${keys.one}`
|
|
141
|
+
assert.equal(sh(winner, "git", "rev-parse", "--abbrev-ref", "HEAD"), branch)
|
|
130
142
|
assert.match(
|
|
131
|
-
readFileSync(join(winner, ".task", "tickets",
|
|
143
|
+
readFileSync(join(winner, ".task", "tickets", keys.one, "ticket.md"), "utf8"),
|
|
132
144
|
/status: in_progress/,
|
|
133
145
|
)
|
|
134
|
-
assert.
|
|
135
|
-
sh(winner, "git", "ls-remote", "--heads", "origin", "task/claim/clm-1"),
|
|
136
|
-
/refs\/heads\/task\/claim\/clm-1/,
|
|
137
|
-
)
|
|
146
|
+
assert.ok(sh(winner, "git", "ls-remote", "--heads", "origin", branch).includes(`refs/heads/${branch}`))
|
|
138
147
|
// Origin's default branch never saw the flip — the claim rides the branch.
|
|
139
148
|
assert.equal(sh(winner, "git", "diff", "--stat", "origin/main..HEAD", "--", "README.md"), "")
|
|
140
|
-
assert.match(
|
|
149
|
+
assert.match(
|
|
150
|
+
sh(winner, "git", "show", `origin/main:.task/tickets/${keys.one}/ticket.md`),
|
|
151
|
+
/status: todo/,
|
|
152
|
+
)
|
|
141
153
|
void bare
|
|
142
154
|
})
|
|
143
155
|
|
|
144
156
|
test("unclaimable tickets exit 2, before anything touches origin", async () => {
|
|
145
|
-
const { clone } = fixture()
|
|
157
|
+
const { clone, keys } = fixture()
|
|
146
158
|
const dir = clone()
|
|
147
159
|
|
|
148
|
-
assert.equal((await cli(dir, "claim",
|
|
149
|
-
assert.equal((await cli(dir, "claim",
|
|
150
|
-
assert.equal((await cli(dir, "claim", "
|
|
151
|
-
assert.
|
|
152
|
-
|
|
160
|
+
assert.equal((await cli(dir, "claim", keys.parked)).code, 2) // backlog
|
|
161
|
+
assert.equal((await cli(dir, "claim", keys.blocked)).code, 2) // blocked
|
|
162
|
+
assert.equal((await cli(dir, "claim", "zz9zz")).code, 2) // missing
|
|
163
|
+
assert.match(
|
|
164
|
+
(await cli(dir, "claim", keys.blocked)).stderr,
|
|
165
|
+
new RegExp(`blocked by CLM-${keys.one}`),
|
|
166
|
+
)
|
|
153
167
|
|
|
154
|
-
// A dirty tree refuses too — claiming switches branches.
|
|
155
|
-
|
|
156
|
-
|
|
168
|
+
// A dirty tree refuses too — claiming switches branches. Only *tracked*
|
|
169
|
+
// modifications count: untracked files ride a branch switch untouched
|
|
170
|
+
// (release deliberately leaves one — the reason comment).
|
|
171
|
+
const gitignore = join(dir, ".task", ".gitignore")
|
|
172
|
+
writeFileSync(gitignore, `${readFileSync(gitignore, "utf8")}\nwip\n`)
|
|
173
|
+
const dirty = await cli(dir, "claim", keys.two)
|
|
157
174
|
assert.equal(dirty.code, 2)
|
|
158
175
|
assert.match(dirty.stderr, /working tree is dirty/)
|
|
159
|
-
|
|
160
|
-
|
|
176
|
+
sh(dir, "git", "checkout", "--", ".task/.gitignore")
|
|
177
|
+
writeFileSync(join(dir, "scratch.txt"), "untracked is fine")
|
|
178
|
+
assert.equal((await cli(dir, "claim", keys.two)).code, 0)
|
|
161
179
|
|
|
162
180
|
// Nothing above created a stray claim branch for the failed ids.
|
|
163
181
|
const remote = sh(dir, "git", "ls-remote", "--heads", "origin", "task/claim/*")
|
|
164
182
|
assert.deepEqual(
|
|
165
183
|
remote.split("\n").map((l) => l.split("refs/heads/")[1]),
|
|
166
|
-
[
|
|
184
|
+
[`task/claim/clm-${keys.two}`],
|
|
167
185
|
)
|
|
168
186
|
})
|
|
169
187
|
|
|
170
188
|
test("list --claimable is the queue: position order, minus everything unclaimable", async () => {
|
|
171
|
-
const { clone } = fixture()
|
|
189
|
+
const { clone, keys } = fixture()
|
|
172
190
|
const a = clone()
|
|
173
|
-
assert.equal((await cli(a, "claim",
|
|
191
|
+
assert.equal((await cli(a, "claim", keys.jumper)).code, 0)
|
|
174
192
|
|
|
175
193
|
const b = clone()
|
|
176
194
|
const listed = await cli(b, "list", "--claimable", "--json")
|
|
177
195
|
assert.equal(listed.code, 0)
|
|
178
|
-
const { tasks } = JSON.parse(listed.stdout) as { tasks: {
|
|
179
|
-
//
|
|
180
|
-
//
|
|
181
|
-
|
|
196
|
+
const { tasks } = JSON.parse(listed.stdout) as { tasks: { key: string }[] }
|
|
197
|
+
// jumper claimed, blocked blocked, parked not todo — position order keeps
|
|
198
|
+
// one before two (creation order within the column). person carries an open
|
|
199
|
+
// ask and is claimable anyway: asks gate done, not claim.
|
|
200
|
+
assert.deepEqual(tasks.map((t) => t.key), [keys.one, keys.two, keys.person])
|
|
182
201
|
|
|
183
202
|
assert.equal((await cli(b, "list", "--claimable", "--status", "todo")).code, 1)
|
|
184
203
|
})
|
|
185
204
|
|
|
186
205
|
test("release deletes the branch everywhere and reopens the claim", async () => {
|
|
187
|
-
const { clone } = fixture()
|
|
206
|
+
const { clone, keys } = fixture()
|
|
188
207
|
const dir = clone()
|
|
189
|
-
assert.equal((await cli(dir, "claim",
|
|
190
|
-
assert.equal((await cli(dir, "claim",
|
|
208
|
+
assert.equal((await cli(dir, "claim", keys.two)).code, 0)
|
|
209
|
+
assert.equal((await cli(dir, "claim", keys.two)).code, 1) // locally claimed too
|
|
191
210
|
|
|
192
|
-
const released = await cli(dir, "claim", "--release", "
|
|
211
|
+
const released = await cli(dir, "claim", "--release", keys.two, "--comment", "trying another shape")
|
|
193
212
|
assert.equal(released.code, 0, released.stderr)
|
|
194
|
-
assert.
|
|
213
|
+
assert.ok(released.stdout.includes(`released CLM-${keys.two}`), released.stdout)
|
|
195
214
|
|
|
196
215
|
// Back on the default branch, branch gone on both sides, ticket todo again.
|
|
197
216
|
assert.equal(sh(dir, "git", "rev-parse", "--abbrev-ref", "HEAD"), "main")
|
|
198
|
-
assert.equal(sh(dir, "git", "ls-remote", "--heads", "origin",
|
|
217
|
+
assert.equal(sh(dir, "git", "ls-remote", "--heads", "origin", `task/claim/clm-${keys.two}`), "")
|
|
199
218
|
assert.match(
|
|
200
|
-
readFileSync(join(dir, ".task", "tickets",
|
|
219
|
+
readFileSync(join(dir, ".task", "tickets", keys.two, "ticket.md"), "utf8"),
|
|
201
220
|
/status: todo/,
|
|
202
221
|
)
|
|
203
|
-
assert.equal((await cli(dir, "claim",
|
|
222
|
+
assert.equal((await cli(dir, "claim", keys.two)).code, 0)
|
|
204
223
|
|
|
205
224
|
// Releasing an unclaimed ticket is a quiet no-op, not an error.
|
|
206
|
-
const noop = await cli(dir, "claim", "--release", "
|
|
225
|
+
const noop = await cli(dir, "claim", "--release", keys.one, "--comment", "noop")
|
|
207
226
|
assert.equal(noop.code, 0)
|
|
208
227
|
assert.match(noop.stdout, /wasn't claimed/)
|
|
209
228
|
})
|
|
210
229
|
|
|
211
|
-
test("a configured
|
|
212
|
-
const { clone } = fixture("claude/task/")
|
|
230
|
+
test("a configured claims.branchPrefix moves the whole namespace", async () => {
|
|
231
|
+
const { clone, keys } = fixture("claude/task/")
|
|
213
232
|
const dir = clone()
|
|
214
233
|
|
|
215
|
-
assert.equal((await cli(dir, "claim",
|
|
216
|
-
assert.equal(sh(dir, "git", "rev-parse", "--abbrev-ref", "HEAD"),
|
|
217
|
-
assert.
|
|
218
|
-
sh(dir, "git", "ls-remote", "--heads", "origin", "claude/task/*")
|
|
219
|
-
|
|
234
|
+
assert.equal((await cli(dir, "claim", keys.one)).code, 0)
|
|
235
|
+
assert.equal(sh(dir, "git", "rev-parse", "--abbrev-ref", "HEAD"), `claude/task/clm-${keys.one}`)
|
|
236
|
+
assert.ok(
|
|
237
|
+
sh(dir, "git", "ls-remote", "--heads", "origin", "claude/task/*").includes(
|
|
238
|
+
`refs/heads/claude/task/clm-${keys.one}`,
|
|
239
|
+
),
|
|
220
240
|
)
|
|
221
241
|
|
|
222
242
|
// Every side of the feature reads the same key: the queue looks for claims
|
|
223
243
|
// under the configured namespace, and release deletes there too.
|
|
224
244
|
const other = clone()
|
|
225
245
|
const listed = await cli(other, "list", "--claimable", "--json")
|
|
226
|
-
const { tasks } = JSON.parse(listed.stdout) as { tasks: {
|
|
227
|
-
assert.deepEqual(tasks.map((t) => t.
|
|
228
|
-
assert.equal((await cli(other, "claim",
|
|
229
|
-
assert.equal(
|
|
246
|
+
const { tasks } = JSON.parse(listed.stdout) as { tasks: { key: string }[] }
|
|
247
|
+
assert.deepEqual(tasks.map((t) => t.key), [keys.jumper, keys.two, keys.person])
|
|
248
|
+
assert.equal((await cli(other, "claim", keys.one)).code, 1)
|
|
249
|
+
assert.equal(
|
|
250
|
+
(await cli(dir, "claim", "--release", keys.one, "--comment", "handing it back")).code,
|
|
251
|
+
0,
|
|
252
|
+
)
|
|
230
253
|
assert.equal(sh(dir, "git", "ls-remote", "--heads", "origin", "claude/task/*"), "")
|
|
231
254
|
|
|
232
255
|
// A missing trailing slash is forgiven; a malformed value fails loudly.
|
|
233
256
|
const bad = clone()
|
|
234
257
|
const configPath = join(bad, ".task", "config.json")
|
|
235
258
|
const config = JSON.parse(readFileSync(configPath, "utf8")) as Record<string, unknown>
|
|
236
|
-
config.
|
|
259
|
+
config.claims = { branchPrefix: "spaces are not refs/" }
|
|
237
260
|
writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`)
|
|
238
261
|
sh(bad, "git", "commit", "--quiet", "-am", "bad prefix")
|
|
239
|
-
const invalid = await cli(bad, "claim",
|
|
262
|
+
const invalid = await cli(bad, "claim", keys.two)
|
|
240
263
|
assert.equal(invalid.code, 2)
|
|
241
|
-
assert.match(invalid.stderr, /invalid
|
|
264
|
+
assert.match(invalid.stderr, /invalid claims\.branchPrefix/)
|
|
265
|
+
|
|
266
|
+
// The retired pre-0.8 `claimPrefix` spelling is not honored (TAS-41): a
|
|
267
|
+
// board carrying only it refuses by name rather than silently defaulting
|
|
268
|
+
// the lock convention out from under its workers.
|
|
269
|
+
const retired = clone()
|
|
270
|
+
const retiredPath = join(retired, ".task", "config.json")
|
|
271
|
+
const retiredConfig = JSON.parse(readFileSync(retiredPath, "utf8")) as Record<string, unknown>
|
|
272
|
+
delete retiredConfig.claims
|
|
273
|
+
retiredConfig.claimPrefix = "claude/task/"
|
|
274
|
+
writeFileSync(retiredPath, `${JSON.stringify(retiredConfig, null, 2)}\n`)
|
|
275
|
+
sh(retired, "git", "commit", "--quiet", "-am", "retired key")
|
|
276
|
+
const refused = await cli(retired, "claim", keys.two)
|
|
277
|
+
assert.equal(refused.code, 2)
|
|
278
|
+
assert.match(refused.stderr, /pre-0\.8 spelling and is no longer read/)
|
|
279
|
+
})
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* A monorepo with two boards for the multi-board selection paths (TAS-27):
|
|
283
|
+
* a root board (ROO, one ready ticket) and a nested one under pkg/ (NES,
|
|
284
|
+
* one ready ticket). The root config carries the new `claims` block and,
|
|
285
|
+
* optionally, the top-level `boards` list — NES deliberately ahead of ROO,
|
|
286
|
+
* so priority provably follows list order, not nearness.
|
|
287
|
+
*/
|
|
288
|
+
interface MultiFixture {
|
|
289
|
+
bare: string
|
|
290
|
+
clone: () => string
|
|
291
|
+
rootKey: string
|
|
292
|
+
nestedKey: string
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function multiFixture(options: { boards?: string[]; maxOpenCount?: number } = {}): MultiFixture {
|
|
296
|
+
const home = tempDir()
|
|
297
|
+
const bare = join(home, "origin.git")
|
|
298
|
+
sh(home, "git", "init", "--quiet", "--bare", "-b", "main", bare)
|
|
299
|
+
|
|
300
|
+
const seed = join(home, "seed")
|
|
301
|
+
sh(home, "git", "clone", "--quiet", bare, seed)
|
|
302
|
+
sh(seed, "git", "config", "user.name", "Test")
|
|
303
|
+
sh(seed, "git", "config", "user.email", "test@example.com")
|
|
304
|
+
|
|
305
|
+
const root = initProject(seed, { name: "root board", prefix: "ROO" })
|
|
306
|
+
const configPath = join(seed, ".task", "config.json")
|
|
307
|
+
const config = JSON.parse(readFileSync(configPath, "utf8")) as Record<string, unknown>
|
|
308
|
+
if (options.boards) config.boards = options.boards
|
|
309
|
+
config.claims = { branchPrefix: "task/claim/", maxOpenCount: options.maxOpenCount }
|
|
310
|
+
writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`)
|
|
311
|
+
const rootKey = root.create({ title: "Root ready", status: "todo" }).key
|
|
312
|
+
|
|
313
|
+
mkdirSync(join(seed, "pkg"), { recursive: true })
|
|
314
|
+
const nested = initProject(join(seed, "pkg"), { name: "nested board", prefix: "NES" })
|
|
315
|
+
const nestedKey = nested.create({ title: "Nested ready", status: "todo" }).key
|
|
316
|
+
|
|
317
|
+
sh(seed, "git", "add", "-A")
|
|
318
|
+
sh(seed, "git", "commit", "--quiet", "-m", "boards")
|
|
319
|
+
sh(seed, "git", "push", "--quiet", "-u", "origin", "main")
|
|
320
|
+
|
|
321
|
+
let clones = 0
|
|
322
|
+
const clone = (): string => {
|
|
323
|
+
const dir = join(home, `clone-${++clones}`)
|
|
324
|
+
sh(home, "git", "clone", "--quiet", bare, dir)
|
|
325
|
+
sh(dir, "git", "config", "user.name", "Test")
|
|
326
|
+
sh(dir, "git", "config", "user.email", "test@example.com")
|
|
327
|
+
return dir
|
|
328
|
+
}
|
|
329
|
+
return { bare, clone, rootKey, nestedKey }
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
test("claim --next drains the queue in position order and exits 1 when empty", async () => {
|
|
333
|
+
const { clone, keys } = fixture()
|
|
334
|
+
const dir = clone()
|
|
335
|
+
|
|
336
|
+
const order: string[] = []
|
|
337
|
+
for (;;) {
|
|
338
|
+
const result = await cli(dir, "claim", "--next", "--json")
|
|
339
|
+
if (result.code === 1) {
|
|
340
|
+
assert.equal(JSON.parse(result.stdout).task, null)
|
|
341
|
+
break
|
|
342
|
+
}
|
|
343
|
+
assert.equal(result.code, 0, result.stderr)
|
|
344
|
+
const { task, branch } = JSON.parse(result.stdout) as { task: { key: string }; branch: string }
|
|
345
|
+
order.push(task.key)
|
|
346
|
+
assert.equal(sh(dir, "git", "rev-parse", "--abbrev-ref", "HEAD"), branch)
|
|
347
|
+
}
|
|
348
|
+
// The jumper leads (position order), then creation order; blocked and
|
|
349
|
+
// backlog tickets never surface, while the open ask on `person` doesn't
|
|
350
|
+
// keep it out of the queue.
|
|
351
|
+
assert.deepEqual(order, [keys.jumper, keys.one, keys.two, keys.person])
|
|
352
|
+
})
|
|
353
|
+
|
|
354
|
+
test("two concurrent claim --next calls land on different tickets", async () => {
|
|
355
|
+
const { clone } = fixture()
|
|
356
|
+
const a = clone()
|
|
357
|
+
const b = clone()
|
|
358
|
+
|
|
359
|
+
const [ra, rb] = await Promise.all([
|
|
360
|
+
cli(a, "claim", "--next", "--json"),
|
|
361
|
+
cli(b, "claim", "--next", "--json"),
|
|
362
|
+
])
|
|
363
|
+
assert.equal(ra.code, 0, ra.stderr)
|
|
364
|
+
assert.equal(rb.code, 0, rb.stderr)
|
|
365
|
+
const ids = [ra, rb].map((r) => (JSON.parse(r.stdout) as { task: { id: string } }).task.id)
|
|
366
|
+
assert.notEqual(ids[0], ids[1], `both claimed ${ids[0]}`)
|
|
367
|
+
})
|
|
368
|
+
|
|
369
|
+
test("--next walks the root boards list in priority order; --board overrides it", async () => {
|
|
370
|
+
const { clone, rootKey, nestedKey } = multiFixture({ boards: ["NES", "ROO"] })
|
|
371
|
+
const a = clone()
|
|
372
|
+
|
|
373
|
+
// list --claimable reads the same scope, in the same order.
|
|
374
|
+
const listed = await cli(a, "list", "--claimable", "--json")
|
|
375
|
+
assert.equal(listed.code, 0, listed.stderr)
|
|
376
|
+
const { tasks } = JSON.parse(listed.stdout) as { tasks: { id: string }[] }
|
|
377
|
+
assert.deepEqual(tasks.map((t) => t.id), [`NES-${nestedKey}`, `ROO-${rootKey}`])
|
|
378
|
+
|
|
379
|
+
// The nested board wins despite the root board being nearest.
|
|
380
|
+
const next = await cli(a, "claim", "--next", "--json")
|
|
381
|
+
assert.equal(next.code, 0, next.stderr)
|
|
382
|
+
const claimed = JSON.parse(next.stdout) as { task: { id: string }; branch: string }
|
|
383
|
+
assert.equal(claimed.task.id, `NES-${nestedKey}`)
|
|
384
|
+
assert.equal(claimed.branch, `task/claim/nes-${nestedKey}`)
|
|
385
|
+
|
|
386
|
+
// An explicit --board list is the whole scope, in the given order.
|
|
387
|
+
const b = clone()
|
|
388
|
+
const overridden = await cli(b, "claim", "--next", "--board", "ROO", "--json")
|
|
389
|
+
assert.equal(overridden.code, 0, overridden.stderr)
|
|
390
|
+
assert.equal((JSON.parse(overridden.stdout) as { task: { id: string } }).task.id, `ROO-${rootKey}`)
|
|
391
|
+
|
|
392
|
+
const unknown = await cli(b, "claim", "--next", "--board", "NOPE")
|
|
393
|
+
assert.equal(unknown.code, 2)
|
|
394
|
+
assert.match(unknown.stderr, /no board with prefix NOPE/)
|
|
395
|
+
})
|
|
396
|
+
|
|
397
|
+
test("without a boards list, --next sees the nearest board only", async () => {
|
|
398
|
+
const { clone, rootKey } = multiFixture()
|
|
399
|
+
const dir = clone()
|
|
400
|
+
|
|
401
|
+
const next = await cli(dir, "claim", "--next", "--json")
|
|
402
|
+
assert.equal(next.code, 0, next.stderr)
|
|
403
|
+
assert.equal((JSON.parse(next.stdout) as { task: { id: string } }).task.id, `ROO-${rootKey}`)
|
|
404
|
+
|
|
405
|
+
// ROO's queue is drained and NES never entered the scope.
|
|
406
|
+
const empty = await cli(dir, "claim", "--next", "--json")
|
|
407
|
+
assert.equal(empty.code, 1)
|
|
408
|
+
})
|
|
409
|
+
|
|
410
|
+
test("maxOpenCount caps every claim repo-wide; --force is the escape", async () => {
|
|
411
|
+
const { clone, rootKey } = multiFixture({ boards: ["NES", "ROO"], maxOpenCount: 1 })
|
|
412
|
+
const a = clone()
|
|
413
|
+
assert.equal((await cli(a, "claim", "--next", "--json")).code, 0)
|
|
414
|
+
|
|
415
|
+
// At the cap, --next and explicit-id claims both refuse with exit 2 —
|
|
416
|
+
// the fix is to finish or release a claim, not to pick another ticket.
|
|
417
|
+
const b = clone()
|
|
418
|
+
const capped = await cli(b, "claim", "--next")
|
|
419
|
+
assert.equal(capped.code, 2)
|
|
420
|
+
assert.match(capped.stderr, /maxOpenCount cap \(1\)/)
|
|
421
|
+
const explicit = await cli(b, "claim", `ROO-${rootKey}`)
|
|
422
|
+
assert.equal(explicit.code, 2)
|
|
423
|
+
assert.match(explicit.stderr, /maxOpenCount cap \(1\)/)
|
|
424
|
+
|
|
425
|
+
const forced = await cli(b, "claim", "--next", "--force", "--json")
|
|
426
|
+
assert.equal(forced.code, 0, forced.stderr)
|
|
427
|
+
assert.equal((JSON.parse(forced.stdout) as { task: { id: string } }).task.id, `ROO-${rootKey}`)
|
|
428
|
+
})
|
|
429
|
+
|
|
430
|
+
test("release requires a reason and leaves it as a comment on the ticket", async () => {
|
|
431
|
+
const { clone, keys } = fixture()
|
|
432
|
+
const dir = clone()
|
|
433
|
+
assert.equal((await cli(dir, "claim", keys.one)).code, 0)
|
|
434
|
+
|
|
435
|
+
// No comment, and an empty one, are both refused before anything happens.
|
|
436
|
+
const missing = await cli(dir, "claim", "--release", keys.one)
|
|
437
|
+
assert.equal(missing.code, 1)
|
|
438
|
+
assert.match(missing.stderr, /requires --comment/)
|
|
439
|
+
const empty = await cli(dir, "claim", "--release", keys.one, "--comment", "")
|
|
440
|
+
assert.equal(empty.code, 1)
|
|
441
|
+
assert.match(empty.stderr, /requires --comment/)
|
|
442
|
+
assert.ok(
|
|
443
|
+
sh(dir, "git", "ls-remote", "--heads", "origin", `task/claim/clm-${keys.one}`).includes(
|
|
444
|
+
`clm-${keys.one}`,
|
|
445
|
+
),
|
|
446
|
+
)
|
|
447
|
+
|
|
448
|
+
const released = await cli(
|
|
449
|
+
dir, "claim", "--release", keys.one,
|
|
450
|
+
"--comment", "the migration kept flaking — needs a fixture, not a retry",
|
|
451
|
+
"--author", "claude",
|
|
452
|
+
)
|
|
453
|
+
assert.equal(released.code, 0, released.stderr)
|
|
454
|
+
|
|
455
|
+
// The reason survives the branch: an ordinary attributed comment, sitting
|
|
456
|
+
// uncommitted in the working tree on the default branch.
|
|
457
|
+
const shown = await cli(dir, "show", keys.one, "--json")
|
|
458
|
+
const { comments } = JSON.parse(shown.stdout) as {
|
|
459
|
+
comments: { author: string; body: string }[]
|
|
460
|
+
}
|
|
461
|
+
assert.equal(comments.length, 1)
|
|
462
|
+
assert.equal(comments[0].author, "claude")
|
|
463
|
+
assert.match(comments[0].body, /kept flaking/)
|
|
464
|
+
assert.ok(sh(dir, "git", "status", "--porcelain").includes(`tickets/${keys.one}/comments`))
|
|
465
|
+
|
|
466
|
+
// Releasing an unclaimed ticket stays a no-op — no second comment.
|
|
467
|
+
const noop = await cli(dir, "claim", "--release", keys.one, "--comment", "again")
|
|
468
|
+
assert.equal(noop.code, 0)
|
|
469
|
+
assert.match(noop.stdout, /wasn't claimed/)
|
|
470
|
+
const after = await cli(dir, "show", keys.one, "--json")
|
|
471
|
+
assert.equal((JSON.parse(after.stdout) as { comments: unknown[] }).comments.length, 1)
|
|
242
472
|
})
|