@marcomigozzi/trailhead 0.12.1 → 0.14.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 +38 -13
- package/README.md +48 -13
- package/bin/lib/codex-projection.js +18 -11
- package/bin/lib/codex-projection.test.js +39 -11
- package/bin/lib/install-verify.js +10 -5
- package/bin/lib/install-verify.test.js +51 -0
- package/bin/trailhead.js +37 -25
- package/bin/trailhead.test.js +162 -5
- package/package.json +4 -1
- package/plugins/trailhead/.claude-plugin/plugin.json +3 -2
- package/plugins/trailhead/commands/panel.md +10 -0
- package/plugins/trailhead/hooks/hooks.json +3 -18
- package/plugins/trailhead/hooks/lib/body-sources.js +795 -0
- package/plugins/trailhead/hooks/lib/gh-subcommand.js +92 -10
- package/plugins/trailhead/hooks/lib/gh-subcommand.test.js +55 -0
- package/plugins/trailhead/hooks/lib/mockup-link.js +21 -5
- package/plugins/trailhead/hooks/lib/shell-scan.js +496 -64
- package/plugins/trailhead/hooks/lib/shell-scan.test.js +420 -0
- package/plugins/trailhead/hooks/lib/unmodelled.js +345 -0
- package/plugins/trailhead/hooks/lib/unmodelled.test.js +173 -0
- package/plugins/trailhead/hooks/lib/work-state.test.js +252 -0
- package/plugins/trailhead/hooks/mod/README.md +187 -0
- package/plugins/trailhead/hooks/mod/map-pane-view.ts +453 -0
- package/plugins/trailhead/hooks/mod/map-pane.tsx +663 -0
- package/plugins/trailhead/hooks/mod/register.tsx +280 -0
- package/plugins/trailhead/hooks/mod/work-state-prompt.ts +69 -0
- package/plugins/trailhead/hooks/mod/work-state-view.ts +93 -0
- package/plugins/trailhead/hooks/trailhead-bash-guards.js +140 -0
- package/plugins/trailhead/hooks/trailhead-bash-guards.test.js +233 -0
- package/plugins/trailhead/hooks/trailhead-body-guard.js +145 -61
- package/plugins/trailhead/hooks/trailhead-body-guard.test.js +224 -3
- package/plugins/trailhead/hooks/trailhead-commit-guard.js +41 -14
- package/plugins/trailhead/hooks/trailhead-commit-guard.test.js +2 -1
- package/plugins/trailhead/hooks/trailhead-install-guard.js +22 -12
- package/plugins/trailhead/hooks/trailhead-install-guard.test.js +2 -1
- package/plugins/trailhead/hooks/trailhead-mockup-link-guard.js +48 -11
- package/plugins/trailhead/hooks/trailhead-mockup-link-guard.test.js +79 -7
- package/plugins/trailhead/hooks/trailhead-mockup-link-stop.test.js +1 -1
- package/plugins/trailhead/hooks/trailhead-search-guard.js +19 -9
- package/plugins/trailhead/hooks/trailhead-search-guard.test.js +2 -1
- package/plugins/trailhead/hooks/trailhead-secret-guard.js +541 -57
- package/plugins/trailhead/hooks/trailhead-secret-guard.test.js +1000 -4
- package/plugins/trailhead/hooks/trailhead-secret-read-guard.js +171 -6
- package/plugins/trailhead/hooks/trailhead-secret-read-guard.test.js +170 -0
- package/plugins/trailhead/skills/_shared/choices.md +3 -1
- package/plugins/trailhead/skills/_shared/configuration-reference.md +5 -4
- package/plugins/trailhead/skills/_shared/configuration.md +1 -1
- package/plugins/trailhead/skills/_shared/multi-map.md +1 -1
- package/plugins/trailhead/skills/_shared/session-handoff.md +1 -1
- package/plugins/trailhead/skills/_shared/teamwork.md +4 -2
- package/plugins/trailhead/skills/_shared/techniques/acceptance-testing.md +1 -1
- package/plugins/trailhead/skills/_shared/techniques/prototype.md +15 -16
- package/plugins/trailhead/skills/_shared/techniques.md +1 -1
- package/plugins/trailhead/skills/trailhead/SKILL.md +2 -2
- package/plugins/trailhead/skills/trailhead-work/SKILL.md +3 -2
- package/plugins/trailhead/skills/trailhead-work/references/auto.md +2 -0
- package/plugins/trailhead/skills/trailhead-work/references/ticket-engines.md +3 -1
- package/plugins/trailhead/templates/trailhead-work-state.js +199 -0
- package/plugins/trailhead/tests/map-pane-ui.test.ts +1374 -0
- package/plugins/trailhead/tests/map-pane-view.test.ts +565 -0
- package/plugins/trailhead/tests/mod.test.ts +14 -0
- package/plugins/trailhead/tests/work-state-prompt-hook.test.ts +207 -0
- package/plugins/trailhead/tests/work-state-prompt.test.ts +109 -0
- package/plugins/trailhead/tests/work-state-ui.test.ts +365 -0
- package/plugins/trailhead/tests/work-state-view.test.ts +86 -0
- package/plugins/trailhead/types/index.d.ts +69 -0
|
@@ -0,0 +1,453 @@
|
|
|
1
|
+
// Pure view logic of the map pane: parse what `gh` printed, group the rows, word a
|
|
2
|
+
// failure and an age, decide when a refresh may run. No engine calls here, so it is easy to test.
|
|
3
|
+
import { fit } from './work-state-view'
|
|
4
|
+
import type { BurndownHistory, BurndownIssue, MapInfo, MapSummary, PaneTicket, TicketState, TicketType } from '../../types'
|
|
5
|
+
|
|
6
|
+
export const TICKET_TYPES: readonly TicketType[] = ['decision', 'research', 'prototype', 'build', 'bug', 'task']
|
|
7
|
+
|
|
8
|
+
// `isCapped`: gh returned as many rows as it was asked for, so the list may be cut.
|
|
9
|
+
export type ParsedTickets = { tickets: readonly PaneTicket[]; unverifiedCount: number; isCapped: boolean }
|
|
10
|
+
export type TicketGroups = {
|
|
11
|
+
frontier: readonly PaneTicket[]
|
|
12
|
+
inProgress: readonly PaneTicket[]
|
|
13
|
+
blocked: readonly PaneTicket[]
|
|
14
|
+
}
|
|
15
|
+
export type Failure = { error: unknown } | { exitCode: number; stderr: string }
|
|
16
|
+
export type RefreshDecision = { run: true } | { run: false; waitMs: number }
|
|
17
|
+
|
|
18
|
+
// The most rows one `gh issue list` is asked for.
|
|
19
|
+
export const LIST_LIMIT = 200
|
|
20
|
+
// Same for the history read (`--state all`); newest first, so a cap loses the oldest tickets.
|
|
21
|
+
export const HISTORY_LIMIT = 5000
|
|
22
|
+
|
|
23
|
+
const LABEL_PREFIX = 'trailhead:'
|
|
24
|
+
|
|
25
|
+
const isPosInt = (v: unknown): v is number => Number.isInteger(v) && (v as number) > 0
|
|
26
|
+
|
|
27
|
+
const isRecord = (v: unknown): v is Record<string, unknown> => v !== null && typeof v === 'object' && !Array.isArray(v)
|
|
28
|
+
|
|
29
|
+
// Text from GitHub is untrusted and only drawn: one line, no control, zero-width or bidi characters.
|
|
30
|
+
const flatten = (text: string): string =>
|
|
31
|
+
text
|
|
32
|
+
.replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f-\u009f\u061c\u200b-\u200f\u202a-\u202e\u2060-\u2064\u2066-\u2069\ufeff]/g, '')
|
|
33
|
+
.replace(/\s+/g, ' ')
|
|
34
|
+
.trim()
|
|
35
|
+
|
|
36
|
+
// The `.trailhead/active-map` marker: one line, `#<n>` or `<n>`.
|
|
37
|
+
export const parseActiveMap = (text: string): number | null => {
|
|
38
|
+
const m = /^#?(\d+)$/.exec(text.trim())
|
|
39
|
+
const n = m === null ? 0 : Number(m[1])
|
|
40
|
+
return isPosInt(n) ? n : null
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const labelNames = (raw: unknown): string[] =>
|
|
44
|
+
Array.isArray(raw) ? raw.flatMap(l => (isRecord(l) && typeof l.name === 'string' ? [l.name] : [])) : []
|
|
45
|
+
|
|
46
|
+
const logins = (raw: unknown): string[] =>
|
|
47
|
+
Array.isArray(raw)
|
|
48
|
+
? raw.flatMap(a => (isRecord(a) && typeof a.login === 'string' && a.login !== '' ? [flatten(a.login)] : []))
|
|
49
|
+
: []
|
|
50
|
+
|
|
51
|
+
const typeOf = (names: readonly string[]): TicketType | null => {
|
|
52
|
+
const found = names.find(n => TICKET_TYPES.some(t => n === `${LABEL_PREFIX}${t}`))
|
|
53
|
+
return found === undefined ? null : (found.slice(LABEL_PREFIX.length) as TicketType)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const stateOf = (names: readonly string[], assignees: readonly string[]): TicketState =>
|
|
57
|
+
names.includes(`${LABEL_PREFIX}blocked`) ? 'blocked' : assignees.length > 0 ? 'in-progress' : 'frontier'
|
|
58
|
+
|
|
59
|
+
// Only an https link is kept; anything else would be drawn as plain text.
|
|
60
|
+
const safeUrl = (raw: unknown): string => (typeof raw === 'string' && /^https:\/\//.test(raw) ? raw : '')
|
|
61
|
+
|
|
62
|
+
// `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
|
+
export const parseTickets = (stdout: string): ParsedTickets | null => {
|
|
65
|
+
let raw: unknown
|
|
66
|
+
try {
|
|
67
|
+
raw = JSON.parse(stdout)
|
|
68
|
+
} catch {
|
|
69
|
+
return null
|
|
70
|
+
}
|
|
71
|
+
if (!Array.isArray(raw)) return null
|
|
72
|
+
const rows = raw.filter(isRecord).filter(r => isPosInt(r.number))
|
|
73
|
+
const named = rows.map(r => ({ r, names: labelNames(r.labels) }))
|
|
74
|
+
const unverifiedCount = named.filter(({ names }) => names.includes(`${LABEL_PREFIX}unverified`)).length
|
|
75
|
+
const tickets = named
|
|
76
|
+
.filter(({ names }) => !names.includes(`${LABEL_PREFIX}unverified`) && !names.includes(`${LABEL_PREFIX}whiteboard`))
|
|
77
|
+
.map(({ r, names }): PaneTicket => {
|
|
78
|
+
const assignees = logins(r.assignees)
|
|
79
|
+
return {
|
|
80
|
+
number: r.number as number,
|
|
81
|
+
title: typeof r.title === 'string' ? flatten(r.title) : '',
|
|
82
|
+
type: typeOf(names),
|
|
83
|
+
state: stateOf(names, assignees),
|
|
84
|
+
assignees,
|
|
85
|
+
url: safeUrl(r.url),
|
|
86
|
+
}
|
|
87
|
+
})
|
|
88
|
+
return { tickets, unverifiedCount, isCapped: raw.length >= LIST_LIMIT }
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Markdown emphasis, code ticks and links reduced to their text.
|
|
92
|
+
const stripMarkdown = (line: string): string =>
|
|
93
|
+
line
|
|
94
|
+
.replace(/\[([^\]]*)\]\([^)]*\)/g, '$1')
|
|
95
|
+
.replace(/(\*\*|__|\*|`)/g, '')
|
|
96
|
+
.replace(/(^|\s)_([^_]+)_(?=\s|$|[.,;:!?])/g, '$1$2')
|
|
97
|
+
.replace(/^>\s*/, '')
|
|
98
|
+
|
|
99
|
+
const destinationOf = (body: string): string | null => {
|
|
100
|
+
const lines = body.split(/\r?\n/)
|
|
101
|
+
const at = lines.findIndex(l => /^##\s+Destination\s*$/i.test(l))
|
|
102
|
+
if (at < 0) return null
|
|
103
|
+
const first = lines.slice(at + 1).find(l => l.trim() !== '')
|
|
104
|
+
if (first === undefined || /^#/.test(first.trim())) return null
|
|
105
|
+
const text = flatten(stripMarkdown(first))
|
|
106
|
+
return text === '' ? null : text
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// `gh issue view <n> --json title,body,url`.
|
|
110
|
+
export const parseMap = (stdout: string, number: number): MapInfo | null => {
|
|
111
|
+
let raw: unknown
|
|
112
|
+
try {
|
|
113
|
+
raw = JSON.parse(stdout)
|
|
114
|
+
} catch {
|
|
115
|
+
return null
|
|
116
|
+
}
|
|
117
|
+
if (!isRecord(raw) || typeof raw.title !== 'string') return null
|
|
118
|
+
return {
|
|
119
|
+
number,
|
|
120
|
+
title: flatten(raw.title),
|
|
121
|
+
destination: typeof raw.body === 'string' ? destinationOf(raw.body) : null,
|
|
122
|
+
url: safeUrl(raw.url),
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// `gh issue list --label trailhead:map --state open --json number,title,url`: the open maps by number.
|
|
127
|
+
// A row with no usable number is skipped; anything that is not a list is unreadable.
|
|
128
|
+
export const parseMaps = (stdout: string): readonly MapSummary[] | null => {
|
|
129
|
+
let raw: unknown
|
|
130
|
+
try {
|
|
131
|
+
raw = JSON.parse(stdout)
|
|
132
|
+
} catch {
|
|
133
|
+
return null
|
|
134
|
+
}
|
|
135
|
+
if (!Array.isArray(raw)) return null
|
|
136
|
+
return raw
|
|
137
|
+
.filter(isRecord)
|
|
138
|
+
.filter(r => isPosInt(r.number))
|
|
139
|
+
.map((r): MapSummary => ({ number: r.number as number, title: typeof r.title === 'string' ? flatten(r.title) : '' }))
|
|
140
|
+
.sort((a, b) => a.number - b.number)
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// The map the pane draws: the one the person is viewing, else the active one.
|
|
144
|
+
export const viewTarget = (active: number | null, viewed: number | null): number | null => viewed ?? active
|
|
145
|
+
|
|
146
|
+
export type ViewChange = { activeChanged: boolean; maps: readonly MapSummary[] | null }
|
|
147
|
+
|
|
148
|
+
// The view goes back to following the active map when that map changed, or when the viewed
|
|
149
|
+
// map is gone from a freshly read open list (a missing list judges nothing).
|
|
150
|
+
export const nextViewed = (viewed: number | null, change: ViewChange): number | null => {
|
|
151
|
+
if (viewed === null || change.activeChanged) return null
|
|
152
|
+
return change.maps !== null && !change.maps.some(m => m.number === viewed) ? null : viewed
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export type MapRowLayout = {
|
|
156
|
+
mark: string
|
|
157
|
+
label: string
|
|
158
|
+
aside: string | null
|
|
159
|
+
isActive: boolean
|
|
160
|
+
isViewed: boolean
|
|
161
|
+
// Every map but the viewed one: a press shows it (the active one: back to active).
|
|
162
|
+
isPressable: boolean
|
|
163
|
+
}
|
|
164
|
+
export type MapRowContext = { active: number | null; viewed: number | null; columns: number }
|
|
165
|
+
|
|
166
|
+
const ACTIVE_MARK = '● '
|
|
167
|
+
const ACTIVE_ASIDE = ' active'
|
|
168
|
+
|
|
169
|
+
// What one row of the maps list draws, cut to the pane width (a pressable row is a Button).
|
|
170
|
+
export const mapRowLayout = (m: MapSummary, ctx: MapRowContext): MapRowLayout => {
|
|
171
|
+
const isActive = m.number === ctx.active
|
|
172
|
+
const isViewed = m.number === viewTarget(ctx.active, ctx.viewed)
|
|
173
|
+
const mark = isActive ? ACTIVE_MARK : ' '
|
|
174
|
+
const aside = isActive ? ACTIVE_ASIDE : null
|
|
175
|
+
const prefix = `#${m.number}`
|
|
176
|
+
const isPressable = !isViewed
|
|
177
|
+
const room = ctx.columns - mark.length - (aside?.length ?? 0) - (isPressable ? BUTTON_CHROME : 0) - prefix.length - 1
|
|
178
|
+
const title = fit(m.title, room)
|
|
179
|
+
return { mark, label: title === '' ? prefix : `${prefix} ${title}`, aside, isActive, isViewed, isPressable }
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const byNumber = (a: PaneTicket, b: PaneTicket) => a.number - b.number
|
|
183
|
+
|
|
184
|
+
export const groupTickets = (tickets: readonly PaneTicket[]): TicketGroups => {
|
|
185
|
+
const of = (state: TicketState) => tickets.filter(t => t.state === state).sort(byNumber)
|
|
186
|
+
return { frontier: of('frontier'), inProgress: of('in-progress'), blocked: of('blocked') }
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const errorText = (err: unknown): string => {
|
|
190
|
+
const e = err as { code?: unknown; message?: unknown } | null
|
|
191
|
+
return `${String(e?.code ?? '')} ${String(e?.message ?? err)}`
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// One short line saying why `gh` gave nothing.
|
|
195
|
+
export const failureReason = (failure: Failure): string => {
|
|
196
|
+
if ('error' in failure) {
|
|
197
|
+
const text = errorText(failure.error)
|
|
198
|
+
if (/ENOENT|not found/i.test(text)) return 'gh not installed'
|
|
199
|
+
if (/timed? ?out/i.test(text)) return 'offline: cannot reach GitHub'
|
|
200
|
+
return 'gh failed (did not finish)'
|
|
201
|
+
}
|
|
202
|
+
if (/gh auth login|not logged in|authentication/i.test(failure.stderr)) return 'gh not authenticated (run gh auth login)'
|
|
203
|
+
if (/error connecting|could not resolve|dial tcp|no such host|network is unreachable|connection (refused|reset)|timed? ?out/i.test(failure.stderr)) {
|
|
204
|
+
return 'offline: cannot reach GitHub'
|
|
205
|
+
}
|
|
206
|
+
return `gh failed (exit ${failure.exitCode})`
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Run now, or wait the rest of the floor (a clock that moved back waits the whole floor at most).
|
|
210
|
+
export const refreshDecision = (now: number, lastStartedAt: number | null, minMs: number): RefreshDecision => {
|
|
211
|
+
if (lastStartedAt === null) return { run: true }
|
|
212
|
+
const waitMs = Math.min(minMs, minMs - (now - lastStartedAt))
|
|
213
|
+
return waitMs <= 0 ? { run: true } : { run: false, waitMs }
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
export const ageLabel = (ms: number): string => {
|
|
217
|
+
if (ms < 5_000) return 'just now'
|
|
218
|
+
if (ms < 60_000) return `${Math.floor(ms / 1_000)}s ago`
|
|
219
|
+
if (ms < 3_600_000) return `${Math.floor(ms / 60_000)}m ago`
|
|
220
|
+
return `${Math.floor(ms / 3_600_000)}h ago`
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
export type RowAside = { kind: 'work' | 'queued' | 'holder'; text: string }
|
|
224
|
+
export type RowLayout = {
|
|
225
|
+
mark: string | null
|
|
226
|
+
label: string
|
|
227
|
+
type: string | null
|
|
228
|
+
aside: RowAside | null
|
|
229
|
+
isDim: boolean
|
|
230
|
+
isInPlay: boolean
|
|
231
|
+
}
|
|
232
|
+
export type RowContext = {
|
|
233
|
+
isInPlay: boolean
|
|
234
|
+
// True when the group holds the ticket in play: its rows share a two-cell mark column.
|
|
235
|
+
hasMarkColumn: boolean
|
|
236
|
+
pendingWork: number | null
|
|
237
|
+
columns: number
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const MARK = '▸ '
|
|
241
|
+
const MARK_PAD = ' '
|
|
242
|
+
// A terminal Button draws `[ label ]`.
|
|
243
|
+
const BUTTON_CHROME = 4
|
|
244
|
+
|
|
245
|
+
const REFRESH_LABEL = 'refresh'
|
|
246
|
+
const CLOSE_LABEL = 'close'
|
|
247
|
+
// The gap between the refresh button and the status, and the one kept before the close button.
|
|
248
|
+
const FOOTER_GAPS = 2
|
|
249
|
+
|
|
250
|
+
export type FooterStatus = { text: string; isInline: boolean }
|
|
251
|
+
|
|
252
|
+
// The footer status beside the `refresh` and `close` buttons when it fits the room they leave;
|
|
253
|
+
// otherwise on a row of its own under them, cut only to the pane width (a reason keeps its hint).
|
|
254
|
+
export const footerStatus = (status: string, columns: number): FooterStatus => {
|
|
255
|
+
const buttons = REFRESH_LABEL.length + CLOSE_LABEL.length + 2 * BUTTON_CHROME
|
|
256
|
+
const room = columns - buttons - FOOTER_GAPS
|
|
257
|
+
return Array.from(status).length <= room ? { text: status, isInline: true } : { text: fit(status, columns), isInline: false }
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const asideOf = (t: PaneTicket, pendingWork: number | null): RowAside | null => {
|
|
261
|
+
if (t.state === 'frontier') return pendingWork === t.number ? { kind: 'queued', text: 'queued' } : { kind: 'work', text: 'work' }
|
|
262
|
+
return t.assignees.length === 0 ? null : { kind: 'holder', text: t.assignees.map(a => `@${a}`).join(' ') }
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const asideWidth = (a: RowAside | null): number =>
|
|
266
|
+
a === null ? 0 : a.text.length + (a.kind === 'work' ? BUTTON_CHROME : 0) + 1
|
|
267
|
+
|
|
268
|
+
// What one row draws, cut to the pane width: mark column, `#n title`, type, right-hand part.
|
|
269
|
+
export const rowLayout = (t: PaneTicket, ctx: RowContext): RowLayout => {
|
|
270
|
+
const mark = ctx.isInPlay ? MARK : ctx.hasMarkColumn ? MARK_PAD : null
|
|
271
|
+
const aside = asideOf(t, ctx.pendingWork)
|
|
272
|
+
const type = t.type === null ? null : ` ${t.type}`
|
|
273
|
+
const prefix = `#${t.number}`
|
|
274
|
+
const room = ctx.columns - (mark?.length ?? 0) - asideWidth(aside) - (type?.length ?? 0) - prefix.length - 1
|
|
275
|
+
const title = fit(t.title, room)
|
|
276
|
+
return {
|
|
277
|
+
mark,
|
|
278
|
+
label: title === '' ? prefix : `${prefix} ${title}`,
|
|
279
|
+
type,
|
|
280
|
+
aside,
|
|
281
|
+
isDim: t.state === 'blocked',
|
|
282
|
+
isInPlay: ctx.isInPlay,
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
const REMOVED_LABELS = [`${LABEL_PREFIX}superseded`, `${LABEL_PREFIX}out-of-scope`]
|
|
287
|
+
|
|
288
|
+
const parseTime = (raw: unknown): number | null => {
|
|
289
|
+
if (typeof raw !== 'string') return null
|
|
290
|
+
const ms = Date.parse(raw)
|
|
291
|
+
return Number.isNaN(ms) ? null : ms
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// gh prints an open issue's closedAt as the zero date: null, absent or anything at or before the epoch
|
|
295
|
+
// means open. Any other value that does not parse is unreadable (undefined), and the row is skipped.
|
|
296
|
+
const closedTime = (raw: unknown): number | null | undefined => {
|
|
297
|
+
if (raw === null || raw === undefined) return null
|
|
298
|
+
const ms = parseTime(raw)
|
|
299
|
+
if (ms === null) return undefined
|
|
300
|
+
return ms <= 0 ? null : ms
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
const historyIssue = (r: Record<string, unknown>): BurndownIssue[] => {
|
|
304
|
+
const names = labelNames(r.labels)
|
|
305
|
+
if (names.includes(`${LABEL_PREFIX}unverified`) || names.includes(`${LABEL_PREFIX}whiteboard`)) return []
|
|
306
|
+
const openedAt = parseTime(r.createdAt)
|
|
307
|
+
const closedAt = closedTime(r.closedAt)
|
|
308
|
+
if (openedAt === null || closedAt === undefined || (closedAt !== null && closedAt < openedAt)) return []
|
|
309
|
+
return [{ openedAt, closedAt, isRemoved: REMOVED_LABELS.some(l => names.includes(l)) }]
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// `gh issue list --state all --json number,labels,createdAt,closedAt`.
|
|
313
|
+
export const parseHistory = (stdout: string): BurndownHistory | null => {
|
|
314
|
+
let raw: unknown
|
|
315
|
+
try {
|
|
316
|
+
raw = JSON.parse(stdout)
|
|
317
|
+
} catch {
|
|
318
|
+
return null
|
|
319
|
+
}
|
|
320
|
+
if (!Array.isArray(raw)) return null
|
|
321
|
+
return { issues: raw.filter(isRecord).flatMap(historyIssue), isCapped: raw.length >= HISTORY_LIMIT }
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
export type BurndownEnd = { at: number; label: string }
|
|
325
|
+
|
|
326
|
+
// Where the chart ends: now when its history was read by the last good fetch, else the time it was
|
|
327
|
+
// read, labelled with its age (a failed read keeps the last curve, it is not stretched to the present).
|
|
328
|
+
export const burndownEnd = (
|
|
329
|
+
d: { historyAt: number | null; fetchedAt: number | null; error: string | null },
|
|
330
|
+
now: number,
|
|
331
|
+
): BurndownEnd =>
|
|
332
|
+
d.historyAt === null || (d.error === null && d.historyAt === d.fetchedAt)
|
|
333
|
+
? { at: now, label: 'now' }
|
|
334
|
+
: { at: d.historyAt, label: ageLabel(now - d.historyAt) }
|
|
335
|
+
|
|
336
|
+
export type BurndownSeries = {
|
|
337
|
+
values: readonly number[]
|
|
338
|
+
open: number
|
|
339
|
+
peak: number
|
|
340
|
+
done: number
|
|
341
|
+
removed: number
|
|
342
|
+
startedAt: number
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// Open tickets at `buckets` evenly spaced times from the first ticket to `now` (the last sample).
|
|
346
|
+
// The most open at once over every event, not only at the samples: same rule as a sample,
|
|
347
|
+
// a time counts every open and close at or before it.
|
|
348
|
+
const truePeak = (opened: readonly number[], closedTimes: readonly number[]): number => {
|
|
349
|
+
let peak = 0
|
|
350
|
+
let closes = 0
|
|
351
|
+
opened.forEach((t, n) => {
|
|
352
|
+
while (closes < closedTimes.length && (closedTimes[closes] as number) <= t) closes += 1
|
|
353
|
+
peak = Math.max(peak, n + 1 - closes)
|
|
354
|
+
})
|
|
355
|
+
return peak
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
export const burndownSeries = (history: BurndownHistory, now: number, buckets: number): BurndownSeries | null => {
|
|
359
|
+
if (history.issues.length === 0 || buckets <= 0) return null
|
|
360
|
+
const issues = history.issues.map(i => ({
|
|
361
|
+
...i,
|
|
362
|
+
openedAt: Math.min(i.openedAt, now),
|
|
363
|
+
closedAt: i.closedAt === null ? null : Math.min(i.closedAt, now),
|
|
364
|
+
}))
|
|
365
|
+
const startedAt = Math.min(...issues.map(i => i.openedAt))
|
|
366
|
+
const timeAt = (n: number) => (n === buckets - 1 ? now : startedAt + ((now - startedAt) * n) / (buckets - 1))
|
|
367
|
+
// Sort once, then sweep: the samples only move forward, so each time is passed over once.
|
|
368
|
+
const opened = issues.map(i => i.openedAt).sort((a, b) => a - b)
|
|
369
|
+
const closedTimes = issues.flatMap(i => (i.closedAt === null ? [] : [i.closedAt])).sort((a, b) => a - b)
|
|
370
|
+
let opens = 0
|
|
371
|
+
let closes = 0
|
|
372
|
+
const values = Array.from({ length: buckets }, (_, n) => {
|
|
373
|
+
const t = timeAt(n)
|
|
374
|
+
while (opens < opened.length && (opened[opens] as number) <= t) opens += 1
|
|
375
|
+
while (closes < closedTimes.length && (closedTimes[closes] as number) <= t) closes += 1
|
|
376
|
+
return opens - closes
|
|
377
|
+
})
|
|
378
|
+
const closed = issues.filter(i => i.closedAt !== null)
|
|
379
|
+
return {
|
|
380
|
+
values,
|
|
381
|
+
open: values[values.length - 1] as number,
|
|
382
|
+
peak: Math.max(truePeak(opened, closedTimes), ...values),
|
|
383
|
+
done: closed.filter(i => !i.isRemoved).length,
|
|
384
|
+
removed: closed.filter(i => i.isRemoved).length,
|
|
385
|
+
startedAt,
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
const EIGHTHS = '▁▂▃▄▅▆▇█'
|
|
390
|
+
|
|
391
|
+
// Bar height in eighths of a cell over `levels`: zero stays zero, any other value is at least one.
|
|
392
|
+
const heightOf = (value: number, peak: number, levels: number): number =>
|
|
393
|
+
value <= 0 || peak <= 0 ? 0 : Math.min(levels, Math.max(1, Math.round((value / peak) * levels)))
|
|
394
|
+
|
|
395
|
+
// One row of eighth blocks; a zero is a space.
|
|
396
|
+
export const sparkline = (values: readonly number[], peak: number): string =>
|
|
397
|
+
values.map(v => (heightOf(v, peak, 8) === 0 ? ' ' : (EIGHTHS[heightOf(v, peak, 8) - 1] as string))).join('')
|
|
398
|
+
|
|
399
|
+
const BASE64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
|
|
400
|
+
const DEFAULT_COLOR = 0x01000000
|
|
401
|
+
const SPACE = 0x20
|
|
402
|
+
const FULL_BLOCK = 0x2588
|
|
403
|
+
const LOWER_BLOCK_BASE = 0x2580
|
|
404
|
+
|
|
405
|
+
// Padded base64 written here: the mod runtime is not promised Buffer, btoa or toBase64.
|
|
406
|
+
const base64 = (bytes: Uint8Array): string => {
|
|
407
|
+
const chars: string[] = []
|
|
408
|
+
for (let i = 0; i < bytes.length; i += 3) {
|
|
409
|
+
const b0 = bytes[i] as number
|
|
410
|
+
const b1 = bytes[i + 1]
|
|
411
|
+
const b2 = bytes[i + 2]
|
|
412
|
+
const n = (b0 << 16) | ((b1 ?? 0) << 8) | (b2 ?? 0)
|
|
413
|
+
chars.push(BASE64[(n >> 18) & 63] as string, BASE64[(n >> 12) & 63] as string)
|
|
414
|
+
chars.push(b1 === undefined ? '=' : (BASE64[(n >> 6) & 63] as string), b2 === undefined ? '=' : (BASE64[n & 63] as string))
|
|
415
|
+
}
|
|
416
|
+
return chars.join('')
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
// The glyph of one cell: `fromBottom` rows below it are full, `eighths` is the bar height.
|
|
420
|
+
const glyphAt = (eighths: number, fromBottom: number): number => {
|
|
421
|
+
const full = Math.floor(eighths / 8)
|
|
422
|
+
if (fromBottom < full) return FULL_BLOCK
|
|
423
|
+
return fromBottom === full && eighths % 8 > 0 ? LOWER_BLOCK_BASE + (eighths % 8) : SPACE
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
// The Raster `cells` prop: row-major (top first) little-endian u32 triplets [codePoint, fg, bg].
|
|
427
|
+
export const rasterCells = (values: readonly number[], peak: number, rows: number, fg: number): string => {
|
|
428
|
+
const columns = values.length
|
|
429
|
+
const heights = values.map(v => heightOf(v, peak, rows * 8))
|
|
430
|
+
const bytes = new Uint8Array(columns * rows * 12)
|
|
431
|
+
const view = new DataView(bytes.buffer)
|
|
432
|
+
for (let row = 0; row < rows; row += 1) {
|
|
433
|
+
heights.forEach((h, col) => {
|
|
434
|
+
const at = (row * columns + col) * 12
|
|
435
|
+
view.setUint32(at, glyphAt(h, rows - 1 - row), true)
|
|
436
|
+
view.setUint32(at + 4, fg, true)
|
|
437
|
+
view.setUint32(at + 8, DEFAULT_COLOR, true)
|
|
438
|
+
})
|
|
439
|
+
}
|
|
440
|
+
return base64(bytes)
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
|
|
444
|
+
const DAY_MS = 86_400_000
|
|
445
|
+
const pad2 = (n: number) => String(n).padStart(2, '0')
|
|
446
|
+
|
|
447
|
+
// Left axis label, in UTC so it is the same everywhere: `Sep 18`, or `today 09:05` under a day.
|
|
448
|
+
export const axisStart = (startedAt: number, now: number): string => {
|
|
449
|
+
const d = new Date(startedAt)
|
|
450
|
+
return now - startedAt < DAY_MS
|
|
451
|
+
? `today ${pad2(d.getUTCHours())}:${pad2(d.getUTCMinutes())}`
|
|
452
|
+
: `${MONTHS[d.getUTCMonth()]} ${d.getUTCDate()}`
|
|
453
|
+
}
|