@nickmeriano/task 0.8.0 → 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 +65 -22
- 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 +3 -2
- package/dist/check.d.ts.map +1 -1
- package/dist/check.js +99 -60
- package/dist/check.js.map +1 -1
- package/dist/check.test.js +99 -60
- package/dist/check.test.js.map +1 -1
- 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 +21 -22
- package/dist/claim.d.ts.map +1 -1
- package/dist/claim.js +58 -83
- package/dist/claim.js.map +1 -1
- package/dist/claim.test.d.ts +2 -2
- package/dist/claim.test.js +111 -94
- package/dist/claim.test.js.map +1 -1
- package/dist/cli.js +241 -64
- package/dist/cli.js.map +1 -1
- package/dist/file-store.d.ts +70 -24
- package/dist/file-store.d.ts.map +1 -1
- package/dist/file-store.js +319 -141
- 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/overview.d.ts +9 -0
- package/dist/overview.d.ts.map +1 -1
- package/dist/overview.js +19 -11
- package/dist/overview.js.map +1 -1
- package/dist/overview.test.js +6 -5
- package/dist/overview.test.js.map +1 -1
- package/dist/promote.test.d.ts +3 -2
- package/dist/promote.test.d.ts.map +1 -1
- package/dist/promote.test.js +29 -31
- package/dist/promote.test.js.map +1 -1
- 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 +3 -1
- package/dist/search.d.ts.map +1 -1
- package/dist/search.js +15 -2
- package/dist/search.js.map +1 -1
- package/dist/search.test.js +9 -9
- package/dist/search.test.js.map +1 -1
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +218 -21
- package/dist/server.js.map +1 -1
- package/dist/store.d.ts +28 -14
- package/dist/store.d.ts.map +1 -1
- package/dist/store.js.map +1 -1
- package/dist/store.test.js +88 -75
- package/dist/store.test.js.map +1 -1
- package/dist/ticket-doc.d.ts +26 -10
- package/dist/ticket-doc.d.ts.map +1 -1
- package/dist/ticket-doc.js +71 -27
- package/dist/ticket-doc.js.map +1 -1
- package/dist/types.d.ts +51 -17
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/package.json +1 -1
- package/skill/SKILL.md +70 -23
- package/src/asks.test.ts +355 -0
- package/src/check.test.ts +129 -72
- package/src/check.ts +101 -58
- package/src/claim-io.ts +401 -0
- package/src/claim.test.ts +141 -99
- package/src/claim.ts +69 -96
- package/src/cli.ts +251 -63
- package/src/file-store.ts +357 -145
- 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/overview.test.ts +6 -5
- package/src/overview.ts +25 -10
- package/src/promote.test.ts +40 -28
- package/src/publish.ts +6 -53
- package/src/search.test.ts +11 -11
- package/src/search.ts +17 -3
- package/src/server.ts +221 -19
- package/src/store.test.ts +95 -77
- package/src/store.ts +28 -13
- package/src/ticket-doc.ts +93 -30
- package/src/types.ts +52 -17
- 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-BJmOsOdR.js +0 -229
- package/ui/dist/assets/index-BoqQlqSU.css +0 -1
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
|
*/
|
|
@@ -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", status: "todo" })
|
|
94
|
-
store.create({ title: "Ready two", status: "todo" })
|
|
95
|
-
store.create({ title: "Blocked", status: "todo", blockedBy: [
|
|
96
|
-
store.create({ title: "For a person", status: "todo"
|
|
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,123 +137,145 @@ 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
168
|
// A dirty tree refuses too — claiming switches branches. Only *tracked*
|
|
155
169
|
// modifications count: untracked files ride a branch switch untouched
|
|
156
170
|
// (release deliberately leaves one — the reason comment).
|
|
157
171
|
const gitignore = join(dir, ".task", ".gitignore")
|
|
158
172
|
writeFileSync(gitignore, `${readFileSync(gitignore, "utf8")}\nwip\n`)
|
|
159
|
-
const dirty = await cli(dir, "claim",
|
|
173
|
+
const dirty = await cli(dir, "claim", keys.two)
|
|
160
174
|
assert.equal(dirty.code, 2)
|
|
161
175
|
assert.match(dirty.stderr, /working tree is dirty/)
|
|
162
176
|
sh(dir, "git", "checkout", "--", ".task/.gitignore")
|
|
163
177
|
writeFileSync(join(dir, "scratch.txt"), "untracked is fine")
|
|
164
|
-
assert.equal((await cli(dir, "claim",
|
|
178
|
+
assert.equal((await cli(dir, "claim", keys.two)).code, 0)
|
|
165
179
|
|
|
166
180
|
// Nothing above created a stray claim branch for the failed ids.
|
|
167
181
|
const remote = sh(dir, "git", "ls-remote", "--heads", "origin", "task/claim/*")
|
|
168
182
|
assert.deepEqual(
|
|
169
183
|
remote.split("\n").map((l) => l.split("refs/heads/")[1]),
|
|
170
|
-
[
|
|
184
|
+
[`task/claim/clm-${keys.two}`],
|
|
171
185
|
)
|
|
172
186
|
})
|
|
173
187
|
|
|
174
188
|
test("list --claimable is the queue: position order, minus everything unclaimable", async () => {
|
|
175
|
-
const { clone } = fixture()
|
|
189
|
+
const { clone, keys } = fixture()
|
|
176
190
|
const a = clone()
|
|
177
|
-
assert.equal((await cli(a, "claim",
|
|
191
|
+
assert.equal((await cli(a, "claim", keys.jumper)).code, 0)
|
|
178
192
|
|
|
179
193
|
const b = clone()
|
|
180
194
|
const listed = await cli(b, "list", "--claimable", "--json")
|
|
181
195
|
assert.equal(listed.code, 0)
|
|
182
|
-
const { tasks } = JSON.parse(listed.stdout) as { tasks: {
|
|
183
|
-
//
|
|
184
|
-
//
|
|
185
|
-
|
|
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])
|
|
186
201
|
|
|
187
202
|
assert.equal((await cli(b, "list", "--claimable", "--status", "todo")).code, 1)
|
|
188
203
|
})
|
|
189
204
|
|
|
190
205
|
test("release deletes the branch everywhere and reopens the claim", async () => {
|
|
191
|
-
const { clone } = fixture()
|
|
206
|
+
const { clone, keys } = fixture()
|
|
192
207
|
const dir = clone()
|
|
193
|
-
assert.equal((await cli(dir, "claim",
|
|
194
|
-
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
|
|
195
210
|
|
|
196
|
-
const released = await cli(dir, "claim", "--release",
|
|
211
|
+
const released = await cli(dir, "claim", "--release", keys.two, "--comment", "trying another shape")
|
|
197
212
|
assert.equal(released.code, 0, released.stderr)
|
|
198
|
-
assert.
|
|
213
|
+
assert.ok(released.stdout.includes(`released CLM-${keys.two}`), released.stdout)
|
|
199
214
|
|
|
200
215
|
// Back on the default branch, branch gone on both sides, ticket todo again.
|
|
201
216
|
assert.equal(sh(dir, "git", "rev-parse", "--abbrev-ref", "HEAD"), "main")
|
|
202
|
-
assert.equal(sh(dir, "git", "ls-remote", "--heads", "origin",
|
|
217
|
+
assert.equal(sh(dir, "git", "ls-remote", "--heads", "origin", `task/claim/clm-${keys.two}`), "")
|
|
203
218
|
assert.match(
|
|
204
|
-
readFileSync(join(dir, ".task", "tickets",
|
|
219
|
+
readFileSync(join(dir, ".task", "tickets", keys.two, "ticket.md"), "utf8"),
|
|
205
220
|
/status: todo/,
|
|
206
221
|
)
|
|
207
|
-
assert.equal((await cli(dir, "claim",
|
|
222
|
+
assert.equal((await cli(dir, "claim", keys.two)).code, 0)
|
|
208
223
|
|
|
209
224
|
// Releasing an unclaimed ticket is a quiet no-op, not an error.
|
|
210
|
-
const noop = await cli(dir, "claim", "--release",
|
|
225
|
+
const noop = await cli(dir, "claim", "--release", keys.one, "--comment", "noop")
|
|
211
226
|
assert.equal(noop.code, 0)
|
|
212
227
|
assert.match(noop.stdout, /wasn't claimed/)
|
|
213
228
|
})
|
|
214
229
|
|
|
215
|
-
test("a configured
|
|
216
|
-
const { clone } = fixture("claude/task/")
|
|
230
|
+
test("a configured claims.branchPrefix moves the whole namespace", async () => {
|
|
231
|
+
const { clone, keys } = fixture("claude/task/")
|
|
217
232
|
const dir = clone()
|
|
218
233
|
|
|
219
|
-
assert.equal((await cli(dir, "claim",
|
|
220
|
-
assert.equal(sh(dir, "git", "rev-parse", "--abbrev-ref", "HEAD"),
|
|
221
|
-
assert.
|
|
222
|
-
sh(dir, "git", "ls-remote", "--heads", "origin", "claude/task/*")
|
|
223
|
-
|
|
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
|
+
),
|
|
224
240
|
)
|
|
225
241
|
|
|
226
242
|
// Every side of the feature reads the same key: the queue looks for claims
|
|
227
243
|
// under the configured namespace, and release deletes there too.
|
|
228
244
|
const other = clone()
|
|
229
245
|
const listed = await cli(other, "list", "--claimable", "--json")
|
|
230
|
-
const { tasks } = JSON.parse(listed.stdout) as { tasks: {
|
|
231
|
-
assert.deepEqual(tasks.map((t) => t.
|
|
232
|
-
assert.equal((await cli(other, "claim",
|
|
233
|
-
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
|
+
)
|
|
234
253
|
assert.equal(sh(dir, "git", "ls-remote", "--heads", "origin", "claude/task/*"), "")
|
|
235
254
|
|
|
236
255
|
// A missing trailing slash is forgiven; a malformed value fails loudly.
|
|
237
256
|
const bad = clone()
|
|
238
257
|
const configPath = join(bad, ".task", "config.json")
|
|
239
258
|
const config = JSON.parse(readFileSync(configPath, "utf8")) as Record<string, unknown>
|
|
240
|
-
config.
|
|
259
|
+
config.claims = { branchPrefix: "spaces are not refs/" }
|
|
241
260
|
writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`)
|
|
242
261
|
sh(bad, "git", "commit", "--quiet", "-am", "bad prefix")
|
|
243
|
-
const invalid = await cli(bad, "claim",
|
|
262
|
+
const invalid = await cli(bad, "claim", keys.two)
|
|
244
263
|
assert.equal(invalid.code, 2)
|
|
245
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/)
|
|
246
279
|
})
|
|
247
280
|
|
|
248
281
|
/**
|
|
@@ -252,7 +285,14 @@ test("a configured claimPrefix moves the whole namespace", async () => {
|
|
|
252
285
|
* optionally, the top-level `boards` list — NES deliberately ahead of ROO,
|
|
253
286
|
* so priority provably follows list order, not nearness.
|
|
254
287
|
*/
|
|
255
|
-
|
|
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 {
|
|
256
296
|
const home = tempDir()
|
|
257
297
|
const bare = join(home, "origin.git")
|
|
258
298
|
sh(home, "git", "init", "--quiet", "--bare", "-b", "main", bare)
|
|
@@ -268,11 +308,11 @@ function multiFixture(options: { boards?: string[]; maxOpenCount?: number } = {}
|
|
|
268
308
|
if (options.boards) config.boards = options.boards
|
|
269
309
|
config.claims = { branchPrefix: "task/claim/", maxOpenCount: options.maxOpenCount }
|
|
270
310
|
writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`)
|
|
271
|
-
root.create({ title: "Root ready", status: "todo" })
|
|
311
|
+
const rootKey = root.create({ title: "Root ready", status: "todo" }).key
|
|
272
312
|
|
|
273
313
|
mkdirSync(join(seed, "pkg"), { recursive: true })
|
|
274
314
|
const nested = initProject(join(seed, "pkg"), { name: "nested board", prefix: "NES" })
|
|
275
|
-
nested.create({ title: "Nested ready", status: "todo" })
|
|
315
|
+
const nestedKey = nested.create({ title: "Nested ready", status: "todo" }).key
|
|
276
316
|
|
|
277
317
|
sh(seed, "git", "add", "-A")
|
|
278
318
|
sh(seed, "git", "commit", "--quiet", "-m", "boards")
|
|
@@ -286,14 +326,14 @@ function multiFixture(options: { boards?: string[]; maxOpenCount?: number } = {}
|
|
|
286
326
|
sh(dir, "git", "config", "user.email", "test@example.com")
|
|
287
327
|
return dir
|
|
288
328
|
}
|
|
289
|
-
return { bare, clone }
|
|
329
|
+
return { bare, clone, rootKey, nestedKey }
|
|
290
330
|
}
|
|
291
331
|
|
|
292
332
|
test("claim --next drains the queue in position order and exits 1 when empty", async () => {
|
|
293
|
-
const { clone } = fixture()
|
|
333
|
+
const { clone, keys } = fixture()
|
|
294
334
|
const dir = clone()
|
|
295
335
|
|
|
296
|
-
const order:
|
|
336
|
+
const order: string[] = []
|
|
297
337
|
for (;;) {
|
|
298
338
|
const result = await cli(dir, "claim", "--next", "--json")
|
|
299
339
|
if (result.code === 1) {
|
|
@@ -301,13 +341,14 @@ test("claim --next drains the queue in position order and exits 1 when empty", a
|
|
|
301
341
|
break
|
|
302
342
|
}
|
|
303
343
|
assert.equal(result.code, 0, result.stderr)
|
|
304
|
-
const { task, branch } = JSON.parse(result.stdout) as { task: {
|
|
305
|
-
order.push(task.
|
|
344
|
+
const { task, branch } = JSON.parse(result.stdout) as { task: { key: string }; branch: string }
|
|
345
|
+
order.push(task.key)
|
|
306
346
|
assert.equal(sh(dir, "git", "rev-parse", "--abbrev-ref", "HEAD"), branch)
|
|
307
347
|
}
|
|
308
|
-
// The jumper leads (position order), then creation order; blocked
|
|
309
|
-
//
|
|
310
|
-
|
|
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])
|
|
311
352
|
})
|
|
312
353
|
|
|
313
354
|
test("two concurrent claim --next calls land on different tickets", async () => {
|
|
@@ -326,27 +367,27 @@ test("two concurrent claim --next calls land on different tickets", async () =>
|
|
|
326
367
|
})
|
|
327
368
|
|
|
328
369
|
test("--next walks the root boards list in priority order; --board overrides it", async () => {
|
|
329
|
-
const { clone } = multiFixture({ boards: ["NES", "ROO"] })
|
|
370
|
+
const { clone, rootKey, nestedKey } = multiFixture({ boards: ["NES", "ROO"] })
|
|
330
371
|
const a = clone()
|
|
331
372
|
|
|
332
373
|
// list --claimable reads the same scope, in the same order.
|
|
333
374
|
const listed = await cli(a, "list", "--claimable", "--json")
|
|
334
375
|
assert.equal(listed.code, 0, listed.stderr)
|
|
335
376
|
const { tasks } = JSON.parse(listed.stdout) as { tasks: { id: string }[] }
|
|
336
|
-
assert.deepEqual(tasks.map((t) => t.id), [
|
|
377
|
+
assert.deepEqual(tasks.map((t) => t.id), [`NES-${nestedKey}`, `ROO-${rootKey}`])
|
|
337
378
|
|
|
338
379
|
// The nested board wins despite the root board being nearest.
|
|
339
380
|
const next = await cli(a, "claim", "--next", "--json")
|
|
340
381
|
assert.equal(next.code, 0, next.stderr)
|
|
341
382
|
const claimed = JSON.parse(next.stdout) as { task: { id: string }; branch: string }
|
|
342
|
-
assert.equal(claimed.task.id,
|
|
343
|
-
assert.equal(claimed.branch,
|
|
383
|
+
assert.equal(claimed.task.id, `NES-${nestedKey}`)
|
|
384
|
+
assert.equal(claimed.branch, `task/claim/nes-${nestedKey}`)
|
|
344
385
|
|
|
345
386
|
// An explicit --board list is the whole scope, in the given order.
|
|
346
387
|
const b = clone()
|
|
347
388
|
const overridden = await cli(b, "claim", "--next", "--board", "ROO", "--json")
|
|
348
389
|
assert.equal(overridden.code, 0, overridden.stderr)
|
|
349
|
-
assert.equal((JSON.parse(overridden.stdout) as { task: { id: string } }).task.id,
|
|
390
|
+
assert.equal((JSON.parse(overridden.stdout) as { task: { id: string } }).task.id, `ROO-${rootKey}`)
|
|
350
391
|
|
|
351
392
|
const unknown = await cli(b, "claim", "--next", "--board", "NOPE")
|
|
352
393
|
assert.equal(unknown.code, 2)
|
|
@@ -354,12 +395,12 @@ test("--next walks the root boards list in priority order; --board overrides it"
|
|
|
354
395
|
})
|
|
355
396
|
|
|
356
397
|
test("without a boards list, --next sees the nearest board only", async () => {
|
|
357
|
-
const { clone } = multiFixture()
|
|
398
|
+
const { clone, rootKey } = multiFixture()
|
|
358
399
|
const dir = clone()
|
|
359
400
|
|
|
360
401
|
const next = await cli(dir, "claim", "--next", "--json")
|
|
361
402
|
assert.equal(next.code, 0, next.stderr)
|
|
362
|
-
assert.equal((JSON.parse(next.stdout) as { task: { id: string } }).task.id,
|
|
403
|
+
assert.equal((JSON.parse(next.stdout) as { task: { id: string } }).task.id, `ROO-${rootKey}`)
|
|
363
404
|
|
|
364
405
|
// ROO's queue is drained and NES never entered the scope.
|
|
365
406
|
const empty = await cli(dir, "claim", "--next", "--json")
|
|
@@ -367,7 +408,7 @@ test("without a boards list, --next sees the nearest board only", async () => {
|
|
|
367
408
|
})
|
|
368
409
|
|
|
369
410
|
test("maxOpenCount caps every claim repo-wide; --force is the escape", async () => {
|
|
370
|
-
const { clone } = multiFixture({ boards: ["NES", "ROO"], maxOpenCount: 1 })
|
|
411
|
+
const { clone, rootKey } = multiFixture({ boards: ["NES", "ROO"], maxOpenCount: 1 })
|
|
371
412
|
const a = clone()
|
|
372
413
|
assert.equal((await cli(a, "claim", "--next", "--json")).code, 0)
|
|
373
414
|
|
|
@@ -377,34 +418,35 @@ test("maxOpenCount caps every claim repo-wide; --force is the escape", async ()
|
|
|
377
418
|
const capped = await cli(b, "claim", "--next")
|
|
378
419
|
assert.equal(capped.code, 2)
|
|
379
420
|
assert.match(capped.stderr, /maxOpenCount cap \(1\)/)
|
|
380
|
-
const explicit = await cli(b, "claim",
|
|
421
|
+
const explicit = await cli(b, "claim", `ROO-${rootKey}`)
|
|
381
422
|
assert.equal(explicit.code, 2)
|
|
382
423
|
assert.match(explicit.stderr, /maxOpenCount cap \(1\)/)
|
|
383
424
|
|
|
384
425
|
const forced = await cli(b, "claim", "--next", "--force", "--json")
|
|
385
426
|
assert.equal(forced.code, 0, forced.stderr)
|
|
386
|
-
assert.equal((JSON.parse(forced.stdout) as { task: { id: string } }).task.id,
|
|
427
|
+
assert.equal((JSON.parse(forced.stdout) as { task: { id: string } }).task.id, `ROO-${rootKey}`)
|
|
387
428
|
})
|
|
388
429
|
|
|
389
430
|
test("release requires a reason and leaves it as a comment on the ticket", async () => {
|
|
390
|
-
const { clone } = fixture()
|
|
431
|
+
const { clone, keys } = fixture()
|
|
391
432
|
const dir = clone()
|
|
392
|
-
assert.equal((await cli(dir, "claim",
|
|
433
|
+
assert.equal((await cli(dir, "claim", keys.one)).code, 0)
|
|
393
434
|
|
|
394
435
|
// No comment, and an empty one, are both refused before anything happens.
|
|
395
|
-
const missing = await cli(dir, "claim", "--release",
|
|
436
|
+
const missing = await cli(dir, "claim", "--release", keys.one)
|
|
396
437
|
assert.equal(missing.code, 1)
|
|
397
438
|
assert.match(missing.stderr, /requires --comment/)
|
|
398
|
-
const empty = await cli(dir, "claim", "--release",
|
|
439
|
+
const empty = await cli(dir, "claim", "--release", keys.one, "--comment", "")
|
|
399
440
|
assert.equal(empty.code, 1)
|
|
400
441
|
assert.match(empty.stderr, /requires --comment/)
|
|
401
|
-
assert.
|
|
402
|
-
sh(dir, "git", "ls-remote", "--heads", "origin",
|
|
403
|
-
|
|
442
|
+
assert.ok(
|
|
443
|
+
sh(dir, "git", "ls-remote", "--heads", "origin", `task/claim/clm-${keys.one}`).includes(
|
|
444
|
+
`clm-${keys.one}`,
|
|
445
|
+
),
|
|
404
446
|
)
|
|
405
447
|
|
|
406
448
|
const released = await cli(
|
|
407
|
-
dir, "claim", "--release",
|
|
449
|
+
dir, "claim", "--release", keys.one,
|
|
408
450
|
"--comment", "the migration kept flaking — needs a fixture, not a retry",
|
|
409
451
|
"--author", "claude",
|
|
410
452
|
)
|
|
@@ -412,19 +454,19 @@ test("release requires a reason and leaves it as a comment on the ticket", async
|
|
|
412
454
|
|
|
413
455
|
// The reason survives the branch: an ordinary attributed comment, sitting
|
|
414
456
|
// uncommitted in the working tree on the default branch.
|
|
415
|
-
const shown = await cli(dir, "show",
|
|
457
|
+
const shown = await cli(dir, "show", keys.one, "--json")
|
|
416
458
|
const { comments } = JSON.parse(shown.stdout) as {
|
|
417
459
|
comments: { author: string; body: string }[]
|
|
418
460
|
}
|
|
419
461
|
assert.equal(comments.length, 1)
|
|
420
462
|
assert.equal(comments[0].author, "claude")
|
|
421
463
|
assert.match(comments[0].body, /kept flaking/)
|
|
422
|
-
assert.
|
|
464
|
+
assert.ok(sh(dir, "git", "status", "--porcelain").includes(`tickets/${keys.one}/comments`))
|
|
423
465
|
|
|
424
466
|
// Releasing an unclaimed ticket stays a no-op — no second comment.
|
|
425
|
-
const noop = await cli(dir, "claim", "--release",
|
|
467
|
+
const noop = await cli(dir, "claim", "--release", keys.one, "--comment", "again")
|
|
426
468
|
assert.equal(noop.code, 0)
|
|
427
469
|
assert.match(noop.stdout, /wasn't claimed/)
|
|
428
|
-
const after = await cli(dir, "show",
|
|
470
|
+
const after = await cli(dir, "show", keys.one, "--json")
|
|
429
471
|
assert.equal((JSON.parse(after.stdout) as { comments: unknown[] }).comments.length, 1)
|
|
430
472
|
})
|