@nickmeriano/task 0.8.0 → 0.10.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 +137 -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 +108 -23
- package/dist/claim.d.ts.map +1 -1
- package/dist/claim.js +320 -86
- package/dist/claim.js.map +1 -1
- package/dist/claim.test.d.ts +10 -2
- package/dist/claim.test.d.ts.map +1 -1
- package/dist/claim.test.js +282 -96
- package/dist/claim.test.js.map +1 -1
- package/dist/cli.js +346 -68
- 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 +96 -24
- 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 +372 -101
- package/src/claim.ts +418 -99
- package/src/cli.ts +370 -67
- 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,16 +6,24 @@
|
|
|
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
|
+
* 5. `task claim --lock-only` takes the same lock without touching the
|
|
14
|
+
* checkout — a branch-pinned (even dirty) session can hold it — and
|
|
15
|
+
* `--release` works on it symmetrically.
|
|
16
|
+
* 6. Deletions are verified: against a remote that silently drops deletion
|
|
17
|
+
* pushes (TAS-8b7s9), release and sweep fail loudly instead of lying.
|
|
18
|
+
* 7. `task sweep` deletes claim branches whose tickets are done, canceled or
|
|
19
|
+
* missing on the current checkout, and leaves live ones — and any branch
|
|
20
|
+
* named outside the claim scheme — alone.
|
|
13
21
|
*/
|
|
14
22
|
|
|
15
23
|
import assert from "node:assert/strict"
|
|
16
24
|
import { test } from "node:test"
|
|
17
25
|
import { execFile, spawnSync } from "node:child_process"
|
|
18
|
-
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"
|
|
26
|
+
import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"
|
|
19
27
|
import { readFileSync } from "node:fs"
|
|
20
28
|
import { tmpdir } from "node:os"
|
|
21
29
|
import { join } from "node:path"
|
|
@@ -46,11 +54,16 @@ interface CliResult {
|
|
|
46
54
|
|
|
47
55
|
/** The CLI as callers see it — a subprocess with an exit code. */
|
|
48
56
|
function cli(cwd: string, ...args: string[]): Promise<CliResult> {
|
|
57
|
+
return cliEnv(cwd, {}, ...args)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Like cli(), with extra env — the delete-dropping git shim rides in PATH. */
|
|
61
|
+
function cliEnv(cwd: string, env: Record<string, string>, ...args: string[]): Promise<CliResult> {
|
|
49
62
|
return new Promise((resolve) => {
|
|
50
63
|
execFile(
|
|
51
64
|
process.execPath,
|
|
52
65
|
["--experimental-strip-types", CLI, ...args],
|
|
53
|
-
{ cwd },
|
|
66
|
+
{ cwd, env: { ...process.env, ...env } },
|
|
54
67
|
(error, stdout, stderr) => {
|
|
55
68
|
resolve({ code: error ? ((error as { code?: number }).code ?? 1) : 0, stdout, stderr })
|
|
56
69
|
},
|
|
@@ -58,23 +71,45 @@ function cli(cwd: string, ...args: string[]): Promise<CliResult> {
|
|
|
58
71
|
})
|
|
59
72
|
}
|
|
60
73
|
|
|
74
|
+
/**
|
|
75
|
+
* A PATH whose `git` silently swallows branch-deletion pushes — exit 0,
|
|
76
|
+
* nothing deleted — and delegates everything else to the real git. This is
|
|
77
|
+
* the observed cloud-proxy behavior TAS-8b7s9 documents: the deletion "works"
|
|
78
|
+
* while the ref survives, which is exactly what verified release must catch.
|
|
79
|
+
* POSIX-only by construction, like the failure it reproduces.
|
|
80
|
+
*/
|
|
81
|
+
function deleteDroppingPath(): string {
|
|
82
|
+
const dir = mkdtempSync(join(tmpdir(), "task-fake-git-"))
|
|
83
|
+
process.on("exit", () => rmSync(dir, { recursive: true, force: true }))
|
|
84
|
+
const real = spawnSync("sh", ["-c", "command -v git"], { encoding: "utf8" }).stdout.trim()
|
|
85
|
+
const shim = join(dir, "git")
|
|
86
|
+
writeFileSync(
|
|
87
|
+
shim,
|
|
88
|
+
`#!/bin/sh\nfor a in "$@"; do case "$a" in --delete) exit 0;; esac; done\nexec "${real}" "$@"\n`,
|
|
89
|
+
)
|
|
90
|
+
chmodSync(shim, 0o755)
|
|
91
|
+
return `${dir}:${process.env.PATH ?? ""}`
|
|
92
|
+
}
|
|
93
|
+
|
|
61
94
|
interface Fixture {
|
|
62
95
|
bare: string
|
|
63
96
|
clone: () => string
|
|
97
|
+
/** Keys of the seeded tickets, by role. */
|
|
98
|
+
keys: { one: string; two: string; blocked: string; person: string; parked: string; jumper: string }
|
|
64
99
|
}
|
|
65
100
|
|
|
66
101
|
/**
|
|
67
102
|
* A bare origin holding a board with the claimable shapes:
|
|
68
|
-
*
|
|
69
|
-
*
|
|
70
|
-
*
|
|
71
|
-
*
|
|
72
|
-
*
|
|
73
|
-
*
|
|
74
|
-
*
|
|
75
|
-
*
|
|
103
|
+
* one "Ready one" todo
|
|
104
|
+
* two "Ready two" todo
|
|
105
|
+
* blocked "Blocked" todo, blocked by `one`
|
|
106
|
+
* person "For a person" todo, carrying an open ask
|
|
107
|
+
* parked "Still parked" backlog
|
|
108
|
+
* jumper "Jumped queue" todo, moved to the top of the column after
|
|
109
|
+
* creation — claimable order must be [jumper, one, two], proving
|
|
110
|
+
* position order beats creation order.
|
|
76
111
|
*/
|
|
77
|
-
function fixture(
|
|
112
|
+
function fixture(branchPrefix?: string): Fixture {
|
|
78
113
|
const home = tempDir()
|
|
79
114
|
const bare = join(home, "origin.git")
|
|
80
115
|
sh(home, "git", "init", "--quiet", "--bare", "-b", "main", bare)
|
|
@@ -84,20 +119,29 @@ function fixture(claimPrefix?: string): Fixture {
|
|
|
84
119
|
sh(seed, "git", "config", "user.name", "Test")
|
|
85
120
|
sh(seed, "git", "config", "user.email", "test@example.com")
|
|
86
121
|
const store = initProject(seed, { name: "claim board", prefix: "CLM" })
|
|
87
|
-
if (
|
|
122
|
+
if (branchPrefix !== undefined) {
|
|
88
123
|
const configPath = join(seed, ".task", "config.json")
|
|
89
124
|
const config = JSON.parse(readFileSync(configPath, "utf8")) as Record<string, unknown>
|
|
90
|
-
config.
|
|
125
|
+
config.claims = { branchPrefix }
|
|
91
126
|
writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`)
|
|
92
127
|
}
|
|
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.
|
|
128
|
+
const one = store.create({ title: "Ready one", status: "todo" })
|
|
129
|
+
const two = store.create({ title: "Ready two", status: "todo" })
|
|
130
|
+
const blocked = store.create({ title: "Blocked", status: "todo", blockedBy: [one.key] })
|
|
131
|
+
const person = store.create({ title: "For a person", status: "todo" })
|
|
132
|
+
store.addAsk(person.key, "Approve the vendor contract", "claude")
|
|
133
|
+
const parked = store.create({ title: "Still parked", status: "backlog" })
|
|
98
134
|
const jumper = store.create({ title: "Jumped queue", status: "backlog" })
|
|
99
135
|
// A status move without an explicit slot lands on top of the new column.
|
|
100
|
-
store.update(jumper.
|
|
136
|
+
store.update(jumper.key, { status: "todo" })
|
|
137
|
+
const keys = {
|
|
138
|
+
one: one.key,
|
|
139
|
+
two: two.key,
|
|
140
|
+
blocked: blocked.key,
|
|
141
|
+
person: person.key,
|
|
142
|
+
parked: parked.key,
|
|
143
|
+
jumper: jumper.key,
|
|
144
|
+
}
|
|
101
145
|
sh(seed, "git", "add", "-A")
|
|
102
146
|
sh(seed, "git", "commit", "--quiet", "-m", "board")
|
|
103
147
|
sh(seed, "git", "push", "--quiet", "-u", "origin", "main")
|
|
@@ -110,15 +154,15 @@ function fixture(claimPrefix?: string): Fixture {
|
|
|
110
154
|
sh(dir, "git", "config", "user.email", "test@example.com")
|
|
111
155
|
return dir
|
|
112
156
|
}
|
|
113
|
-
return { bare, clone }
|
|
157
|
+
return { bare, clone, keys }
|
|
114
158
|
}
|
|
115
159
|
|
|
116
160
|
test("two concurrent claims of one ticket: exactly one wins", async () => {
|
|
117
|
-
const { bare, clone } = fixture()
|
|
161
|
+
const { bare, clone, keys } = fixture()
|
|
118
162
|
const a = clone()
|
|
119
163
|
const b = clone()
|
|
120
164
|
|
|
121
|
-
const [ra, rb] = await Promise.all([cli(a, "claim",
|
|
165
|
+
const [ra, rb] = await Promise.all([cli(a, "claim", keys.one), cli(b, "claim", keys.one)])
|
|
122
166
|
assert.deepEqual([ra.code, rb.code].sort(), [0, 1], `a: ${ra.stderr}\nb: ${rb.stderr}`)
|
|
123
167
|
|
|
124
168
|
const winner = ra.code === 0 ? a : b
|
|
@@ -126,123 +170,145 @@ test("two concurrent claims of one ticket: exactly one wins", async () => {
|
|
|
126
170
|
assert.match(loser.stderr, /already claimed|rejected/)
|
|
127
171
|
|
|
128
172
|
// The winner is left on the claim branch, flip committed and pushed.
|
|
129
|
-
|
|
173
|
+
const branch = `task/claim/clm-${keys.one}`
|
|
174
|
+
assert.equal(sh(winner, "git", "rev-parse", "--abbrev-ref", "HEAD"), branch)
|
|
130
175
|
assert.match(
|
|
131
|
-
readFileSync(join(winner, ".task", "tickets",
|
|
176
|
+
readFileSync(join(winner, ".task", "tickets", keys.one, "ticket.md"), "utf8"),
|
|
132
177
|
/status: in_progress/,
|
|
133
178
|
)
|
|
134
|
-
assert.
|
|
135
|
-
sh(winner, "git", "ls-remote", "--heads", "origin", "task/claim/clm-1"),
|
|
136
|
-
/refs\/heads\/task\/claim\/clm-1/,
|
|
137
|
-
)
|
|
179
|
+
assert.ok(sh(winner, "git", "ls-remote", "--heads", "origin", branch).includes(`refs/heads/${branch}`))
|
|
138
180
|
// Origin's default branch never saw the flip — the claim rides the branch.
|
|
139
181
|
assert.equal(sh(winner, "git", "diff", "--stat", "origin/main..HEAD", "--", "README.md"), "")
|
|
140
|
-
assert.match(
|
|
182
|
+
assert.match(
|
|
183
|
+
sh(winner, "git", "show", `origin/main:.task/tickets/${keys.one}/ticket.md`),
|
|
184
|
+
/status: todo/,
|
|
185
|
+
)
|
|
141
186
|
void bare
|
|
142
187
|
})
|
|
143
188
|
|
|
144
189
|
test("unclaimable tickets exit 2, before anything touches origin", async () => {
|
|
145
|
-
const { clone } = fixture()
|
|
190
|
+
const { clone, keys } = fixture()
|
|
146
191
|
const dir = clone()
|
|
147
192
|
|
|
148
|
-
assert.equal((await cli(dir, "claim",
|
|
149
|
-
assert.equal((await cli(dir, "claim",
|
|
150
|
-
assert.equal((await cli(dir, "claim", "
|
|
151
|
-
assert.
|
|
152
|
-
|
|
193
|
+
assert.equal((await cli(dir, "claim", keys.parked)).code, 2) // backlog
|
|
194
|
+
assert.equal((await cli(dir, "claim", keys.blocked)).code, 2) // blocked
|
|
195
|
+
assert.equal((await cli(dir, "claim", "zz9zz")).code, 2) // missing
|
|
196
|
+
assert.match(
|
|
197
|
+
(await cli(dir, "claim", keys.blocked)).stderr,
|
|
198
|
+
new RegExp(`blocked by CLM-${keys.one}`),
|
|
199
|
+
)
|
|
153
200
|
|
|
154
201
|
// A dirty tree refuses too — claiming switches branches. Only *tracked*
|
|
155
202
|
// modifications count: untracked files ride a branch switch untouched
|
|
156
203
|
// (release deliberately leaves one — the reason comment).
|
|
157
204
|
const gitignore = join(dir, ".task", ".gitignore")
|
|
158
205
|
writeFileSync(gitignore, `${readFileSync(gitignore, "utf8")}\nwip\n`)
|
|
159
|
-
const dirty = await cli(dir, "claim",
|
|
206
|
+
const dirty = await cli(dir, "claim", keys.two)
|
|
160
207
|
assert.equal(dirty.code, 2)
|
|
161
208
|
assert.match(dirty.stderr, /working tree is dirty/)
|
|
162
209
|
sh(dir, "git", "checkout", "--", ".task/.gitignore")
|
|
163
210
|
writeFileSync(join(dir, "scratch.txt"), "untracked is fine")
|
|
164
|
-
assert.equal((await cli(dir, "claim",
|
|
211
|
+
assert.equal((await cli(dir, "claim", keys.two)).code, 0)
|
|
165
212
|
|
|
166
213
|
// Nothing above created a stray claim branch for the failed ids.
|
|
167
214
|
const remote = sh(dir, "git", "ls-remote", "--heads", "origin", "task/claim/*")
|
|
168
215
|
assert.deepEqual(
|
|
169
216
|
remote.split("\n").map((l) => l.split("refs/heads/")[1]),
|
|
170
|
-
[
|
|
217
|
+
[`task/claim/clm-${keys.two}`],
|
|
171
218
|
)
|
|
172
219
|
})
|
|
173
220
|
|
|
174
221
|
test("list --claimable is the queue: position order, minus everything unclaimable", async () => {
|
|
175
|
-
const { clone } = fixture()
|
|
222
|
+
const { clone, keys } = fixture()
|
|
176
223
|
const a = clone()
|
|
177
|
-
assert.equal((await cli(a, "claim",
|
|
224
|
+
assert.equal((await cli(a, "claim", keys.jumper)).code, 0)
|
|
178
225
|
|
|
179
226
|
const b = clone()
|
|
180
227
|
const listed = await cli(b, "list", "--claimable", "--json")
|
|
181
228
|
assert.equal(listed.code, 0)
|
|
182
|
-
const { tasks } = JSON.parse(listed.stdout) as { tasks: {
|
|
183
|
-
//
|
|
184
|
-
//
|
|
185
|
-
|
|
229
|
+
const { tasks } = JSON.parse(listed.stdout) as { tasks: { key: string }[] }
|
|
230
|
+
// jumper claimed, blocked blocked, parked not todo — position order keeps
|
|
231
|
+
// one before two (creation order within the column). person carries an open
|
|
232
|
+
// ask and is claimable anyway: asks gate done, not claim.
|
|
233
|
+
assert.deepEqual(tasks.map((t) => t.key), [keys.one, keys.two, keys.person])
|
|
186
234
|
|
|
187
235
|
assert.equal((await cli(b, "list", "--claimable", "--status", "todo")).code, 1)
|
|
188
236
|
})
|
|
189
237
|
|
|
190
238
|
test("release deletes the branch everywhere and reopens the claim", async () => {
|
|
191
|
-
const { clone } = fixture()
|
|
239
|
+
const { clone, keys } = fixture()
|
|
192
240
|
const dir = clone()
|
|
193
|
-
assert.equal((await cli(dir, "claim",
|
|
194
|
-
assert.equal((await cli(dir, "claim",
|
|
241
|
+
assert.equal((await cli(dir, "claim", keys.two)).code, 0)
|
|
242
|
+
assert.equal((await cli(dir, "claim", keys.two)).code, 1) // locally claimed too
|
|
195
243
|
|
|
196
|
-
const released = await cli(dir, "claim", "--release",
|
|
244
|
+
const released = await cli(dir, "claim", "--release", keys.two, "--comment", "trying another shape")
|
|
197
245
|
assert.equal(released.code, 0, released.stderr)
|
|
198
|
-
assert.
|
|
246
|
+
assert.ok(released.stdout.includes(`released CLM-${keys.two}`), released.stdout)
|
|
199
247
|
|
|
200
248
|
// Back on the default branch, branch gone on both sides, ticket todo again.
|
|
201
249
|
assert.equal(sh(dir, "git", "rev-parse", "--abbrev-ref", "HEAD"), "main")
|
|
202
|
-
assert.equal(sh(dir, "git", "ls-remote", "--heads", "origin",
|
|
250
|
+
assert.equal(sh(dir, "git", "ls-remote", "--heads", "origin", `task/claim/clm-${keys.two}`), "")
|
|
203
251
|
assert.match(
|
|
204
|
-
readFileSync(join(dir, ".task", "tickets",
|
|
252
|
+
readFileSync(join(dir, ".task", "tickets", keys.two, "ticket.md"), "utf8"),
|
|
205
253
|
/status: todo/,
|
|
206
254
|
)
|
|
207
|
-
assert.equal((await cli(dir, "claim",
|
|
255
|
+
assert.equal((await cli(dir, "claim", keys.two)).code, 0)
|
|
208
256
|
|
|
209
257
|
// Releasing an unclaimed ticket is a quiet no-op, not an error.
|
|
210
|
-
const noop = await cli(dir, "claim", "--release",
|
|
258
|
+
const noop = await cli(dir, "claim", "--release", keys.one, "--comment", "noop")
|
|
211
259
|
assert.equal(noop.code, 0)
|
|
212
260
|
assert.match(noop.stdout, /wasn't claimed/)
|
|
213
261
|
})
|
|
214
262
|
|
|
215
|
-
test("a configured
|
|
216
|
-
const { clone } = fixture("claude/task/")
|
|
263
|
+
test("a configured claims.branchPrefix moves the whole namespace", async () => {
|
|
264
|
+
const { clone, keys } = fixture("claude/task/")
|
|
217
265
|
const dir = clone()
|
|
218
266
|
|
|
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
|
-
|
|
267
|
+
assert.equal((await cli(dir, "claim", keys.one)).code, 0)
|
|
268
|
+
assert.equal(sh(dir, "git", "rev-parse", "--abbrev-ref", "HEAD"), `claude/task/clm-${keys.one}`)
|
|
269
|
+
assert.ok(
|
|
270
|
+
sh(dir, "git", "ls-remote", "--heads", "origin", "claude/task/*").includes(
|
|
271
|
+
`refs/heads/claude/task/clm-${keys.one}`,
|
|
272
|
+
),
|
|
224
273
|
)
|
|
225
274
|
|
|
226
275
|
// Every side of the feature reads the same key: the queue looks for claims
|
|
227
276
|
// under the configured namespace, and release deletes there too.
|
|
228
277
|
const other = clone()
|
|
229
278
|
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(
|
|
279
|
+
const { tasks } = JSON.parse(listed.stdout) as { tasks: { key: string }[] }
|
|
280
|
+
assert.deepEqual(tasks.map((t) => t.key), [keys.jumper, keys.two, keys.person])
|
|
281
|
+
assert.equal((await cli(other, "claim", keys.one)).code, 1)
|
|
282
|
+
assert.equal(
|
|
283
|
+
(await cli(dir, "claim", "--release", keys.one, "--comment", "handing it back")).code,
|
|
284
|
+
0,
|
|
285
|
+
)
|
|
234
286
|
assert.equal(sh(dir, "git", "ls-remote", "--heads", "origin", "claude/task/*"), "")
|
|
235
287
|
|
|
236
288
|
// A missing trailing slash is forgiven; a malformed value fails loudly.
|
|
237
289
|
const bad = clone()
|
|
238
290
|
const configPath = join(bad, ".task", "config.json")
|
|
239
291
|
const config = JSON.parse(readFileSync(configPath, "utf8")) as Record<string, unknown>
|
|
240
|
-
config.
|
|
292
|
+
config.claims = { branchPrefix: "spaces are not refs/" }
|
|
241
293
|
writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`)
|
|
242
294
|
sh(bad, "git", "commit", "--quiet", "-am", "bad prefix")
|
|
243
|
-
const invalid = await cli(bad, "claim",
|
|
295
|
+
const invalid = await cli(bad, "claim", keys.two)
|
|
244
296
|
assert.equal(invalid.code, 2)
|
|
245
297
|
assert.match(invalid.stderr, /invalid claims\.branchPrefix/)
|
|
298
|
+
|
|
299
|
+
// The retired pre-0.8 `claimPrefix` spelling is not honored (TAS-41): a
|
|
300
|
+
// board carrying only it refuses by name rather than silently defaulting
|
|
301
|
+
// the lock convention out from under its workers.
|
|
302
|
+
const retired = clone()
|
|
303
|
+
const retiredPath = join(retired, ".task", "config.json")
|
|
304
|
+
const retiredConfig = JSON.parse(readFileSync(retiredPath, "utf8")) as Record<string, unknown>
|
|
305
|
+
delete retiredConfig.claims
|
|
306
|
+
retiredConfig.claimPrefix = "claude/task/"
|
|
307
|
+
writeFileSync(retiredPath, `${JSON.stringify(retiredConfig, null, 2)}\n`)
|
|
308
|
+
sh(retired, "git", "commit", "--quiet", "-am", "retired key")
|
|
309
|
+
const refused = await cli(retired, "claim", keys.two)
|
|
310
|
+
assert.equal(refused.code, 2)
|
|
311
|
+
assert.match(refused.stderr, /pre-0\.8 spelling and is no longer read/)
|
|
246
312
|
})
|
|
247
313
|
|
|
248
314
|
/**
|
|
@@ -252,7 +318,14 @@ test("a configured claimPrefix moves the whole namespace", async () => {
|
|
|
252
318
|
* optionally, the top-level `boards` list — NES deliberately ahead of ROO,
|
|
253
319
|
* so priority provably follows list order, not nearness.
|
|
254
320
|
*/
|
|
255
|
-
|
|
321
|
+
interface MultiFixture {
|
|
322
|
+
bare: string
|
|
323
|
+
clone: () => string
|
|
324
|
+
rootKey: string
|
|
325
|
+
nestedKey: string
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function multiFixture(options: { boards?: string[]; maxOpenCount?: number } = {}): MultiFixture {
|
|
256
329
|
const home = tempDir()
|
|
257
330
|
const bare = join(home, "origin.git")
|
|
258
331
|
sh(home, "git", "init", "--quiet", "--bare", "-b", "main", bare)
|
|
@@ -268,11 +341,11 @@ function multiFixture(options: { boards?: string[]; maxOpenCount?: number } = {}
|
|
|
268
341
|
if (options.boards) config.boards = options.boards
|
|
269
342
|
config.claims = { branchPrefix: "task/claim/", maxOpenCount: options.maxOpenCount }
|
|
270
343
|
writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`)
|
|
271
|
-
root.create({ title: "Root ready", status: "todo" })
|
|
344
|
+
const rootKey = root.create({ title: "Root ready", status: "todo" }).key
|
|
272
345
|
|
|
273
346
|
mkdirSync(join(seed, "pkg"), { recursive: true })
|
|
274
347
|
const nested = initProject(join(seed, "pkg"), { name: "nested board", prefix: "NES" })
|
|
275
|
-
nested.create({ title: "Nested ready", status: "todo" })
|
|
348
|
+
const nestedKey = nested.create({ title: "Nested ready", status: "todo" }).key
|
|
276
349
|
|
|
277
350
|
sh(seed, "git", "add", "-A")
|
|
278
351
|
sh(seed, "git", "commit", "--quiet", "-m", "boards")
|
|
@@ -286,14 +359,14 @@ function multiFixture(options: { boards?: string[]; maxOpenCount?: number } = {}
|
|
|
286
359
|
sh(dir, "git", "config", "user.email", "test@example.com")
|
|
287
360
|
return dir
|
|
288
361
|
}
|
|
289
|
-
return { bare, clone }
|
|
362
|
+
return { bare, clone, rootKey, nestedKey }
|
|
290
363
|
}
|
|
291
364
|
|
|
292
365
|
test("claim --next drains the queue in position order and exits 1 when empty", async () => {
|
|
293
|
-
const { clone } = fixture()
|
|
366
|
+
const { clone, keys } = fixture()
|
|
294
367
|
const dir = clone()
|
|
295
368
|
|
|
296
|
-
const order:
|
|
369
|
+
const order: string[] = []
|
|
297
370
|
for (;;) {
|
|
298
371
|
const result = await cli(dir, "claim", "--next", "--json")
|
|
299
372
|
if (result.code === 1) {
|
|
@@ -301,13 +374,14 @@ test("claim --next drains the queue in position order and exits 1 when empty", a
|
|
|
301
374
|
break
|
|
302
375
|
}
|
|
303
376
|
assert.equal(result.code, 0, result.stderr)
|
|
304
|
-
const { task, branch } = JSON.parse(result.stdout) as { task: {
|
|
305
|
-
order.push(task.
|
|
377
|
+
const { task, branch } = JSON.parse(result.stdout) as { task: { key: string }; branch: string }
|
|
378
|
+
order.push(task.key)
|
|
306
379
|
assert.equal(sh(dir, "git", "rev-parse", "--abbrev-ref", "HEAD"), branch)
|
|
307
380
|
}
|
|
308
|
-
// The jumper leads (position order), then creation order; blocked
|
|
309
|
-
//
|
|
310
|
-
|
|
381
|
+
// The jumper leads (position order), then creation order; blocked and
|
|
382
|
+
// backlog tickets never surface, while the open ask on `person` doesn't
|
|
383
|
+
// keep it out of the queue.
|
|
384
|
+
assert.deepEqual(order, [keys.jumper, keys.one, keys.two, keys.person])
|
|
311
385
|
})
|
|
312
386
|
|
|
313
387
|
test("two concurrent claim --next calls land on different tickets", async () => {
|
|
@@ -326,27 +400,27 @@ test("two concurrent claim --next calls land on different tickets", async () =>
|
|
|
326
400
|
})
|
|
327
401
|
|
|
328
402
|
test("--next walks the root boards list in priority order; --board overrides it", async () => {
|
|
329
|
-
const { clone } = multiFixture({ boards: ["NES", "ROO"] })
|
|
403
|
+
const { clone, rootKey, nestedKey } = multiFixture({ boards: ["NES", "ROO"] })
|
|
330
404
|
const a = clone()
|
|
331
405
|
|
|
332
406
|
// list --claimable reads the same scope, in the same order.
|
|
333
407
|
const listed = await cli(a, "list", "--claimable", "--json")
|
|
334
408
|
assert.equal(listed.code, 0, listed.stderr)
|
|
335
409
|
const { tasks } = JSON.parse(listed.stdout) as { tasks: { id: string }[] }
|
|
336
|
-
assert.deepEqual(tasks.map((t) => t.id), [
|
|
410
|
+
assert.deepEqual(tasks.map((t) => t.id), [`NES-${nestedKey}`, `ROO-${rootKey}`])
|
|
337
411
|
|
|
338
412
|
// The nested board wins despite the root board being nearest.
|
|
339
413
|
const next = await cli(a, "claim", "--next", "--json")
|
|
340
414
|
assert.equal(next.code, 0, next.stderr)
|
|
341
415
|
const claimed = JSON.parse(next.stdout) as { task: { id: string }; branch: string }
|
|
342
|
-
assert.equal(claimed.task.id,
|
|
343
|
-
assert.equal(claimed.branch,
|
|
416
|
+
assert.equal(claimed.task.id, `NES-${nestedKey}`)
|
|
417
|
+
assert.equal(claimed.branch, `task/claim/nes-${nestedKey}`)
|
|
344
418
|
|
|
345
419
|
// An explicit --board list is the whole scope, in the given order.
|
|
346
420
|
const b = clone()
|
|
347
421
|
const overridden = await cli(b, "claim", "--next", "--board", "ROO", "--json")
|
|
348
422
|
assert.equal(overridden.code, 0, overridden.stderr)
|
|
349
|
-
assert.equal((JSON.parse(overridden.stdout) as { task: { id: string } }).task.id,
|
|
423
|
+
assert.equal((JSON.parse(overridden.stdout) as { task: { id: string } }).task.id, `ROO-${rootKey}`)
|
|
350
424
|
|
|
351
425
|
const unknown = await cli(b, "claim", "--next", "--board", "NOPE")
|
|
352
426
|
assert.equal(unknown.code, 2)
|
|
@@ -354,12 +428,12 @@ test("--next walks the root boards list in priority order; --board overrides it"
|
|
|
354
428
|
})
|
|
355
429
|
|
|
356
430
|
test("without a boards list, --next sees the nearest board only", async () => {
|
|
357
|
-
const { clone } = multiFixture()
|
|
431
|
+
const { clone, rootKey } = multiFixture()
|
|
358
432
|
const dir = clone()
|
|
359
433
|
|
|
360
434
|
const next = await cli(dir, "claim", "--next", "--json")
|
|
361
435
|
assert.equal(next.code, 0, next.stderr)
|
|
362
|
-
assert.equal((JSON.parse(next.stdout) as { task: { id: string } }).task.id,
|
|
436
|
+
assert.equal((JSON.parse(next.stdout) as { task: { id: string } }).task.id, `ROO-${rootKey}`)
|
|
363
437
|
|
|
364
438
|
// ROO's queue is drained and NES never entered the scope.
|
|
365
439
|
const empty = await cli(dir, "claim", "--next", "--json")
|
|
@@ -367,7 +441,7 @@ test("without a boards list, --next sees the nearest board only", async () => {
|
|
|
367
441
|
})
|
|
368
442
|
|
|
369
443
|
test("maxOpenCount caps every claim repo-wide; --force is the escape", async () => {
|
|
370
|
-
const { clone } = multiFixture({ boards: ["NES", "ROO"], maxOpenCount: 1 })
|
|
444
|
+
const { clone, rootKey } = multiFixture({ boards: ["NES", "ROO"], maxOpenCount: 1 })
|
|
371
445
|
const a = clone()
|
|
372
446
|
assert.equal((await cli(a, "claim", "--next", "--json")).code, 0)
|
|
373
447
|
|
|
@@ -377,34 +451,35 @@ test("maxOpenCount caps every claim repo-wide; --force is the escape", async ()
|
|
|
377
451
|
const capped = await cli(b, "claim", "--next")
|
|
378
452
|
assert.equal(capped.code, 2)
|
|
379
453
|
assert.match(capped.stderr, /maxOpenCount cap \(1\)/)
|
|
380
|
-
const explicit = await cli(b, "claim",
|
|
454
|
+
const explicit = await cli(b, "claim", `ROO-${rootKey}`)
|
|
381
455
|
assert.equal(explicit.code, 2)
|
|
382
456
|
assert.match(explicit.stderr, /maxOpenCount cap \(1\)/)
|
|
383
457
|
|
|
384
458
|
const forced = await cli(b, "claim", "--next", "--force", "--json")
|
|
385
459
|
assert.equal(forced.code, 0, forced.stderr)
|
|
386
|
-
assert.equal((JSON.parse(forced.stdout) as { task: { id: string } }).task.id,
|
|
460
|
+
assert.equal((JSON.parse(forced.stdout) as { task: { id: string } }).task.id, `ROO-${rootKey}`)
|
|
387
461
|
})
|
|
388
462
|
|
|
389
463
|
test("release requires a reason and leaves it as a comment on the ticket", async () => {
|
|
390
|
-
const { clone } = fixture()
|
|
464
|
+
const { clone, keys } = fixture()
|
|
391
465
|
const dir = clone()
|
|
392
|
-
assert.equal((await cli(dir, "claim",
|
|
466
|
+
assert.equal((await cli(dir, "claim", keys.one)).code, 0)
|
|
393
467
|
|
|
394
468
|
// No comment, and an empty one, are both refused before anything happens.
|
|
395
|
-
const missing = await cli(dir, "claim", "--release",
|
|
469
|
+
const missing = await cli(dir, "claim", "--release", keys.one)
|
|
396
470
|
assert.equal(missing.code, 1)
|
|
397
471
|
assert.match(missing.stderr, /requires --comment/)
|
|
398
|
-
const empty = await cli(dir, "claim", "--release",
|
|
472
|
+
const empty = await cli(dir, "claim", "--release", keys.one, "--comment", "")
|
|
399
473
|
assert.equal(empty.code, 1)
|
|
400
474
|
assert.match(empty.stderr, /requires --comment/)
|
|
401
|
-
assert.
|
|
402
|
-
sh(dir, "git", "ls-remote", "--heads", "origin",
|
|
403
|
-
|
|
475
|
+
assert.ok(
|
|
476
|
+
sh(dir, "git", "ls-remote", "--heads", "origin", `task/claim/clm-${keys.one}`).includes(
|
|
477
|
+
`clm-${keys.one}`,
|
|
478
|
+
),
|
|
404
479
|
)
|
|
405
480
|
|
|
406
481
|
const released = await cli(
|
|
407
|
-
dir, "claim", "--release",
|
|
482
|
+
dir, "claim", "--release", keys.one,
|
|
408
483
|
"--comment", "the migration kept flaking — needs a fixture, not a retry",
|
|
409
484
|
"--author", "claude",
|
|
410
485
|
)
|
|
@@ -412,19 +487,215 @@ test("release requires a reason and leaves it as a comment on the ticket", async
|
|
|
412
487
|
|
|
413
488
|
// The reason survives the branch: an ordinary attributed comment, sitting
|
|
414
489
|
// uncommitted in the working tree on the default branch.
|
|
415
|
-
const shown = await cli(dir, "show",
|
|
490
|
+
const shown = await cli(dir, "show", keys.one, "--json")
|
|
416
491
|
const { comments } = JSON.parse(shown.stdout) as {
|
|
417
492
|
comments: { author: string; body: string }[]
|
|
418
493
|
}
|
|
419
494
|
assert.equal(comments.length, 1)
|
|
420
495
|
assert.equal(comments[0].author, "claude")
|
|
421
496
|
assert.match(comments[0].body, /kept flaking/)
|
|
422
|
-
assert.
|
|
497
|
+
assert.ok(sh(dir, "git", "status", "--porcelain").includes(`tickets/${keys.one}/comments`))
|
|
423
498
|
|
|
424
499
|
// Releasing an unclaimed ticket stays a no-op — no second comment.
|
|
425
|
-
const noop = await cli(dir, "claim", "--release",
|
|
500
|
+
const noop = await cli(dir, "claim", "--release", keys.one, "--comment", "again")
|
|
426
501
|
assert.equal(noop.code, 0)
|
|
427
502
|
assert.match(noop.stdout, /wasn't claimed/)
|
|
428
|
-
const after = await cli(dir, "show",
|
|
503
|
+
const after = await cli(dir, "show", keys.one, "--json")
|
|
429
504
|
assert.equal((JSON.parse(after.stdout) as { comments: unknown[] }).comments.length, 1)
|
|
430
505
|
})
|
|
506
|
+
|
|
507
|
+
test("--lock-only claims from a dirty, branch-pinned checkout without touching it", async () => {
|
|
508
|
+
const { clone, keys } = fixture()
|
|
509
|
+
const dir = clone()
|
|
510
|
+
const branch = `task/claim/clm-${keys.one}`
|
|
511
|
+
|
|
512
|
+
// The branch-pinned session: a provisioned branch, uncommitted tracked work.
|
|
513
|
+
sh(dir, "git", "checkout", "--quiet", "-b", "pinned")
|
|
514
|
+
const gitignore = join(dir, ".task", ".gitignore")
|
|
515
|
+
writeFileSync(gitignore, `${readFileSync(gitignore, "utf8")}\nwip\n`)
|
|
516
|
+
|
|
517
|
+
const claimed = await cli(dir, "claim", keys.one, "--lock-only", "--json")
|
|
518
|
+
assert.equal(claimed.code, 0, claimed.stderr)
|
|
519
|
+
const parsed = JSON.parse(claimed.stdout) as {
|
|
520
|
+
task: { status: string }
|
|
521
|
+
branch: string
|
|
522
|
+
deliveredBy: string
|
|
523
|
+
}
|
|
524
|
+
assert.equal(parsed.branch, branch)
|
|
525
|
+
assert.equal(parsed.deliveredBy, "pinned")
|
|
526
|
+
assert.equal(parsed.task.status, "in_progress")
|
|
527
|
+
|
|
528
|
+
// The checkout is exactly where it was: same branch, same dirty file, and
|
|
529
|
+
// the local ticket file never flipped — the claim lives on origin only.
|
|
530
|
+
assert.equal(sh(dir, "git", "rev-parse", "--abbrev-ref", "HEAD"), "pinned")
|
|
531
|
+
assert.match(sh(dir, "git", "status", "--porcelain", "-uno"), /\.task\/\.gitignore/)
|
|
532
|
+
assert.match(readFileSync(join(dir, ".task", "tickets", keys.one, "ticket.md"), "utf8"), /status: todo/)
|
|
533
|
+
|
|
534
|
+
// On origin the claim is a real flip commit built on the default branch,
|
|
535
|
+
// carrying the delivery branch as a trailer.
|
|
536
|
+
assert.ok(sh(dir, "git", "ls-remote", "--heads", "origin", branch).includes(`refs/heads/${branch}`))
|
|
537
|
+
assert.equal(
|
|
538
|
+
sh(dir, "git", "rev-parse", `origin/${branch}~1`),
|
|
539
|
+
sh(dir, "git", "rev-parse", "origin/main"),
|
|
540
|
+
)
|
|
541
|
+
assert.match(sh(dir, "git", "show", `origin/${branch}:.task/tickets/${keys.one}/ticket.md`), /status: in_progress/)
|
|
542
|
+
const message = sh(dir, "git", "log", "-1", "--format=%B", `origin/${branch}`)
|
|
543
|
+
assert.match(message, /lock-only/)
|
|
544
|
+
assert.match(message, new RegExp(`delivered_by: pinned`))
|
|
545
|
+
|
|
546
|
+
// The lock is the same lock: both claim flavors lose to it from elsewhere.
|
|
547
|
+
const other = clone()
|
|
548
|
+
assert.equal((await cli(other, "claim", keys.one)).code, 1)
|
|
549
|
+
assert.equal((await cli(other, "claim", keys.one, "--lock-only")).code, 1)
|
|
550
|
+
|
|
551
|
+
// Release is symmetric: no local branch to delete, the origin one goes, and
|
|
552
|
+
// the pinned checkout still isn't touched.
|
|
553
|
+
const released = await cli(dir, "claim", "--release", keys.one, "--comment", "handing back a lock-only claim")
|
|
554
|
+
assert.equal(released.code, 0, released.stderr)
|
|
555
|
+
assert.equal(sh(dir, "git", "rev-parse", "--abbrev-ref", "HEAD"), "pinned")
|
|
556
|
+
assert.equal(sh(dir, "git", "ls-remote", "--heads", "origin", branch), "")
|
|
557
|
+
assert.equal((await cli(other, "claim", keys.one)).code, 0)
|
|
558
|
+
})
|
|
559
|
+
|
|
560
|
+
test("--lock-only validates against origin's default branch, not the checkout", async () => {
|
|
561
|
+
const { clone, keys } = fixture()
|
|
562
|
+
const dir = clone()
|
|
563
|
+
|
|
564
|
+
// Not todo on the base tree → exit 2, and nothing minted on origin.
|
|
565
|
+
const parked = await cli(dir, "claim", keys.parked, "--lock-only")
|
|
566
|
+
assert.equal(parked.code, 2)
|
|
567
|
+
assert.match(parked.stderr, /is backlog on origin\/main/)
|
|
568
|
+
|
|
569
|
+
// A local promote that hasn't landed on the default branch doesn't count:
|
|
570
|
+
// the flip commit is built on origin/main, where the ticket is still backlog.
|
|
571
|
+
assert.equal((await cli(dir, "promote", keys.parked)).code, 0)
|
|
572
|
+
const promoted = await cli(dir, "claim", keys.parked, "--lock-only")
|
|
573
|
+
assert.equal(promoted.code, 2)
|
|
574
|
+
assert.match(promoted.stderr, /is backlog on origin\/main/)
|
|
575
|
+
assert.equal(sh(dir, "git", "ls-remote", "--heads", "origin", `task/claim/clm-${keys.parked}`), "")
|
|
576
|
+
|
|
577
|
+
// Blocked on the base tree refuses too.
|
|
578
|
+
const blocked = await cli(dir, "claim", keys.blocked, "--lock-only")
|
|
579
|
+
assert.equal(blocked.code, 2)
|
|
580
|
+
assert.match(blocked.stderr, new RegExp(`blocked by CLM-${keys.one}`))
|
|
581
|
+
})
|
|
582
|
+
|
|
583
|
+
test("plain claim warns when it moves the checkout off a non-default branch", async () => {
|
|
584
|
+
const { clone, keys } = fixture()
|
|
585
|
+
const dir = clone()
|
|
586
|
+
sh(dir, "git", "checkout", "--quiet", "-b", "feature")
|
|
587
|
+
|
|
588
|
+
const claimed = await cli(dir, "claim", keys.one)
|
|
589
|
+
assert.equal(claimed.code, 0, claimed.stderr)
|
|
590
|
+
assert.match(claimed.stdout, /moved you off feature/)
|
|
591
|
+
assert.match(claimed.stdout, /--lock-only/)
|
|
592
|
+
|
|
593
|
+
// From the default branch there is nothing to warn about.
|
|
594
|
+
const other = clone()
|
|
595
|
+
const quiet = await cli(other, "claim", keys.two)
|
|
596
|
+
assert.equal(quiet.code, 0, quiet.stderr)
|
|
597
|
+
assert.doesNotMatch(quiet.stdout, /moved you off/)
|
|
598
|
+
})
|
|
599
|
+
|
|
600
|
+
test("sweep deletes done/canceled/missing claims and keeps live ones", async () => {
|
|
601
|
+
const { clone, keys } = fixture()
|
|
602
|
+
const a = clone()
|
|
603
|
+
assert.equal((await cli(a, "claim", keys.one)).code, 0)
|
|
604
|
+
assert.equal((await cli(a, "claim", keys.two)).code, 0)
|
|
605
|
+
assert.equal((await cli(a, "claim", keys.person)).code, 0)
|
|
606
|
+
|
|
607
|
+
// A second checkout is where the tickets' fates land (a merge, normally):
|
|
608
|
+
// one finishes, person is canceled, two stays live — plus a branch under
|
|
609
|
+
// the namespace whose ticket never existed.
|
|
610
|
+
const b = clone()
|
|
611
|
+
assert.equal((await cli(b, "move", keys.one, "done")).code, 0)
|
|
612
|
+
assert.equal((await cli(b, "move", keys.person, "canceled")).code, 0)
|
|
613
|
+
sh(b, "git", "push", "--quiet", "origin", "main:refs/heads/task/claim/clm-zz9zz")
|
|
614
|
+
// Namespace squatters sweep must NOT touch: a pre-migration numeric claim
|
|
615
|
+
// (its branch may head an open PR carrying unmerged work — the TAS-31 case)
|
|
616
|
+
// and a branch matching no board's prefix. Skipped and reported, never
|
|
617
|
+
// deleted — sweep's jurisdiction is exactly the names `claim` mints.
|
|
618
|
+
sh(b, "git", "push", "--quiet", "origin", "main:refs/heads/task/claim/clm-31")
|
|
619
|
+
sh(b, "git", "push", "--quiet", "origin", "main:refs/heads/task/claim/other-zz9zz")
|
|
620
|
+
|
|
621
|
+
const swept = await cli(b, "sweep", "--json")
|
|
622
|
+
assert.equal(swept.code, 0, swept.stderr)
|
|
623
|
+
const { sweep } = JSON.parse(swept.stdout) as {
|
|
624
|
+
sweep: {
|
|
625
|
+
swept: { branch: string; reason: string }[]
|
|
626
|
+
kept: { branch: string; status: string }[]
|
|
627
|
+
skipped: string[]
|
|
628
|
+
failed: unknown[]
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
assert.deepEqual(
|
|
632
|
+
sweep.swept.map((s) => [s.branch, s.reason]).sort(),
|
|
633
|
+
[
|
|
634
|
+
[`task/claim/clm-${keys.one}`, "done"],
|
|
635
|
+
[`task/claim/clm-${keys.person}`, "canceled"],
|
|
636
|
+
["task/claim/clm-zz9zz", "missing"],
|
|
637
|
+
].sort(),
|
|
638
|
+
)
|
|
639
|
+
assert.deepEqual(sweep.kept, [
|
|
640
|
+
{ branch: `task/claim/clm-${keys.two}`, ticket: `CLM-${keys.two}`, status: "todo" },
|
|
641
|
+
])
|
|
642
|
+
assert.deepEqual(sweep.skipped.sort(), ["task/claim/clm-31", "task/claim/other-zz9zz"])
|
|
643
|
+
assert.equal(sweep.failed.length, 0)
|
|
644
|
+
|
|
645
|
+
// Origin agrees: the live claim and the out-of-scheme branches survive,
|
|
646
|
+
// and a re-run is a no-op.
|
|
647
|
+
const remote = sh(b, "git", "ls-remote", "--heads", "origin", "task/claim/*")
|
|
648
|
+
assert.deepEqual(
|
|
649
|
+
remote.split("\n").map((l) => l.split("refs/heads/")[1]).sort(),
|
|
650
|
+
["task/claim/clm-31", `task/claim/clm-${keys.two}`, "task/claim/other-zz9zz"].sort(),
|
|
651
|
+
)
|
|
652
|
+
const again = await cli(b, "sweep", "--json")
|
|
653
|
+
assert.equal(again.code, 0)
|
|
654
|
+
assert.equal((JSON.parse(again.stdout) as { sweep: { swept: unknown[] } }).sweep.swept.length, 0)
|
|
655
|
+
})
|
|
656
|
+
|
|
657
|
+
test(
|
|
658
|
+
"verified deletion: a remote that drops deletions makes release and sweep fail loudly",
|
|
659
|
+
{ skip: process.platform === "win32" },
|
|
660
|
+
async () => {
|
|
661
|
+
const { clone, keys } = fixture()
|
|
662
|
+
const path = deleteDroppingPath()
|
|
663
|
+
const dir = clone()
|
|
664
|
+
assert.equal((await cli(dir, "claim", keys.one)).code, 0)
|
|
665
|
+
|
|
666
|
+
// Release under the shim: git says the deletion worked, the ref survives —
|
|
667
|
+
// pre-TAS-8b7s9 this reported "released" and left a stale lock behind.
|
|
668
|
+
const released = await cliEnv(
|
|
669
|
+
dir, { PATH: path },
|
|
670
|
+
"claim", "--release", keys.one, "--comment", "should not land",
|
|
671
|
+
)
|
|
672
|
+
assert.notEqual(released.code, 0)
|
|
673
|
+
assert.match(released.stderr, /origin still has/)
|
|
674
|
+
assert.doesNotMatch(released.stdout, /released CLM/)
|
|
675
|
+
assert.ok(
|
|
676
|
+
sh(dir, "git", "ls-remote", "--heads", "origin", `task/claim/clm-${keys.one}`).includes(
|
|
677
|
+
`clm-${keys.one}`,
|
|
678
|
+
),
|
|
679
|
+
)
|
|
680
|
+
// No release, no comment: the failure must not read as a done release.
|
|
681
|
+
const shown = await cli(dir, "show", keys.one, "--json")
|
|
682
|
+
assert.equal((JSON.parse(shown.stdout) as { comments: unknown[] }).comments.length, 0)
|
|
683
|
+
|
|
684
|
+
// Sweep under the shim reports the failed deletion and exits non-zero.
|
|
685
|
+
const b = clone()
|
|
686
|
+
assert.equal((await cli(b, "move", keys.one, "done")).code, 0)
|
|
687
|
+
const blocked = await cliEnv(b, { PATH: path }, "sweep", "--json")
|
|
688
|
+
assert.equal(blocked.code, 1)
|
|
689
|
+
const { sweep } = JSON.parse(blocked.stdout) as {
|
|
690
|
+
sweep: { swept: unknown[]; failed: { branch: string; error: string }[] }
|
|
691
|
+
}
|
|
692
|
+
assert.equal(sweep.swept.length, 0)
|
|
693
|
+
assert.equal(sweep.failed.length, 1)
|
|
694
|
+
assert.match(sweep.failed[0].error, /origin still has/)
|
|
695
|
+
|
|
696
|
+
// A capable runner heals it: the same sweep with real git succeeds.
|
|
697
|
+
const healed = await cli(b, "sweep")
|
|
698
|
+
assert.equal(healed.code, 0, healed.stderr)
|
|
699
|
+
assert.equal(sh(b, "git", "ls-remote", "--heads", "origin", `task/claim/clm-${keys.one}`), "")
|
|
700
|
+
},
|
|
701
|
+
)
|