@marcomigozzi/trailhead 0.15.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 +45 -9
- package/README.md +46 -10
- 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/grilling-holistic-review-docs.test.js +90 -0
- package/bin/lib/codex-projection.js +5 -5
- package/bin/lib/codex-projection.test.js +2 -2
- package/bin/needs-you-docs.test.js +41 -0
- package/bin/pick-stress-test-docs.test.js +105 -0
- package/bin/trailhead.js +3 -3
- package/bin/trailhead.test.js +17 -18
- package/package.json +1 -1
- package/plugins/trailhead/.claude-plugin/plugin.json +1 -1
- 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/checkout-in.test.js +173 -0
- package/plugins/trailhead/hooks/lib/dashboard.test.js +66 -3
- 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/statusline-context.test.js +140 -0
- package/plugins/trailhead/hooks/lib/uat-followup.test.js +169 -0
- package/plugins/trailhead/hooks/lib/unblock.test.js +207 -0
- package/plugins/trailhead/hooks/mod/README.md +180 -39
- 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 +150 -25
- package/plugins/trailhead/hooks/mod/map-pane.tsx +526 -92
- package/plugins/trailhead/hooks/mod/register.tsx +298 -39
- 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 +21 -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-labels-guard.js +231 -0
- package/plugins/trailhead/hooks/trailhead-labels-guard.test.js +212 -0
- 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 +7 -2
- package/plugins/trailhead/skills/_shared/configuration-reference.md +3 -3
- package/plugins/trailhead/skills/_shared/substrate-commands.md +26 -4
- package/plugins/trailhead/skills/_shared/substrate.md +4 -1
- package/plugins/trailhead/skills/_shared/teamwork.md +2 -2
- package/plugins/trailhead/skills/_shared/techniques/acceptance-testing.md +2 -2
- 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/trailhead-capture/references/capture.md +1 -1
- package/plugins/trailhead/skills/trailhead-chart/SKILL.md +1 -1
- package/plugins/trailhead/skills/trailhead-chart/references/charting.md +4 -4
- package/plugins/trailhead/skills/trailhead-chart/references/inbox.md +2 -2
- package/plugins/trailhead/skills/trailhead-view/SKILL.md +1 -0
- package/plugins/trailhead/skills/trailhead-work/SKILL.md +21 -10
- package/plugins/trailhead/skills/trailhead-work/references/auto.md +70 -22
- package/plugins/trailhead/skills/trailhead-work/references/ticket-engines.md +6 -6
- 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-checkout-in.js +88 -0
- package/plugins/trailhead/templates/trailhead-dashboard.js +32 -12
- 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-uat-followup.js +128 -0
- package/plugins/trailhead/templates/trailhead-unblock.js +111 -0
- 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 +1732 -142
- package/plugins/trailhead/tests/map-pane-view.test.ts +308 -14
- 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 +19 -0
- package/plugins/trailhead/tests/work-state-worktree.test.ts +310 -0
- package/plugins/trailhead/types/index.d.ts +26 -1
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
// The autonomous run seen from the main checkout (#296): which slots are in flight and the scope counts.
|
|
2
|
+
// Read-only: the mod only watches, it never writes a work state or the tracker. Pure parts are in
|
|
3
|
+
// auto-run-view.ts; here the engine part that scans the checkouts and reads the counts.
|
|
4
|
+
import type { EngineInterface } from 'claude-code'
|
|
5
|
+
|
|
6
|
+
import type { AutoRun, RepoPin, RunCounts, WorkState } from '../../types'
|
|
7
|
+
import { GH_TIMEOUT_MS, MIN_REFRESH_MS } from './map-pane'
|
|
8
|
+
import { aggregateRun, isRunMember, nextRunAfterFailure, parseRunCounts, parseRunScope, runCountsArgs, slotTicket } from './auto-run-view'
|
|
9
|
+
import type { Where } from './auto-run-view'
|
|
10
|
+
import { pinKey } from './repo-pin'
|
|
11
|
+
import { listSiblings, listWorktreeBranches, readAt, trim } from './work-state-locate'
|
|
12
|
+
import { isMissing, parseWorkState } from './work-state-view'
|
|
13
|
+
|
|
14
|
+
const RECORD_PATH = '.trailhead/auto-run.json'
|
|
15
|
+
const GIT_TIMEOUT_MS = 5_000
|
|
16
|
+
|
|
17
|
+
// L'ultimo run noto e i fallimenti di fila (con l'istante dell'ultimo contato): sono macchinario del modulo, non stato disegnato.
|
|
18
|
+
let lastRun: AutoRun | null = null
|
|
19
|
+
let failures = 0
|
|
20
|
+
let lastFailureAt: number | null = null
|
|
21
|
+
let counts: { key: string; at: number; value: RunCounts | null } | null = null
|
|
22
|
+
|
|
23
|
+
export const resetAutoRun = (): void => {
|
|
24
|
+
lastRun = null
|
|
25
|
+
failures = 0
|
|
26
|
+
lastFailureAt = null
|
|
27
|
+
counts = null
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Il run sparisce: anche i conteggi e il loro pavimento.
|
|
31
|
+
export const clearRunCounts = (): void => {
|
|
32
|
+
counts = null
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
type Gate = { kind: 'closed' } | { kind: 'open'; scope: string | null } | { kind: 'failed' }
|
|
36
|
+
|
|
37
|
+
// Il run può esistere solo se c'è il record o lo stato proprio è auto: altrimenti nessun processo parte.
|
|
38
|
+
async function gate($: EngineInterface, main: string, own: WorkState | null): Promise<Gate> {
|
|
39
|
+
try {
|
|
40
|
+
const text = (await $.fs.read(`${trim(main)}/${RECORD_PATH}`)) as string
|
|
41
|
+
return { kind: 'open', scope: parseRunScope(text) }
|
|
42
|
+
} catch (err) {
|
|
43
|
+
if (!isMissing(err)) return own?.auto === true ? { kind: 'open', scope: null } : { kind: 'failed' }
|
|
44
|
+
return own?.auto === true ? { kind: 'open', scope: null } : { kind: 'closed' }
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// La regola di cloneOnBranch (trailhead-auto-slot.js): git dir dentro la cartella e HEAD sul branch dello slot.
|
|
49
|
+
// undefined: git non è riuscito a girare.
|
|
50
|
+
async function cloneOnBranch($: EngineInterface, dir: string, ticket: number): Promise<boolean | undefined> {
|
|
51
|
+
const git = (args: string[]) => $.process.run(['git', '-C', dir, ...args], { cwd: dir, timeoutMs: GIT_TIMEOUT_MS })
|
|
52
|
+
try {
|
|
53
|
+
const g = await git(['rev-parse', '--absolute-git-dir'])
|
|
54
|
+
if (g.exitCode !== 0) return false
|
|
55
|
+
const gitDir = trim(g.stdout.trim())
|
|
56
|
+
if (gitDir !== dir && !gitDir.startsWith(`${dir}/`)) return false
|
|
57
|
+
const b = await git(['symbolic-ref', '--quiet', 'HEAD'])
|
|
58
|
+
return b.exitCode === 0 && b.stdout.trim() === `refs/heads/trailhead/t${ticket}`
|
|
59
|
+
} catch {
|
|
60
|
+
return undefined
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
type Candidate = { path: string; where: Where | { kind: 'sibling'; ticket: number } }
|
|
65
|
+
|
|
66
|
+
// Un checkout alla volta, senza doppioni: vince la grafia di git, il main e il pin contano come main.
|
|
67
|
+
const candidatesOf = (main: string, pinned: string, listed: readonly { path: string; branch: string | null }[], siblings: readonly string[]): Candidate[] => {
|
|
68
|
+
const all: Candidate[] = [{ path: trim(main), where: { kind: 'main' } }]
|
|
69
|
+
if (trim(pinned) !== trim(main)) all.push({ path: trim(pinned), where: { kind: 'main' } })
|
|
70
|
+
for (const w of listed) {
|
|
71
|
+
const ticket = slotTicket(w.branch)
|
|
72
|
+
if (ticket !== null) all.push({ path: trim(w.path), where: { kind: 'worktree', ticket } })
|
|
73
|
+
}
|
|
74
|
+
for (const path of siblings) {
|
|
75
|
+
const ticket = Number(/-t(\d+)$/.exec(path)?.[1])
|
|
76
|
+
if (ticket > 0) all.push({ path: trim(path), where: { kind: 'sibling', ticket } })
|
|
77
|
+
}
|
|
78
|
+
return all.filter((c, i) => all.findIndex(o => o.path === c.path) === i)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
type Scan = { ok: true; run: AutoRun | null } | { ok: false }
|
|
82
|
+
|
|
83
|
+
async function scan($: EngineInterface, main: string, pinned: string): Promise<Scan> {
|
|
84
|
+
const listed = await listWorktreeBranches($, main)
|
|
85
|
+
const siblings = await listSiblings($, main)
|
|
86
|
+
if (listed === null || siblings === null) return { ok: false }
|
|
87
|
+
const candidates = candidatesOf(main, pinned, listed, siblings)
|
|
88
|
+
const texts = await Promise.all(candidates.map(c => readAt($, c.path)))
|
|
89
|
+
if (texts.includes(undefined)) return { ok: false }
|
|
90
|
+
const members: WorkState[] = []
|
|
91
|
+
for (const [i, c] of candidates.entries()) {
|
|
92
|
+
const text = texts[i]
|
|
93
|
+
const state = typeof text === 'string' ? parseWorkState(text) : null
|
|
94
|
+
if (state === null || !state.auto) continue
|
|
95
|
+
let where: Where
|
|
96
|
+
if (c.where.kind === 'sibling') {
|
|
97
|
+
// Il controllo git costa due processi: solo per un fratello il cui stato è già auto del suo ticket.
|
|
98
|
+
if (state.ticket !== c.where.ticket) continue
|
|
99
|
+
const onBranch = await cloneOnBranch($, c.path, c.where.ticket)
|
|
100
|
+
if (onBranch === undefined) return { ok: false }
|
|
101
|
+
where = { kind: 'clone', ticket: c.where.ticket, onBranch }
|
|
102
|
+
} else where = c.where
|
|
103
|
+
if (isRunMember(where, state)) members.push(state)
|
|
104
|
+
}
|
|
105
|
+
return { ok: true, run: aggregateRun(members) }
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Un turno fa più refresh: un fallimento conta solo se dall'ultimo contato è passato MIN_REFRESH_MS,
|
|
109
|
+
// così un singhiozzo breve di git non azzera un run vivo. Senza orologio ogni fallimento conta.
|
|
110
|
+
async function isNewFailure($: EngineInterface): Promise<boolean> {
|
|
111
|
+
const now = await nowOf($)
|
|
112
|
+
if (now === null) return true
|
|
113
|
+
if (lastFailureAt !== null && now - lastFailureAt < MIN_REFRESH_MS) return false
|
|
114
|
+
lastFailureAt = now
|
|
115
|
+
return true
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Il run attuale e lo scope del record. Una scansione fallita tiene l'ultimo risultato per al massimo
|
|
119
|
+
// MAX_FAILURES volte di fila; un miss pulito o il gate chiuso lo azzerano subito.
|
|
120
|
+
export async function scanRun($: EngineInterface, input: { main: string; pinned: string; ownState: WorkState | null }): Promise<{ run: AutoRun | null; scope: string | null }> {
|
|
121
|
+
const g = await gate($, input.main, input.ownState)
|
|
122
|
+
if (g.kind === 'closed') {
|
|
123
|
+
lastRun = null
|
|
124
|
+
failures = 0
|
|
125
|
+
lastFailureAt = null
|
|
126
|
+
return { run: null, scope: null }
|
|
127
|
+
}
|
|
128
|
+
const result = g.kind === 'open' ? await scan($, trim(input.main), input.pinned) : ({ ok: false } as const)
|
|
129
|
+
if (result.ok) {
|
|
130
|
+
failures = 0
|
|
131
|
+
lastFailureAt = null
|
|
132
|
+
lastRun = result.run
|
|
133
|
+
} else if (await isNewFailure($)) {
|
|
134
|
+
failures += 1
|
|
135
|
+
lastRun = nextRunAfterFailure(lastRun, failures)
|
|
136
|
+
}
|
|
137
|
+
return { run: lastRun, scope: g.kind === 'open' ? g.scope : null }
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
async function nowOf($: EngineInterface): Promise<number | null> {
|
|
141
|
+
try {
|
|
142
|
+
return await $.clock.now()
|
|
143
|
+
} catch {
|
|
144
|
+
return null
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// I conteggi dello scope, mai più spesso di MIN_REFRESH_MS; una lettura fallita tiene gli ultimi.
|
|
149
|
+
export async function readRunCounts($: EngineInterface, pin: RepoPin, scope: string | null): Promise<RunCounts | null> {
|
|
150
|
+
const key = `${pinKey(pin)}|${scope ?? ''}`
|
|
151
|
+
const now = await nowOf($)
|
|
152
|
+
const cached = counts !== null && counts.key === key ? counts : null
|
|
153
|
+
// Senza orologio non c'è pavimento: niente gh a ruota libera, vale il valore in cache (o nulla).
|
|
154
|
+
if (now === null) return cached?.value ?? null
|
|
155
|
+
if (cached !== null && now - cached.at < MIN_REFRESH_MS) return cached.value
|
|
156
|
+
let value = cached?.value ?? null
|
|
157
|
+
try {
|
|
158
|
+
const out = await $.process.run(['gh', ...runCountsArgs(pin.host, pin.slug, scope)], { cwd: pin.root, timeoutMs: GH_TIMEOUT_MS })
|
|
159
|
+
if (out.exitCode === 0) value = parseRunCounts(out.stdout) ?? value
|
|
160
|
+
} catch {
|
|
161
|
+
// Lettura fallita: restano gli ultimi conteggi.
|
|
162
|
+
}
|
|
163
|
+
counts = { key, at: now, value }
|
|
164
|
+
return value
|
|
165
|
+
}
|
|
@@ -1,13 +1,14 @@
|
|
|
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, DashboardRef, MapCounts, MapInfo, MapsRead, MapSummary, PaneTicket, TicketState, TicketType, UatParent } 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[]
|
|
13
14
|
uat: readonly PaneTicket[]
|
|
@@ -68,17 +69,10 @@ const stateOf = (names: readonly string[], assignees: readonly string[]): Ticket
|
|
|
68
69
|
// Only an https link is kept; anything else would be drawn as plain text.
|
|
69
70
|
const safeUrl = (raw: unknown): string => (typeof raw === 'string' && /^https:\/\//.test(raw) ? raw : '')
|
|
70
71
|
|
|
71
|
-
// `gh issue list --json number,title,labels,assignees,url
|
|
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
|
|
75
|
-
let raw: unknown
|
|
76
|
-
try {
|
|
77
|
-
raw = JSON.parse(stdout)
|
|
78
|
-
} catch {
|
|
79
|
-
return null
|
|
80
|
-
}
|
|
81
|
-
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 => {
|
|
82
76
|
const rows = raw.filter(isRecord).filter(r => isPosInt(r.number))
|
|
83
77
|
const named = rows.map(r => ({ r, names: labelNames(r.labels) }))
|
|
84
78
|
const unverifiedCount = named.filter(({ names }) => names.includes(`${LABEL_PREFIX}unverified`)).length
|
|
@@ -95,13 +89,61 @@ export const parseTickets = (stdout: string, opts: { isWhiteboard?: boolean } =
|
|
|
95
89
|
assignees,
|
|
96
90
|
url: safeUrl(r.url),
|
|
97
91
|
isUat: names.includes(`${LABEL_PREFIX}uat`),
|
|
92
|
+
isNeedsYou: names.includes(`${LABEL_PREFIX}needs-you`),
|
|
98
93
|
uatOf: null,
|
|
99
94
|
}
|
|
100
95
|
})
|
|
101
96
|
return { tickets, unverifiedCount, isCapped: raw.length >= LIST_LIMIT }
|
|
102
97
|
}
|
|
103
98
|
|
|
104
|
-
// `gh issue list --
|
|
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
|
|
105
147
|
// `UAT of:` line names, `[<name>](<url>/issues/<n>)` (or a bare `#<n>`, with an empty title). A row with
|
|
106
148
|
// no such line, or an unreadable one, is skipped; not a list is unreadable (null).
|
|
107
149
|
export const parseUatParents = (stdout: string): ReadonlyMap<number, UatParent> | null => {
|
|
@@ -237,7 +279,55 @@ export const parseMapsRead = (stdout: string): MapsRead | null => {
|
|
|
237
279
|
}),
|
|
238
280
|
)
|
|
239
281
|
.sort((a, b) => a.number - b.number)
|
|
240
|
-
return { maps, whiteboard: whiteboardOf(repo) }
|
|
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
|
|
241
331
|
}
|
|
242
332
|
|
|
243
333
|
// `gh issue list --label trailhead:dashboard --json number,url`: the pinned index, the lowest number
|
|
@@ -322,13 +412,13 @@ export const whiteboardHeading = (tickets: readonly PaneTicket[] | null, isCappe
|
|
|
322
412
|
|
|
323
413
|
const byNumber = (a: PaneTicket, b: PaneTicket) => a.number - b.number
|
|
324
414
|
|
|
325
|
-
//
|
|
326
|
-
type Place = TicketState | 'uat'
|
|
327
|
-
const placeOf = (t: PaneTicket): Place => (t.state === 'paused' ? 'paused' : t.isUat ? 'uat' : t.state)
|
|
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)
|
|
328
418
|
|
|
329
419
|
export const groupTickets = (tickets: readonly PaneTicket[]): TicketGroups => {
|
|
330
420
|
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') }
|
|
421
|
+
return { needsYou: of('needs-you'), frontier: of('frontier'), inProgress: of('in-progress'), uat: of('uat'), paused: of('paused'), blocked: of('blocked') }
|
|
332
422
|
}
|
|
333
423
|
|
|
334
424
|
const errorText = (err: unknown): string => {
|
|
@@ -358,6 +448,10 @@ export const refreshDecision = (now: number, lastStartedAt: number | null, minMs
|
|
|
358
448
|
return waitMs <= 0 ? { run: true } : { run: false, waitMs }
|
|
359
449
|
}
|
|
360
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
|
+
|
|
361
455
|
export const ageLabel = (ms: number): string => {
|
|
362
456
|
if (ms < 5_000) return 'just now'
|
|
363
457
|
if (ms < 60_000) return `${Math.floor(ms / 1_000)}s ago`
|
|
@@ -365,7 +459,7 @@ export const ageLabel = (ms: number): string => {
|
|
|
365
459
|
return `${Math.floor(ms / 3_600_000)}h ago`
|
|
366
460
|
}
|
|
367
461
|
|
|
368
|
-
export type RowAside = { kind: 'work' | 'queued' | 'holder' | 'self'; text: string }
|
|
462
|
+
export type RowAside = { kind: 'work' | 'resume' | 'queued' | 'starting' | 'holder' | 'self'; text: string }
|
|
369
463
|
export type RowLayout = {
|
|
370
464
|
mark: string | null
|
|
371
465
|
label: string
|
|
@@ -379,6 +473,8 @@ export type RowContext = {
|
|
|
379
473
|
// True when the group holds the ticket in play: its rows share a two-cell mark column.
|
|
380
474
|
hasMarkColumn: boolean
|
|
381
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
|
|
382
478
|
columns: number
|
|
383
479
|
}
|
|
384
480
|
|
|
@@ -408,20 +504,49 @@ export const footerStatus = (status: string, columns: number): FooterStatus => {
|
|
|
408
504
|
|
|
409
505
|
const SELF_ASIDE: RowAside = { kind: 'self', text: 'this session' }
|
|
410
506
|
|
|
411
|
-
|
|
412
|
-
|
|
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 => {
|
|
413
525
|
if (isInPlay) return SELF_ASIDE
|
|
414
|
-
if (t.
|
|
415
|
-
|
|
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)
|
|
416
532
|
}
|
|
417
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
|
+
|
|
418
538
|
const asideWidth = (a: RowAside | null): number =>
|
|
419
|
-
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 }
|
|
420
545
|
|
|
421
546
|
// What one row draws, cut to the pane width: mark column, `#n title`, type, right-hand part.
|
|
422
547
|
export const rowLayout = (t: PaneTicket, ctx: RowContext): RowLayout => {
|
|
423
548
|
const mark = ctx.isInPlay ? MARK : ctx.hasMarkColumn ? MARK_PAD : null
|
|
424
|
-
const aside = asideOf(t, ctx.pendingWork, ctx.isInPlay)
|
|
549
|
+
const aside = asideOf(t, { number: ctx.pendingWork, phase: ctx.pendingPhase }, ctx.isInPlay)
|
|
425
550
|
const type = t.type === null ? null : ` ${t.type}`
|
|
426
551
|
const prefix = `#${t.number}`
|
|
427
552
|
const room = ctx.columns - (mark?.length ?? 0) - asideWidth(aside) - (type?.length ?? 0) - prefix.length - 1
|