@marcomigozzi/trailhead 0.14.0 → 0.16.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 +158 -34
- package/README.md +147 -33
- package/bin/auto-intro-docs.test.js +72 -0
- package/bin/auto-merge-docs.test.js +33 -0
- package/bin/auto-merge-ticket-docs.test.js +32 -0
- package/bin/auto-review-rounds-docs.test.js +45 -0
- package/bin/auto-run-docs.test.js +60 -0
- package/bin/auto-scope-docs.test.js +33 -0
- package/bin/auto-slots-docs.test.js +45 -0
- package/bin/auto-uat-docs.test.js +47 -0
- package/bin/base-preflight-docs.test.js +65 -0
- package/bin/execute-split-docs.test.js +146 -0
- package/bin/grilling-holistic-review-docs.test.js +90 -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 +6 -6
- package/bin/lib/codex-projection.test.js +43 -2
- package/bin/needs-you-docs.test.js +41 -0
- package/bin/pick-stress-test-docs.test.js +105 -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 +6 -6
- package/bin/trailhead.test.js +355 -16
- 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/commands/auto.md +2 -2
- package/plugins/trailhead/hooks/lib/auto-merge.test.js +467 -0
- package/plugins/trailhead/hooks/lib/auto-queue.test.js +245 -0
- package/plugins/trailhead/hooks/lib/auto-run.test.js +318 -0
- package/plugins/trailhead/hooks/lib/auto-slot.test.js +278 -0
- package/plugins/trailhead/hooks/lib/base-preflight.test.js +513 -0
- package/plugins/trailhead/hooks/lib/checkout-in.test.js +173 -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 +472 -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/merge-ticket.test.js +394 -0
- package/plugins/trailhead/hooks/lib/needs-you.test.js +177 -0
- package/plugins/trailhead/hooks/lib/plan-review-rounds.test.js +319 -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/statusline-context.test.js +140 -0
- package/plugins/trailhead/hooks/lib/uat-backfill.test.js +150 -0
- package/plugins/trailhead/hooks/lib/uat-followup.test.js +169 -0
- package/plugins/trailhead/hooks/lib/uat-setup.test.js +340 -0
- package/plugins/trailhead/hooks/lib/unblock.test.js +207 -0
- package/plugins/trailhead/hooks/lib/work-state.test.js +45 -0
- package/plugins/trailhead/hooks/mod/README.md +209 -36
- package/plugins/trailhead/hooks/mod/auto-run-view.ts +93 -0
- package/plugins/trailhead/hooks/mod/auto-run.ts +165 -0
- package/plugins/trailhead/hooks/mod/map-pane-view.ts +304 -26
- package/plugins/trailhead/hooks/mod/map-pane.tsx +763 -160
- package/plugins/trailhead/hooks/mod/register.tsx +312 -40
- package/plugins/trailhead/hooks/mod/repo-pin.ts +167 -0
- package/plugins/trailhead/hooks/mod/session-registry.ts +65 -0
- package/plugins/trailhead/hooks/mod/turn-state.ts +14 -0
- package/plugins/trailhead/hooks/mod/whiteboard-follow.ts +98 -0
- package/plugins/trailhead/hooks/mod/work-state-locate.ts +191 -0
- package/plugins/trailhead/hooks/mod/work-state-prompt.ts +13 -15
- package/plugins/trailhead/hooks/mod/work-state-view.ts +38 -5
- package/plugins/trailhead/hooks/mod/worktree-porcelain.ts +17 -0
- package/plugins/trailhead/hooks/trailhead-bash-guards.js +8 -8
- package/plugins/trailhead/hooks/trailhead-bash-guards.test.js +22 -16
- 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-labels-guard.js +231 -0
- package/plugins/trailhead/hooks/trailhead-labels-guard.test.js +212 -0
- package/plugins/trailhead/hooks/trailhead-search-guard.js +43 -7
- package/plugins/trailhead/hooks/trailhead-search-guard.test.js +66 -2
- package/plugins/trailhead/hooks/trailhead-secret-read-guard.js +296 -22
- package/plugins/trailhead/hooks/trailhead-secret-read-guard.test.js +253 -0
- package/plugins/trailhead/skills/_shared/choices.md +11 -4
- package/plugins/trailhead/skills/_shared/configuration-reference.md +17 -5
- 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 +47 -14
- package/plugins/trailhead/skills/_shared/substrate.md +9 -4
- package/plugins/trailhead/skills/_shared/teamwork.md +17 -8
- package/plugins/trailhead/skills/_shared/techniques/acceptance-testing.md +18 -10
- 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/grilling.md +4 -2
- package/plugins/trailhead/skills/_shared/techniques/plan-review.md +3 -1
- 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 +3 -3
- package/plugins/trailhead/skills/trailhead-chart/SKILL.md +2 -2
- package/plugins/trailhead/skills/trailhead-chart/references/charting.md +5 -5
- package/plugins/trailhead/skills/trailhead-chart/references/inbox.md +2 -2
- 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 +9 -6
- package/plugins/trailhead/skills/trailhead-work/SKILL.md +31 -18
- package/plugins/trailhead/skills/trailhead-work/references/auto.md +70 -22
- package/plugins/trailhead/skills/trailhead-work/references/executor-brief.md +29 -0
- package/plugins/trailhead/skills/trailhead-work/references/ticket-engines.md +29 -13
- package/plugins/trailhead/templates/statusline-trailhead.sh +77 -12
- package/plugins/trailhead/templates/trailhead-auto-merge.js +318 -0
- package/plugins/trailhead/templates/trailhead-auto-queue.js +186 -0
- package/plugins/trailhead/templates/trailhead-auto-run.js +294 -0
- package/plugins/trailhead/templates/trailhead-auto-slot.js +239 -0
- package/plugins/trailhead/templates/trailhead-base-preflight.js +305 -0
- package/plugins/trailhead/templates/trailhead-checkout-in.js +88 -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 +360 -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-merge-ticket.js +366 -0
- package/plugins/trailhead/templates/trailhead-needs-you.js +193 -0
- package/plugins/trailhead/templates/trailhead-plan-review-rounds.js +289 -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-followup.js +128 -0
- package/plugins/trailhead/templates/trailhead-uat-setup.js +385 -0
- package/plugins/trailhead/templates/trailhead-unblock.js +111 -0
- package/plugins/trailhead/templates/trailhead-work-state.js +25 -7
- package/plugins/trailhead/tests/auto-run-ui.test.ts +444 -0
- package/plugins/trailhead/tests/auto-run-view.test.ts +111 -0
- package/plugins/trailhead/tests/map-pane-ui.test.ts +2519 -382
- package/plugins/trailhead/tests/map-pane-view.test.ts +600 -18
- package/plugins/trailhead/tests/map-pane-write.test.ts +51 -0
- package/plugins/trailhead/tests/repo-pin-late-repo.test.ts +77 -0
- package/plugins/trailhead/tests/repo-pin.test.ts +123 -0
- package/plugins/trailhead/tests/session-registry.test.ts +50 -0
- package/plugins/trailhead/tests/whiteboard-follow.test.ts +90 -0
- package/plugins/trailhead/tests/work-state-locate.test.ts +33 -0
- package/plugins/trailhead/tests/work-state-prompt-hook.test.ts +276 -2
- package/plugins/trailhead/tests/work-state-prompt.test.ts +29 -35
- package/plugins/trailhead/tests/work-state-ui.test.ts +73 -1
- package/plugins/trailhead/tests/work-state-view.test.ts +44 -1
- package/plugins/trailhead/tests/work-state-worktree.test.ts +310 -0
- package/plugins/trailhead/types/index.d.ts +62 -3
|
@@ -1,15 +1,18 @@
|
|
|
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, OpenTotal, PaneTicket, TicketState, TicketType, UatParent } from '../../types'
|
|
5
5
|
|
|
6
6
|
export const TICKET_TYPES: readonly TicketType[] = ['decision', 'research', 'prototype', 'build', 'bug', 'task']
|
|
7
7
|
|
|
8
8
|
// `isCapped`: gh returned as many rows as it was asked for, so the list may be cut.
|
|
9
9
|
export type ParsedTickets = { tickets: readonly PaneTicket[]; unverifiedCount: number; isCapped: boolean }
|
|
10
10
|
export type TicketGroups = {
|
|
11
|
+
needsYou: readonly PaneTicket[]
|
|
11
12
|
frontier: readonly PaneTicket[]
|
|
12
13
|
inProgress: readonly PaneTicket[]
|
|
14
|
+
uat: readonly PaneTicket[]
|
|
15
|
+
paused: readonly PaneTicket[]
|
|
13
16
|
blocked: readonly PaneTicket[]
|
|
14
17
|
}
|
|
15
18
|
export type Failure = { error: unknown } | { exitCode: number; stderr: string }
|
|
@@ -53,27 +56,29 @@ const typeOf = (names: readonly string[]): TicketType | null => {
|
|
|
53
56
|
return found === undefined ? null : (found.slice(LABEL_PREFIX.length) as TicketType)
|
|
54
57
|
}
|
|
55
58
|
|
|
59
|
+
// A paused label wins over blocked and in-progress: the ticket shows once, under Paused.
|
|
56
60
|
const stateOf = (names: readonly string[], assignees: readonly string[]): TicketState =>
|
|
57
|
-
names.includes(`${LABEL_PREFIX}
|
|
61
|
+
names.includes(`${LABEL_PREFIX}paused`)
|
|
62
|
+
? 'paused'
|
|
63
|
+
: names.includes(`${LABEL_PREFIX}blocked`)
|
|
64
|
+
? 'blocked'
|
|
65
|
+
: assignees.length > 0
|
|
66
|
+
? 'in-progress'
|
|
67
|
+
: 'frontier'
|
|
58
68
|
|
|
59
69
|
// Only an https link is kept; anything else would be drawn as plain text.
|
|
60
70
|
const safeUrl = (raw: unknown): string => (typeof raw === 'string' && /^https:\/\//.test(raw) ? raw : '')
|
|
61
71
|
|
|
62
|
-
// `gh issue list --json number,title,labels,assignees,url
|
|
63
|
-
// quarantined (counted, never listed); whiteboard rows do not belong to a map
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
try {
|
|
67
|
-
raw = JSON.parse(stdout)
|
|
68
|
-
} catch {
|
|
69
|
-
return null
|
|
70
|
-
}
|
|
71
|
-
if (!Array.isArray(raw)) return null
|
|
72
|
+
// The rows of an open-ticket list (the `gh issue list --json number,title,labels,assignees,url` shape).
|
|
73
|
+
// Unverified rows are quarantined (counted, never listed); whiteboard rows do not belong to a map, and on the
|
|
74
|
+
// whiteboard (`isWhiteboard`) only they are kept. A list that reached LIST_LIMIT rows is capped.
|
|
75
|
+
export const ticketsFromRows = (raw: readonly unknown[], opts: { isWhiteboard?: boolean } = {}): ParsedTickets => {
|
|
72
76
|
const rows = raw.filter(isRecord).filter(r => isPosInt(r.number))
|
|
73
77
|
const named = rows.map(r => ({ r, names: labelNames(r.labels) }))
|
|
74
78
|
const unverifiedCount = named.filter(({ names }) => names.includes(`${LABEL_PREFIX}unverified`)).length
|
|
75
79
|
const tickets = named
|
|
76
|
-
.filter(({ names }) => !names.includes(`${LABEL_PREFIX}unverified`)
|
|
80
|
+
.filter(({ names }) => !names.includes(`${LABEL_PREFIX}unverified`))
|
|
81
|
+
.filter(({ names }) => names.includes(`${LABEL_PREFIX}whiteboard`) === (opts.isWhiteboard === true))
|
|
77
82
|
.map(({ r, names }): PaneTicket => {
|
|
78
83
|
const assignees = logins(r.assignees)
|
|
79
84
|
return {
|
|
@@ -83,11 +88,100 @@ export const parseTickets = (stdout: string): ParsedTickets | null => {
|
|
|
83
88
|
state: stateOf(names, assignees),
|
|
84
89
|
assignees,
|
|
85
90
|
url: safeUrl(r.url),
|
|
91
|
+
isUat: names.includes(`${LABEL_PREFIX}uat`),
|
|
92
|
+
isNeedsYou: names.includes(`${LABEL_PREFIX}needs-you`),
|
|
93
|
+
uatOf: null,
|
|
86
94
|
}
|
|
87
95
|
})
|
|
88
96
|
return { tickets, unverifiedCount, isCapped: raw.length >= LIST_LIMIT }
|
|
89
97
|
}
|
|
90
98
|
|
|
99
|
+
// `gh issue list --json ...` output as a ticket list; not a list is unreadable (null).
|
|
100
|
+
export const parseTickets = (stdout: string, opts: { isWhiteboard?: boolean } = {}): ParsedTickets | null => {
|
|
101
|
+
let raw: unknown
|
|
102
|
+
try {
|
|
103
|
+
raw = JSON.parse(stdout)
|
|
104
|
+
} catch {
|
|
105
|
+
return null
|
|
106
|
+
}
|
|
107
|
+
return Array.isArray(raw) ? ticketsFromRows(raw, opts) : null
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// The rows that carry `name`: a GraphQL `labels:` filter is any-of, so an AND of two labels is done here.
|
|
111
|
+
export const withLabel = (rows: readonly unknown[], name: string): unknown[] =>
|
|
112
|
+
rows.filter(r => isRecord(r) && labelNames(r.labels).includes(name))
|
|
113
|
+
|
|
114
|
+
export type IssuesPage = { rows: unknown[]; hasNextPage: boolean; endCursor: string | null }
|
|
115
|
+
|
|
116
|
+
// One page of the GraphQL open-ticket read (`repository.issues(...){nodes{...}pageInfo{...}}`), its nodes
|
|
117
|
+
// flattened to the rows `gh issue list --json` prints so one parser serves both (`body` rides along only when
|
|
118
|
+
// the node has a string one, so the ticket rows keep their shape). Any `errors` (even beside
|
|
119
|
+
// partial `data`), a missing page info or a next page without a cursor is unreadable: the caller decides (`readOpen` falls back to
|
|
120
|
+
// `gh issue list`, `readUat` fails closed to null).
|
|
121
|
+
export const parseIssuesPage = (stdout: string): IssuesPage | null => {
|
|
122
|
+
let raw: unknown
|
|
123
|
+
try {
|
|
124
|
+
raw = JSON.parse(stdout)
|
|
125
|
+
} catch {
|
|
126
|
+
return null
|
|
127
|
+
}
|
|
128
|
+
if (!isRecord(raw) || 'errors' in raw || !isRecord(raw.data) || !isRecord(raw.data.repository)) return null
|
|
129
|
+
const issues = raw.data.repository.issues
|
|
130
|
+
if (!isRecord(issues) || !Array.isArray(issues.nodes) || !isRecord(issues.pageInfo)) return null
|
|
131
|
+
const { hasNextPage, endCursor } = issues.pageInfo
|
|
132
|
+
if (typeof hasNextPage !== 'boolean') return null
|
|
133
|
+
if (hasNextPage && (typeof endCursor !== 'string' || endCursor === '')) return null
|
|
134
|
+
const inner = (v: unknown) => (isRecord(v) && Array.isArray(v.nodes) ? v.nodes : [])
|
|
135
|
+
const rows = issues.nodes.filter(isRecord).map(n => ({
|
|
136
|
+
number: n.number,
|
|
137
|
+
title: n.title,
|
|
138
|
+
url: n.url,
|
|
139
|
+
labels: inner(n.labels),
|
|
140
|
+
assignees: inner(n.assignees),
|
|
141
|
+
...(typeof n.body === 'string' ? { body: n.body } : {}),
|
|
142
|
+
}))
|
|
143
|
+
return { rows, hasNextPage, endCursor: hasNextPage ? (endCursor as string) : null }
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// The open `trailhead:uat` rows of the GraphQL read, as JSON (`number` and `body`): ticket number -> the parent its first
|
|
147
|
+
// `UAT of:` line names, `[<name>](<url>/issues/<n>)` (or a bare `#<n>`, with an empty title). A row with
|
|
148
|
+
// no such line, or an unreadable one, is skipped; not a list is unreadable (null).
|
|
149
|
+
export const parseUatParents = (stdout: string): ReadonlyMap<number, UatParent> | null => {
|
|
150
|
+
let raw: unknown
|
|
151
|
+
try {
|
|
152
|
+
raw = JSON.parse(stdout)
|
|
153
|
+
} catch {
|
|
154
|
+
return null
|
|
155
|
+
}
|
|
156
|
+
if (!Array.isArray(raw)) return null
|
|
157
|
+
const parents = new Map<number, UatParent>()
|
|
158
|
+
for (const r of raw.filter(isRecord)) {
|
|
159
|
+
if (!isPosInt(r.number) || typeof r.body !== 'string') continue
|
|
160
|
+
const line = r.body.split(/\r?\n/).find(l => l.startsWith('UAT of:'))
|
|
161
|
+
if (line === undefined) continue
|
|
162
|
+
const pointer = line.slice('UAT of:'.length).trim()
|
|
163
|
+
const link = /^\[([^\]]*)\]\(https:\/\/[^)\s]*\/issues\/(\d+)[^)]*\)/.exec(pointer)
|
|
164
|
+
const bare = /^#(\d+)\b/.exec(pointer)
|
|
165
|
+
const found = link !== null ? { number: Number(link[2]), title: flatten(link[1] as string) } : bare !== null ? { number: Number(bare[1]), title: '' } : null
|
|
166
|
+
if (found !== null && isPosInt(found.number)) parents.set(r.number, found)
|
|
167
|
+
}
|
|
168
|
+
return parents
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// The tickets with the parent of each UAT one; a missing read leaves them as they are.
|
|
172
|
+
export const withUatParents = (tickets: readonly PaneTicket[], parents: ReadonlyMap<number, UatParent> | null): readonly PaneTicket[] =>
|
|
173
|
+
parents === null ? tickets : tickets.map(t => (t.isUat ? { ...t, uatOf: parents.get(t.number) ?? null } : t))
|
|
174
|
+
|
|
175
|
+
// The dim line under a UAT row, `tests <parent title> #<n>`, cut to the pane width; null without a parent.
|
|
176
|
+
export const uatParentLine = (t: PaneTicket, columns: number): string | null => {
|
|
177
|
+
if (t.uatOf === null) return null
|
|
178
|
+
const suffix = ` #${t.uatOf.number}`
|
|
179
|
+
const prefix = 'tests'
|
|
180
|
+
if (t.uatOf.title === '') return fit(`${prefix}${suffix}`, columns)
|
|
181
|
+
const title = fit(t.uatOf.title, columns - prefix.length - 1 - suffix.length)
|
|
182
|
+
return `${prefix} ${title}${suffix}`
|
|
183
|
+
}
|
|
184
|
+
|
|
91
185
|
// Markdown emphasis, code ticks and links reduced to their text.
|
|
92
186
|
const stripMarkdown = (line: string): string =>
|
|
93
187
|
line
|
|
@@ -136,8 +230,119 @@ export const parseMaps = (stdout: string): readonly MapSummary[] | null => {
|
|
|
136
230
|
return raw
|
|
137
231
|
.filter(isRecord)
|
|
138
232
|
.filter(r => isPosInt(r.number))
|
|
139
|
-
.map((r): MapSummary => ({ number: r.number as number, title: typeof r.title === 'string' ? flatten(r.title) : '' }))
|
|
233
|
+
.map((r): MapSummary => ({ number: r.number as number, title: typeof r.title === 'string' ? flatten(r.title) : '', counts: null }))
|
|
234
|
+
.sort((a, b) => a.number - b.number)
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const countsOf = (raw: unknown): MapCounts | null => {
|
|
238
|
+
if (!isRecord(raw)) return null
|
|
239
|
+
const { total, completed } = raw
|
|
240
|
+
if (!Number.isInteger(total) || !Number.isInteger(completed) || (total as number) < 0 || (completed as number) < 0) return null
|
|
241
|
+
return { open: Math.max(0, (total as number) - (completed as number)), total: total as number }
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// A GraphQL `totalCount` as a count, else null.
|
|
245
|
+
const totalOf = (raw: unknown): number | null =>
|
|
246
|
+
isRecord(raw) && Number.isInteger(raw.totalCount) && (raw.totalCount as number) >= 0 ? (raw.totalCount as number) : null
|
|
247
|
+
|
|
248
|
+
// The whiteboard's open and total tickets; unusable (missing, negative, open above total) is unknown.
|
|
249
|
+
const whiteboardOf = (repo: Record<string, unknown>): MapCounts | null => {
|
|
250
|
+
const open = totalOf(repo.whiteboardOpen)
|
|
251
|
+
const total = totalOf(repo.whiteboardAll)
|
|
252
|
+
return open === null || total === null || open > total ? null : { open, total }
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// The GraphQL reply of the maps read: the open maps with their sub-issue counts, same row rules as
|
|
256
|
+
// `parseMaps`, and the whiteboard's counts. A malformed summary keeps the row name-only; unusable
|
|
257
|
+
// whiteboard fields leave only `whiteboard` null. Not that shape, or any `errors` (even next to
|
|
258
|
+
// partial `data`), is unreadable: the caller falls back to the name-only list.
|
|
259
|
+
export const parseMapsRead = (stdout: string): MapsRead | null => {
|
|
260
|
+
let raw: unknown
|
|
261
|
+
try {
|
|
262
|
+
raw = JSON.parse(stdout)
|
|
263
|
+
} catch {
|
|
264
|
+
return null
|
|
265
|
+
}
|
|
266
|
+
if (!isRecord(raw) || 'errors' in raw || !isRecord(raw.data)) return null
|
|
267
|
+
const repo = raw.data.repository
|
|
268
|
+
const issues = isRecord(repo) ? repo.issues : null
|
|
269
|
+
const nodes = isRecord(issues) ? issues.nodes : null
|
|
270
|
+
if (!isRecord(repo) || !Array.isArray(nodes)) return null
|
|
271
|
+
const maps = nodes
|
|
272
|
+
.filter(isRecord)
|
|
273
|
+
.filter(r => isPosInt(r.number))
|
|
274
|
+
.map(
|
|
275
|
+
(r): MapSummary => ({
|
|
276
|
+
number: r.number as number,
|
|
277
|
+
title: typeof r.title === 'string' ? flatten(r.title) : '',
|
|
278
|
+
counts: countsOf(r.subIssuesSummary),
|
|
279
|
+
}),
|
|
280
|
+
)
|
|
140
281
|
.sort((a, b) => a.number - b.number)
|
|
282
|
+
return { maps, whiteboard: whiteboardOf(repo), isMapsCapped: isCappedBy(issues, nodes.length) }
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// More open maps exist than the read returned (`totalCount` above the nodes read); without a usable
|
|
286
|
+
// `totalCount` a read that filled MAPS_READ_LIMIT is taken as cut.
|
|
287
|
+
const isCappedBy = (issues: Record<string, unknown>, read: number): boolean => {
|
|
288
|
+
const count = totalOf(issues)
|
|
289
|
+
return count === null ? read >= MAPS_READ_LIMIT : count > read
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// The most open maps one maps read asks for.
|
|
293
|
+
export const MAPS_READ_LIMIT = 100
|
|
294
|
+
|
|
295
|
+
// One query counting the open tickets of every open map and of the whiteboard: the dashboard's own scopes
|
|
296
|
+
// (a ticket carries one map label or the whiteboard label, never both, so nothing counts twice).
|
|
297
|
+
export const openTotalQuery = (maps: readonly number[]): string => {
|
|
298
|
+
const scope = (alias: string, label: string) => `${alias}:issues(states:OPEN,labels:["${label}"]){totalCount}`
|
|
299
|
+
const fields = [...maps.map(n => scope(`m${n}`, `${LABEL_PREFIX}map-${n}`)), scope('wb', `${LABEL_PREFIX}whiteboard`)]
|
|
300
|
+
return `query($owner:String!,$repo:String!){repository(owner:$owner,name:$repo){${fields.join('')}}}`
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// The sum of every alias of `openTotalQuery`; any `errors`, a missing alias or an unusable count is
|
|
304
|
+
// unreadable (null), never a partial sum shown as exact.
|
|
305
|
+
export const parseOpenTotal = (stdout: string, maps: readonly number[]): number | null => {
|
|
306
|
+
let raw: unknown
|
|
307
|
+
try {
|
|
308
|
+
raw = JSON.parse(stdout)
|
|
309
|
+
} catch {
|
|
310
|
+
return null
|
|
311
|
+
}
|
|
312
|
+
if (!isRecord(raw) || 'errors' in raw || !isRecord(raw.data) || !isRecord(raw.data.repository)) return null
|
|
313
|
+
const repo = raw.data.repository
|
|
314
|
+
const counts = [...maps.map(n => totalOf(repo[`m${n}`])), totalOf(repo.wb)]
|
|
315
|
+
return counts.some(c => c === null) ? null : counts.reduce((sum: number, c) => sum + (c as number), 0)
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// The last good total, kept through a failed count read only while it was computed over exactly these open
|
|
319
|
+
// maps (and the same cut of the maps read); a changed map set hides it rather than show it stale.
|
|
320
|
+
export const keepOpenTotal = (kept: OpenTotal | null, maps: readonly number[], isMapsCapped: boolean): OpenTotal | null =>
|
|
321
|
+
kept !== null && kept.isMapsCapped === isMapsCapped && kept.maps.length === maps.length && kept.maps.every((n, i) => n === maps[i]) ? kept : null
|
|
322
|
+
|
|
323
|
+
// The line of the total, long form first, the short one when that does not fit, null when unknown or when
|
|
324
|
+
// not even the short one fits. A truncated maps read is a lower bound, worded as one.
|
|
325
|
+
export const openTotalLabel = (total: number | null, isCapped: boolean, columns: number): string | null => {
|
|
326
|
+
if (total === null) return null
|
|
327
|
+
const long = `${isCapped ? 'at least ' : ''}${total} open across all maps + whiteboard`
|
|
328
|
+
if (long.length <= columns) return long
|
|
329
|
+
const short = `${isCapped ? '≥' : ''}${total} open in total`
|
|
330
|
+
return short.length <= columns ? short : null
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// `gh issue list --label trailhead:dashboard --json number,url`: the pinned index, the lowest number
|
|
334
|
+
// when there are several. `null` when unreadable; `{ ref: null }` when the repo has none yet.
|
|
335
|
+
export const parseDashboard = (stdout: string): { ref: DashboardRef | null } | null => {
|
|
336
|
+
let raw: unknown
|
|
337
|
+
try {
|
|
338
|
+
raw = JSON.parse(stdout)
|
|
339
|
+
} catch {
|
|
340
|
+
return null
|
|
341
|
+
}
|
|
342
|
+
if (!Array.isArray(raw)) return null
|
|
343
|
+
const rows = raw.filter(isRecord).filter(r => isPosInt(r.number))
|
|
344
|
+
const first = rows.sort((a, b) => (a.number as number) - (b.number as number))[0]
|
|
345
|
+
return { ref: first === undefined ? null : { number: first.number as number, url: safeUrl(first.url) } }
|
|
141
346
|
}
|
|
142
347
|
|
|
143
348
|
// The map the pane draws: the one the person is viewing, else the active one.
|
|
@@ -161,17 +366,24 @@ export type MapRowLayout = {
|
|
|
161
366
|
// Every map but the viewed one: a press shows it (the active one: back to active).
|
|
162
367
|
isPressable: boolean
|
|
163
368
|
}
|
|
164
|
-
|
|
369
|
+
// `isDashboard`: the dashboard is shown, so no map is viewed and every row is pressable.
|
|
370
|
+
export type MapRowContext = { active: number | null; viewed: number | null; columns: number; isDashboard?: boolean }
|
|
165
371
|
|
|
166
372
|
const ACTIVE_MARK = '● '
|
|
167
|
-
const
|
|
373
|
+
const ACTIVE_LABEL = 'active'
|
|
374
|
+
const ASIDE_SEP = ' · '
|
|
375
|
+
|
|
376
|
+
// `<open> open / <total>`: the counts beside a map or the whiteboard.
|
|
377
|
+
export const countsLabel = (c: MapCounts): string => `${c.open} open / ${c.total}`
|
|
168
378
|
|
|
169
379
|
// What one row of the maps list draws, cut to the pane width (a pressable row is a Button).
|
|
170
380
|
export const mapRowLayout = (m: MapSummary, ctx: MapRowContext): MapRowLayout => {
|
|
171
381
|
const isActive = m.number === ctx.active
|
|
172
|
-
const isViewed = m.number === viewTarget(ctx.active, ctx.viewed)
|
|
382
|
+
const isViewed = ctx.isDashboard !== true && m.number === viewTarget(ctx.active, ctx.viewed)
|
|
173
383
|
const mark = isActive ? ACTIVE_MARK : ' '
|
|
174
|
-
const
|
|
384
|
+
const parts = [m.counts === null ? null : countsLabel(m.counts), isActive ? ACTIVE_LABEL : null]
|
|
385
|
+
const shown = parts.filter((p): p is string => p !== null)
|
|
386
|
+
const aside = shown.length === 0 ? null : ` ${shown.join(ASIDE_SEP)}`
|
|
175
387
|
const prefix = `#${m.number}`
|
|
176
388
|
const isPressable = !isViewed
|
|
177
389
|
const room = ctx.columns - mark.length - (aside?.length ?? 0) - (isPressable ? BUTTON_CHROME : 0) - prefix.length - 1
|
|
@@ -179,11 +391,34 @@ export const mapRowLayout = (m: MapSummary, ctx: MapRowContext): MapRowLayout =>
|
|
|
179
391
|
return { mark, label: title === '' ? prefix : `${prefix} ${title}`, aside, isActive, isViewed, isPressable }
|
|
180
392
|
}
|
|
181
393
|
|
|
394
|
+
export type WhiteboardRowLayout = { mark: string; label: string; aside: string | null }
|
|
395
|
+
const WHITEBOARD_LABEL = 'Whiteboard'
|
|
396
|
+
|
|
397
|
+
// The Whiteboard row above the maps list: a Button with its counts beside it, name-only when they are
|
|
398
|
+
// unknown or would not fit (the name is kept whole).
|
|
399
|
+
export const whiteboardRowLayout = (counts: MapCounts | null, columns: number): WhiteboardRowLayout => {
|
|
400
|
+
const mark = ' '
|
|
401
|
+
const aside = counts === null ? null : ` ${countsLabel(counts)}`
|
|
402
|
+
const fits = aside !== null && mark.length + BUTTON_CHROME + WHITEBOARD_LABEL.length + aside.length <= columns
|
|
403
|
+
return { mark, label: WHITEBOARD_LABEL, aside: fits ? aside : null }
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// The whiteboard heading with its counts over the listed (trusted) tickets; bare before the first good read.
|
|
407
|
+
export const whiteboardHeading = (tickets: readonly PaneTicket[] | null, isCapped: boolean): string => {
|
|
408
|
+
if (tickets === null) return 'Whiteboard'
|
|
409
|
+
const open = isCapped ? `${LIST_LIMIT}+` : String(tickets.length)
|
|
410
|
+
return `Whiteboard · ${open} open · ${tickets.filter(t => t.assignees.length > 0).length} assigned`
|
|
411
|
+
}
|
|
412
|
+
|
|
182
413
|
const byNumber = (a: PaneTicket, b: PaneTicket) => a.number - b.number
|
|
183
414
|
|
|
415
|
+
// Needs-you wins, then paused, then a UAT ticket (whatever its state), then the state: each ticket shows once.
|
|
416
|
+
type Place = TicketState | 'uat' | 'needs-you'
|
|
417
|
+
const placeOf = (t: PaneTicket): Place => (t.isNeedsYou ? 'needs-you' : t.state === 'paused' ? 'paused' : t.isUat ? 'uat' : t.state)
|
|
418
|
+
|
|
184
419
|
export const groupTickets = (tickets: readonly PaneTicket[]): TicketGroups => {
|
|
185
|
-
const of = (
|
|
186
|
-
return { frontier: of('frontier'), inProgress: of('in-progress'), blocked: of('blocked') }
|
|
420
|
+
const of = (place: Place) => tickets.filter(t => placeOf(t) === place).sort(byNumber)
|
|
421
|
+
return { needsYou: of('needs-you'), frontier: of('frontier'), inProgress: of('in-progress'), uat: of('uat'), paused: of('paused'), blocked: of('blocked') }
|
|
187
422
|
}
|
|
188
423
|
|
|
189
424
|
const errorText = (err: unknown): string => {
|
|
@@ -213,6 +448,10 @@ export const refreshDecision = (now: number, lastStartedAt: number | null, minMs
|
|
|
213
448
|
return waitMs <= 0 ? { run: true } : { run: false, waitMs }
|
|
214
449
|
}
|
|
215
450
|
|
|
451
|
+
// A host timer dies silently (a refused `clock.after` never fires, a refused `every` period ends the
|
|
452
|
+
// interval): one whose due time is past by more than the grace is held dead and started again.
|
|
453
|
+
export const isTimerLate = (now: number, dueAt: number, graceMs: number): boolean => now - dueAt > graceMs
|
|
454
|
+
|
|
216
455
|
export const ageLabel = (ms: number): string => {
|
|
217
456
|
if (ms < 5_000) return 'just now'
|
|
218
457
|
if (ms < 60_000) return `${Math.floor(ms / 1_000)}s ago`
|
|
@@ -220,7 +459,7 @@ export const ageLabel = (ms: number): string => {
|
|
|
220
459
|
return `${Math.floor(ms / 3_600_000)}h ago`
|
|
221
460
|
}
|
|
222
461
|
|
|
223
|
-
export type RowAside = { kind: 'work' | 'queued' | 'holder'; text: string }
|
|
462
|
+
export type RowAside = { kind: 'work' | 'resume' | 'queued' | 'starting' | 'holder' | 'self'; text: string }
|
|
224
463
|
export type RowLayout = {
|
|
225
464
|
mark: string | null
|
|
226
465
|
label: string
|
|
@@ -234,6 +473,8 @@ export type RowContext = {
|
|
|
234
473
|
// True when the group holds the ticket in play: its rows share a two-cell mark column.
|
|
235
474
|
hasMarkColumn: boolean
|
|
236
475
|
pendingWork: number | null
|
|
476
|
+
// Whether the pending press waits for the turn to end (`queued`) or has been sent (`starting`).
|
|
477
|
+
pendingPhase: 'queued' | 'starting' | null
|
|
237
478
|
columns: number
|
|
238
479
|
}
|
|
239
480
|
|
|
@@ -242,6 +483,10 @@ const MARK_PAD = ' '
|
|
|
242
483
|
// A terminal Button draws `[ label ]`.
|
|
243
484
|
const BUTTON_CHROME = 4
|
|
244
485
|
|
|
486
|
+
// The dashboard header's button back to the map; the title before it gets the rest of the row.
|
|
487
|
+
export const MAP_LABEL = 'map'
|
|
488
|
+
export const headingRoom = (columns: number, label: string): number => Math.max(0, columns - label.length - BUTTON_CHROME - 1)
|
|
489
|
+
|
|
245
490
|
const REFRESH_LABEL = 'refresh'
|
|
246
491
|
const CLOSE_LABEL = 'close'
|
|
247
492
|
// The gap between the refresh button and the status, and the one kept before the close button.
|
|
@@ -257,18 +502,51 @@ export const footerStatus = (status: string, columns: number): FooterStatus => {
|
|
|
257
502
|
return Array.from(status).length <= room ? { text: status, isInline: true } : { text: fit(status, columns), isInline: false }
|
|
258
503
|
}
|
|
259
504
|
|
|
260
|
-
const
|
|
261
|
-
|
|
262
|
-
|
|
505
|
+
const SELF_ASIDE: RowAside = { kind: 'self', text: 'this session' }
|
|
506
|
+
|
|
507
|
+
type Pending = { number: number | null; phase: 'queued' | 'starting' | null }
|
|
508
|
+
// Only a press known to wait for the turn to end is offered a cancel; one sent, or not yet classed, is not.
|
|
509
|
+
const pendingAside = (phase: Pending['phase']): RowAside =>
|
|
510
|
+
phase === 'queued' ? { kind: 'queued', text: 'queued' } : { kind: 'starting', text: 'starting' }
|
|
511
|
+
|
|
512
|
+
const holderOf = (t: PaneTicket): RowAside => ({ kind: 'holder', text: t.assignees.map(a => `@${a}`).join(' ') })
|
|
513
|
+
|
|
514
|
+
// A needs-you row: any holder first (a `work` press would steal the claim), then nothing when blocked,
|
|
515
|
+
// else `work` (never `resume`: the claim clears the mark, resume would not).
|
|
516
|
+
const needsYouAside = (t: PaneTicket, pending: Pending): RowAside | null => {
|
|
517
|
+
if (t.assignees.length > 0) return holderOf(t)
|
|
518
|
+
if (t.state === 'blocked') return null
|
|
519
|
+
return pending.number === t.number ? pendingAside(pending.phase) : { kind: 'work', text: 'work' }
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
// The ticket in play says `this session`; a needs-you row follows its own rule; otherwise a frontier row has
|
|
523
|
+
// a `work` button, a paused one a `resume` button (the verb checks the claim), and the rest their holder.
|
|
524
|
+
const asideOf = (t: PaneTicket, pending: Pending, isInPlay: boolean): RowAside | null => {
|
|
525
|
+
if (isInPlay) return SELF_ASIDE
|
|
526
|
+
if (t.isNeedsYou) return needsYouAside(t, pending)
|
|
527
|
+
if (t.state === 'frontier' || t.state === 'paused') {
|
|
528
|
+
if (pending.number === t.number) return pendingAside(pending.phase)
|
|
529
|
+
return t.state === 'frontier' ? { kind: 'work', text: 'work' } : { kind: 'resume', text: 'resume' }
|
|
530
|
+
}
|
|
531
|
+
return t.assignees.length === 0 ? null : holderOf(t)
|
|
263
532
|
}
|
|
264
533
|
|
|
534
|
+
// The button that takes a queued ticket back out of the queue.
|
|
535
|
+
export const CANCEL_LABEL = 'cancel'
|
|
536
|
+
const CANCEL_WIDTH = CANCEL_LABEL.length + BUTTON_CHROME + 1
|
|
537
|
+
|
|
265
538
|
const asideWidth = (a: RowAside | null): number =>
|
|
266
|
-
a === null
|
|
539
|
+
a === null
|
|
540
|
+
? 0
|
|
541
|
+
: a.text.length + (a.kind === 'work' || a.kind === 'resume' ? BUTTON_CHROME : 0) + 1 + (a.kind === 'queued' ? CANCEL_WIDTH : 0)
|
|
542
|
+
|
|
543
|
+
// The press the panel holds back while a turn runs, and releases when it ends.
|
|
544
|
+
export type HeldPress = { id: number; verb: 'work' | 'resume'; number: number }
|
|
267
545
|
|
|
268
546
|
// What one row draws, cut to the pane width: mark column, `#n title`, type, right-hand part.
|
|
269
547
|
export const rowLayout = (t: PaneTicket, ctx: RowContext): RowLayout => {
|
|
270
548
|
const mark = ctx.isInPlay ? MARK : ctx.hasMarkColumn ? MARK_PAD : null
|
|
271
|
-
const aside = asideOf(t, ctx.pendingWork)
|
|
549
|
+
const aside = asideOf(t, { number: ctx.pendingWork, phase: ctx.pendingPhase }, ctx.isInPlay)
|
|
272
550
|
const type = t.type === null ? null : ` ${t.type}`
|
|
273
551
|
const prefix = `#${t.number}`
|
|
274
552
|
const room = ctx.columns - (mark?.length ?? 0) - asideWidth(aside) - (type?.length ?? 0) - prefix.length - 1
|