@marcomigozzi/trailhead 0.14.0 → 0.15.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.it.md +118 -30
- package/README.md +107 -29
- package/bin/base-preflight-docs.test.js +65 -0
- package/bin/execute-split-docs.test.js +146 -0
- package/bin/isolation-answer-docs.test.js +41 -0
- package/bin/isolation-copy-docs.test.js +28 -0
- package/bin/lib/codex-projection.js +1 -1
- package/bin/lib/codex-projection.test.js +41 -0
- package/bin/report-status-docs.test.js +195 -0
- package/bin/resolve-helpers-docs.test.js +36 -0
- package/bin/scope-confirm-skip-docs.test.js +70 -0
- package/bin/trailhead.js +3 -3
- package/bin/trailhead.test.js +341 -1
- package/bin/uat-setup-docs.test.js +65 -0
- package/package.json +1 -1
- package/plugins/trailhead/.claude-plugin/plugin.json +1 -1
- package/plugins/trailhead/agents/trailhead-code-review.md +15 -2
- package/plugins/trailhead/agents/trailhead-codebase-map.md +20 -1
- package/plugins/trailhead/agents/trailhead-debug.md +20 -1
- package/plugins/trailhead/agents/trailhead-executor.md +29 -2
- package/plugins/trailhead/agents/trailhead-fix.md +29 -2
- package/plugins/trailhead/agents/trailhead-plan.md +20 -1
- package/plugins/trailhead/agents/trailhead-research.md +22 -3
- package/plugins/trailhead/agents/trailhead-verify.md +15 -2
- package/plugins/trailhead/hooks/lib/base-preflight.test.js +513 -0
- package/plugins/trailhead/hooks/lib/commit-message-check.js +1 -1
- package/plugins/trailhead/hooks/lib/commit-message-check.test.js +3 -2
- package/plugins/trailhead/hooks/lib/commit-msg-module-repo.test.js +116 -0
- package/plugins/trailhead/hooks/lib/commit-msg-sync.test.js +100 -9
- package/plugins/trailhead/hooks/lib/dashboard.test.js +409 -0
- package/plugins/trailhead/hooks/lib/fixtures/commit-msg-legacy-js +104 -0
- package/plugins/trailhead/hooks/lib/isolation-answer.test.js +258 -0
- package/plugins/trailhead/hooks/lib/isolation-copy.test.js +298 -0
- package/plugins/trailhead/hooks/lib/issue-body.test.js +470 -0
- package/plugins/trailhead/hooks/lib/report-status.test.js +366 -0
- package/plugins/trailhead/hooks/lib/shell-scan.js +36 -1
- package/plugins/trailhead/hooks/lib/shell-scan.test.js +14 -0
- package/plugins/trailhead/hooks/lib/uat-backfill.test.js +150 -0
- package/plugins/trailhead/hooks/lib/uat-setup.test.js +340 -0
- package/plugins/trailhead/hooks/lib/work-state.test.js +45 -0
- package/plugins/trailhead/hooks/mod/README.md +38 -6
- package/plugins/trailhead/hooks/mod/map-pane-view.ts +168 -15
- package/plugins/trailhead/hooks/mod/map-pane.tsx +275 -106
- package/plugins/trailhead/hooks/mod/register.tsx +15 -2
- package/plugins/trailhead/hooks/mod/work-state-view.ts +17 -0
- package/plugins/trailhead/hooks/trailhead-install-guard.js +169 -34
- package/plugins/trailhead/hooks/trailhead-install-guard.test.js +180 -1
- package/plugins/trailhead/hooks/trailhead-search-guard.js +43 -7
- package/plugins/trailhead/hooks/trailhead-search-guard.test.js +66 -2
- package/plugins/trailhead/skills/_shared/choices.md +4 -2
- package/plugins/trailhead/skills/_shared/configuration-reference.md +15 -3
- package/plugins/trailhead/skills/_shared/load-first.md +3 -3
- package/plugins/trailhead/skills/_shared/multi-map.md +2 -2
- package/plugins/trailhead/skills/_shared/session-handoff.md +1 -1
- package/plugins/trailhead/skills/_shared/substrate-commands.md +22 -11
- package/plugins/trailhead/skills/_shared/substrate.md +6 -4
- package/plugins/trailhead/skills/_shared/teamwork.md +16 -7
- package/plugins/trailhead/skills/_shared/techniques/acceptance-testing.md +17 -9
- package/plugins/trailhead/skills/_shared/techniques/code-review.md +1 -1
- package/plugins/trailhead/skills/_shared/techniques/codebase-map.md +1 -1
- package/plugins/trailhead/skills/_shared/techniques/debug.md +2 -0
- package/plugins/trailhead/skills/_shared/techniques/research.md +4 -2
- package/plugins/trailhead/skills/_shared/techniques/verify.md +1 -1
- package/plugins/trailhead/skills/_shared/techniques.md +12 -2
- package/plugins/trailhead/skills/trailhead/SKILL.md +1 -1
- package/plugins/trailhead/skills/trailhead-capture/SKILL.md +1 -1
- package/plugins/trailhead/skills/trailhead-capture/references/capture.md +2 -2
- package/plugins/trailhead/skills/trailhead-chart/SKILL.md +1 -1
- package/plugins/trailhead/skills/trailhead-chart/references/charting.md +1 -1
- package/plugins/trailhead/skills/trailhead-manage/SKILL.md +1 -1
- package/plugins/trailhead/skills/trailhead-manage/references/auditing.md +1 -1
- package/plugins/trailhead/skills/trailhead-view/SKILL.md +8 -6
- package/plugins/trailhead/skills/trailhead-work/SKILL.md +14 -12
- package/plugins/trailhead/skills/trailhead-work/references/auto.md +4 -4
- package/plugins/trailhead/skills/trailhead-work/references/executor-brief.md +29 -0
- package/plugins/trailhead/skills/trailhead-work/references/ticket-engines.md +26 -10
- package/plugins/trailhead/templates/trailhead-base-preflight.js +305 -0
- package/plugins/trailhead/templates/trailhead-commit-msg +38 -102
- package/plugins/trailhead/templates/trailhead-commit-msg-sync.js +58 -27
- package/plugins/trailhead/templates/trailhead-commit-msg.cjs +114 -0
- package/plugins/trailhead/templates/trailhead-dashboard.js +340 -0
- package/plugins/trailhead/templates/trailhead-isolation-answer.js +227 -0
- package/plugins/trailhead/templates/trailhead-isolation-copy.js +239 -0
- package/plugins/trailhead/templates/trailhead-issue-body.js +459 -0
- package/plugins/trailhead/templates/trailhead-report-status.js +274 -0
- package/plugins/trailhead/templates/trailhead-uat-backfill.js +114 -0
- package/plugins/trailhead/templates/trailhead-uat-setup.js +385 -0
- package/plugins/trailhead/templates/trailhead-work-state.js +25 -7
- package/plugins/trailhead/tests/map-pane-ui.test.ts +566 -19
- package/plugins/trailhead/tests/map-pane-view.test.ts +304 -16
- package/plugins/trailhead/tests/work-state-view.test.ts +25 -1
- package/plugins/trailhead/types/index.d.ts +37 -3
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Pure view logic of the map pane: parse what `gh` printed, group the rows, word a
|
|
2
2
|
// failure and an age, decide when a refresh may run. No engine calls here, so it is easy to test.
|
|
3
3
|
import { fit } from './work-state-view'
|
|
4
|
-
import type { BurndownHistory, BurndownIssue, MapInfo, MapSummary, PaneTicket, TicketState, TicketType } from '../../types'
|
|
4
|
+
import type { BurndownHistory, BurndownIssue, DashboardRef, MapCounts, MapInfo, MapsRead, MapSummary, PaneTicket, TicketState, TicketType, UatParent } from '../../types'
|
|
5
5
|
|
|
6
6
|
export const TICKET_TYPES: readonly TicketType[] = ['decision', 'research', 'prototype', 'build', 'bug', 'task']
|
|
7
7
|
|
|
@@ -10,6 +10,8 @@ export type ParsedTickets = { tickets: readonly PaneTicket[]; unverifiedCount: n
|
|
|
10
10
|
export type TicketGroups = {
|
|
11
11
|
frontier: readonly PaneTicket[]
|
|
12
12
|
inProgress: readonly PaneTicket[]
|
|
13
|
+
uat: readonly PaneTicket[]
|
|
14
|
+
paused: readonly PaneTicket[]
|
|
13
15
|
blocked: readonly PaneTicket[]
|
|
14
16
|
}
|
|
15
17
|
export type Failure = { error: unknown } | { exitCode: number; stderr: string }
|
|
@@ -53,15 +55,23 @@ const typeOf = (names: readonly string[]): TicketType | null => {
|
|
|
53
55
|
return found === undefined ? null : (found.slice(LABEL_PREFIX.length) as TicketType)
|
|
54
56
|
}
|
|
55
57
|
|
|
58
|
+
// A paused label wins over blocked and in-progress: the ticket shows once, under Paused.
|
|
56
59
|
const stateOf = (names: readonly string[], assignees: readonly string[]): TicketState =>
|
|
57
|
-
names.includes(`${LABEL_PREFIX}
|
|
60
|
+
names.includes(`${LABEL_PREFIX}paused`)
|
|
61
|
+
? 'paused'
|
|
62
|
+
: names.includes(`${LABEL_PREFIX}blocked`)
|
|
63
|
+
? 'blocked'
|
|
64
|
+
: assignees.length > 0
|
|
65
|
+
? 'in-progress'
|
|
66
|
+
: 'frontier'
|
|
58
67
|
|
|
59
68
|
// Only an https link is kept; anything else would be drawn as plain text.
|
|
60
69
|
const safeUrl = (raw: unknown): string => (typeof raw === 'string' && /^https:\/\//.test(raw) ? raw : '')
|
|
61
70
|
|
|
62
71
|
// `gh issue list --json number,title,labels,assignees,url`. Unverified rows are
|
|
63
|
-
// quarantined (counted, never listed); whiteboard rows do not belong to a map
|
|
64
|
-
|
|
72
|
+
// quarantined (counted, never listed); whiteboard rows do not belong to a map, and on the
|
|
73
|
+
// whiteboard (`isWhiteboard`) only they are kept.
|
|
74
|
+
export const parseTickets = (stdout: string, opts: { isWhiteboard?: boolean } = {}): ParsedTickets | null => {
|
|
65
75
|
let raw: unknown
|
|
66
76
|
try {
|
|
67
77
|
raw = JSON.parse(stdout)
|
|
@@ -73,7 +83,8 @@ export const parseTickets = (stdout: string): ParsedTickets | null => {
|
|
|
73
83
|
const named = rows.map(r => ({ r, names: labelNames(r.labels) }))
|
|
74
84
|
const unverifiedCount = named.filter(({ names }) => names.includes(`${LABEL_PREFIX}unverified`)).length
|
|
75
85
|
const tickets = named
|
|
76
|
-
.filter(({ names }) => !names.includes(`${LABEL_PREFIX}unverified`)
|
|
86
|
+
.filter(({ names }) => !names.includes(`${LABEL_PREFIX}unverified`))
|
|
87
|
+
.filter(({ names }) => names.includes(`${LABEL_PREFIX}whiteboard`) === (opts.isWhiteboard === true))
|
|
77
88
|
.map(({ r, names }): PaneTicket => {
|
|
78
89
|
const assignees = logins(r.assignees)
|
|
79
90
|
return {
|
|
@@ -83,11 +94,52 @@ export const parseTickets = (stdout: string): ParsedTickets | null => {
|
|
|
83
94
|
state: stateOf(names, assignees),
|
|
84
95
|
assignees,
|
|
85
96
|
url: safeUrl(r.url),
|
|
97
|
+
isUat: names.includes(`${LABEL_PREFIX}uat`),
|
|
98
|
+
uatOf: null,
|
|
86
99
|
}
|
|
87
100
|
})
|
|
88
101
|
return { tickets, unverifiedCount, isCapped: raw.length >= LIST_LIMIT }
|
|
89
102
|
}
|
|
90
103
|
|
|
104
|
+
// `gh issue list --label trailhead:uat ... --json number,body`: ticket number -> the parent its first
|
|
105
|
+
// `UAT of:` line names, `[<name>](<url>/issues/<n>)` (or a bare `#<n>`, with an empty title). A row with
|
|
106
|
+
// no such line, or an unreadable one, is skipped; not a list is unreadable (null).
|
|
107
|
+
export const parseUatParents = (stdout: string): ReadonlyMap<number, UatParent> | null => {
|
|
108
|
+
let raw: unknown
|
|
109
|
+
try {
|
|
110
|
+
raw = JSON.parse(stdout)
|
|
111
|
+
} catch {
|
|
112
|
+
return null
|
|
113
|
+
}
|
|
114
|
+
if (!Array.isArray(raw)) return null
|
|
115
|
+
const parents = new Map<number, UatParent>()
|
|
116
|
+
for (const r of raw.filter(isRecord)) {
|
|
117
|
+
if (!isPosInt(r.number) || typeof r.body !== 'string') continue
|
|
118
|
+
const line = r.body.split(/\r?\n/).find(l => l.startsWith('UAT of:'))
|
|
119
|
+
if (line === undefined) continue
|
|
120
|
+
const pointer = line.slice('UAT of:'.length).trim()
|
|
121
|
+
const link = /^\[([^\]]*)\]\(https:\/\/[^)\s]*\/issues\/(\d+)[^)]*\)/.exec(pointer)
|
|
122
|
+
const bare = /^#(\d+)\b/.exec(pointer)
|
|
123
|
+
const found = link !== null ? { number: Number(link[2]), title: flatten(link[1] as string) } : bare !== null ? { number: Number(bare[1]), title: '' } : null
|
|
124
|
+
if (found !== null && isPosInt(found.number)) parents.set(r.number, found)
|
|
125
|
+
}
|
|
126
|
+
return parents
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// The tickets with the parent of each UAT one; a missing read leaves them as they are.
|
|
130
|
+
export const withUatParents = (tickets: readonly PaneTicket[], parents: ReadonlyMap<number, UatParent> | null): readonly PaneTicket[] =>
|
|
131
|
+
parents === null ? tickets : tickets.map(t => (t.isUat ? { ...t, uatOf: parents.get(t.number) ?? null } : t))
|
|
132
|
+
|
|
133
|
+
// The dim line under a UAT row, `tests <parent title> #<n>`, cut to the pane width; null without a parent.
|
|
134
|
+
export const uatParentLine = (t: PaneTicket, columns: number): string | null => {
|
|
135
|
+
if (t.uatOf === null) return null
|
|
136
|
+
const suffix = ` #${t.uatOf.number}`
|
|
137
|
+
const prefix = 'tests'
|
|
138
|
+
if (t.uatOf.title === '') return fit(`${prefix}${suffix}`, columns)
|
|
139
|
+
const title = fit(t.uatOf.title, columns - prefix.length - 1 - suffix.length)
|
|
140
|
+
return `${prefix} ${title}${suffix}`
|
|
141
|
+
}
|
|
142
|
+
|
|
91
143
|
// Markdown emphasis, code ticks and links reduced to their text.
|
|
92
144
|
const stripMarkdown = (line: string): string =>
|
|
93
145
|
line
|
|
@@ -136,10 +188,73 @@ export const parseMaps = (stdout: string): readonly MapSummary[] | null => {
|
|
|
136
188
|
return raw
|
|
137
189
|
.filter(isRecord)
|
|
138
190
|
.filter(r => isPosInt(r.number))
|
|
139
|
-
.map((r): MapSummary => ({ number: r.number as number, title: typeof r.title === 'string' ? flatten(r.title) : '' }))
|
|
191
|
+
.map((r): MapSummary => ({ number: r.number as number, title: typeof r.title === 'string' ? flatten(r.title) : '', counts: null }))
|
|
140
192
|
.sort((a, b) => a.number - b.number)
|
|
141
193
|
}
|
|
142
194
|
|
|
195
|
+
const countsOf = (raw: unknown): MapCounts | null => {
|
|
196
|
+
if (!isRecord(raw)) return null
|
|
197
|
+
const { total, completed } = raw
|
|
198
|
+
if (!Number.isInteger(total) || !Number.isInteger(completed) || (total as number) < 0 || (completed as number) < 0) return null
|
|
199
|
+
return { open: Math.max(0, (total as number) - (completed as number)), total: total as number }
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// A GraphQL `totalCount` as a count, else null.
|
|
203
|
+
const totalOf = (raw: unknown): number | null =>
|
|
204
|
+
isRecord(raw) && Number.isInteger(raw.totalCount) && (raw.totalCount as number) >= 0 ? (raw.totalCount as number) : null
|
|
205
|
+
|
|
206
|
+
// The whiteboard's open and total tickets; unusable (missing, negative, open above total) is unknown.
|
|
207
|
+
const whiteboardOf = (repo: Record<string, unknown>): MapCounts | null => {
|
|
208
|
+
const open = totalOf(repo.whiteboardOpen)
|
|
209
|
+
const total = totalOf(repo.whiteboardAll)
|
|
210
|
+
return open === null || total === null || open > total ? null : { open, total }
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// The GraphQL reply of the maps read: the open maps with their sub-issue counts, same row rules as
|
|
214
|
+
// `parseMaps`, and the whiteboard's counts. A malformed summary keeps the row name-only; unusable
|
|
215
|
+
// whiteboard fields leave only `whiteboard` null. Not that shape, or any `errors` (even next to
|
|
216
|
+
// partial `data`), is unreadable: the caller falls back to the name-only list.
|
|
217
|
+
export const parseMapsRead = (stdout: string): MapsRead | null => {
|
|
218
|
+
let raw: unknown
|
|
219
|
+
try {
|
|
220
|
+
raw = JSON.parse(stdout)
|
|
221
|
+
} catch {
|
|
222
|
+
return null
|
|
223
|
+
}
|
|
224
|
+
if (!isRecord(raw) || 'errors' in raw || !isRecord(raw.data)) return null
|
|
225
|
+
const repo = raw.data.repository
|
|
226
|
+
const issues = isRecord(repo) ? repo.issues : null
|
|
227
|
+
const nodes = isRecord(issues) ? issues.nodes : null
|
|
228
|
+
if (!isRecord(repo) || !Array.isArray(nodes)) return null
|
|
229
|
+
const maps = nodes
|
|
230
|
+
.filter(isRecord)
|
|
231
|
+
.filter(r => isPosInt(r.number))
|
|
232
|
+
.map(
|
|
233
|
+
(r): MapSummary => ({
|
|
234
|
+
number: r.number as number,
|
|
235
|
+
title: typeof r.title === 'string' ? flatten(r.title) : '',
|
|
236
|
+
counts: countsOf(r.subIssuesSummary),
|
|
237
|
+
}),
|
|
238
|
+
)
|
|
239
|
+
.sort((a, b) => a.number - b.number)
|
|
240
|
+
return { maps, whiteboard: whiteboardOf(repo) }
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// `gh issue list --label trailhead:dashboard --json number,url`: the pinned index, the lowest number
|
|
244
|
+
// when there are several. `null` when unreadable; `{ ref: null }` when the repo has none yet.
|
|
245
|
+
export const parseDashboard = (stdout: string): { ref: DashboardRef | null } | null => {
|
|
246
|
+
let raw: unknown
|
|
247
|
+
try {
|
|
248
|
+
raw = JSON.parse(stdout)
|
|
249
|
+
} catch {
|
|
250
|
+
return null
|
|
251
|
+
}
|
|
252
|
+
if (!Array.isArray(raw)) return null
|
|
253
|
+
const rows = raw.filter(isRecord).filter(r => isPosInt(r.number))
|
|
254
|
+
const first = rows.sort((a, b) => (a.number as number) - (b.number as number))[0]
|
|
255
|
+
return { ref: first === undefined ? null : { number: first.number as number, url: safeUrl(first.url) } }
|
|
256
|
+
}
|
|
257
|
+
|
|
143
258
|
// The map the pane draws: the one the person is viewing, else the active one.
|
|
144
259
|
export const viewTarget = (active: number | null, viewed: number | null): number | null => viewed ?? active
|
|
145
260
|
|
|
@@ -161,17 +276,24 @@ export type MapRowLayout = {
|
|
|
161
276
|
// Every map but the viewed one: a press shows it (the active one: back to active).
|
|
162
277
|
isPressable: boolean
|
|
163
278
|
}
|
|
164
|
-
|
|
279
|
+
// `isDashboard`: the dashboard is shown, so no map is viewed and every row is pressable.
|
|
280
|
+
export type MapRowContext = { active: number | null; viewed: number | null; columns: number; isDashboard?: boolean }
|
|
165
281
|
|
|
166
282
|
const ACTIVE_MARK = '● '
|
|
167
|
-
const
|
|
283
|
+
const ACTIVE_LABEL = 'active'
|
|
284
|
+
const ASIDE_SEP = ' · '
|
|
285
|
+
|
|
286
|
+
// `<open> open / <total>`: the counts beside a map or the whiteboard.
|
|
287
|
+
export const countsLabel = (c: MapCounts): string => `${c.open} open / ${c.total}`
|
|
168
288
|
|
|
169
289
|
// What one row of the maps list draws, cut to the pane width (a pressable row is a Button).
|
|
170
290
|
export const mapRowLayout = (m: MapSummary, ctx: MapRowContext): MapRowLayout => {
|
|
171
291
|
const isActive = m.number === ctx.active
|
|
172
|
-
const isViewed = m.number === viewTarget(ctx.active, ctx.viewed)
|
|
292
|
+
const isViewed = ctx.isDashboard !== true && m.number === viewTarget(ctx.active, ctx.viewed)
|
|
173
293
|
const mark = isActive ? ACTIVE_MARK : ' '
|
|
174
|
-
const
|
|
294
|
+
const parts = [m.counts === null ? null : countsLabel(m.counts), isActive ? ACTIVE_LABEL : null]
|
|
295
|
+
const shown = parts.filter((p): p is string => p !== null)
|
|
296
|
+
const aside = shown.length === 0 ? null : ` ${shown.join(ASIDE_SEP)}`
|
|
175
297
|
const prefix = `#${m.number}`
|
|
176
298
|
const isPressable = !isViewed
|
|
177
299
|
const room = ctx.columns - mark.length - (aside?.length ?? 0) - (isPressable ? BUTTON_CHROME : 0) - prefix.length - 1
|
|
@@ -179,11 +301,34 @@ export const mapRowLayout = (m: MapSummary, ctx: MapRowContext): MapRowLayout =>
|
|
|
179
301
|
return { mark, label: title === '' ? prefix : `${prefix} ${title}`, aside, isActive, isViewed, isPressable }
|
|
180
302
|
}
|
|
181
303
|
|
|
304
|
+
export type WhiteboardRowLayout = { mark: string; label: string; aside: string | null }
|
|
305
|
+
const WHITEBOARD_LABEL = 'Whiteboard'
|
|
306
|
+
|
|
307
|
+
// The Whiteboard row above the maps list: a Button with its counts beside it, name-only when they are
|
|
308
|
+
// unknown or would not fit (the name is kept whole).
|
|
309
|
+
export const whiteboardRowLayout = (counts: MapCounts | null, columns: number): WhiteboardRowLayout => {
|
|
310
|
+
const mark = ' '
|
|
311
|
+
const aside = counts === null ? null : ` ${countsLabel(counts)}`
|
|
312
|
+
const fits = aside !== null && mark.length + BUTTON_CHROME + WHITEBOARD_LABEL.length + aside.length <= columns
|
|
313
|
+
return { mark, label: WHITEBOARD_LABEL, aside: fits ? aside : null }
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// The whiteboard heading with its counts over the listed (trusted) tickets; bare before the first good read.
|
|
317
|
+
export const whiteboardHeading = (tickets: readonly PaneTicket[] | null, isCapped: boolean): string => {
|
|
318
|
+
if (tickets === null) return 'Whiteboard'
|
|
319
|
+
const open = isCapped ? `${LIST_LIMIT}+` : String(tickets.length)
|
|
320
|
+
return `Whiteboard · ${open} open · ${tickets.filter(t => t.assignees.length > 0).length} assigned`
|
|
321
|
+
}
|
|
322
|
+
|
|
182
323
|
const byNumber = (a: PaneTicket, b: PaneTicket) => a.number - b.number
|
|
183
324
|
|
|
325
|
+
// Paused wins, then a UAT ticket (whatever its state), then the state: each ticket shows once.
|
|
326
|
+
type Place = TicketState | 'uat'
|
|
327
|
+
const placeOf = (t: PaneTicket): Place => (t.state === 'paused' ? 'paused' : t.isUat ? 'uat' : t.state)
|
|
328
|
+
|
|
184
329
|
export const groupTickets = (tickets: readonly PaneTicket[]): TicketGroups => {
|
|
185
|
-
const of = (
|
|
186
|
-
return { frontier: of('frontier'), inProgress: of('in-progress'), blocked: of('blocked') }
|
|
330
|
+
const of = (place: Place) => tickets.filter(t => placeOf(t) === place).sort(byNumber)
|
|
331
|
+
return { frontier: of('frontier'), inProgress: of('in-progress'), uat: of('uat'), paused: of('paused'), blocked: of('blocked') }
|
|
187
332
|
}
|
|
188
333
|
|
|
189
334
|
const errorText = (err: unknown): string => {
|
|
@@ -220,7 +365,7 @@ export const ageLabel = (ms: number): string => {
|
|
|
220
365
|
return `${Math.floor(ms / 3_600_000)}h ago`
|
|
221
366
|
}
|
|
222
367
|
|
|
223
|
-
export type RowAside = { kind: 'work' | 'queued' | 'holder'; text: string }
|
|
368
|
+
export type RowAside = { kind: 'work' | 'queued' | 'holder' | 'self'; text: string }
|
|
224
369
|
export type RowLayout = {
|
|
225
370
|
mark: string | null
|
|
226
371
|
label: string
|
|
@@ -242,6 +387,10 @@ const MARK_PAD = ' '
|
|
|
242
387
|
// A terminal Button draws `[ label ]`.
|
|
243
388
|
const BUTTON_CHROME = 4
|
|
244
389
|
|
|
390
|
+
// The dashboard header's button back to the map; the title before it gets the rest of the row.
|
|
391
|
+
export const MAP_LABEL = 'map'
|
|
392
|
+
export const headingRoom = (columns: number, label: string): number => Math.max(0, columns - label.length - BUTTON_CHROME - 1)
|
|
393
|
+
|
|
245
394
|
const REFRESH_LABEL = 'refresh'
|
|
246
395
|
const CLOSE_LABEL = 'close'
|
|
247
396
|
// The gap between the refresh button and the status, and the one kept before the close button.
|
|
@@ -257,7 +406,11 @@ export const footerStatus = (status: string, columns: number): FooterStatus => {
|
|
|
257
406
|
return Array.from(status).length <= room ? { text: status, isInline: true } : { text: fit(status, columns), isInline: false }
|
|
258
407
|
}
|
|
259
408
|
|
|
260
|
-
const
|
|
409
|
+
const SELF_ASIDE: RowAside = { kind: 'self', text: 'this session' }
|
|
410
|
+
|
|
411
|
+
// The ticket in play says `this session`; otherwise a frontier row has its button and the rest their holder.
|
|
412
|
+
const asideOf = (t: PaneTicket, pendingWork: number | null, isInPlay: boolean): RowAside | null => {
|
|
413
|
+
if (isInPlay) return SELF_ASIDE
|
|
261
414
|
if (t.state === 'frontier') return pendingWork === t.number ? { kind: 'queued', text: 'queued' } : { kind: 'work', text: 'work' }
|
|
262
415
|
return t.assignees.length === 0 ? null : { kind: 'holder', text: t.assignees.map(a => `@${a}`).join(' ') }
|
|
263
416
|
}
|
|
@@ -268,7 +421,7 @@ const asideWidth = (a: RowAside | null): number =>
|
|
|
268
421
|
// What one row draws, cut to the pane width: mark column, `#n title`, type, right-hand part.
|
|
269
422
|
export const rowLayout = (t: PaneTicket, ctx: RowContext): RowLayout => {
|
|
270
423
|
const mark = ctx.isInPlay ? MARK : ctx.hasMarkColumn ? MARK_PAD : null
|
|
271
|
-
const aside = asideOf(t, ctx.pendingWork)
|
|
424
|
+
const aside = asideOf(t, ctx.pendingWork, ctx.isInPlay)
|
|
272
425
|
const type = t.type === null ? null : ` ${t.type}`
|
|
273
426
|
const prefix = `#${t.number}`
|
|
274
427
|
const room = ctx.columns - (mark?.length ?? 0) - asideWidth(aside) - (type?.length ?? 0) - prefix.length - 1
|