@nickmeriano/task 0.9.0 → 0.11.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 +129 -0
- package/dist/asks.test.js +6 -0
- package/dist/asks.test.js.map +1 -1
- 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 +99 -1
- package/dist/claim.d.ts.map +1 -1
- package/dist/claim.js +271 -6
- package/dist/claim.js.map +1 -1
- package/dist/claim.test.d.ts +8 -0
- package/dist/claim.test.d.ts.map +1 -1
- package/dist/claim.test.js +211 -2
- package/dist/claim.test.js.map +1 -1
- package/dist/cli.js +193 -7
- package/dist/cli.js.map +1 -1
- package/dist/export.d.ts +132 -0
- package/dist/export.d.ts.map +1 -0
- package/dist/export.js +199 -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 +185 -0
- package/dist/export.test.js.map +1 -0
- 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 +1 -1
- package/skill/SKILL.md +34 -1
- package/src/asks.test.ts +15 -0
- package/src/claim-io.ts +12 -3
- package/src/claim.test.ts +286 -2
- package/src/claim.ts +375 -6
- package/src/cli.ts +208 -7
- package/src/export.test.ts +254 -0
- package/src/export.ts +276 -0
- package/src/server.ts +12 -3
- package/src/trailers.ts +65 -0
- package/ui/dist/assets/{index-CoKCUYic.css → index-eHsqltgs.css} +1 -1
- package/ui/dist/assets/{index-BjsorZOU.js → index-mJmm4sWq.js} +53 -53
- package/ui/dist/index.html +3 -3
package/src/export.ts
ADDED
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `task export` — the board as a directory of files.
|
|
3
|
+
*
|
|
4
|
+
* The third deployment of this app (after `task serve` and the hosted board)
|
|
5
|
+
* is a static host: the same prebuilt UI the package already ships for serve,
|
|
6
|
+
* plus a `snapshot.json` this module writes out of the local `.task/`. There
|
|
7
|
+
* is no server behind it, so the app reads its answers out of that file —
|
|
8
|
+
* `ui/src/snapshot.ts` is the reader, and this is the writer.
|
|
9
|
+
*
|
|
10
|
+
* The whole correctness question is one sentence: **the snapshot must answer
|
|
11
|
+
* what `routeApi` answers**. Every field below is sourced from the same store
|
|
12
|
+
* call the matching `/api/…` route makes, so a board rendered from a file and
|
|
13
|
+
* a board rendered from the server differ only in how stale they are.
|
|
14
|
+
*
|
|
15
|
+
* Split in two on purpose: `buildSnapshot` is the shape (no writes, so it is
|
|
16
|
+
* testable without a built UI, and the UI's own test can round-trip it through
|
|
17
|
+
* `parseSnapshot`), `writeExport` is the directory.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { execFileSync } from "node:child_process"
|
|
21
|
+
import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"
|
|
22
|
+
import { join, resolve } from "node:path"
|
|
23
|
+
import { resolveAuthor } from "./author.ts"
|
|
24
|
+
import { openBoard } from "./file-store.ts"
|
|
25
|
+
import { findBoards } from "./store.ts"
|
|
26
|
+
import { STATUSES, type Comment, type Goal, type Status, type Task } from "./types.ts"
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The version the reader accepts, restated from `ui/src/snapshot.ts`. The UI
|
|
30
|
+
* is built separately from this NodeNext package, so the contract is mirrored
|
|
31
|
+
* rather than cross-imported — the same precedent `ui/src/api.ts` sets for
|
|
32
|
+
* `types.ts`. `ui/src/deployment.test.ts` imports `buildSnapshot` and feeds
|
|
33
|
+
* the result to the real `parseSnapshot`, which is what keeps the two honest.
|
|
34
|
+
*/
|
|
35
|
+
export const SNAPSHOT_VERSION = 2
|
|
36
|
+
|
|
37
|
+
/** The file's name in the output directory, matching `SNAPSHOT_PATH`. */
|
|
38
|
+
export const SNAPSHOT_FILE = "snapshot.json"
|
|
39
|
+
|
|
40
|
+
/** Where an export goes when `--out` doesn't say. */
|
|
41
|
+
export const DEFAULT_OUT_DIR = "task-export"
|
|
42
|
+
|
|
43
|
+
/** The sentence `serveStatic` prints for the same missing directory. */
|
|
44
|
+
const NO_UI_BUILD = "UI build not found — run the package build (vite build ui)."
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The assets are built with relative refs (`base: "./"` in ui/vite.config.ts)
|
|
48
|
+
* because one build ships three ways and can't know where it will live. The
|
|
49
|
+
* `<base href>` each deployment stamps into index.html is what completes them:
|
|
50
|
+
* the browser resolves `./assets/…` — and the app resolves its routes, API and
|
|
51
|
+
* snapshot fetch — against it instead of the page's own URL, which is what
|
|
52
|
+
* keeps deep links working under any mount point.
|
|
53
|
+
*/
|
|
54
|
+
|
|
55
|
+
/** `board` / `/board` / `board/` → `/board/`; `""` and `/` → `/`. */
|
|
56
|
+
export function normalizeBase(base: string): string {
|
|
57
|
+
let path = base.trim()
|
|
58
|
+
if (path === "" || path === "/") return "/"
|
|
59
|
+
if (!path.startsWith("/")) path = `/${path}`
|
|
60
|
+
if (!path.endsWith("/")) path = `${path}/`
|
|
61
|
+
if (path.includes("//") || /[\s<>"']/.test(path)) {
|
|
62
|
+
throw new Error(`--base must be a plain URL path like /board/ — got ${JSON.stringify(base)}`)
|
|
63
|
+
}
|
|
64
|
+
return path
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Stamp the deployment's mount point into the built page. The built
|
|
69
|
+
* index.html always has exactly one `<head>` for this to anchor on; refusing
|
|
70
|
+
* a page without one beats silently shipping a board that 404s its assets.
|
|
71
|
+
*/
|
|
72
|
+
export function injectBase(html: string, base: string): string {
|
|
73
|
+
if (!html.includes("<head>")) {
|
|
74
|
+
throw new Error("index.html has no <head> to carry the <base> tag — is this the built UI?")
|
|
75
|
+
}
|
|
76
|
+
return html.replace("<head>", `<head><base href="${normalizeBase(base)}" />`)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export interface SnapshotMeta {
|
|
80
|
+
/** The commit exported from (`git rev-parse HEAD`), or null outside a repo. */
|
|
81
|
+
commit: string | null
|
|
82
|
+
/** When the export ran, ISO-8601 — the other half of the staleness banner. */
|
|
83
|
+
builtAt: string
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** What `/api/boards` answers, per board. */
|
|
87
|
+
export interface SnapshotBoardInfo {
|
|
88
|
+
id: string
|
|
89
|
+
name: string
|
|
90
|
+
prefix: string
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** What `/api/project` answers. `readOnly` is the reader's call, not ours. */
|
|
94
|
+
export interface SnapshotProject {
|
|
95
|
+
name: string
|
|
96
|
+
prefix: string
|
|
97
|
+
statuses: readonly Status[]
|
|
98
|
+
author: string
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** One board's baked answers, in the shapes the serve API returns. */
|
|
102
|
+
export interface SnapshotBoard {
|
|
103
|
+
project: SnapshotProject
|
|
104
|
+
tasks: Task[]
|
|
105
|
+
goals: Goal[]
|
|
106
|
+
/** Task key to its comments, oldest first; absent means none. */
|
|
107
|
+
comments: Record<string, Comment[]>
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export interface Snapshot {
|
|
111
|
+
version: number
|
|
112
|
+
meta: SnapshotMeta
|
|
113
|
+
boards: SnapshotBoardInfo[]
|
|
114
|
+
data: Record<string, SnapshotBoard>
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export interface BuildSnapshotOptions {
|
|
118
|
+
/** Overridable so tests get a fixed build time. */
|
|
119
|
+
builtAt?: string
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* The commit this export is of.
|
|
124
|
+
*
|
|
125
|
+
* Every way this can fail — git not installed, not a repository, a repo with
|
|
126
|
+
* no commits yet — is the same non-answer, so they all collapse to null. The
|
|
127
|
+
* reader's type already says `string | null` and its banner degrades to
|
|
128
|
+
* "snapshot, built …", which is the honest thing to show.
|
|
129
|
+
*/
|
|
130
|
+
export function exportedCommit(cwd: string): string | null {
|
|
131
|
+
try {
|
|
132
|
+
const out = execFileSync("git", ["rev-parse", "HEAD"], {
|
|
133
|
+
cwd,
|
|
134
|
+
encoding: "utf8",
|
|
135
|
+
timeout: 2000,
|
|
136
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
137
|
+
})
|
|
138
|
+
return out.trim() || null
|
|
139
|
+
} catch {
|
|
140
|
+
return null
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Bake every board at or below `serveRoot` into one snapshot.
|
|
146
|
+
*
|
|
147
|
+
* Anchoring matches `cmdServe` exactly (`findRoot` ?? cwd, then `findBoards`),
|
|
148
|
+
* so "export what serve would serve" is true by construction — one board in a
|
|
149
|
+
* package, every nested board at a monorepo root, `.` first either way.
|
|
150
|
+
*/
|
|
151
|
+
export function buildSnapshot(serveRoot: string, options: BuildSnapshotOptions = {}): Snapshot {
|
|
152
|
+
const refs = findBoards(serveRoot)
|
|
153
|
+
if (refs.length === 0) {
|
|
154
|
+
throw new Error(
|
|
155
|
+
`no .task directory found at or below ${serveRoot} — run \`task init\` first`,
|
|
156
|
+
)
|
|
157
|
+
}
|
|
158
|
+
// Resolved once, server-side, exactly as `createTaskServer` does it: all the
|
|
159
|
+
// boards live in one checkout, so one resolution covers them.
|
|
160
|
+
const author = resolveAuthor(undefined, serveRoot).name
|
|
161
|
+
|
|
162
|
+
const boards: SnapshotBoardInfo[] = []
|
|
163
|
+
const data: Record<string, SnapshotBoard> = {}
|
|
164
|
+
for (const ref of refs) {
|
|
165
|
+
const store = openBoard(ref.root)
|
|
166
|
+
try {
|
|
167
|
+
boards.push({ id: ref.id, name: store.config.name, prefix: store.config.prefix })
|
|
168
|
+
const counts = store.commentCounts()
|
|
169
|
+
const tasks = store.list().map((task) => ({
|
|
170
|
+
...task,
|
|
171
|
+
commentCount: counts.get(task.key) ?? 0,
|
|
172
|
+
}))
|
|
173
|
+
const comments: Record<string, Comment[]> = {}
|
|
174
|
+
for (const task of tasks) {
|
|
175
|
+
const list = store.comments(task.key)
|
|
176
|
+
// Omitted rather than empty: the reader defaults a missing key to [],
|
|
177
|
+
// and a snapshot is a file people open.
|
|
178
|
+
if (list.length > 0) comments[task.key] = list
|
|
179
|
+
}
|
|
180
|
+
data[ref.id] = {
|
|
181
|
+
project: { name: store.config.name, prefix: store.config.prefix, statuses: STATUSES, author },
|
|
182
|
+
tasks,
|
|
183
|
+
// Archived goals ride along flagged, as `/api/goals` sends them, so an
|
|
184
|
+
// old chip on a task can still resolve a title.
|
|
185
|
+
goals: [...store.goals(), ...store.goals(true)],
|
|
186
|
+
comments,
|
|
187
|
+
}
|
|
188
|
+
} finally {
|
|
189
|
+
store.close()
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
return {
|
|
194
|
+
version: SNAPSHOT_VERSION,
|
|
195
|
+
meta: {
|
|
196
|
+
commit: exportedCommit(serveRoot),
|
|
197
|
+
builtAt: options.builtAt ?? new Date().toISOString(),
|
|
198
|
+
},
|
|
199
|
+
boards,
|
|
200
|
+
data,
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export interface WriteExportOptions {
|
|
205
|
+
serveRoot: string
|
|
206
|
+
outDir: string
|
|
207
|
+
/** The built SPA to copy. Injectable so tests never need a vite build. */
|
|
208
|
+
uiDir: string
|
|
209
|
+
/** Write into a directory that isn't a previous export. Never deletes. */
|
|
210
|
+
force?: boolean
|
|
211
|
+
/** URL path the export will be served under (default `/`) — see `injectBase`. */
|
|
212
|
+
base?: string
|
|
213
|
+
builtAt?: string
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
export interface ExportSummary {
|
|
217
|
+
outDir: string
|
|
218
|
+
base: string
|
|
219
|
+
boards: number
|
|
220
|
+
tasks: number
|
|
221
|
+
commit: string | null
|
|
222
|
+
builtAt: string
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Write the assets and the snapshot into `outDir`.
|
|
227
|
+
*
|
|
228
|
+
* Refresh is the interesting case. Re-exporting over a previous export
|
|
229
|
+
* replaces the UI's own entries and `snapshot.json` and leaves everything else
|
|
230
|
+
* alone — notably a `functions/` directory, so a deployed live-proxy function
|
|
231
|
+
* survives a re-export. Anything else in a non-empty directory is refused
|
|
232
|
+
* rather than overwritten: `--out .` against a repo is the one genuinely
|
|
233
|
+
* destructive mistake available here.
|
|
234
|
+
*/
|
|
235
|
+
export function writeExport(options: WriteExportOptions): ExportSummary {
|
|
236
|
+
const { serveRoot, outDir, uiDir, force = false } = options
|
|
237
|
+
const base = normalizeBase(options.base ?? "/")
|
|
238
|
+
if (!existsSync(uiDir)) throw new Error(NO_UI_BUILD)
|
|
239
|
+
// Exporting onto the assets it copies from would clear them first and then
|
|
240
|
+
// copy an empty directory — the one way this can destroy the package itself.
|
|
241
|
+
if (resolve(outDir) === resolve(uiDir)) {
|
|
242
|
+
throw new Error(`${outDir} is the UI build itself — export somewhere else`)
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const snapshot = buildSnapshot(serveRoot, { builtAt: options.builtAt })
|
|
246
|
+
|
|
247
|
+
const existing = existsSync(outDir) ? readdirSync(outDir) : null
|
|
248
|
+
if (existing && existing.length > 0 && !existing.includes(SNAPSHOT_FILE) && !force) {
|
|
249
|
+
throw new Error(
|
|
250
|
+
`${outDir} is not empty and isn't a previous export — pass --force to write into it anyway`,
|
|
251
|
+
)
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
mkdirSync(outDir, { recursive: true })
|
|
255
|
+
// Clear only what this export is about to write: a stale `assets/` full of
|
|
256
|
+
// last build's hashed bundles is dead weight, and everything else in there
|
|
257
|
+
// belongs to whoever put it there.
|
|
258
|
+
for (const entry of readdirSync(uiDir)) {
|
|
259
|
+
rmSync(join(outDir, entry), { recursive: true, force: true })
|
|
260
|
+
}
|
|
261
|
+
cpSync(uiDir, outDir, { recursive: true })
|
|
262
|
+
const indexPath = join(outDir, "index.html")
|
|
263
|
+
writeFileSync(indexPath, injectBase(readFileSync(indexPath, "utf8"), base))
|
|
264
|
+
// Pretty-printed: this lands in CI artifacts and gets diffed, and at this
|
|
265
|
+
// size the whitespace costs nothing.
|
|
266
|
+
writeFileSync(join(outDir, SNAPSHOT_FILE), `${JSON.stringify(snapshot, null, 2)}\n`)
|
|
267
|
+
|
|
268
|
+
return {
|
|
269
|
+
outDir,
|
|
270
|
+
base,
|
|
271
|
+
boards: snapshot.boards.length,
|
|
272
|
+
tasks: Object.values(snapshot.data).reduce((n, board) => n + board.tasks.length, 0),
|
|
273
|
+
commit: snapshot.meta.commit,
|
|
274
|
+
builtAt: snapshot.meta.builtAt,
|
|
275
|
+
}
|
|
276
|
+
}
|
package/src/server.ts
CHANGED
|
@@ -4,14 +4,19 @@ import { extname, join, normalize } from "node:path"
|
|
|
4
4
|
import { fileURLToPath } from "node:url"
|
|
5
5
|
import { resolveAuthor } from "./author.ts"
|
|
6
6
|
import { addAskRouted, deleteAskRouted, setAskResolvedRouted } from "./claim-io.ts"
|
|
7
|
+
import { injectBase } from "./export.ts"
|
|
7
8
|
import { openBoard } from "./file-store.ts"
|
|
8
9
|
import { GitServe, GitServeError } from "./git-serve.ts"
|
|
9
10
|
import { buildInbox } from "./inbox.ts"
|
|
10
11
|
import { boardConfig, findBoards, type BoardRef, type Store } from "./store.ts"
|
|
11
12
|
import { STATUSES, isStatus, type TaskPatch } from "./types.ts"
|
|
12
13
|
|
|
13
|
-
/**
|
|
14
|
-
|
|
14
|
+
/**
|
|
15
|
+
* The prebuilt SPA, shipped inside the package next to dist/. Exported because
|
|
16
|
+
* `task export` copies the same assets it serves — one definition of where the
|
|
17
|
+
* UI lives, or the two deployments drift.
|
|
18
|
+
*/
|
|
19
|
+
export const UI_DIR = fileURLToPath(new URL("../ui/dist", import.meta.url))
|
|
15
20
|
|
|
16
21
|
const MIME: Record<string, string> = {
|
|
17
22
|
".html": "text/html; charset=utf-8",
|
|
@@ -535,7 +540,11 @@ export function createTaskServer(serveRoot: string): Server {
|
|
|
535
540
|
"Content-Type": MIME[extname(file)] ?? "application/octet-stream",
|
|
536
541
|
"Cache-Control": "no-cache",
|
|
537
542
|
})
|
|
538
|
-
|
|
543
|
+
// The build's asset refs are relative (ui/vite.config.ts) and the page —
|
|
544
|
+
// this one included, when it's the SPA fallback for a deep link — needs a
|
|
545
|
+
// `<base>` to resolve them against. serve always mounts the app at the
|
|
546
|
+
// origin root; `task export --base` is where any other answer lives.
|
|
547
|
+
res.end(file.endsWith("index.html") ? injectBase(readFileSync(file, "utf8"), "/") : readFileSync(file))
|
|
539
548
|
}
|
|
540
549
|
|
|
541
550
|
return server
|
package/src/trailers.ts
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The commit-trailer contract: every commit the CLI itself authors for a
|
|
3
|
+
* board operation carries machine-readable git trailers naming the operation
|
|
4
|
+
* and the ticket, so automation can react to board events without parsing
|
|
5
|
+
* human prose. Subjects like "chore(board): resolve ask [1] on TAS-x" are
|
|
6
|
+
* presentation and may be reworded freely; the trailers are the interface.
|
|
7
|
+
*
|
|
8
|
+
* chore(board): resolve ask [1] on TAS-x7k4m
|
|
9
|
+
*
|
|
10
|
+
* Task-Op: resolve-ask
|
|
11
|
+
* Task-Id: TAS-x7k4m
|
|
12
|
+
*
|
|
13
|
+
* Git parses trailers natively — consumers read them with
|
|
14
|
+
* `git log -1 --format='%(trailers:key=Task-Op,valueonly)'`, or match
|
|
15
|
+
* `Task-Op: <op>` in a forge webhook's head-commit message (each routed
|
|
16
|
+
* write is its own single-commit push, so the head commit is the operation).
|
|
17
|
+
*
|
|
18
|
+
* STABILITY: `Task-Op` and `Task-Id` are a documented, test-pinned contract
|
|
19
|
+
* (see the "commit trailers" tests). Existing op names never change meaning
|
|
20
|
+
* or disappear; new ops may be added. Renaming an op or dropping a trailer
|
|
21
|
+
* is a breaking change to every consumer's automation.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The operation vocabulary. Emitted today:
|
|
26
|
+
*
|
|
27
|
+
* - `claim` — the claim branch's first commit, the in_progress flip
|
|
28
|
+
* (plain and --lock-only alike)
|
|
29
|
+
* - `ask` — an ask filed onto a claim branch (routed write)
|
|
30
|
+
* - `resolve-ask` — an ask checked off on a claim branch: the signal that a
|
|
31
|
+
* stalled claim may be implementable again
|
|
32
|
+
* - `reopen-ask` — a resolved ask put back
|
|
33
|
+
* - `delete-ask` — an ask removed from a claim branch
|
|
34
|
+
*
|
|
35
|
+
* Reserved, not yet emitted:
|
|
36
|
+
*
|
|
37
|
+
* - `begin-implement` — a worker marking the start of an implement pass on a
|
|
38
|
+
* claim (an empty commit pushed at tick start), so "this claim is being
|
|
39
|
+
* worked right now" becomes board state a dispatcher can read instead of a
|
|
40
|
+
* blind spot. Designed for the mid-flight-visibility problem; deliberately
|
|
41
|
+
* deferred until collisions prove worth the extra moving part. Consumers
|
|
42
|
+
* should treat unknown ops as "not the op I'm looking for", never an error.
|
|
43
|
+
*/
|
|
44
|
+
export type TaskOp =
|
|
45
|
+
| "claim"
|
|
46
|
+
| "ask"
|
|
47
|
+
| "resolve-ask"
|
|
48
|
+
| "reopen-ask"
|
|
49
|
+
| "delete-ask"
|
|
50
|
+
| "begin-implement"
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Compose a board commit message: the human subject, then one trailer block.
|
|
54
|
+
* `extra` rides in the same block (e.g. lock-only's `Delivered-By:`) — git
|
|
55
|
+
* only recognizes a *contiguous* trailing block, so every trailer must go
|
|
56
|
+
* through this one seam.
|
|
57
|
+
*/
|
|
58
|
+
export function boardCommitMessage(
|
|
59
|
+
subject: string,
|
|
60
|
+
op: TaskOp,
|
|
61
|
+
id: string,
|
|
62
|
+
extra: string[] = [],
|
|
63
|
+
): string {
|
|
64
|
+
return `${subject}\n\n${[...extra, `Task-Op: ${op}`, `Task-Id: ${id}`].join("\n")}`
|
|
65
|
+
}
|