@nickmeriano/task 0.10.0 → 0.12.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 +232 -1
- package/dist/asks.test.js +6 -0
- package/dist/asks.test.js.map +1 -1
- package/dist/board/github.d.ts +68 -0
- package/dist/board/github.d.ts.map +1 -0
- package/dist/board/github.js +112 -0
- package/dist/board/github.js.map +1 -0
- package/dist/board/handler.d.ts +50 -0
- package/dist/board/handler.d.ts.map +1 -0
- package/dist/board/handler.js +183 -0
- package/dist/board/handler.js.map +1 -0
- package/dist/board/handler.test.d.ts +11 -0
- package/dist/board/handler.test.d.ts.map +1 -0
- package/dist/board/handler.test.js +229 -0
- package/dist/board/handler.test.js.map +1 -0
- package/dist/board/pages-function.d.ts +23 -0
- package/dist/board/pages-function.d.ts.map +1 -0
- package/dist/board/pages-function.js +34 -0
- package/dist/board/pages-function.js.map +1 -0
- package/dist/board/source.d.ts +118 -0
- package/dist/board/source.d.ts.map +1 -0
- package/dist/board/source.js +333 -0
- package/dist/board/source.js.map +1 -0
- package/dist/board/source.test.d.ts +12 -0
- package/dist/board/source.test.d.ts.map +1 -0
- package/dist/board/source.test.js +165 -0
- package/dist/board/source.test.js.map +1 -0
- package/dist/board/tar.d.ts +28 -0
- package/dist/board/tar.d.ts.map +1 -0
- package/dist/board/tar.js +188 -0
- package/dist/board/tar.js.map +1 -0
- package/dist/board/tar.test.d.ts +9 -0
- package/dist/board/tar.test.d.ts.map +1 -0
- package/dist/board/tar.test.js +110 -0
- package/dist/board/tar.test.js.map +1 -0
- package/dist/claim-io.d.ts.map +1 -1
- package/dist/claim-io.js +4 -3
- package/dist/claim-io.js.map +1 -1
- package/dist/claim.d.ts +14 -2
- package/dist/claim.d.ts.map +1 -1
- package/dist/claim.js +9 -3
- package/dist/claim.js.map +1 -1
- package/dist/claim.test.js +41 -1
- package/dist/claim.test.js.map +1 -1
- package/dist/cli.js +127 -7
- package/dist/cli.js.map +1 -1
- package/dist/export.d.ts +163 -0
- package/dist/export.d.ts.map +1 -0
- package/dist/export.js +259 -0
- package/dist/export.js.map +1 -0
- package/dist/export.test.d.ts +12 -0
- package/dist/export.test.d.ts.map +1 -0
- package/dist/export.test.js +264 -0
- package/dist/export.test.js.map +1 -0
- package/dist/functions/board.js +709 -0
- package/dist/git-serve.d.ts +14 -1
- package/dist/git-serve.d.ts.map +1 -1
- package/dist/git-serve.js +30 -2
- package/dist/git-serve.js.map +1 -1
- package/dist/git-serve.test.d.ts +1 -0
- package/dist/git-serve.test.d.ts.map +1 -1
- package/dist/git-serve.test.js +36 -1
- package/dist/git-serve.test.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -0
- package/dist/index.js.map +1 -1
- package/dist/server.d.ts +6 -0
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +12 -3
- package/dist/server.js.map +1 -1
- package/dist/trailers.d.ts +51 -0
- package/dist/trailers.d.ts.map +1 -0
- package/dist/trailers.js +32 -0
- package/dist/trailers.js.map +1 -0
- package/package.json +3 -3
- package/skill/SKILL.md +16 -1
- package/src/asks.test.ts +15 -0
- package/src/board/github.ts +151 -0
- package/src/board/handler.test.ts +276 -0
- package/src/board/handler.ts +228 -0
- package/src/board/pages-function.ts +42 -0
- package/src/board/source.test.ts +203 -0
- package/src/board/source.ts +422 -0
- package/src/board/tar.test.ts +128 -0
- package/src/board/tar.ts +199 -0
- package/src/claim-io.ts +12 -3
- package/src/claim.test.ts +56 -1
- package/src/claim.ts +27 -4
- package/src/cli.ts +127 -7
- package/src/export.test.ts +361 -0
- package/src/export.ts +354 -0
- package/src/git-serve.test.ts +41 -1
- package/src/git-serve.ts +30 -2
- package/src/index.ts +10 -0
- package/src/server.ts +12 -3
- package/src/trailers.ts +65 -0
- package/ui/dist/assets/index-B8M_DaOt.js +229 -0
- package/ui/dist/assets/{index-CoKCUYic.css → index-eHsqltgs.css} +1 -1
- package/ui/dist/index.html +3 -3
- package/ui/dist/assets/index-BjsorZOU.js +0 -229
package/src/board/tar.ts
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A minimal streaming tar reader — just enough to walk a GitHub repository
|
|
3
|
+
* tarball. One pass, bounded memory: every entry's *path* is recorded, but
|
|
4
|
+
* bytes are kept only for the paths the caller's `keep` predicate selects;
|
|
5
|
+
* everything else is discarded chunk by chunk as it streams through, so a big
|
|
6
|
+
* repository costs no more memory than its boards.
|
|
7
|
+
*
|
|
8
|
+
* Understands what GitHub's tarballs actually contain: ustar headers with the
|
|
9
|
+
* split `prefix` field, a leading `pax_global_header`, and pax extended
|
|
10
|
+
* headers (`x`) or GNU longname entries (`L`) for paths longer than the
|
|
11
|
+
* header's 100 bytes. Checksums are not verified — the bytes come off a TLS
|
|
12
|
+
* connection from GitHub, not a tape from 1988.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
export class TarError extends Error {}
|
|
16
|
+
|
|
17
|
+
const BLOCK = 512
|
|
18
|
+
|
|
19
|
+
/** Reads exact byte counts off a stream, buffering only what's unconsumed. */
|
|
20
|
+
class ByteReader {
|
|
21
|
+
private reader: ReadableStreamDefaultReader<Uint8Array>
|
|
22
|
+
private chunks: Uint8Array[] = []
|
|
23
|
+
private have = 0
|
|
24
|
+
private done = false
|
|
25
|
+
|
|
26
|
+
constructor(stream: ReadableStream<Uint8Array>) {
|
|
27
|
+
this.reader = stream.getReader()
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
private async pull(): Promise<boolean> {
|
|
31
|
+
if (this.done) return false
|
|
32
|
+
const { done, value } = await this.reader.read()
|
|
33
|
+
if (done) {
|
|
34
|
+
this.done = true
|
|
35
|
+
return false
|
|
36
|
+
}
|
|
37
|
+
if (value && value.length > 0) {
|
|
38
|
+
this.chunks.push(value)
|
|
39
|
+
this.have += value.length
|
|
40
|
+
}
|
|
41
|
+
return true
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Exactly `n` bytes; null at a clean end-of-stream, throws mid-entry. */
|
|
45
|
+
async read(n: number): Promise<Uint8Array | null> {
|
|
46
|
+
while (this.have < n) {
|
|
47
|
+
if (!(await this.pull())) {
|
|
48
|
+
if (this.have === 0) return null
|
|
49
|
+
throw new TarError("truncated archive")
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
const out = new Uint8Array(n)
|
|
53
|
+
let filled = 0
|
|
54
|
+
while (filled < n) {
|
|
55
|
+
const head = this.chunks[0]
|
|
56
|
+
const take = Math.min(head.length, n - filled)
|
|
57
|
+
out.set(head.subarray(0, take), filled)
|
|
58
|
+
filled += take
|
|
59
|
+
if (take === head.length) this.chunks.shift()
|
|
60
|
+
else this.chunks[0] = head.subarray(take)
|
|
61
|
+
}
|
|
62
|
+
this.have -= n
|
|
63
|
+
return out
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Discard `n` bytes without accumulating them. */
|
|
67
|
+
async skip(n: number): Promise<void> {
|
|
68
|
+
while (n > 0) {
|
|
69
|
+
if (this.chunks.length === 0) {
|
|
70
|
+
if (!(await this.pull())) throw new TarError("truncated archive")
|
|
71
|
+
continue
|
|
72
|
+
}
|
|
73
|
+
const head = this.chunks[0]
|
|
74
|
+
const take = Math.min(head.length, n)
|
|
75
|
+
if (take === head.length) this.chunks.shift()
|
|
76
|
+
else this.chunks[0] = head.subarray(take)
|
|
77
|
+
this.have -= take
|
|
78
|
+
n -= take
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const decoder = new TextDecoder()
|
|
84
|
+
|
|
85
|
+
function field(header: Uint8Array, start: number, length: number): string {
|
|
86
|
+
const slice = header.subarray(start, start + length)
|
|
87
|
+
const nul = slice.indexOf(0)
|
|
88
|
+
return decoder.decode(nul < 0 ? slice : slice.subarray(0, nul))
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function octal(header: Uint8Array, start: number, length: number): number {
|
|
92
|
+
const text = field(header, start, length).trim()
|
|
93
|
+
if (text === "") return 0
|
|
94
|
+
const value = parseInt(text, 8)
|
|
95
|
+
if (Number.isNaN(value) || value < 0) throw new TarError("bad size field")
|
|
96
|
+
return value
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** The `path=` record of a pax extended header, if it has one. */
|
|
100
|
+
function paxPath(data: Uint8Array): string | null {
|
|
101
|
+
// Records are "<len> <key>=<value>\n", len covering the whole record.
|
|
102
|
+
const text = decoder.decode(data)
|
|
103
|
+
for (let at = 0; at < text.length; ) {
|
|
104
|
+
const space = text.indexOf(" ", at)
|
|
105
|
+
if (space < 0) break
|
|
106
|
+
const length = Number(text.slice(at, space))
|
|
107
|
+
if (!Number.isInteger(length) || length <= 0) break
|
|
108
|
+
const record = text.slice(space + 1, at + length)
|
|
109
|
+
if (record.startsWith("path=")) return record.slice(5).replace(/\n$/, "")
|
|
110
|
+
at += length
|
|
111
|
+
}
|
|
112
|
+
return null
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export interface TarContents {
|
|
116
|
+
/** Every regular file's path, root directory stripped. */
|
|
117
|
+
paths: string[]
|
|
118
|
+
/** Bytes of the paths `keep` selected. */
|
|
119
|
+
files: Map<string, Uint8Array>
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Walk a (decompressed) tar stream. Paths are reported with the archive's
|
|
124
|
+
* root directory stripped — GitHub tarballs wrap everything in
|
|
125
|
+
* `<owner>-<repo>-<sha>/` — matching what a git tree listing would say.
|
|
126
|
+
*/
|
|
127
|
+
export async function readTar(
|
|
128
|
+
stream: ReadableStream<Uint8Array>,
|
|
129
|
+
keep: (path: string) => boolean,
|
|
130
|
+
): Promise<TarContents> {
|
|
131
|
+
const bytes = new ByteReader(stream)
|
|
132
|
+
const paths: string[] = []
|
|
133
|
+
const files = new Map<string, Uint8Array>()
|
|
134
|
+
|
|
135
|
+
let overridePath: string | null = null
|
|
136
|
+
for (;;) {
|
|
137
|
+
const header = await bytes.read(BLOCK)
|
|
138
|
+
if (header === null) break
|
|
139
|
+
if (header.every((b) => b === 0)) {
|
|
140
|
+
// End marker: two zero blocks, then maybe padding. Nothing left to parse.
|
|
141
|
+
break
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const size = octal(header, 124, 12)
|
|
145
|
+
const padded = Math.ceil(size / BLOCK) * BLOCK
|
|
146
|
+
const type = header[156]
|
|
147
|
+
|
|
148
|
+
// Extended headers name the *next* entry; the global one ('g') is skipped.
|
|
149
|
+
if (type === 0x78 /* x */ || type === 0x4c /* L */) {
|
|
150
|
+
const data = await bytes.read(size)
|
|
151
|
+
if (data === null) throw new TarError("truncated archive")
|
|
152
|
+
overridePath =
|
|
153
|
+
type === 0x78 ? (paxPath(data) ?? overridePath) : decoder.decode(data).replace(/\0+$/, "")
|
|
154
|
+
await bytes.skip(padded - size)
|
|
155
|
+
continue
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
let name = field(header, 0, 100)
|
|
159
|
+
// ustar splits long paths into prefix + name.
|
|
160
|
+
const prefix = field(header, 257, 6).startsWith("ustar") ? field(header, 345, 155) : ""
|
|
161
|
+
if (prefix) name = `${prefix}/${name}`
|
|
162
|
+
if (overridePath !== null) {
|
|
163
|
+
name = overridePath
|
|
164
|
+
overridePath = null
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Regular file entries only — directories, links and the global header
|
|
168
|
+
// carry no board bytes. Type '0' or the ancient NUL both mean "file".
|
|
169
|
+
if (type !== 0x30 && type !== 0) {
|
|
170
|
+
await bytes.skip(padded)
|
|
171
|
+
continue
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Strip the tarball's root directory. A path without one (no slash) is
|
|
175
|
+
// the metadata GitHub puts at the top level — not repository content.
|
|
176
|
+
const slash = name.indexOf("/")
|
|
177
|
+
if (slash < 0) {
|
|
178
|
+
await bytes.skip(padded)
|
|
179
|
+
continue
|
|
180
|
+
}
|
|
181
|
+
const path = name.slice(slash + 1)
|
|
182
|
+
if (path === "") {
|
|
183
|
+
await bytes.skip(padded)
|
|
184
|
+
continue
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
paths.push(path)
|
|
188
|
+
if (keep(path)) {
|
|
189
|
+
const data = await bytes.read(size)
|
|
190
|
+
if (data === null) throw new TarError("truncated archive")
|
|
191
|
+
files.set(path, data)
|
|
192
|
+
await bytes.skip(padded - size)
|
|
193
|
+
} else {
|
|
194
|
+
await bytes.skip(padded)
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
return { paths, files }
|
|
199
|
+
}
|
package/src/claim-io.ts
CHANGED
|
@@ -24,6 +24,7 @@ import { join, relative, sep } from "node:path"
|
|
|
24
24
|
import { claimBranch, claimNamespace } from "./claim.ts"
|
|
25
25
|
import { gitOut, runGit } from "./git.ts"
|
|
26
26
|
import { commentStem, parseAskLenient, serializeAsk, type AskDoc } from "./ticket-doc.ts"
|
|
27
|
+
import { boardCommitMessage } from "./trailers.ts"
|
|
27
28
|
import { ASKS_DIR, TICKETS_DIR } from "./file-store.ts"
|
|
28
29
|
import type { Store } from "./store.ts"
|
|
29
30
|
import type { Ask, Task } from "./types.ts"
|
|
@@ -298,7 +299,7 @@ export function addAskRouted(store: Store, key: string, text: string, author = "
|
|
|
298
299
|
store,
|
|
299
300
|
branch,
|
|
300
301
|
`${dir}/${id}.md`,
|
|
301
|
-
`chore(board): ask on ${store.displayId(key)}`,
|
|
302
|
+
boardCommitMessage(`chore(board): ask on ${store.displayId(key)}`, "ask", store.displayId(key)),
|
|
302
303
|
// Absent → create; if a racing write took the name, keep theirs.
|
|
303
304
|
(current) => current ?? serializeAsk(doc),
|
|
304
305
|
)
|
|
@@ -340,7 +341,11 @@ export function setAskResolvedRouted(
|
|
|
340
341
|
store,
|
|
341
342
|
branch,
|
|
342
343
|
`${dir}/${target.id}.md`,
|
|
343
|
-
|
|
344
|
+
boardCommitMessage(
|
|
345
|
+
`chore(board): ${resolved ? "resolve" : "reopen"} ask [${target.ordinal}] on ${store.displayId(key)}`,
|
|
346
|
+
resolved ? "resolve-ask" : "reopen-ask",
|
|
347
|
+
store.displayId(key),
|
|
348
|
+
),
|
|
344
349
|
(current) => {
|
|
345
350
|
if (current === null) return null // deleted under us — nothing to write
|
|
346
351
|
const doc = parseAskLenient(current)
|
|
@@ -374,7 +379,11 @@ export function deleteAskRouted(store: Store, key: string, ref: string): RoutedA
|
|
|
374
379
|
store,
|
|
375
380
|
branch,
|
|
376
381
|
`${dir}/${target.id}.md`,
|
|
377
|
-
|
|
382
|
+
boardCommitMessage(
|
|
383
|
+
`chore(board): delete ask [${target.ordinal}] on ${store.displayId(key)}`,
|
|
384
|
+
"delete-ask",
|
|
385
|
+
store.displayId(key),
|
|
386
|
+
),
|
|
378
387
|
() => null,
|
|
379
388
|
)
|
|
380
389
|
// The local copy of a both-sides ask goes too, or the union resurrects it.
|
package/src/claim.test.ts
CHANGED
|
@@ -183,9 +183,54 @@ test("two concurrent claims of one ticket: exactly one wins", async () => {
|
|
|
183
183
|
sh(winner, "git", "show", `origin/main:.task/tickets/${keys.one}/ticket.md`),
|
|
184
184
|
/status: todo/,
|
|
185
185
|
)
|
|
186
|
+
// The trailer contract (trailers.ts): the flip commit names its op and
|
|
187
|
+
// ticket in machine-readable trailers — pinned here because automation
|
|
188
|
+
// keys on them.
|
|
189
|
+
assert.equal(sh(winner, "git", "log", "-1", "--format=%(trailers:key=Task-Op,valueonly)"), "claim")
|
|
190
|
+
assert.equal(
|
|
191
|
+
sh(winner, "git", "log", "-1", "--format=%(trailers:key=Task-Id,valueonly)"),
|
|
192
|
+
`CLM-${keys.one}`,
|
|
193
|
+
)
|
|
186
194
|
void bare
|
|
187
195
|
})
|
|
188
196
|
|
|
197
|
+
test("claim --dry-run: the real gates, the real exit codes, zero writes", async () => {
|
|
198
|
+
const { clone, keys } = fixture()
|
|
199
|
+
const dir = clone()
|
|
200
|
+
const branch = `task/claim/clm-${keys.one}`
|
|
201
|
+
|
|
202
|
+
// Would claim: exit 0 — and nothing moved. No branch local or remote, the
|
|
203
|
+
// checkout still on main, the ticket still todo.
|
|
204
|
+
const yes = await cli(dir, "claim", keys.one, "--dry-run")
|
|
205
|
+
assert.equal(yes.code, 0, yes.stderr)
|
|
206
|
+
assert.match(yes.stdout, /would claim CLM-/)
|
|
207
|
+
assert.equal(sh(dir, "git", "rev-parse", "--abbrev-ref", "HEAD"), "main")
|
|
208
|
+
assert.equal(sh(dir, "git", "branch", "--list", branch), "")
|
|
209
|
+
assert.equal(sh(dir, "git", "ls-remote", "--heads", "origin", branch), "")
|
|
210
|
+
assert.match(readFileSync(join(dir, ".task", "tickets", keys.one, "ticket.md"), "utf8"), /status: todo/)
|
|
211
|
+
|
|
212
|
+
// The gates are the real command's gates: blocked exits 2, a ticket someone
|
|
213
|
+
// else claimed exits 1 — the codes a scheduler scripts against.
|
|
214
|
+
assert.equal((await cli(dir, "claim", keys.blocked, "--dry-run")).code, 2)
|
|
215
|
+
const other = clone()
|
|
216
|
+
assert.equal((await cli(other, "claim", keys.one)).code, 0)
|
|
217
|
+
assert.equal((await cli(dir, "claim", keys.one, "--dry-run")).code, 1)
|
|
218
|
+
|
|
219
|
+
// --next --dry-run names the top of the queue without taking it.
|
|
220
|
+
const next = await cli(dir, "claim", "--next", "--dry-run", "--json")
|
|
221
|
+
assert.equal(next.code, 0, next.stderr)
|
|
222
|
+
const picked = JSON.parse(next.stdout) as { task: { key: string }; dryRun: boolean }
|
|
223
|
+
assert.equal(picked.dryRun, true)
|
|
224
|
+
assert.equal(picked.task.key, keys.jumper)
|
|
225
|
+
assert.equal(sh(dir, "git", "ls-remote", "--heads", "origin", `task/claim/clm-${keys.jumper}`), "")
|
|
226
|
+
|
|
227
|
+
// A dry run reserves nothing — the real claim right after still works.
|
|
228
|
+
assert.equal((await cli(dir, "claim", keys.jumper)).code, 0)
|
|
229
|
+
|
|
230
|
+
// No stop-before-writing seam exists for --release or --lock-only.
|
|
231
|
+
assert.equal((await cli(other, "claim", keys.two, "--lock-only", "--dry-run")).code, 1)
|
|
232
|
+
})
|
|
233
|
+
|
|
189
234
|
test("unclaimable tickets exit 2, before anything touches origin", async () => {
|
|
190
235
|
const { clone, keys } = fixture()
|
|
191
236
|
const dir = clone()
|
|
@@ -541,7 +586,17 @@ test("--lock-only claims from a dirty, branch-pinned checkout without touching i
|
|
|
541
586
|
assert.match(sh(dir, "git", "show", `origin/${branch}:.task/tickets/${keys.one}/ticket.md`), /status: in_progress/)
|
|
542
587
|
const message = sh(dir, "git", "log", "-1", "--format=%B", `origin/${branch}`)
|
|
543
588
|
assert.match(message, /lock-only/)
|
|
544
|
-
assert.match(message, new RegExp(`
|
|
589
|
+
assert.match(message, new RegExp(`Delivered-By: pinned`))
|
|
590
|
+
// Delivered-By shares one contiguous trailer block with the Task-Op/Task-Id
|
|
591
|
+
// contract (trailers.ts) — git must parse all three.
|
|
592
|
+
assert.equal(
|
|
593
|
+
sh(dir, "git", "log", "-1", "--format=%(trailers:key=Task-Op,valueonly)", `origin/${branch}`),
|
|
594
|
+
"claim",
|
|
595
|
+
)
|
|
596
|
+
assert.equal(
|
|
597
|
+
sh(dir, "git", "log", "-1", "--format=%(trailers:key=Delivered-By,valueonly)", `origin/${branch}`),
|
|
598
|
+
"pinned",
|
|
599
|
+
)
|
|
545
600
|
|
|
546
601
|
// The lock is the same lock: both claim flavors lose to it from elsewhere.
|
|
547
602
|
const other = clone()
|
package/src/claim.ts
CHANGED
|
@@ -30,6 +30,7 @@ import {
|
|
|
30
30
|
type Store,
|
|
31
31
|
} from "./store.ts"
|
|
32
32
|
import { isTicketKey } from "./id.ts"
|
|
33
|
+
import { boardCommitMessage } from "./trailers.ts"
|
|
33
34
|
import { parseTicket, parseTicketLenient, serializeTicket, type TicketDoc } from "./ticket-doc.ts"
|
|
34
35
|
import type { ProjectConfig, Status, Task } from "./types.ts"
|
|
35
36
|
|
|
@@ -193,6 +194,18 @@ export interface ClaimResult {
|
|
|
193
194
|
export interface ClaimOptions {
|
|
194
195
|
/** Claim past the root board's `claims.maxOpenCount` cap. */
|
|
195
196
|
force?: boolean
|
|
197
|
+
/**
|
|
198
|
+
* Run the real claim's decision path — every validation, in order, against
|
|
199
|
+
* live origin state — and stop before the first write. No branch is
|
|
200
|
+
* created, no commit made, nothing pushed; the checkout is untouched. Exit
|
|
201
|
+
* semantics match the real command exactly, which is the point: automation
|
|
202
|
+
* (a scheduler deciding whether to wake a worker) asks "would a claim
|
|
203
|
+
* succeed right now?" through the same code that would perform it, so the
|
|
204
|
+
* answer can't drift from the rules. A "yes" is a reading, not a
|
|
205
|
+
* reservation — the dry run takes no lock, so a concurrent claimer can
|
|
206
|
+
* still win the ticket a moment later.
|
|
207
|
+
*/
|
|
208
|
+
dryRun?: boolean
|
|
196
209
|
}
|
|
197
210
|
|
|
198
211
|
/**
|
|
@@ -278,6 +291,8 @@ export function claim(store: Store, key: string, options: ClaimOptions = {}): Cl
|
|
|
278
291
|
const previous =
|
|
279
292
|
git(cwd, "symbolic-ref", "--quiet", "--short", "HEAD").stdout ||
|
|
280
293
|
gitMust(cwd, "rev-parse", "HEAD")
|
|
294
|
+
// Every gate is passed; the writes start here. A dry run stops instead.
|
|
295
|
+
if (options.dryRun) return { task: store.get(key)!, branch, base, previous }
|
|
281
296
|
gitMust(cwd, "checkout", "--quiet", "-b", branch, base)
|
|
282
297
|
|
|
283
298
|
const undo = (): void => {
|
|
@@ -292,7 +307,13 @@ export function claim(store: Store, key: string, options: ClaimOptions = {}): Cl
|
|
|
292
307
|
assertClaimable(store, store.get(key), key)
|
|
293
308
|
store.update(key, { status: "in_progress" })
|
|
294
309
|
gitMust(cwd, "add", "--", join(store.taskDir, TICKETS_DIR, key))
|
|
295
|
-
gitMust(
|
|
310
|
+
gitMust(
|
|
311
|
+
cwd,
|
|
312
|
+
"commit",
|
|
313
|
+
"--quiet",
|
|
314
|
+
"-m",
|
|
315
|
+
boardCommitMessage(`chore(board): claim ${id} → in_progress`, "claim", id),
|
|
316
|
+
)
|
|
296
317
|
} catch (error) {
|
|
297
318
|
undo()
|
|
298
319
|
throw error
|
|
@@ -331,7 +352,7 @@ export interface LockOnlyClaimResult {
|
|
|
331
352
|
task: Task
|
|
332
353
|
branch: string
|
|
333
354
|
base: string
|
|
334
|
-
/** The checkout's branch at claim time, recorded in the flip commit's `
|
|
355
|
+
/** The checkout's branch at claim time, recorded in the flip commit's `Delivered-By:` trailer. */
|
|
335
356
|
deliveredBy: string
|
|
336
357
|
}
|
|
337
358
|
|
|
@@ -348,7 +369,7 @@ export interface LockOnlyClaimResult {
|
|
|
348
369
|
* update-index → write-tree → commit-tree) and pushed with the same
|
|
349
370
|
* must-not-exist lease plain `claim` uses — the compare-and-swap is identical,
|
|
350
371
|
* only the checkout stays where it is (dirty or pinned, doesn't matter). The
|
|
351
|
-
* flip commit carries a `
|
|
372
|
+
* flip commit carries a `Delivered-By: <branch>` trailer naming the checkout's
|
|
352
373
|
* branch, so a lock-only claim is legible state — anyone reading the claim
|
|
353
374
|
* branch sees where the work will actually land. Exit-code contract and
|
|
354
375
|
* `--release` behave exactly like plain claim.
|
|
@@ -465,7 +486,9 @@ export function claimLockOnly(
|
|
|
465
486
|
"-p",
|
|
466
487
|
baseSha,
|
|
467
488
|
"-m",
|
|
468
|
-
`chore(board): claim ${id} → in_progress (lock-only)
|
|
489
|
+
boardCommitMessage(`chore(board): claim ${id} → in_progress (lock-only)`, "claim", id, [
|
|
490
|
+
`Delivered-By: ${deliveredBy}`,
|
|
491
|
+
]),
|
|
469
492
|
)
|
|
470
493
|
if (made.status !== 0) {
|
|
471
494
|
throw new Error("git commit-tree failed — is git user.name/user.email configured?")
|
package/src/cli.ts
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
import { spawn } from "node:child_process"
|
|
13
13
|
import { readFileSync } from "node:fs"
|
|
14
14
|
import type { Server } from "node:http"
|
|
15
|
-
import { basename, join, relative, sep } from "node:path"
|
|
15
|
+
import { basename, isAbsolute, join, relative, resolve, sep } from "node:path"
|
|
16
16
|
import process from "node:process"
|
|
17
17
|
import { resolveAuthor } from "./author.ts"
|
|
18
18
|
import { checkBoards, checkRoot } from "./check.ts"
|
|
@@ -32,7 +32,8 @@ import { buildInbox, type InboxEntry } from "./inbox.ts"
|
|
|
32
32
|
import { STALE_DAYS, buildOverview } from "./overview.ts"
|
|
33
33
|
import { searchStore } from "./search.ts"
|
|
34
34
|
import { detectRepo, parseSlug, publish, resolveHost } from "./publish.ts"
|
|
35
|
-
import {
|
|
35
|
+
import { DEFAULT_OUT_DIR, writeExport } from "./export.ts"
|
|
36
|
+
import { UI_DIR, createTaskServer } from "./server.ts"
|
|
36
37
|
import { initProject, openBoard } from "./file-store.ts"
|
|
37
38
|
import {
|
|
38
39
|
CONFIG_FILE,
|
|
@@ -95,8 +96,10 @@ const BOOLEAN_FLAGS = new Set([
|
|
|
95
96
|
"fix",
|
|
96
97
|
"next",
|
|
97
98
|
"force",
|
|
99
|
+
"no-functions",
|
|
98
100
|
"reopen",
|
|
99
101
|
"lock-only",
|
|
102
|
+
"dry-run",
|
|
100
103
|
])
|
|
101
104
|
|
|
102
105
|
/**
|
|
@@ -830,7 +833,7 @@ function cmdLink(args: Args, action: "link" | "unlink"): void {
|
|
|
830
833
|
}
|
|
831
834
|
|
|
832
835
|
const CLAIM_USAGE =
|
|
833
|
-
'usage: task claim <id> [--lock-only] | task claim --next | task claim --release <id> --comment "<why>"'
|
|
836
|
+
'usage: task claim <id> [--lock-only] [--dry-run] | task claim --next [--dry-run] | task claim --release <id> --comment "<why>"'
|
|
834
837
|
|
|
835
838
|
/**
|
|
836
839
|
* `task claim <id>` / `task claim --next` / `task claim --release <id>` — see
|
|
@@ -840,8 +843,19 @@ const CLAIM_USAGE =
|
|
|
840
843
|
*/
|
|
841
844
|
function cmdClaim(args: Args): void {
|
|
842
845
|
const ref = args.positional[0]
|
|
843
|
-
const
|
|
846
|
+
const dryRun = Boolean(args.flags["dry-run"])
|
|
847
|
+
const options = { force: Boolean(args.flags.force), dryRun }
|
|
844
848
|
const printClaim = (result: { task: Task; branch: string; base: string; previous: string }): void => {
|
|
849
|
+
if (dryRun) {
|
|
850
|
+
// Same exit-code contract as the real claim, nothing written: the
|
|
851
|
+
// scripted consumer branches on the code, the human reads the verdict.
|
|
852
|
+
if (args.flags.json) {
|
|
853
|
+
console.log(JSON.stringify({ task: result.task, branch: result.branch, dryRun: true }, null, 2))
|
|
854
|
+
} else {
|
|
855
|
+
console.log(`would claim ${result.task.id} — ${result.branch} (from ${result.base}); dry run, nothing written`)
|
|
856
|
+
}
|
|
857
|
+
return
|
|
858
|
+
}
|
|
845
859
|
if (args.flags.json) {
|
|
846
860
|
console.log(JSON.stringify({ task: result.task, branch: result.branch }, null, 2))
|
|
847
861
|
} else {
|
|
@@ -878,12 +892,17 @@ function cmdClaim(args: Args): void {
|
|
|
878
892
|
return
|
|
879
893
|
}
|
|
880
894
|
if (!ref) fail(CLAIM_USAGE)
|
|
895
|
+
if (dryRun && (args.flags["lock-only"] || args.flags.release)) {
|
|
896
|
+
// --lock-only builds its flip commit as it validates and --release only
|
|
897
|
+
// deletes; neither has a meaningful stop-before-writing seam.
|
|
898
|
+
fail("--dry-run applies to plain claim and --next only")
|
|
899
|
+
}
|
|
881
900
|
const store = openStoreFor(ref)
|
|
882
901
|
const key = store.parseId(ref)
|
|
883
902
|
if (args.flags["lock-only"]) {
|
|
884
903
|
// The branch-pinned session's claim: same lock, no checkout switch —
|
|
885
904
|
// the work delivers on the branch this session was handed, and the flip
|
|
886
|
-
// commit's
|
|
905
|
+
// commit's Delivered-By: trailer says so. See claimLockOnly.
|
|
887
906
|
if (args.flags.release) fail(CLAIM_USAGE)
|
|
888
907
|
const result = claimLockOnly(store, key, options)
|
|
889
908
|
if (args.flags.json) {
|
|
@@ -899,7 +918,7 @@ function cmdClaim(args: Args): void {
|
|
|
899
918
|
`claimed ${result.task.id} — ${result.branch} on origin (lock only, from ${result.base}), status in_progress`,
|
|
900
919
|
)
|
|
901
920
|
console.log(
|
|
902
|
-
`checkout untouched — keep working on ${result.deliveredBy} (recorded as
|
|
921
|
+
`checkout untouched — keep working on ${result.deliveredBy} (recorded as Delivered-By in the claim commit)`,
|
|
903
922
|
)
|
|
904
923
|
}
|
|
905
924
|
return
|
|
@@ -1296,6 +1315,75 @@ function cmdServe(args: Args): void {
|
|
|
1296
1315
|
})
|
|
1297
1316
|
}
|
|
1298
1317
|
|
|
1318
|
+
/**
|
|
1319
|
+
* `task export` — the same board, as files a static host can serve.
|
|
1320
|
+
*
|
|
1321
|
+
* Anchored exactly like `cmdServe`, because that's the promise: what comes out
|
|
1322
|
+
* is what `task serve` would have served, read-only and honestly stale. The
|
|
1323
|
+
* output is the prebuilt UI plus a `snapshot.json` the app reads instead of an
|
|
1324
|
+
* API — no server, no network calls beyond the static files.
|
|
1325
|
+
*/
|
|
1326
|
+
function cmdExport(args: Args): void {
|
|
1327
|
+
const serveRoot = findRoot(process.cwd()) ?? process.cwd()
|
|
1328
|
+
const boards = findBoards(serveRoot)
|
|
1329
|
+
if (boards.length === 0) {
|
|
1330
|
+
fail("no .task directory found in this directory, any parent, or below — run `task init` first")
|
|
1331
|
+
}
|
|
1332
|
+
// Deliberately not under `.task/`: `task serve` watches that tree
|
|
1333
|
+
// recursively, so an export written there would fire the change feed on
|
|
1334
|
+
// every asset copied.
|
|
1335
|
+
const out = str(args.flags, "out") ?? join(serveRoot, DEFAULT_OUT_DIR)
|
|
1336
|
+
const outDir = isAbsolute(out) ? out : resolve(process.cwd(), out)
|
|
1337
|
+
|
|
1338
|
+
// The live proxy is written unless told not to. It is inert until the host
|
|
1339
|
+
// has a token — it answers 503 and the board reads the snapshot — so the
|
|
1340
|
+
// default costs nothing, and turning a deploy live becomes one secret
|
|
1341
|
+
// rather than a re-export. The repository it reads is baked in: the export
|
|
1342
|
+
// already knows (the same remote detection `task publish` uses), and one
|
|
1343
|
+
// fewer setting on the host is one fewer thing to get wrong.
|
|
1344
|
+
let functions: { repo: string } | undefined
|
|
1345
|
+
let noRemote = false
|
|
1346
|
+
if (!args.flags["no-functions"]) {
|
|
1347
|
+
const slug = str(args.flags, "repo")
|
|
1348
|
+
const ref = slug ? parseSlug(slug) : detectRepo(serveRoot)
|
|
1349
|
+
if (slug && !ref) fail(`--repo must be owner/name — got ${JSON.stringify(slug)}`)
|
|
1350
|
+
if (ref) functions = { repo: `${ref.owner}/${ref.repo}` }
|
|
1351
|
+
else noRemote = true
|
|
1352
|
+
}
|
|
1353
|
+
|
|
1354
|
+
let summary
|
|
1355
|
+
try {
|
|
1356
|
+
summary = writeExport({
|
|
1357
|
+
serveRoot,
|
|
1358
|
+
outDir,
|
|
1359
|
+
uiDir: UI_DIR,
|
|
1360
|
+
force: Boolean(args.flags.force),
|
|
1361
|
+
base: str(args.flags, "base"),
|
|
1362
|
+
functions,
|
|
1363
|
+
})
|
|
1364
|
+
} catch (error) {
|
|
1365
|
+
fail(error instanceof Error ? error.message : String(error))
|
|
1366
|
+
}
|
|
1367
|
+
|
|
1368
|
+
if (args.flags.json) {
|
|
1369
|
+
console.log(JSON.stringify(summary, null, 2))
|
|
1370
|
+
return
|
|
1371
|
+
}
|
|
1372
|
+
const what = `${summary.tasks} task${summary.tasks === 1 ? "" : "s"} across ${summary.boards} board${summary.boards === 1 ? "" : "s"}`
|
|
1373
|
+
console.log(`Exported ${what}${summary.commit ? ` at ${summary.commit.slice(0, 7)}` : ""}`)
|
|
1374
|
+
console.log(` ${summary.outDir}`)
|
|
1375
|
+
console.log("Serve that directory from any static host — the board reads snapshot.json.")
|
|
1376
|
+
if (summary.functions && functions) {
|
|
1377
|
+
// One line, every run: this tier has no viewer auth, and the moment the
|
|
1378
|
+
// function is written is the moment to say so. The README says the rest.
|
|
1379
|
+
console.log(
|
|
1380
|
+
` ${summary.functions} reads ${functions.repo} live once the host has a TASK_GITHUB_TOKEN secret — to anyone who can reach it (README: "Live data for an exported board")`,
|
|
1381
|
+
)
|
|
1382
|
+
} else if (noRemote) {
|
|
1383
|
+
console.log(" no live function written — no GitHub remote to read from; pass --repo owner/name to add one")
|
|
1384
|
+
}
|
|
1385
|
+
}
|
|
1386
|
+
|
|
1299
1387
|
/**
|
|
1300
1388
|
* `task publish` — the board, at a URL.
|
|
1301
1389
|
*
|
|
@@ -1488,7 +1576,7 @@ Usage
|
|
|
1488
1576
|
a provisioned branch (CI, a Claude web
|
|
1489
1577
|
session): the lock lives on the claim
|
|
1490
1578
|
branch, the work delivers on yours, and the
|
|
1491
|
-
flip commit's
|
|
1579
|
+
flip commit's Delivered-By: trailer records
|
|
1492
1580
|
which. --release works on a lock-only claim
|
|
1493
1581
|
the same way
|
|
1494
1582
|
task claim --next [--board <P>] [--force]
|
|
@@ -1500,6 +1588,16 @@ Usage
|
|
|
1500
1588
|
the nearest board. Exit codes: 0 claimed
|
|
1501
1589
|
(prints the ticket), 1 queue empty, 2
|
|
1502
1590
|
preconditions failed
|
|
1591
|
+
task claim <id|--next> --dry-run
|
|
1592
|
+
the same decision path — every gate, against
|
|
1593
|
+
live origin state — stopped before the first
|
|
1594
|
+
write: no branch, no commit, checkout
|
|
1595
|
+
untouched. Same exit codes as the real
|
|
1596
|
+
command, so automation asks "would a claim
|
|
1597
|
+
succeed right now?" through the code that
|
|
1598
|
+
would perform it. A yes is a reading, not a
|
|
1599
|
+
reservation — no lock is taken. Plain claim
|
|
1600
|
+
and --next only
|
|
1503
1601
|
task claim --release <id> --comment "<why>"
|
|
1504
1602
|
abandon a claim: delete the branch on origin
|
|
1505
1603
|
and locally — the status flip only lived on
|
|
@@ -1577,6 +1675,26 @@ Usage
|
|
|
1577
1675
|
fails instead). Serves every board at or below
|
|
1578
1676
|
here — in a monorepo the header becomes a
|
|
1579
1677
|
board switcher
|
|
1678
|
+
task export [--out <dir>] [--base <path>] [--force] [--no-functions | --repo <owner/name>]
|
|
1679
|
+
write the board out as static files — the same
|
|
1680
|
+
UI plus a snapshot.json it reads instead of an
|
|
1681
|
+
API, so any static host serves it read-only
|
|
1682
|
+
with no server behind it. Same boards as
|
|
1683
|
+
\`task serve\`; defaults to ./${DEFAULT_OUT_DIR}.
|
|
1684
|
+
--base is the URL path the export will live
|
|
1685
|
+
under (default /) — pass /board/ when it's
|
|
1686
|
+
hosted at example.com/board/ instead of a
|
|
1687
|
+
domain root. Re-exporting refreshes an earlier
|
|
1688
|
+
export in place and leaves anything else in
|
|
1689
|
+
there alone; a non-empty directory that isn't
|
|
1690
|
+
one needs --force. Also writes a Cloudflare
|
|
1691
|
+
Pages Function that reads the repo live from
|
|
1692
|
+
GitHub: inert until the host has a
|
|
1693
|
+
TASK_GITHUB_TOKEN secret (a fine-grained PAT,
|
|
1694
|
+
Contents: read-only), at which point the same
|
|
1695
|
+
deploy upgrades itself — no viewer auth, mind.
|
|
1696
|
+
The repo comes from your remotes; --repo sets
|
|
1697
|
+
it, --no-functions skips the function
|
|
1580
1698
|
task publish [--public | --private] [--repo <owner/name>]
|
|
1581
1699
|
give this repo's board a URL at
|
|
1582
1700
|
task.nickmeriano.com, read-only, updated from
|
|
@@ -1679,6 +1797,8 @@ function main(): void | Promise<void> {
|
|
|
1679
1797
|
return cmdCheck(args)
|
|
1680
1798
|
case "serve":
|
|
1681
1799
|
return cmdServe(args)
|
|
1800
|
+
case "export":
|
|
1801
|
+
return cmdExport(args)
|
|
1682
1802
|
case "publish":
|
|
1683
1803
|
return cmdPublish(args)
|
|
1684
1804
|
case "unpublish":
|