@nickmeriano/task 0.7.1 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (128) hide show
  1. package/README.md +73 -25
  2. package/dist/asks.test.d.ts +17 -0
  3. package/dist/asks.test.d.ts.map +1 -0
  4. package/dist/asks.test.js +278 -0
  5. package/dist/asks.test.js.map +1 -0
  6. package/dist/check.d.ts +43 -0
  7. package/dist/check.d.ts.map +1 -0
  8. package/dist/check.js +403 -0
  9. package/dist/check.js.map +1 -0
  10. package/dist/check.test.d.ts +9 -0
  11. package/dist/check.test.d.ts.map +1 -0
  12. package/dist/check.test.js +248 -0
  13. package/dist/check.test.js.map +1 -0
  14. package/dist/claim-io.d.ts +73 -0
  15. package/dist/claim-io.d.ts.map +1 -0
  16. package/dist/claim-io.js +344 -0
  17. package/dist/claim-io.js.map +1 -0
  18. package/dist/claim.d.ts +61 -9
  19. package/dist/claim.d.ts.map +1 -1
  20. package/dist/claim.js +197 -67
  21. package/dist/claim.js.map +1 -1
  22. package/dist/claim.test.d.ts +2 -2
  23. package/dist/claim.test.js +235 -64
  24. package/dist/claim.test.js.map +1 -1
  25. package/dist/cli.js +724 -136
  26. package/dist/cli.js.map +1 -1
  27. package/dist/file-store.d.ts +110 -38
  28. package/dist/file-store.d.ts.map +1 -1
  29. package/dist/file-store.js +514 -238
  30. package/dist/file-store.js.map +1 -1
  31. package/dist/git-serve.d.ts +183 -0
  32. package/dist/git-serve.d.ts.map +1 -0
  33. package/dist/git-serve.js +503 -0
  34. package/dist/git-serve.js.map +1 -0
  35. package/dist/git-serve.test.d.ts +16 -0
  36. package/dist/git-serve.test.d.ts.map +1 -0
  37. package/dist/git-serve.test.js +183 -0
  38. package/dist/git-serve.test.js.map +1 -0
  39. package/dist/git.d.ts +65 -0
  40. package/dist/git.d.ts.map +1 -0
  41. package/dist/git.js +114 -0
  42. package/dist/git.js.map +1 -0
  43. package/dist/id.d.ts +39 -0
  44. package/dist/id.d.ts.map +1 -0
  45. package/dist/id.js +67 -0
  46. package/dist/id.js.map +1 -0
  47. package/dist/inbox.d.ts +41 -0
  48. package/dist/inbox.d.ts.map +1 -0
  49. package/dist/inbox.js +56 -0
  50. package/dist/inbox.js.map +1 -0
  51. package/dist/index.d.ts +5 -3
  52. package/dist/index.d.ts.map +1 -1
  53. package/dist/index.js +5 -3
  54. package/dist/index.js.map +1 -1
  55. package/dist/overview.d.ts +52 -0
  56. package/dist/overview.d.ts.map +1 -0
  57. package/dist/overview.js +61 -0
  58. package/dist/overview.js.map +1 -0
  59. package/dist/overview.test.d.ts +8 -0
  60. package/dist/overview.test.d.ts.map +1 -0
  61. package/dist/overview.test.js +48 -0
  62. package/dist/overview.test.js.map +1 -0
  63. package/dist/promote.test.d.ts +15 -0
  64. package/dist/promote.test.d.ts.map +1 -0
  65. package/dist/promote.test.js +104 -0
  66. package/dist/promote.test.js.map +1 -0
  67. package/dist/publish.d.ts +2 -17
  68. package/dist/publish.d.ts.map +1 -1
  69. package/dist/publish.js +4 -49
  70. package/dist/publish.js.map +1 -1
  71. package/dist/search.d.ts +34 -0
  72. package/dist/search.d.ts.map +1 -0
  73. package/dist/search.js +79 -0
  74. package/dist/search.js.map +1 -0
  75. package/dist/search.test.d.ts +2 -0
  76. package/dist/search.test.d.ts.map +1 -0
  77. package/dist/search.test.js +53 -0
  78. package/dist/search.test.js.map +1 -0
  79. package/dist/server.d.ts.map +1 -1
  80. package/dist/server.js +228 -23
  81. package/dist/server.js.map +1 -1
  82. package/dist/store.d.ts +43 -63
  83. package/dist/store.d.ts.map +1 -1
  84. package/dist/store.js +0 -368
  85. package/dist/store.js.map +1 -1
  86. package/dist/store.test.d.ts +1 -2
  87. package/dist/store.test.d.ts.map +1 -1
  88. package/dist/store.test.js +148 -106
  89. package/dist/store.test.js.map +1 -1
  90. package/dist/ticket-doc.d.ts +74 -5
  91. package/dist/ticket-doc.d.ts.map +1 -1
  92. package/dist/ticket-doc.js +229 -15
  93. package/dist/ticket-doc.js.map +1 -1
  94. package/dist/types.d.ts +115 -28
  95. package/dist/types.d.ts.map +1 -1
  96. package/dist/types.js.map +1 -1
  97. package/package.json +1 -1
  98. package/skill/SKILL.md +153 -40
  99. package/src/asks.test.ts +355 -0
  100. package/src/check.test.ts +328 -0
  101. package/src/check.ts +497 -0
  102. package/src/claim-io.ts +401 -0
  103. package/src/claim.test.ts +301 -71
  104. package/src/claim.ts +238 -81
  105. package/src/cli.ts +740 -131
  106. package/src/file-store.ts +572 -254
  107. package/src/git-serve.test.ts +240 -0
  108. package/src/git-serve.ts +595 -0
  109. package/src/git.ts +141 -0
  110. package/src/id.ts +68 -0
  111. package/src/inbox.ts +77 -0
  112. package/src/index.ts +4 -2
  113. package/src/overview.test.ts +52 -0
  114. package/src/overview.ts +105 -0
  115. package/src/promote.test.ts +143 -0
  116. package/src/publish.ts +6 -53
  117. package/src/search.test.ts +64 -0
  118. package/src/search.ts +105 -0
  119. package/src/server.ts +232 -21
  120. package/src/store.test.ts +166 -116
  121. package/src/store.ts +46 -444
  122. package/src/ticket-doc.ts +284 -21
  123. package/src/types.ts +120 -28
  124. package/ui/dist/assets/index-BjsorZOU.js +229 -0
  125. package/ui/dist/assets/index-CoKCUYic.css +1 -0
  126. package/ui/dist/index.html +2 -2
  127. package/ui/dist/assets/index-COunM-QN.css +0 -1
  128. package/ui/dist/assets/index-D4homvrQ.js +0 -229
@@ -0,0 +1,595 @@
1
+ /**
2
+ * The git surface behind `task serve`'s `/api/git/*` (TAS-42): status,
3
+ * branches, board-scoped commits, checkout, PRs, and claim release — the
4
+ * conductor-style layer that makes board edits (which are plain working-tree
5
+ * writes) visible and committable from the UI.
6
+ *
7
+ * Everything here is deliberately scoped to board files. There is no shadow
8
+ * branch and no auto-commit: edits keep landing in the working tree, and
9
+ * isolation comes from pathspec-scoped commits — `git add`/`git commit`
10
+ * limited to each board's `.task` directory commits board changes cleanly
11
+ * even when the rest of the tree is dirty.
12
+ *
13
+ * The whole surface degrades to absent: no git, no origin, or an unreadable
14
+ * repo means `capability()` returns null, `/api/project` omits its `git`
15
+ * block, and the UI renders nothing — the same self-hiding rule the hosted
16
+ * branch switcher follows. Spawns are the shared non-interactive, bounded
17
+ * ones from git.ts; like the rest of the serve path, a request must fail
18
+ * fast rather than wait on a human.
19
+ */
20
+
21
+ import { spawnSync } from "node:child_process"
22
+ import { relative, sep } from "node:path"
23
+ import { claimBranch, claimNamespace, release } from "./claim.ts"
24
+ import { remoteClaimTips, syncClaimRefs } from "./claim-io.ts"
25
+ import { GIT_NETWORK_TIMEOUT_MS, defaultBase, detectRepo, git, gitOut, runGit, type RepoRef } from "./git.ts"
26
+ import type { Store } from "./store.ts"
27
+
28
+ export interface ServedBoard {
29
+ /** Board id as the API speaks it — path relative to the serve root, "." for the root. */
30
+ id: string
31
+ store: Store
32
+ }
33
+
34
+ /** A failure the route turns into a JSON error with this status. */
35
+ export class GitServeError extends Error {
36
+ readonly status: number
37
+ constructor(message: string, status = 400) {
38
+ super(message)
39
+ this.status = status
40
+ }
41
+ }
42
+
43
+ export interface GitCapability {
44
+ /** GitHub identity when origin parses as one — PR affordances need it. */
45
+ repo: RepoRef | null
46
+ /** Origin's default branch, short name — the base PRs and board branches default to. */
47
+ defaultBranch: string
48
+ /** `gh` on PATH: PR creation and derived PR state; otherwise compare-URL fallback. */
49
+ gh: boolean
50
+ }
51
+
52
+ export interface DirtyFile {
53
+ board: string
54
+ /** Path relative to the repo toplevel — what the commit pathspec speaks. */
55
+ path: string
56
+ /** The ticket the file belongs to, when it's a ticket file. */
57
+ ticket: string | null
58
+ status: "modified" | "added" | "deleted" | "untracked"
59
+ }
60
+
61
+ export interface ClaimInfo {
62
+ board: string
63
+ ticket: string
64
+ branch: string
65
+ /** Commits on the claim branch beyond the default base — what a release discards. */
66
+ commits: number
67
+ }
68
+
69
+ export interface GitStatus {
70
+ branch: string | null
71
+ detached: boolean
72
+ defaultBranch: string
73
+ upstream: string | null
74
+ ahead: number | null
75
+ behind: number | null
76
+ dirty: DirtyFile[]
77
+ /** Tracked modifications outside every board dir — the "rest of the tree" count. */
78
+ otherDirty: number
79
+ claims: ClaimInfo[]
80
+ }
81
+
82
+ export interface GitBranch {
83
+ name: string
84
+ current: boolean
85
+ isDefault: boolean
86
+ }
87
+
88
+ interface PrRef {
89
+ number: number
90
+ url: string
91
+ title: string
92
+ state: "open" | "merged" | "closed"
93
+ draft: boolean
94
+ }
95
+
96
+ /** The gh-less PR fallback: GitHub's compare page, ready to open in a tab. */
97
+ export function compareUrl(repo: RepoRef, base: string, branch: string): string {
98
+ return `https://github.com/${repo.owner}/${repo.repo}/compare/${encodeURIComponent(
99
+ base,
100
+ )}...${encodeURIComponent(branch)}?expand=1`
101
+ }
102
+
103
+ const GH_ENV = { GH_PROMPT_DISABLED: "1", GH_NO_UPDATE_NOTIFIER: "1", GH_PAGER: "cat" }
104
+ const CLAIMS_TTL_MS = 30_000
105
+ const PR_TTL_MS = 30_000
106
+
107
+ function runGh(cwd: string, args: string[]): { status: number; stdout: string; stderr: string } {
108
+ const result = spawnSync("gh", args, {
109
+ cwd,
110
+ encoding: "utf8",
111
+ timeout: GIT_NETWORK_TIMEOUT_MS,
112
+ env: { ...process.env, ...GH_ENV },
113
+ })
114
+ if (result.error) return { status: 1, stdout: "", stderr: result.error.message }
115
+ return {
116
+ status: result.status ?? 1,
117
+ stdout: (result.stdout ?? "").trim(),
118
+ stderr: (result.stderr ?? "").trim(),
119
+ }
120
+ }
121
+
122
+ export class GitServe {
123
+ private readonly root: string
124
+ private readonly boards: () => ServedBoard[]
125
+ private ghChecked: boolean | null = null
126
+ private claimsCache: { at: number; claims: ClaimInfo[] } | null = null
127
+ private readonly prCache = new Map<string, { at: number; pr: PrRef | null }>()
128
+
129
+ constructor(serveRoot: string, boards: () => ServedBoard[]) {
130
+ this.root = serveRoot
131
+ this.boards = boards
132
+ }
133
+
134
+ /** The repo toplevel, or null when this isn't a git checkout at all. */
135
+ toplevel(): string | null {
136
+ return gitOut(this.root, "rev-parse", "--show-toplevel")
137
+ }
138
+
139
+ /** Where the actual git dir lives (worktrees put it elsewhere) — for the HEAD watch. */
140
+ gitDir(): string | null {
141
+ return gitOut(this.root, "rev-parse", "--absolute-git-dir")
142
+ }
143
+
144
+ /**
145
+ * Null is the whole off-switch: not a git repo, no origin, or no resolvable
146
+ * default branch all mean the served UI draws no git surface at all.
147
+ */
148
+ capability(): GitCapability | null {
149
+ const top = this.toplevel()
150
+ if (!top) return null
151
+ if (gitOut(this.root, "remote", "get-url", "origin") === null) return null
152
+ let base: string
153
+ try {
154
+ base = defaultBase(this.root)
155
+ } catch {
156
+ return null
157
+ }
158
+ if (this.ghChecked === null) {
159
+ const gh = spawnSync("gh", ["--version"], { encoding: "utf8", timeout: 10_000 })
160
+ this.ghChecked = !gh.error && gh.status === 0
161
+ }
162
+ return {
163
+ repo: detectRepo(this.root),
164
+ defaultBranch: base.replace(/^origin\//, ""),
165
+ gh: this.ghChecked,
166
+ }
167
+ }
168
+
169
+ /**
170
+ * `git status --porcelain=v2` records as {kind, xy, path}. v2 on purpose,
171
+ * everywhere: its records are token-addressed from the record tag, so the
172
+ * stdout trim in runGit (which eats v1's leading space on the first line)
173
+ * can't shift a path by a character.
174
+ */
175
+ private porcelain(...extra: string[]): { kind: string; xy: string; path: string }[] | null {
176
+ const out = gitOut(this.root, "status", "--porcelain=v2", ...extra)
177
+ return out === null ? null : this.parseRecords(out)
178
+ }
179
+
180
+ private parseRecords(out: string): { kind: string; xy: string; path: string }[] {
181
+ const records: { kind: string; xy: string; path: string }[] = []
182
+ for (const line of out === "" ? [] : out.split("\n")) {
183
+ const kind = line[0]
184
+ if (kind === "?") {
185
+ records.push({ kind, xy: "", path: line.slice(2) })
186
+ } else if (kind === "1" || kind === "u") {
187
+ const parts = line.split(" ")
188
+ records.push({ kind, xy: parts[1] ?? "", path: parts.slice(kind === "1" ? 8 : 10).join(" ") })
189
+ } else if (kind === "2") {
190
+ const parts = line.split(" ")
191
+ records.push({ kind, xy: parts[1] ?? "", path: parts.slice(9).join(" ").split("\t")[0] })
192
+ }
193
+ }
194
+ return records
195
+ }
196
+
197
+ /** Each board's `.task` dir as a toplevel-relative posix pathspec. */
198
+ private boardDirs(top: string): Map<string, string> {
199
+ const dirs = new Map<string, string>()
200
+ for (const { id, store } of this.boards()) {
201
+ const rel = relative(top, store.taskDir).split(sep).join("/")
202
+ if (rel === "" || rel.startsWith("..")) continue
203
+ dirs.set(id, rel)
204
+ }
205
+ return dirs
206
+ }
207
+
208
+ status(): GitStatus {
209
+ const cap = this.capability()
210
+ const top = this.toplevel()
211
+ if (!cap || !top) throw new GitServeError("no git surface here", 404)
212
+
213
+ // One spawn answers branch, upstream, ahead/behind and every path:
214
+ // porcelain v2 with --branch headers. -uall so a freshly added ticket
215
+ // (untracked until its first commit) shows up as board dirt.
216
+ const out = gitOut(this.root, "status", "--porcelain=v2", "--branch", "-uall")
217
+ if (out === null) throw new GitServeError("git status failed", 500)
218
+
219
+ let branch: string | null = null
220
+ let upstream: string | null = null
221
+ let ahead: number | null = null
222
+ let behind: number | null = null
223
+ const dirty: DirtyFile[] = []
224
+ let otherDirty = 0
225
+ const dirs = this.boardDirs(top)
226
+
227
+ const classify = (path: string): { board: string; ticket: string | null } | null => {
228
+ for (const [board, dir] of dirs) {
229
+ if (path === dir || path.startsWith(`${dir}/`)) {
230
+ const rest = path.slice(dir.length + 1)
231
+ const ticket = /^(?:tickets|archive)\/([^/]+)\//.exec(rest)
232
+ return { board, ticket: ticket ? ticket[1] : null }
233
+ }
234
+ }
235
+ return null
236
+ }
237
+
238
+ for (const line of out === "" ? [] : out.split("\n")) {
239
+ if (line.startsWith("# branch.head ")) {
240
+ const name = line.slice("# branch.head ".length)
241
+ branch = name === "(detached)" ? null : name
242
+ continue
243
+ }
244
+ if (line.startsWith("# branch.upstream ")) {
245
+ upstream = line.slice("# branch.upstream ".length)
246
+ continue
247
+ }
248
+ if (line.startsWith("# branch.ab ")) {
249
+ const ab = /^# branch\.ab \+(\d+) -(\d+)$/.exec(line)
250
+ if (ab) {
251
+ ahead = Number(ab[1])
252
+ behind = Number(ab[2])
253
+ }
254
+ continue
255
+ }
256
+ continue
257
+ }
258
+
259
+ for (const { kind, xy, path } of this.parseRecords(out)) {
260
+ const board = classify(path)
261
+ if (board) {
262
+ const status: DirtyFile["status"] =
263
+ kind === "?"
264
+ ? "untracked"
265
+ : xy.includes("D")
266
+ ? "deleted"
267
+ : xy.includes("A")
268
+ ? "added"
269
+ : "modified"
270
+ dirty.push({ board: board.board, path, ticket: board.ticket, status })
271
+ } else if (kind !== "?") {
272
+ otherDirty++
273
+ }
274
+ }
275
+
276
+ dirty.sort((a, b) => a.path.localeCompare(b.path))
277
+ return {
278
+ branch,
279
+ detached: branch === null,
280
+ defaultBranch: cap.defaultBranch,
281
+ upstream,
282
+ ahead,
283
+ behind,
284
+ dirty,
285
+ otherDirty,
286
+ claims: this.claims(),
287
+ }
288
+ }
289
+
290
+ /**
291
+ * Live claims across the served boards — branch name plus the commit count
292
+ * a release would discard. One bounded ls-remote per board (claim-io's
293
+ * moved-tip-only fetch keeps the follow-up cheap), cached briefly because
294
+ * status refetches on every SSE tick and origin doesn't move that fast.
295
+ * Offline degrades to [] — no claims surface, same as the inbox's rule.
296
+ */
297
+ private claims(): ClaimInfo[] {
298
+ const cached = this.claimsCache
299
+ if (cached && Date.now() - cached.at < CLAIMS_TTL_MS) return cached.claims
300
+ const claims: ClaimInfo[] = []
301
+ for (const { id, store } of this.boards()) {
302
+ let namespace: string
303
+ try {
304
+ namespace = claimNamespace(store.config)
305
+ } catch {
306
+ continue
307
+ }
308
+ const tips = remoteClaimTips(store)
309
+ if (!tips || tips.size === 0) continue
310
+ syncClaimRefs(store, namespace, tips)
311
+ for (const task of store.list({ statuses: ["backlog", "todo", "in_progress"] })) {
312
+ const branch = claimBranch(store.config, task.key)
313
+ if (!tips.has(branch)) continue
314
+ const count = gitOut(
315
+ this.root,
316
+ "rev-list",
317
+ "--count",
318
+ `refs/remotes/origin/${branch}`,
319
+ "--not",
320
+ defaultBase(this.root),
321
+ )
322
+ claims.push({ board: id, ticket: task.key, branch, commits: Number(count ?? 0) })
323
+ }
324
+ }
325
+ this.claimsCache = { at: Date.now(), claims }
326
+ return claims
327
+ }
328
+
329
+ branches(): GitBranch[] {
330
+ const cap = this.capability()
331
+ if (!cap) throw new GitServeError("no git surface here", 404)
332
+ const out = gitOut(this.root, "for-each-ref", "--format=%(refname:short)", "refs/heads")
333
+ if (out === null) throw new GitServeError("git for-each-ref failed", 500)
334
+ const current = gitOut(this.root, "symbolic-ref", "--quiet", "--short", "HEAD")
335
+ return (out === "" ? [] : out.split("\n")).map((name) => ({
336
+ name,
337
+ current: name === current,
338
+ isDefault: name === cap.defaultBranch,
339
+ }))
340
+ }
341
+
342
+ /**
343
+ * Commit the working tree's board changes — and nothing else. `git add` and
344
+ * `git commit` are both pathspec-limited to the boards' `.task` dirs, so a
345
+ * dirty tree elsewhere (half-finished code, scratch files) neither blocks
346
+ * the commit nor leaks into it. With `newBranch` the commit lands on a
347
+ * fresh branch cut from `base` (default: wherever HEAD is now) — the
348
+ * default-branch flow, where board edits shouldn't land on main directly.
349
+ */
350
+ commit(body: {
351
+ message?: unknown
352
+ newBranch?: unknown
353
+ base?: unknown
354
+ }): { commit: string; branch: string | null; files: string[] } {
355
+ const top = this.toplevel()
356
+ if (!this.capability() || !top) throw new GitServeError("no git surface here", 404)
357
+ const message = typeof body.message === "string" ? body.message.trim() : ""
358
+ if (!message) throw new GitServeError("message is required")
359
+
360
+ const dirs = [...this.boardDirs(top).values()]
361
+ if (dirs.length === 0) throw new GitServeError("no board directories inside this repository")
362
+ const records = this.porcelain("-uall", "--", ...dirs)
363
+ if (records === null) throw new GitServeError("git status failed", 500)
364
+ if (records.length === 0) {
365
+ throw new GitServeError("nothing to commit — no board files have changed")
366
+ }
367
+ const files = records.map((r) => r.path)
368
+
369
+ if (body.newBranch !== undefined) {
370
+ const name = typeof body.newBranch === "string" ? body.newBranch.trim() : ""
371
+ if (!name || runGit(this.root, ["check-ref-format", "--branch", name]).status !== 0) {
372
+ throw new GitServeError(`not a valid branch name: ${String(body.newBranch)}`)
373
+ }
374
+ if (git(this.root, "show-ref", "--verify", "--quiet", `refs/heads/${name}`).status === 0) {
375
+ throw new GitServeError(`branch ${name} already exists`)
376
+ }
377
+ const base = typeof body.base === "string" && body.base.trim() !== "" ? body.base.trim() : null
378
+ if (base && gitOut(this.root, "rev-parse", "--verify", "--quiet", `${base}^{commit}`) === null) {
379
+ throw new GitServeError(`base not found: ${base}`)
380
+ }
381
+ const created = runGit(this.root, ["checkout", "--quiet", "-b", name, ...(base ? [base] : [])])
382
+ if (created.status !== 0) {
383
+ throw new GitServeError(`couldn't create ${name}: ${created.stderr || created.stdout}`)
384
+ }
385
+ }
386
+
387
+ const add = runGit(this.root, ["add", "-A", "--", ...dirs])
388
+ if (add.status !== 0) throw new GitServeError(`git add failed: ${add.stderr}`, 500)
389
+ const commit = runGit(this.root, ["commit", "--quiet", "-m", message, "--", ...dirs])
390
+ if (commit.status !== 0) {
391
+ throw new GitServeError(`git commit failed: ${commit.stderr || commit.stdout}`, 500)
392
+ }
393
+ return {
394
+ commit: gitOut(this.root, "rev-parse", "HEAD") ?? "",
395
+ branch: gitOut(this.root, "symbolic-ref", "--quiet", "--short", "HEAD"),
396
+ files,
397
+ }
398
+ }
399
+
400
+ /**
401
+ * Switch branches — refused while tracked files outside the board dirs are
402
+ * dirty. No stashing magic: board dirt rides along (that's the working
403
+ * style this whole surface supports), but half-finished code does not get
404
+ * silently carried onto another branch by a click in a board UI.
405
+ */
406
+ checkout(body: { branch?: unknown }): { branch: string } {
407
+ const top = this.toplevel()
408
+ if (!this.capability() || !top) throw new GitServeError("no git surface here", 404)
409
+ const branch = typeof body.branch === "string" ? body.branch.trim() : ""
410
+ if (!branch) throw new GitServeError("branch is required")
411
+ if (git(this.root, "show-ref", "--verify", "--quiet", `refs/heads/${branch}`).status !== 0) {
412
+ throw new GitServeError(`no local branch named ${branch}`)
413
+ }
414
+ const tracked = this.porcelain("-uno")
415
+ if (tracked === null) throw new GitServeError("git status failed", 500)
416
+ const dirs = [...this.boardDirs(top).values()]
417
+ const outside = tracked
418
+ .map((r) => r.path)
419
+ .filter((p) => !dirs.some((d) => p === d || p.startsWith(`${d}/`)))
420
+ if (outside.length > 0) {
421
+ const shown = outside.slice(0, 3).join(", ")
422
+ throw new GitServeError(
423
+ `${outside.length} file${outside.length === 1 ? " has" : "s have"} uncommitted changes outside the board (${shown}${outside.length > 3 ? ", …" : ""}) — commit or stash them in a terminal first; switching under them from here would be a surprise`,
424
+ 409,
425
+ )
426
+ }
427
+ const result = runGit(this.root, ["checkout", "--quiet", branch])
428
+ if (result.status !== 0) {
429
+ throw new GitServeError(`git checkout failed: ${result.stderr || result.stdout}`)
430
+ }
431
+ return { branch }
432
+ }
433
+
434
+ /**
435
+ * Open a PR for a branch: push it, then `gh pr create` when gh is here —
436
+ * falling back to the GitHub compare URL for the UI to open when it isn't.
437
+ * On a created PR the URL is written into the `prs:` frontmatter of every
438
+ * ticket whose files the branch touches: files remember, git informs.
439
+ */
440
+ pr(body: {
441
+ branch?: unknown
442
+ base?: unknown
443
+ title?: unknown
444
+ body?: unknown
445
+ draft?: unknown
446
+ }): { url: string; created: boolean; tickets: string[] } {
447
+ const cap = this.capability()
448
+ const top = this.toplevel()
449
+ if (!cap || !top) throw new GitServeError("no git surface here", 404)
450
+ const current = gitOut(this.root, "symbolic-ref", "--quiet", "--short", "HEAD")
451
+ const branch = typeof body.branch === "string" && body.branch.trim() ? body.branch.trim() : current
452
+ if (!branch) throw new GitServeError("no branch to open a PR for — HEAD is detached")
453
+ const base = typeof body.base === "string" && body.base.trim() ? body.base.trim() : cap.defaultBranch
454
+ if (branch === base) throw new GitServeError(`${branch} is the base branch — nothing to open`)
455
+ // No way to end in a PR (or even a compare page)? Say so before pushing.
456
+ if (!cap.gh && !cap.repo) {
457
+ throw new GitServeError("origin isn't a GitHub remote and gh isn't installed — open the PR by hand", 501)
458
+ }
459
+
460
+ const push = runGit(this.root, ["push", "--quiet", "-u", "origin", branch], {
461
+ timeoutMs: GIT_NETWORK_TIMEOUT_MS,
462
+ })
463
+ if (push.status !== 0) {
464
+ throw new GitServeError(`push of ${branch} failed: ${push.stderr || push.stdout}`, 502)
465
+ }
466
+
467
+ if (!cap.gh) {
468
+ return { url: compareUrl(cap.repo!, base, branch), created: false, tickets: [] }
469
+ }
470
+
471
+ const title = typeof body.title === "string" && body.title.trim() ? body.title.trim() : branch
472
+ const description = typeof body.body === "string" ? body.body : ""
473
+ const args = ["pr", "create", "--head", branch, "--base", base, "--title", title, "--body", description]
474
+ if (body.draft === true) args.push("--draft")
475
+ const created = runGh(this.root, args)
476
+ if (created.status !== 0) {
477
+ throw new GitServeError(`gh pr create failed: ${created.stderr || created.stdout}`, 502)
478
+ }
479
+ const url = /https:\/\/\S+\/pull\/\d+/.exec(created.stdout)?.[0] ?? created.stdout
480
+ this.prCache.delete(branch)
481
+ return { url, created: true, tickets: this.attachPrUrl(top, branch, base, url) }
482
+ }
483
+
484
+ /** Append `url` to the prs: of every ticket the branch's commits touch. */
485
+ private attachPrUrl(top: string, branch: string, base: string, url: string): string[] {
486
+ const baseRef =
487
+ gitOut(this.root, "rev-parse", "--verify", "--quiet", `origin/${base}^{commit}`) !== null
488
+ ? `origin/${base}`
489
+ : base
490
+ const diff = gitOut(this.root, "diff", "--name-only", `${baseRef}...${branch}`)
491
+ if (diff === null || diff === "") return []
492
+ const dirs = this.boardDirs(top)
493
+ const stores = new Map(this.boards().map((b) => [b.id, b.store]))
494
+ const attached: string[] = []
495
+ const seen = new Set<string>()
496
+ for (const path of diff.split("\n")) {
497
+ for (const [board, dir] of dirs) {
498
+ if (!path.startsWith(`${dir}/`)) continue
499
+ const ticket = /^tickets\/([^/]+)\//.exec(path.slice(dir.length + 1))
500
+ if (!ticket) continue
501
+ const ticketKey = ticket[1]
502
+ const seenKey = `${board}:${ticketKey}`
503
+ if (seen.has(seenKey)) continue
504
+ seen.add(seenKey)
505
+ const store = stores.get(board)
506
+ const task = store?.get(ticketKey)
507
+ if (!store || !task || task.prs.includes(url)) continue
508
+ try {
509
+ store.update(ticketKey, { prs: [...task.prs, url] })
510
+ attached.push(task.id)
511
+ } catch {
512
+ // A refused update (archived, gone) must not fail the PR that exists.
513
+ }
514
+ }
515
+ }
516
+ return attached
517
+ }
518
+
519
+ /** Derived, display-only PR state for a branch — gh only, briefly cached. */
520
+ prFor(branch: string): { pr: PrRef | null } {
521
+ const cap = this.capability()
522
+ if (!cap) throw new GitServeError("no git surface here", 404)
523
+ if (!cap.gh) return { pr: null }
524
+ const cached = this.prCache.get(branch)
525
+ if (cached && Date.now() - cached.at < PR_TTL_MS) return { pr: cached.pr }
526
+ const out = runGh(this.root, [
527
+ "pr",
528
+ "list",
529
+ "--head",
530
+ branch,
531
+ "--state",
532
+ "all",
533
+ "--json",
534
+ "number,url,title,state,isDraft",
535
+ "--limit",
536
+ "10",
537
+ ])
538
+ let pr: PrRef | null = null
539
+ if (out.status === 0) {
540
+ try {
541
+ const rows = JSON.parse(out.stdout) as {
542
+ number: number
543
+ url: string
544
+ title: string
545
+ state: string
546
+ isDraft: boolean
547
+ }[]
548
+ const pick =
549
+ rows.find((r) => r.state === "OPEN") ??
550
+ rows.find((r) => r.state === "MERGED") ??
551
+ rows[0] ??
552
+ null
553
+ if (pick) {
554
+ pr = {
555
+ number: pick.number,
556
+ url: pick.url,
557
+ title: pick.title,
558
+ state: pick.state.toLowerCase() as PrRef["state"],
559
+ draft: pick.isDraft,
560
+ }
561
+ }
562
+ } catch {
563
+ pr = null
564
+ }
565
+ }
566
+ this.prCache.set(branch, { at: Date.now(), pr })
567
+ return { pr }
568
+ }
569
+
570
+ /**
571
+ * The drag-to-release flow's server half (TAS-26): abandon a ticket's claim
572
+ * exactly like `task claim --release` — delete the branch on origin and
573
+ * locally — so the caller can then apply the move as a plain file edit.
574
+ * Fails whole: if the branch can't be deleted (offline, no push access),
575
+ * the claim stands and the caller moves nothing.
576
+ */
577
+ releaseClaim(
578
+ store: Store,
579
+ body: { key?: unknown; note?: unknown },
580
+ author: string,
581
+ ): { branch: string } {
582
+ if (!this.capability()) throw new GitServeError("no git surface here", 404)
583
+ const key = typeof body.key === "string" ? body.key.trim() : ""
584
+ if (!key) throw new GitServeError("key is required")
585
+ const result = release(store, key)
586
+ this.claimsCache = null
587
+ const note = typeof body.note === "string" ? body.note.trim() : ""
588
+ store.addComment(
589
+ key,
590
+ note || "Released the claim from the board — the branch and its commits are gone.",
591
+ author,
592
+ )
593
+ return { branch: result.branch }
594
+ }
595
+ }