@nickmeriano/task 0.7.1 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +73 -25
- package/dist/asks.test.d.ts +17 -0
- package/dist/asks.test.d.ts.map +1 -0
- package/dist/asks.test.js +278 -0
- package/dist/asks.test.js.map +1 -0
- package/dist/check.d.ts +43 -0
- package/dist/check.d.ts.map +1 -0
- package/dist/check.js +403 -0
- package/dist/check.js.map +1 -0
- package/dist/check.test.d.ts +9 -0
- package/dist/check.test.d.ts.map +1 -0
- package/dist/check.test.js +248 -0
- package/dist/check.test.js.map +1 -0
- package/dist/claim-io.d.ts +73 -0
- package/dist/claim-io.d.ts.map +1 -0
- package/dist/claim-io.js +344 -0
- package/dist/claim-io.js.map +1 -0
- package/dist/claim.d.ts +61 -9
- package/dist/claim.d.ts.map +1 -1
- package/dist/claim.js +197 -67
- package/dist/claim.js.map +1 -1
- package/dist/claim.test.d.ts +2 -2
- package/dist/claim.test.js +235 -64
- package/dist/claim.test.js.map +1 -1
- package/dist/cli.js +724 -136
- package/dist/cli.js.map +1 -1
- package/dist/file-store.d.ts +110 -38
- package/dist/file-store.d.ts.map +1 -1
- package/dist/file-store.js +514 -238
- package/dist/file-store.js.map +1 -1
- package/dist/git-serve.d.ts +183 -0
- package/dist/git-serve.d.ts.map +1 -0
- package/dist/git-serve.js +503 -0
- package/dist/git-serve.js.map +1 -0
- package/dist/git-serve.test.d.ts +16 -0
- package/dist/git-serve.test.d.ts.map +1 -0
- package/dist/git-serve.test.js +183 -0
- package/dist/git-serve.test.js.map +1 -0
- package/dist/git.d.ts +65 -0
- package/dist/git.d.ts.map +1 -0
- package/dist/git.js +114 -0
- package/dist/git.js.map +1 -0
- package/dist/id.d.ts +39 -0
- package/dist/id.d.ts.map +1 -0
- package/dist/id.js +67 -0
- package/dist/id.js.map +1 -0
- package/dist/inbox.d.ts +41 -0
- package/dist/inbox.d.ts.map +1 -0
- package/dist/inbox.js +56 -0
- package/dist/inbox.js.map +1 -0
- package/dist/index.d.ts +5 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -3
- package/dist/index.js.map +1 -1
- package/dist/overview.d.ts +52 -0
- package/dist/overview.d.ts.map +1 -0
- package/dist/overview.js +61 -0
- package/dist/overview.js.map +1 -0
- package/dist/overview.test.d.ts +8 -0
- package/dist/overview.test.d.ts.map +1 -0
- package/dist/overview.test.js +48 -0
- package/dist/overview.test.js.map +1 -0
- package/dist/promote.test.d.ts +15 -0
- package/dist/promote.test.d.ts.map +1 -0
- package/dist/promote.test.js +104 -0
- package/dist/promote.test.js.map +1 -0
- package/dist/publish.d.ts +2 -17
- package/dist/publish.d.ts.map +1 -1
- package/dist/publish.js +4 -49
- package/dist/publish.js.map +1 -1
- package/dist/search.d.ts +34 -0
- package/dist/search.d.ts.map +1 -0
- package/dist/search.js +79 -0
- package/dist/search.js.map +1 -0
- package/dist/search.test.d.ts +2 -0
- package/dist/search.test.d.ts.map +1 -0
- package/dist/search.test.js +53 -0
- package/dist/search.test.js.map +1 -0
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +228 -23
- package/dist/server.js.map +1 -1
- package/dist/store.d.ts +43 -63
- package/dist/store.d.ts.map +1 -1
- package/dist/store.js +0 -368
- package/dist/store.js.map +1 -1
- package/dist/store.test.d.ts +1 -2
- package/dist/store.test.d.ts.map +1 -1
- package/dist/store.test.js +148 -106
- package/dist/store.test.js.map +1 -1
- package/dist/ticket-doc.d.ts +74 -5
- package/dist/ticket-doc.d.ts.map +1 -1
- package/dist/ticket-doc.js +229 -15
- package/dist/ticket-doc.js.map +1 -1
- package/dist/types.d.ts +115 -28
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/package.json +1 -1
- package/skill/SKILL.md +153 -40
- package/src/asks.test.ts +355 -0
- package/src/check.test.ts +328 -0
- package/src/check.ts +497 -0
- package/src/claim-io.ts +401 -0
- package/src/claim.test.ts +301 -71
- package/src/claim.ts +238 -81
- package/src/cli.ts +740 -131
- package/src/file-store.ts +572 -254
- package/src/git-serve.test.ts +240 -0
- package/src/git-serve.ts +595 -0
- package/src/git.ts +141 -0
- package/src/id.ts +68 -0
- package/src/inbox.ts +77 -0
- package/src/index.ts +4 -2
- package/src/overview.test.ts +52 -0
- package/src/overview.ts +105 -0
- package/src/promote.test.ts +143 -0
- package/src/publish.ts +6 -53
- package/src/search.test.ts +64 -0
- package/src/search.ts +105 -0
- package/src/server.ts +232 -21
- package/src/store.test.ts +166 -116
- package/src/store.ts +46 -444
- package/src/ticket-doc.ts +284 -21
- package/src/types.ts +120 -28
- package/ui/dist/assets/index-BjsorZOU.js +229 -0
- package/ui/dist/assets/index-CoKCUYic.css +1 -0
- package/ui/dist/index.html +2 -2
- package/ui/dist/assets/index-COunM-QN.css +0 -1
- package/ui/dist/assets/index-D4homvrQ.js +0 -229
package/src/publish.ts
CHANGED
|
@@ -18,9 +18,14 @@
|
|
|
18
18
|
* same operation as re-running it here.
|
|
19
19
|
*/
|
|
20
20
|
|
|
21
|
-
import { execFileSync } from "node:child_process"
|
|
22
21
|
import process from "node:process"
|
|
23
22
|
|
|
23
|
+
import { detectRepo, parseRemote, type RepoRef } from "./git.ts"
|
|
24
|
+
|
|
25
|
+
// Re-exported so the CLI keeps one import site for "which repo is this";
|
|
26
|
+
// the implementations moved to git.ts with the rest of the git access.
|
|
27
|
+
export { detectRepo, parseRemote, type RepoRef }
|
|
28
|
+
|
|
24
29
|
/** Overridable so the flow can be exercised against a local `vite dev`. */
|
|
25
30
|
export const DEFAULT_HOST = "https://task.nickmeriano.com"
|
|
26
31
|
|
|
@@ -28,58 +33,6 @@ export type Visibility = "private" | "public"
|
|
|
28
33
|
|
|
29
34
|
export type Action = "publish" | "unpublish"
|
|
30
35
|
|
|
31
|
-
export interface RepoRef {
|
|
32
|
-
owner: string
|
|
33
|
-
repo: string
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
/**
|
|
37
|
-
* The GitHub repo this working copy pushes to.
|
|
38
|
-
*
|
|
39
|
-
* `origin` first, then the only remote if there's exactly one — a clone with
|
|
40
|
-
* `upstream` and a fork remote should not silently publish the wrong one, so
|
|
41
|
-
* ambiguity is an error the user resolves with `--repo`.
|
|
42
|
-
*/
|
|
43
|
-
export function detectRepo(cwd: string): RepoRef | null {
|
|
44
|
-
const remotes = gitRemotes(cwd)
|
|
45
|
-
const url = remotes.get("origin") ?? (remotes.size === 1 ? [...remotes.values()][0] : null)
|
|
46
|
-
return url ? parseRemote(url) : null
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
function gitRemotes(cwd: string): Map<string, string> {
|
|
50
|
-
const out = new Map<string, string>()
|
|
51
|
-
let raw: string
|
|
52
|
-
try {
|
|
53
|
-
raw = execFileSync("git", ["remote", "-v"], {
|
|
54
|
-
cwd,
|
|
55
|
-
encoding: "utf8",
|
|
56
|
-
stdio: ["ignore", "pipe", "ignore"],
|
|
57
|
-
})
|
|
58
|
-
} catch {
|
|
59
|
-
return out
|
|
60
|
-
}
|
|
61
|
-
for (const line of raw.split("\n")) {
|
|
62
|
-
const match = /^(\S+)\s+(\S+)\s+\(fetch\)$/.exec(line.trim())
|
|
63
|
-
if (match) out.set(match[1], match[2])
|
|
64
|
-
}
|
|
65
|
-
return out
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
/**
|
|
69
|
-
* Every shape git writes a GitHub remote in: https, ssh, `git@`-scp, with or
|
|
70
|
-
* without `.git`, and with or without credentials embedded in the host.
|
|
71
|
-
*/
|
|
72
|
-
export function parseRemote(url: string): RepoRef | null {
|
|
73
|
-
const cleaned = url.trim().replace(/\.git$/, "")
|
|
74
|
-
const match =
|
|
75
|
-
/^(?:https?:\/\/|ssh:\/\/)?(?:[^@/]+@)?github\.com[:/]([^/]+)\/([^/]+?)\/?$/.exec(cleaned)
|
|
76
|
-
if (!match) return null
|
|
77
|
-
const [, owner, repo] = match
|
|
78
|
-
if (!/^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})$/.test(owner)) return null
|
|
79
|
-
if (!/^[A-Za-z0-9._-]{1,100}$/.test(repo)) return null
|
|
80
|
-
return { owner, repo }
|
|
81
|
-
}
|
|
82
|
-
|
|
83
36
|
/** `owner/repo`, for `--repo`. */
|
|
84
37
|
export function parseSlug(slug: string): RepoRef | null {
|
|
85
38
|
const [owner, repo, ...rest] = slug.split("/")
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import assert from "node:assert/strict"
|
|
2
|
+
import { test } from "node:test"
|
|
3
|
+
import { mkdtempSync, rmSync } from "node:fs"
|
|
4
|
+
import { tmpdir } from "node:os"
|
|
5
|
+
import { join } from "node:path"
|
|
6
|
+
import { initProject } from "./file-store.ts"
|
|
7
|
+
import { searchStore } from "./search.ts"
|
|
8
|
+
|
|
9
|
+
function tempRoot(): string {
|
|
10
|
+
const dir = mkdtempSync(join(tmpdir(), "task-search-"))
|
|
11
|
+
process.on("exit", () => rmSync(dir, { recursive: true, force: true }))
|
|
12
|
+
return dir
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
test("search covers titles, descriptions and comments, case-insensitively", () => {
|
|
16
|
+
const store = initProject(tempRoot(), { name: "search", prefix: "SRC" })
|
|
17
|
+
const a = store.create({ title: "Wire up Webhook retries", description: "exponential backoff" })
|
|
18
|
+
const b = store.create({ title: "Unrelated", description: "The webhook queue drains here." })
|
|
19
|
+
const c = store.create({ title: "Also unrelated" })
|
|
20
|
+
store.create({ title: "Noise" })
|
|
21
|
+
store.addComment(c.key, "root cause: the WEBHOOK signature check", "claude")
|
|
22
|
+
|
|
23
|
+
const results = searchStore(store, "webhook", {}, false)
|
|
24
|
+
assert.deepEqual(
|
|
25
|
+
results.map((r) => r.task.key).sort(),
|
|
26
|
+
[a.key, b.key, c.key].sort(),
|
|
27
|
+
)
|
|
28
|
+
const byKey = new Map(results.map((r) => [r.task.key, r.matches]))
|
|
29
|
+
assert.deepEqual(byKey.get(a.key)!.map((m) => m.field), ["title"])
|
|
30
|
+
assert.equal(byKey.get(b.key)![0].snippet, "The webhook queue drains here.")
|
|
31
|
+
const comment = byKey.get(c.key)![0]
|
|
32
|
+
assert.equal(comment.field, "comment")
|
|
33
|
+
assert.equal(comment.author, "claude")
|
|
34
|
+
assert.match(comment.snippet, /WEBHOOK signature/)
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
test("filters scope the search; --archived adds the archive in", () => {
|
|
38
|
+
const store = initProject(tempRoot(), { name: "scope", prefix: "SCP" })
|
|
39
|
+
const live = store.create({ title: "deploy pipeline", tags: ["infra"] })
|
|
40
|
+
store.create({ title: "deploy docs", tags: ["docs"] })
|
|
41
|
+
const old = store.create({ title: "deploy v1", status: "done" })
|
|
42
|
+
store.archive(old.key)
|
|
43
|
+
|
|
44
|
+
assert.deepEqual(
|
|
45
|
+
searchStore(store, "deploy", { tags: ["infra"] }, false).map((r) => r.task.key),
|
|
46
|
+
[live.key],
|
|
47
|
+
)
|
|
48
|
+
const withArchive = searchStore(store, "deploy", {}, true)
|
|
49
|
+
assert.ok(withArchive.some((r) => r.task.key === old.key && r.task.archived))
|
|
50
|
+
assert.equal(withArchive.length, 3)
|
|
51
|
+
// Without the flag the archived ticket stays out.
|
|
52
|
+
assert.equal(searchStore(store, "deploy", {}, false).length, 2)
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
test("long lines are windowed so the hit is visible", () => {
|
|
56
|
+
const store = initProject(tempRoot(), { name: "win", prefix: "WIN" })
|
|
57
|
+
const filler = "x".repeat(150)
|
|
58
|
+
store.create({ title: "Long", description: `${filler} needle in here ${filler}` })
|
|
59
|
+
const [result] = searchStore(store, "needle", {}, false)
|
|
60
|
+
assert.ok(result.matches[0].snippet.length <= 102) // window + ellipses
|
|
61
|
+
assert.match(result.matches[0].snippet, /needle in here/)
|
|
62
|
+
assert.match(result.matches[0].snippet, /^…/)
|
|
63
|
+
assert.match(result.matches[0].snippet, /…$/)
|
|
64
|
+
})
|
package/src/search.ts
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `task search` — find tickets by what they say, not just what they're tagged.
|
|
3
|
+
*
|
|
4
|
+
* Case-insensitive substring match over titles, descriptions and comments,
|
|
5
|
+
* scoped by the usual list filters. At current board sizes a straight scan of
|
|
6
|
+
* the parsed files is plenty; if boards ever grow to thousands of tickets,
|
|
7
|
+
* this is the one feature that would justify a derived FTS index — rebuilt
|
|
8
|
+
* from the files, never as state, which the text-canonical format was designed
|
|
9
|
+
* to allow.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { Store } from "./store.ts"
|
|
13
|
+
import { STATUSES, type Task, type TaskFilter } from "./types.ts"
|
|
14
|
+
|
|
15
|
+
export interface SearchMatch {
|
|
16
|
+
field: "title" | "description" | "comment" | "ask"
|
|
17
|
+
/** The matching line, trimmed and windowed around the first hit. */
|
|
18
|
+
snippet: string
|
|
19
|
+
/** Set on comment matches. */
|
|
20
|
+
commentId?: string
|
|
21
|
+
author?: string
|
|
22
|
+
/** Set on ask matches — the ordinal `task resolve` takes. */
|
|
23
|
+
askOrdinal?: number
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface SearchResult {
|
|
27
|
+
task: Task
|
|
28
|
+
matches: SearchMatch[]
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** How much of a matching line a snippet shows. */
|
|
32
|
+
const SNIPPET_MAX = 100
|
|
33
|
+
|
|
34
|
+
/** The first line containing the needle, windowed so the hit is visible. */
|
|
35
|
+
function snippetOf(text: string, needle: string): string {
|
|
36
|
+
const lines = text.split("\n")
|
|
37
|
+
const line = (lines.find((l) => l.toLowerCase().includes(needle)) ?? lines[0]).trim()
|
|
38
|
+
if (line.length <= SNIPPET_MAX) return line
|
|
39
|
+
const at = line.toLowerCase().indexOf(needle)
|
|
40
|
+
const start = Math.max(0, Math.min(at - 30, line.length - SNIPPET_MAX))
|
|
41
|
+
const end = Math.min(line.length, start + SNIPPET_MAX)
|
|
42
|
+
return `${start > 0 ? "…" : ""}${line.slice(start, end).trim()}${end < line.length ? "…" : ""}`
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Every task matching `query`, with one snippet per matching field (and one
|
|
47
|
+
* per matching comment). `includeArchived` searches the archive *in addition
|
|
48
|
+
* to* the board — unlike `list --archived`, which shows the archive instead,
|
|
49
|
+
* a search is a question about everything ever written down.
|
|
50
|
+
*/
|
|
51
|
+
export function searchStore(
|
|
52
|
+
store: Store,
|
|
53
|
+
query: string,
|
|
54
|
+
filter: TaskFilter,
|
|
55
|
+
includeArchived: boolean,
|
|
56
|
+
): SearchResult[] {
|
|
57
|
+
const needle = query.toLowerCase()
|
|
58
|
+
const pool = [
|
|
59
|
+
...store.list(filter),
|
|
60
|
+
...(includeArchived ? store.list({ ...filter, archived: true }) : []),
|
|
61
|
+
]
|
|
62
|
+
|
|
63
|
+
const results: SearchResult[] = []
|
|
64
|
+
for (const task of pool) {
|
|
65
|
+
const matches: SearchMatch[] = []
|
|
66
|
+
if (task.title.toLowerCase().includes(needle)) {
|
|
67
|
+
matches.push({ field: "title", snippet: snippetOf(task.title, needle) })
|
|
68
|
+
}
|
|
69
|
+
if (task.description.toLowerCase().includes(needle)) {
|
|
70
|
+
matches.push({ field: "description", snippet: snippetOf(task.description, needle) })
|
|
71
|
+
}
|
|
72
|
+
// Asks ride along on the task, so no extra read — and a hit names the
|
|
73
|
+
// ordinal, which is the handle you'd resolve it by.
|
|
74
|
+
for (const ask of task.asks) {
|
|
75
|
+
if (!ask.text.toLowerCase().includes(needle)) continue
|
|
76
|
+
matches.push({
|
|
77
|
+
field: "ask",
|
|
78
|
+
snippet: snippetOf(ask.text, needle),
|
|
79
|
+
askOrdinal: ask.ordinal,
|
|
80
|
+
...(ask.author ? { author: ask.author } : {}),
|
|
81
|
+
})
|
|
82
|
+
}
|
|
83
|
+
for (const comment of store.comments(task.key)) {
|
|
84
|
+
if (!comment.body.toLowerCase().includes(needle)) continue
|
|
85
|
+
matches.push({
|
|
86
|
+
field: "comment",
|
|
87
|
+
snippet: snippetOf(comment.body, needle),
|
|
88
|
+
commentId: comment.id,
|
|
89
|
+
...(comment.author ? { author: comment.author } : {}),
|
|
90
|
+
})
|
|
91
|
+
}
|
|
92
|
+
if (matches.length > 0) results.push({ task, matches })
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Board order — grouped by status column, then position — same as `list`.
|
|
96
|
+
const order = new Map(STATUSES.map((s, i) => [s, i]))
|
|
97
|
+
results.sort(
|
|
98
|
+
(a, b) =>
|
|
99
|
+
order.get(a.task.status)! - order.get(b.task.status)! ||
|
|
100
|
+
a.task.position - b.task.position ||
|
|
101
|
+
a.task.createdAt.localeCompare(b.task.createdAt) ||
|
|
102
|
+
a.task.key.localeCompare(b.task.key),
|
|
103
|
+
)
|
|
104
|
+
return results
|
|
105
|
+
}
|
package/src/server.ts
CHANGED
|
@@ -3,8 +3,11 @@ import { watch, existsSync, readFileSync, statSync, type FSWatcher } from "node:
|
|
|
3
3
|
import { extname, join, normalize } from "node:path"
|
|
4
4
|
import { fileURLToPath } from "node:url"
|
|
5
5
|
import { resolveAuthor } from "./author.ts"
|
|
6
|
+
import { addAskRouted, deleteAskRouted, setAskResolvedRouted } from "./claim-io.ts"
|
|
6
7
|
import { openBoard } from "./file-store.ts"
|
|
7
|
-
import {
|
|
8
|
+
import { GitServe, GitServeError } from "./git-serve.ts"
|
|
9
|
+
import { buildInbox } from "./inbox.ts"
|
|
10
|
+
import { boardConfig, findBoards, type BoardRef, type Store } from "./store.ts"
|
|
8
11
|
import { STATUSES, isStatus, type TaskPatch } from "./types.ts"
|
|
9
12
|
|
|
10
13
|
/** The prebuilt SPA, shipped inside the package next to dist/. */
|
|
@@ -45,12 +48,11 @@ function parsePatch(body: Record<string, unknown>): TaskPatch {
|
|
|
45
48
|
patch.status = body.status
|
|
46
49
|
}
|
|
47
50
|
if (Array.isArray(body.tags)) patch.tags = body.tags.map(String)
|
|
48
|
-
if (typeof body.
|
|
49
|
-
patch.
|
|
51
|
+
if (typeof body.goal === "string" || body.goal === null) {
|
|
52
|
+
patch.goal = body.goal === "" ? null : body.goal
|
|
50
53
|
}
|
|
51
|
-
if (
|
|
52
|
-
if (Array.isArray(body.
|
|
53
|
-
if (Array.isArray(body.blockedBy)) patch.blockedBy = body.blockedBy.map(taskNumber)
|
|
54
|
+
if (Array.isArray(body.blocks)) patch.blocks = body.blocks.map(taskKey)
|
|
55
|
+
if (Array.isArray(body.blockedBy)) patch.blockedBy = body.blockedBy.map(taskKey)
|
|
54
56
|
if (Array.isArray(body.prs)) {
|
|
55
57
|
patch.prs = body.prs.map((pr) => {
|
|
56
58
|
const url = String(pr).trim()
|
|
@@ -63,10 +65,32 @@ function parsePatch(body: Record<string, unknown>): TaskPatch {
|
|
|
63
65
|
return patch
|
|
64
66
|
}
|
|
65
67
|
|
|
66
|
-
function
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
68
|
+
function taskKey(value: unknown): string {
|
|
69
|
+
if (typeof value !== "string" || !/^[a-z0-9]+$/.test(value)) {
|
|
70
|
+
throw new Error(`invalid task key: ${String(value)}`)
|
|
71
|
+
}
|
|
72
|
+
return value
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* The CSRF gate. `task serve` is an unauthenticated HTTP server, and any
|
|
77
|
+
* webpage in the same browser can aim a POST at it — before this check, a
|
|
78
|
+
* drive-by page could edit the board, and with the git surface it could
|
|
79
|
+
* commit and push. Same-origin is the whole auth model, so a write has to
|
|
80
|
+
* prove it: browsers attach `Origin` to every cross-origin request (and to
|
|
81
|
+
* all fetch POSTs), and the only origin allowed to write is the board's own —
|
|
82
|
+
* whatever host the request itself was addressed to. No Origin header means
|
|
83
|
+
* no browser page is speaking (curl, scripts), which is the local-trust case
|
|
84
|
+
* the server already accepts.
|
|
85
|
+
*/
|
|
86
|
+
function sameOrigin(req: IncomingMessage): boolean {
|
|
87
|
+
const origin = req.headers.origin
|
|
88
|
+
if (origin === undefined) return true
|
|
89
|
+
try {
|
|
90
|
+
return new URL(origin).host === req.headers.host
|
|
91
|
+
} catch {
|
|
92
|
+
return false // "null" and other unparseable origins are nobody we trust
|
|
93
|
+
}
|
|
70
94
|
}
|
|
71
95
|
|
|
72
96
|
/**
|
|
@@ -144,6 +168,28 @@ export function createTaskServer(serveRoot: string): Server {
|
|
|
144
168
|
throw new Error(`no .task directory found at or below ${serveRoot} — run \`task init\` first`)
|
|
145
169
|
}
|
|
146
170
|
|
|
171
|
+
const gitServe = new GitServe(serveRoot, () =>
|
|
172
|
+
ordered.map((id) => ({ id, store: boards.get(id)!.store })),
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
// Branch switches don't touch `.task/` files, but they change what every
|
|
176
|
+
// board file *says* — so watch the git dir for HEAD moving (a checkout,
|
|
177
|
+
// from this server or a terminal) and ping every board's SSE listeners.
|
|
178
|
+
// Non-recursive on the resolved git dir (worktrees keep HEAD there), and
|
|
179
|
+
// best-effort: a repo this can't watch simply refreshes on the next edit.
|
|
180
|
+
let gitWatcher: FSWatcher | null = null
|
|
181
|
+
const gitDir = gitServe.gitDir()
|
|
182
|
+
if (gitDir) {
|
|
183
|
+
try {
|
|
184
|
+
gitWatcher = watch(gitDir, (_event, filename) => {
|
|
185
|
+
if (filename === "HEAD") for (const id of ordered) noteChange(id)
|
|
186
|
+
})
|
|
187
|
+
gitWatcher.on("error", () => {})
|
|
188
|
+
} catch {
|
|
189
|
+
gitWatcher = null
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
147
193
|
const heartbeat = setInterval(() => {
|
|
148
194
|
for (const client of clients) client.write(`: ping\n\n`)
|
|
149
195
|
}, 30_000)
|
|
@@ -159,6 +205,7 @@ export function createTaskServer(serveRoot: string): Server {
|
|
|
159
205
|
server.on("close", () => {
|
|
160
206
|
if (pending) clearTimeout(pending)
|
|
161
207
|
clearInterval(heartbeat)
|
|
208
|
+
gitWatcher?.close()
|
|
162
209
|
for (const board of boards.values()) {
|
|
163
210
|
board.watcher.close()
|
|
164
211
|
board.store.close()
|
|
@@ -170,6 +217,19 @@ export function createTaskServer(serveRoot: string): Server {
|
|
|
170
217
|
const url = new URL(req.url ?? "/", "http://localhost")
|
|
171
218
|
const path = url.pathname
|
|
172
219
|
|
|
220
|
+
// Every mutation, one gate — the ask routes push commits and the git
|
|
221
|
+
// routes run git, so this cannot be per-endpoint courtesy.
|
|
222
|
+
const method = req.method ?? "GET"
|
|
223
|
+
if (
|
|
224
|
+
path.startsWith("/api/") &&
|
|
225
|
+
method !== "GET" &&
|
|
226
|
+
method !== "HEAD" &&
|
|
227
|
+
!sameOrigin(req)
|
|
228
|
+
) {
|
|
229
|
+
json(res, 403, { error: "cross-origin request refused — the board only writes for its own pages" })
|
|
230
|
+
return
|
|
231
|
+
}
|
|
232
|
+
|
|
173
233
|
if (path === "/api/events") {
|
|
174
234
|
res.writeHead(200, {
|
|
175
235
|
"Content-Type": "text/event-stream",
|
|
@@ -193,6 +253,94 @@ export function createTaskServer(serveRoot: string): Server {
|
|
|
193
253
|
return
|
|
194
254
|
}
|
|
195
255
|
|
|
256
|
+
if (path === "/api/inbox") {
|
|
257
|
+
// Deliberately cross-board — the header badge is repo-wide attention.
|
|
258
|
+
// Scope follows the CLI's selection rule: the root board's `boards`
|
|
259
|
+
// list when it has one (matched by prefix), every served board
|
|
260
|
+
// otherwise — so a vendored board can't put asks in your face unless
|
|
261
|
+
// the root board opted it in.
|
|
262
|
+
syncBoards()
|
|
263
|
+
const all = ordered.map((id) => ({ id, store: boards.get(id)!.store }))
|
|
264
|
+
let scoped = all
|
|
265
|
+
try {
|
|
266
|
+
const wanted = boardConfig(all[0].store.root).boards
|
|
267
|
+
if (Array.isArray(wanted) && wanted.length > 0) {
|
|
268
|
+
const prefixes = new Set(wanted.map((p) => String(p).toUpperCase()))
|
|
269
|
+
const matched = all.filter((b) => prefixes.has(b.store.config.prefix.toUpperCase()))
|
|
270
|
+
if (matched.length > 0) scoped = matched
|
|
271
|
+
}
|
|
272
|
+
} catch {
|
|
273
|
+
// Unreadable root config — serve everything rather than nothing.
|
|
274
|
+
}
|
|
275
|
+
json(res, 200, { inbox: buildInbox(scoped) })
|
|
276
|
+
return
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// The git surface (TAS-42). Repo-level, not board-level — one working
|
|
280
|
+
// tree holds every served board — so it lives beside /api/inbox rather
|
|
281
|
+
// than inside routeApi. Every handler 404s ("no git surface here") when
|
|
282
|
+
// the capability is off, which is also what the prebuilt UI probes.
|
|
283
|
+
if (path.startsWith("/api/git/")) {
|
|
284
|
+
const fail = (error: unknown): void => {
|
|
285
|
+
const status = error instanceof GitServeError ? error.status : 400
|
|
286
|
+
json(res, status, { error: error instanceof Error ? error.message : String(error) })
|
|
287
|
+
}
|
|
288
|
+
try {
|
|
289
|
+
if (path === "/api/git/status" && req.method === "GET") {
|
|
290
|
+
json(res, 200, gitServe.status())
|
|
291
|
+
return
|
|
292
|
+
}
|
|
293
|
+
if (path === "/api/git/branches" && req.method === "GET") {
|
|
294
|
+
json(res, 200, { branches: gitServe.branches() })
|
|
295
|
+
return
|
|
296
|
+
}
|
|
297
|
+
if (path === "/api/git/pr" && req.method === "GET") {
|
|
298
|
+
const branch = url.searchParams.get("branch")
|
|
299
|
+
if (!branch) {
|
|
300
|
+
json(res, 400, { error: "branch is required" })
|
|
301
|
+
return
|
|
302
|
+
}
|
|
303
|
+
json(res, 200, gitServe.prFor(branch))
|
|
304
|
+
return
|
|
305
|
+
}
|
|
306
|
+
if (path === "/api/git/commit" && req.method === "POST") {
|
|
307
|
+
void readBody(req)
|
|
308
|
+
.then((body) => json(res, 200, gitServe.commit(body)))
|
|
309
|
+
.catch(fail)
|
|
310
|
+
return
|
|
311
|
+
}
|
|
312
|
+
if (path === "/api/git/checkout" && req.method === "POST") {
|
|
313
|
+
void readBody(req)
|
|
314
|
+
.then((body) => json(res, 200, gitServe.checkout(body)))
|
|
315
|
+
.catch(fail)
|
|
316
|
+
return
|
|
317
|
+
}
|
|
318
|
+
if (path === "/api/git/pr" && req.method === "POST") {
|
|
319
|
+
void readBody(req)
|
|
320
|
+
.then((body) => json(res, 200, gitServe.pr(body)))
|
|
321
|
+
.catch(fail)
|
|
322
|
+
return
|
|
323
|
+
}
|
|
324
|
+
if (path === "/api/git/release" && req.method === "POST") {
|
|
325
|
+
const id = url.searchParams.get("board") ?? ordered[0]
|
|
326
|
+
const board = boards.get(id)
|
|
327
|
+
if (!board) {
|
|
328
|
+
json(res, 404, { error: `no such board: ${id}` })
|
|
329
|
+
return
|
|
330
|
+
}
|
|
331
|
+
void readBody(req)
|
|
332
|
+
.then((body) => json(res, 200, gitServe.releaseClaim(board.store, body, author)))
|
|
333
|
+
.catch(fail)
|
|
334
|
+
return
|
|
335
|
+
}
|
|
336
|
+
} catch (error) {
|
|
337
|
+
fail(error)
|
|
338
|
+
return
|
|
339
|
+
}
|
|
340
|
+
json(res, 404, { error: "not found" })
|
|
341
|
+
return
|
|
342
|
+
}
|
|
343
|
+
|
|
196
344
|
if (path.startsWith("/api/")) {
|
|
197
345
|
const id = url.searchParams.get("board") ?? ordered[0]
|
|
198
346
|
const board = boards.get(id)
|
|
@@ -219,15 +367,28 @@ export function createTaskServer(serveRoot: string): Server {
|
|
|
219
367
|
prefix: store.config.prefix,
|
|
220
368
|
statuses: STATUSES,
|
|
221
369
|
author,
|
|
370
|
+
// The git capability, or nothing: its absence is how the hosted and
|
|
371
|
+
// snapshot deployments (whose /project never carries it) and a serve
|
|
372
|
+
// with no origin all tell the UI to draw no git surface at all.
|
|
373
|
+
git: gitServe.capability() ?? undefined,
|
|
222
374
|
})
|
|
223
375
|
return
|
|
224
376
|
}
|
|
225
377
|
|
|
378
|
+
if (path === "/api/goals" && req.method === "GET") {
|
|
379
|
+
// Read-only on purpose: a goal is created deliberately, with a
|
|
380
|
+
// description, via `task goal add` — a picker that mints goals from
|
|
381
|
+
// typed strings would just be milestones with extra steps. Archived
|
|
382
|
+
// goals ride along flagged, so old chips can still resolve a title.
|
|
383
|
+
json(res, 200, { goals: [...store.goals(), ...store.goals(true)] })
|
|
384
|
+
return
|
|
385
|
+
}
|
|
386
|
+
|
|
226
387
|
if (path === "/api/tasks" && req.method === "GET") {
|
|
227
388
|
const counts = store.commentCounts()
|
|
228
389
|
const tasks = store
|
|
229
390
|
.list()
|
|
230
|
-
.map((t) => ({ ...t, commentCount: counts.get(t.
|
|
391
|
+
.map((t) => ({ ...t, commentCount: counts.get(t.key) ?? 0 }))
|
|
231
392
|
json(res, 200, { tasks })
|
|
232
393
|
return
|
|
233
394
|
}
|
|
@@ -239,19 +400,69 @@ export function createTaskServer(serveRoot: string): Server {
|
|
|
239
400
|
throw new Error("title is required")
|
|
240
401
|
}
|
|
241
402
|
const patch = parsePatch(body)
|
|
242
|
-
|
|
403
|
+
let task = store.create({ title: body.title.trim(), ...patch })
|
|
404
|
+
// Asks at create time — the dialog's chip. Author is the server's
|
|
405
|
+
// resolved identity, same trust rule as comments.
|
|
406
|
+
if (Array.isArray(body.asks)) {
|
|
407
|
+
for (const text of body.asks) {
|
|
408
|
+
if (typeof text === "string" && text.trim()) {
|
|
409
|
+
store.addAsk(task.key, text.trim(), author)
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
task = store.get(task.key)!
|
|
413
|
+
}
|
|
243
414
|
json(res, 201, { task })
|
|
244
415
|
})
|
|
245
416
|
.catch((error: Error) => json(res, 400, { error: error.message }))
|
|
246
417
|
return
|
|
247
418
|
}
|
|
248
419
|
|
|
420
|
+
// Routed, not raw store calls: on a claimed ticket the write lands on the
|
|
421
|
+
// claim branch as a pushed commit (claim-io.ts) — the branch owns a
|
|
422
|
+
// claimed ticket's asks, checkbox and CLI alike.
|
|
423
|
+
const askMatch = /^\/api\/tasks\/([^/]+)\/asks(?:\/([^/]+))?$/.exec(path)
|
|
424
|
+
if (askMatch) {
|
|
425
|
+
const key = store.parseId(decodeURIComponent(askMatch[1]))
|
|
426
|
+
if (!askMatch[2] && req.method === "POST") {
|
|
427
|
+
void readBody(req)
|
|
428
|
+
.then((body) => {
|
|
429
|
+
if (typeof body.text !== "string" || !body.text.trim()) {
|
|
430
|
+
throw new Error("text is required")
|
|
431
|
+
}
|
|
432
|
+
const { ask, branch } = addAskRouted(store, key, body.text.trim(), author)
|
|
433
|
+
json(res, 201, { ask, branch })
|
|
434
|
+
})
|
|
435
|
+
.catch((error: Error) => json(res, 400, { error: error.message }))
|
|
436
|
+
return
|
|
437
|
+
}
|
|
438
|
+
if (askMatch[2] && req.method === "PATCH") {
|
|
439
|
+
const ref = decodeURIComponent(askMatch[2])
|
|
440
|
+
void readBody(req)
|
|
441
|
+
.then((body) => {
|
|
442
|
+
if (typeof body.resolved !== "boolean") {
|
|
443
|
+
throw new Error("resolved must be true or false")
|
|
444
|
+
}
|
|
445
|
+
const { ask, branch } = setAskResolvedRouted(store, key, ref, body.resolved, author)
|
|
446
|
+
json(res, 200, { ask, branch })
|
|
447
|
+
})
|
|
448
|
+
.catch((error: Error) => json(res, 400, { error: error.message }))
|
|
449
|
+
return
|
|
450
|
+
}
|
|
451
|
+
if (askMatch[2] && req.method === "DELETE") {
|
|
452
|
+
const { branch } = deleteAskRouted(store, key, decodeURIComponent(askMatch[2]))
|
|
453
|
+
json(res, 200, { ok: true, branch })
|
|
454
|
+
return
|
|
455
|
+
}
|
|
456
|
+
json(res, 404, { error: "not found" })
|
|
457
|
+
return
|
|
458
|
+
}
|
|
459
|
+
|
|
249
460
|
const taskMatch = /^\/api\/tasks\/([^/]+)(\/comments(?:\/([^/]+))?)?$/.exec(path)
|
|
250
461
|
if (taskMatch) {
|
|
251
|
-
const
|
|
462
|
+
const key = store.parseId(decodeURIComponent(taskMatch[1]))
|
|
252
463
|
if (taskMatch[3] && req.method === "DELETE") {
|
|
253
464
|
const commentId = decodeURIComponent(taskMatch[3])
|
|
254
|
-
const existing = store.comments(
|
|
465
|
+
const existing = store.comments(key).find((c) => c.id === commentId)
|
|
255
466
|
if (!existing) {
|
|
256
467
|
json(res, 404, { error: `no such comment: ${commentId}` })
|
|
257
468
|
return
|
|
@@ -262,7 +473,7 @@ export function createTaskServer(serveRoot: string): Server {
|
|
|
262
473
|
json(res, 403, { error: "only your own comments can be deleted" })
|
|
263
474
|
return
|
|
264
475
|
}
|
|
265
|
-
store.deleteComment(
|
|
476
|
+
store.deleteComment(key, commentId)
|
|
266
477
|
json(res, 200, { ok: true })
|
|
267
478
|
return
|
|
268
479
|
}
|
|
@@ -273,7 +484,7 @@ export function createTaskServer(serveRoot: string): Server {
|
|
|
273
484
|
throw new Error("body is required")
|
|
274
485
|
}
|
|
275
486
|
// `body.author` is ignored on purpose — unauthenticated noise.
|
|
276
|
-
const comment = store.addComment(
|
|
487
|
+
const comment = store.addComment(key, body.body.trim(), author)
|
|
277
488
|
json(res, 201, { comment })
|
|
278
489
|
})
|
|
279
490
|
.catch((error: Error) => json(res, 400, { error: error.message }))
|
|
@@ -281,25 +492,25 @@ export function createTaskServer(serveRoot: string): Server {
|
|
|
281
492
|
}
|
|
282
493
|
if (!taskMatch[2]) {
|
|
283
494
|
if (req.method === "GET") {
|
|
284
|
-
const task = store.get(
|
|
495
|
+
const task = store.get(key)
|
|
285
496
|
if (!task) {
|
|
286
|
-
json(res, 404, { error: `no such task: ${store.displayId(
|
|
497
|
+
json(res, 404, { error: `no such task: ${store.displayId(key)}` })
|
|
287
498
|
return
|
|
288
499
|
}
|
|
289
|
-
json(res, 200, { task, comments: store.comments(
|
|
500
|
+
json(res, 200, { task, comments: store.comments(key) })
|
|
290
501
|
return
|
|
291
502
|
}
|
|
292
503
|
if (req.method === "PATCH") {
|
|
293
504
|
void readBody(req)
|
|
294
505
|
.then((body) => {
|
|
295
|
-
const task = store.update(
|
|
506
|
+
const task = store.update(key, parsePatch(body))
|
|
296
507
|
json(res, 200, { task })
|
|
297
508
|
})
|
|
298
509
|
.catch((error: Error) => json(res, 400, { error: error.message }))
|
|
299
510
|
return
|
|
300
511
|
}
|
|
301
512
|
if (req.method === "DELETE") {
|
|
302
|
-
store.delete(
|
|
513
|
+
store.delete(key)
|
|
303
514
|
json(res, 200, { ok: true })
|
|
304
515
|
return
|
|
305
516
|
}
|