@marcomigozzi/trailhead 0.16.1 → 0.18.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 +82 -25
- package/README.md +82 -25
- package/bin/base-preflight-docs.test.js +4 -1
- package/bin/bulk-cross-reference-docs.test.js +36 -0
- package/bin/capture-action.test.js +167 -0
- package/bin/capture-docs.test.js +32 -0
- package/bin/engine-route-docs.test.js +155 -0
- package/bin/facet-stamp-docs.test.js +79 -0
- package/bin/forced-mutation-docs.test.js +38 -0
- package/bin/grill-model-docs.test.js +155 -0
- package/bin/grilling-holistic-review-docs.test.js +7 -3
- package/bin/guard-axis-docs.test.js +69 -0
- package/bin/inbox-needs-info-docs.test.js +68 -0
- package/bin/lib/codex-projection.js +28 -19
- package/bin/lib/codex-projection.test.js +77 -20
- package/bin/lib/host-descriptor.js +1 -1
- package/bin/lib/host-descriptor.test.js +6 -0
- package/bin/needs-you-docs.test.js +9 -2
- package/bin/options-in-chat-docs.test.js +65 -0
- package/bin/pick-stress-test-docs.test.js +1 -1
- package/bin/pipeline-review-docs.test.js +118 -0
- package/bin/plan-review-budget-docs.test.js +63 -0
- package/bin/repo-target-docs.test.js +41 -0
- package/bin/report-status-docs.test.js +10 -2
- package/bin/seam-coverage-docs.test.js +46 -0
- package/bin/session-trust-docs.test.js +37 -0
- package/bin/standards-brief-docs.test.js +112 -0
- package/bin/surfaced-scope-docs.test.js +57 -0
- package/bin/test-once-docs.test.js +101 -0
- package/bin/test-run-docs.test.js +47 -0
- package/bin/trailhead.js +15 -8
- package/bin/trailhead.test.js +71 -17
- package/bin/unblock-docs.test.js +56 -0
- package/bin/yes-accepts-recommended-docs.test.js +54 -0
- package/package.json +1 -1
- package/plugins/trailhead/.claude-plugin/plugin.json +1 -1
- package/plugins/trailhead/agents/trailhead-code-review.md +2 -1
- package/plugins/trailhead/agents/trailhead-codebase-map.md +1 -1
- package/plugins/trailhead/agents/trailhead-grill.md +41 -0
- package/plugins/trailhead/agents/trailhead-plan.md +1 -1
- package/plugins/trailhead/agents/trailhead-verify.md +1 -0
- package/plugins/trailhead/hooks/lib/audit-walk.test.js +62 -0
- package/plugins/trailhead/hooks/lib/capture.test.js +439 -0
- package/plugins/trailhead/hooks/lib/checkout-find.test.js +195 -0
- package/plugins/trailhead/hooks/lib/checkout-in.test.js +83 -7
- package/plugins/trailhead/hooks/lib/dashboard.test.js +19 -0
- package/plugins/trailhead/hooks/lib/gh-auth-preflight.js +89 -0
- package/plugins/trailhead/hooks/lib/gh-auth-preflight.test.js +132 -0
- package/plugins/trailhead/hooks/lib/git-env-mod.test.js +55 -0
- package/plugins/trailhead/hooks/lib/git-env-spawn.test.js +73 -0
- package/plugins/trailhead/hooks/lib/git-env.test.js +51 -0
- package/plugins/trailhead/hooks/lib/global-config-dir.test.js +48 -0
- package/plugins/trailhead/hooks/lib/heredoc-mod.test.js +85 -0
- package/plugins/trailhead/hooks/lib/inbox-needs-info.test.js +179 -0
- package/plugins/trailhead/hooks/lib/inherit-session.js +17 -0
- package/plugins/trailhead/hooks/lib/issue-body.test.js +165 -0
- package/plugins/trailhead/hooks/lib/model-defaults-review.js +22 -1
- package/plugins/trailhead/hooks/lib/model-defaults-review.test.js +64 -1
- package/plugins/trailhead/hooks/lib/model-route.js +207 -0
- package/plugins/trailhead/hooks/lib/model-route.test.js +231 -0
- package/plugins/trailhead/hooks/lib/needs-you.test.js +45 -1
- package/plugins/trailhead/hooks/lib/plan-review-rounds.test.js +272 -0
- package/plugins/trailhead/hooks/lib/repo-target.test.js +256 -0
- package/plugins/trailhead/hooks/lib/report-status.test.js +56 -1
- package/plugins/trailhead/hooks/lib/statusline-git-env.test.js +47 -0
- package/plugins/trailhead/hooks/lib/test-run.test.js +757 -0
- package/plugins/trailhead/hooks/lib/trailhead-git-env.js +40 -0
- package/plugins/trailhead/hooks/mod/README.md +42 -17
- package/plugins/trailhead/hooks/mod/auto-run.ts +2 -1
- package/plugins/trailhead/hooks/mod/git-env.ts +31 -0
- package/plugins/trailhead/hooks/mod/heredoc.ts +79 -0
- package/plugins/trailhead/hooks/mod/map-pane-view.ts +3 -1
- package/plugins/trailhead/hooks/mod/map-pane.tsx +204 -49
- package/plugins/trailhead/hooks/mod/my-tickets.ts +76 -0
- package/plugins/trailhead/hooks/mod/repo-pin.ts +113 -9
- package/plugins/trailhead/hooks/mod/work-state-locate.ts +2 -1
- package/plugins/trailhead/hooks/trailhead-bash-guards.js +8 -8
- package/plugins/trailhead/hooks/trailhead-bash-guards.test.js +28 -14
- package/plugins/trailhead/hooks/trailhead-check-update.js +20 -2
- package/plugins/trailhead/hooks/trailhead-check-update.test.js +60 -0
- package/plugins/trailhead/hooks/trailhead-labels-guard.js +2 -2
- package/plugins/trailhead/hooks/trailhead-labels-guard.test.js +1 -1
- package/plugins/trailhead/hooks/trailhead-test-guard.js +232 -0
- package/plugins/trailhead/hooks/trailhead-test-guard.test.js +172 -0
- package/plugins/trailhead/skills/_shared/capture-core.md +123 -0
- package/plugins/trailhead/skills/_shared/choices.md +2 -2
- package/plugins/trailhead/skills/_shared/configuration-reference.md +30 -16
- package/plugins/trailhead/skills/_shared/configuration.md +1 -1
- package/plugins/trailhead/skills/_shared/load-first.md +2 -0
- package/plugins/trailhead/skills/_shared/session-handoff.md +1 -1
- package/plugins/trailhead/skills/_shared/substrate-commands.md +19 -6
- package/plugins/trailhead/skills/_shared/substrate.md +4 -3
- package/plugins/trailhead/skills/_shared/surfaced-scope.md +23 -0
- package/plugins/trailhead/skills/_shared/teamwork.md +11 -5
- package/plugins/trailhead/skills/_shared/techniques/acceptance-testing.md +1 -1
- package/plugins/trailhead/skills/_shared/techniques/code-review-pipeline.md +24 -0
- package/plugins/trailhead/skills/_shared/techniques/code-review.md +22 -6
- package/plugins/trailhead/skills/_shared/techniques/codebase-map.md +6 -3
- package/plugins/trailhead/skills/_shared/techniques/debug.md +5 -2
- package/plugins/trailhead/skills/_shared/techniques/grilling.md +28 -3
- package/plugins/trailhead/skills/_shared/techniques/plan-review.md +16 -4
- package/plugins/trailhead/skills/_shared/techniques/research.md +4 -1
- package/plugins/trailhead/skills/_shared/techniques/tdd.md +4 -2
- package/plugins/trailhead/skills/_shared/techniques/verify.md +5 -1
- package/plugins/trailhead/skills/_shared/techniques.md +8 -6
- package/plugins/trailhead/skills/trailhead-capture/SKILL.md +2 -4
- package/plugins/trailhead/skills/trailhead-capture/references/capture.md +29 -4
- 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 +11 -6
- package/plugins/trailhead/skills/trailhead-manage/references/auditing.md +18 -3
- package/plugins/trailhead/skills/trailhead-work/SKILL.md +12 -9
- package/plugins/trailhead/skills/trailhead-work/references/auto.md +3 -1
- package/plugins/trailhead/skills/trailhead-work/references/executor-brief.md +2 -0
- package/plugins/trailhead/skills/trailhead-work/references/ticket-engines.md +13 -13
- package/plugins/trailhead/templates/statusline-trailhead.sh +10 -3
- package/plugins/trailhead/templates/trailhead-audit-walk.js +56 -4
- package/plugins/trailhead/templates/trailhead-auto-merge.js +2 -1
- package/plugins/trailhead/templates/trailhead-auto-queue.js +2 -1
- package/plugins/trailhead/templates/trailhead-auto-slot.js +2 -1
- package/plugins/trailhead/templates/trailhead-base-preflight.js +10 -5
- package/plugins/trailhead/templates/trailhead-capture.js +419 -0
- package/plugins/trailhead/templates/trailhead-checkout-find.js +257 -0
- package/plugins/trailhead/templates/trailhead-checkout-in.js +78 -21
- package/plugins/trailhead/templates/trailhead-commit-msg-sync.js +2 -1
- package/plugins/trailhead/templates/trailhead-dashboard.js +15 -2
- package/plugins/trailhead/templates/trailhead-git-env.js +40 -0
- package/plugins/trailhead/templates/trailhead-inbox-needs-info.js +161 -0
- package/plugins/trailhead/templates/trailhead-isolation-answer.js +2 -1
- package/plugins/trailhead/templates/trailhead-isolation-copy.js +9 -4
- package/plugins/trailhead/templates/trailhead-issue-body.js +161 -12
- package/plugins/trailhead/templates/trailhead-merge-ticket.js +2 -1
- package/plugins/trailhead/templates/trailhead-needs-you.js +38 -4
- package/plugins/trailhead/templates/trailhead-plan-review-rounds.js +261 -7
- package/plugins/trailhead/templates/trailhead-repo-target.js +302 -0
- package/plugins/trailhead/templates/trailhead-report-status.js +57 -9
- package/plugins/trailhead/templates/trailhead-test-run.js +752 -0
- package/plugins/trailhead/templates/trailhead-uat-backfill.js +2 -1
- package/plugins/trailhead/templates/trailhead-uat-followup.js +2 -1
- package/plugins/trailhead/templates/trailhead-uat-setup.js +9 -4
- package/plugins/trailhead/templates/trailhead-unblock.js +2 -1
- package/plugins/trailhead/tests/auto-run-ui.test.ts +2 -1
- package/plugins/trailhead/tests/git-env.test.ts +20 -0
- package/plugins/trailhead/tests/map-pane-ui.test.ts +386 -50
- package/plugins/trailhead/tests/map-pane-view.test.ts +6 -0
- package/plugins/trailhead/tests/map-pane-write.test.ts +57 -1
- package/plugins/trailhead/tests/my-tickets.test.ts +66 -0
- package/plugins/trailhead/tests/repo-pin-late-repo.test.ts +3 -2
- package/plugins/trailhead/tests/repo-pin-relocate.test.ts +175 -0
- package/plugins/trailhead/tests/repo-pin.test.ts +20 -5
- package/plugins/trailhead/tests/unscrub.ts +8 -0
- package/plugins/trailhead/tests/work-state-prompt-hook.test.ts +3 -2
- package/plugins/trailhead/tests/work-state-worktree.test.ts +2 -1
- package/plugins/trailhead/types/index.d.ts +7 -0
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
import { atom, read, update } from 'claude-code'
|
|
5
5
|
import type { EngineInterface, On } from 'claude-code'
|
|
6
6
|
|
|
7
|
-
import type { BurndownHistory, DashboardPaneData, MapCounts, MapPaneData, MapsRead, MapSummary, OpenTotal, PaneTicket, RepoPin, UatParent, WorkState } from '../../types'
|
|
7
|
+
import type { BurndownHistory, DashboardPaneData, MapCounts, MapPaneData, MapsRead, MapSummary, MyTicket, OpenTotal, PaneTicket, RepoPin, TicketScope, UatParent, WorkState } from '../../types'
|
|
8
8
|
import {
|
|
9
9
|
ageLabel,
|
|
10
10
|
axisStart,
|
|
@@ -48,7 +48,10 @@ import {
|
|
|
48
48
|
whiteboardRowLayout,
|
|
49
49
|
withLabel,
|
|
50
50
|
} from './map-pane-view'
|
|
51
|
+
import { elsewhereInProgress, myTicketsFromRows, parseLogin, scopeLine } from './my-tickets'
|
|
51
52
|
import { checkoutArg, currentRepo, isPinCheckout, isSamePin, pinKey, pinPath, refreshPin, toplevelOf } from './repo-pin'
|
|
53
|
+
import { gitArgv, scrubGitEnv } from './git-env'
|
|
54
|
+
import { stripHeredocBodies } from './heredoc'
|
|
52
55
|
import { isMainTurnRunning } from './turn-state'
|
|
53
56
|
import { createdIssueNumber, isWhiteboardCreate, nextFollow, whiteboardHint } from './whiteboard-follow'
|
|
54
57
|
import { fit, isMissing, sameValue } from './work-state-view'
|
|
@@ -79,6 +82,9 @@ const TICKETS_QUERY = `query($owner:String!,$repo:String!,$label:String!,$after:
|
|
|
79
82
|
// most UAT_MAX_PAGES pages). The scope is filtered by the caller: GraphQL's `labels` is any-of.
|
|
80
83
|
const UAT_QUERY = `query($owner:String!,$repo:String!,$after:String){repository(owner:$owner,name:$repo){issues(first:${PAGE_SIZE},after:$after,states:OPEN,labels:["trailhead:uat"],orderBy:{field:CREATED_AT,direction:DESC}){nodes{number body labels(first:50){nodes{name}}}pageInfo{hasNextPage endCursor}}}}`
|
|
81
84
|
const UAT_MAX_PAGES = 10
|
|
85
|
+
// The gh account's login, then its open tickets on any map or the whiteboard (paged like TICKETS_QUERY).
|
|
86
|
+
const LOGIN_QUERY = 'query{viewer{login}}'
|
|
87
|
+
const MINE_QUERY = `query($owner:String!,$repo:String!,$login:String!,$after:String){repository(owner:$owner,name:$repo){issues(first:${PAGE_SIZE},after:$after,states:OPEN,labels:["trailhead:ticket"],filterBy:{assignee:$login},orderBy:{field:CREATED_AT,direction:DESC}){nodes{number title url labels(first:50){nodes{name}}assignees(first:10){nodes{login}}}pageInfo{hasNextPage endCursor}}}}`
|
|
82
88
|
const DASHBOARD_FIELDS = 'number,url'
|
|
83
89
|
// Same colours as the band.
|
|
84
90
|
const ACCENT = '#7aa2f7'
|
|
@@ -88,6 +94,24 @@ const CHART_ROWS = 3
|
|
|
88
94
|
// Space between ticket rows on the desktop, whose native buttons would touch; the terminal stays compact.
|
|
89
95
|
const DESKTOP_ROW_GAP = 0.5
|
|
90
96
|
const AMBER = '#e0af68'
|
|
97
|
+
// Ogni sezione ha il suo glifo e colore (palette Tokyo Night del band), così si distinguono anche senza colore.
|
|
98
|
+
const SECTION_STYLES = {
|
|
99
|
+
maps: { glyph: '◈', color: '#bb9af7' },
|
|
100
|
+
'in-progress': { glyph: '▶', color: ACCENT },
|
|
101
|
+
'needs-you': { glyph: '!', color: '#ff9e64' },
|
|
102
|
+
frontier: { glyph: '◆', color: '#9ece6a' },
|
|
103
|
+
uat: { glyph: '◎', color: '#7dcfff' },
|
|
104
|
+
paused: { glyph: '‖', color: '#a9b1d6' },
|
|
105
|
+
blocked: { glyph: '■', color: '#f7768e' },
|
|
106
|
+
} as const
|
|
107
|
+
type SectionKey = keyof typeof SECTION_STYLES
|
|
108
|
+
// Il filetto a sinistra di ogni riga, nel colore della sezione: occupa GUTTER colonne.
|
|
109
|
+
const GUTTER = '│ '
|
|
110
|
+
// Sul desktop il filetto è una barra continua di questa larghezza, alta quanto tutto il blocco del ticket.
|
|
111
|
+
const GUTTER_BAR = 0.25
|
|
112
|
+
const RULE_COLOR = '#3b4261'
|
|
113
|
+
// Il tipo di ticket colorato; gli altri tipi restano attenuati.
|
|
114
|
+
const TYPE_COLORS: Readonly<Record<string, string>> = { build: '#7dcfff', decision: '#ff9e64' }
|
|
91
115
|
|
|
92
116
|
const EMPTY: MapPaneData = {
|
|
93
117
|
map: null,
|
|
@@ -123,10 +147,13 @@ export const viewedMap = atom({ plugin: 'trailhead', key: 'viewedMap' } as const
|
|
|
123
147
|
export const isDashboard = atom({ plugin: 'trailhead', key: 'isDashboard' } as const, false)
|
|
124
148
|
export const dashboardPane = atom({ plugin: 'trailhead', key: 'dashboardPane' } as const, EMPTY_DASHBOARD)
|
|
125
149
|
// The ticket whose `work` or `resume` press is queued; one at a time.
|
|
150
|
+
// My open tickets across every map and the whiteboard (the last good read), for every view's In progress group.
|
|
151
|
+
export const myTickets = atom({ plugin: 'trailhead', key: 'myTickets' } as const, [] as readonly MyTicket[])
|
|
126
152
|
export const pendingWork = atom({ plugin: 'trailhead', key: 'pendingWork' } as const, null as number | null)
|
|
127
153
|
// Whether that press waits for the running turn to end (cancellable) or was sent to the host.
|
|
128
154
|
export const pendingPhase = atom({ plugin: 'trailhead', key: 'pendingPhase' } as const, null as 'queued' | 'starting' | null)
|
|
129
|
-
// Set once the pane was
|
|
155
|
+
// Set once the auto-open is done: the pane was seen drawn, or the person acted on it (the command, a close by
|
|
156
|
+
// hand, the close button). An unasked open that stays undrawn does not set it; never auto-opened after it is set.
|
|
130
157
|
export const paneAutoOpened = atom({ plugin: 'trailhead', key: 'paneAutoOpened' } as const, false)
|
|
131
158
|
const PENDING = { plugin: 'trailhead', key: 'pendingWork' } as const
|
|
132
159
|
const WORK_COMMAND = 'trailhead:work'
|
|
@@ -207,12 +234,15 @@ async function setPane($: Dollar, next: MapPaneData) {
|
|
|
207
234
|
type GhResult = { ok: true; stdout: string } | { ok: false; reason: string }
|
|
208
235
|
|
|
209
236
|
// Always run in the pinned repo, so the session's cwd moving out of it never changes what is read.
|
|
237
|
+
// gh resolves {owner}/{repo} through git, so it gets the same scrubbed env as a git call.
|
|
210
238
|
async function gh($: Dollar, pin: RepoPin, args: readonly string[]): Promise<GhResult> {
|
|
211
239
|
try {
|
|
212
|
-
const out = await $.process.run(['gh', ...args], { cwd: pin.root, timeoutMs: GH_TIMEOUT_MS })
|
|
240
|
+
const out = await $.process.run(scrubGitEnv(['gh', ...args]), { cwd: pin.root, timeoutMs: GH_TIMEOUT_MS })
|
|
213
241
|
return out.exitCode === 0 ? { ok: true, stdout: out.stdout } : { ok: false, reason: failureReason(out) }
|
|
214
242
|
} catch (error) {
|
|
215
|
-
|
|
243
|
+
// A deleted cwd fails the spawn as ENOENT, the same as a missing binary: look before blaming gh.
|
|
244
|
+
const missingRoot = (await $.fs.exists(pin.root).catch(() => true)) ? undefined : pin.root
|
|
245
|
+
return { ok: false, reason: failureReason({ error, missingRoot }) }
|
|
216
246
|
}
|
|
217
247
|
}
|
|
218
248
|
|
|
@@ -249,6 +279,40 @@ const readOpen = async ($: Dollar, pin: RepoPin, label: string, also?: string):
|
|
|
249
279
|
const readOpenFallback = ($: Dollar, pin: RepoPin, label: string, also?: string) =>
|
|
250
280
|
gh($, pin, ['issue', 'list', ...(also === undefined ? [] : ['--label', also]), '--label', label, '--state', 'open', '--limit', String(LIST_LIMIT), '--json', TICKET_FIELDS])
|
|
251
281
|
|
|
282
|
+
// The gh login per host, read once: the account does not change under a running session.
|
|
283
|
+
const loginByHost = new Map<string, string>()
|
|
284
|
+
const readLogin = async ($: Dollar, pin: RepoPin): Promise<string | null> => {
|
|
285
|
+
const known = loginByHost.get(pin.host)
|
|
286
|
+
if (known !== undefined) return known
|
|
287
|
+
const out = await gh($, pin, ['api', 'graphql', '-f', `query=${LOGIN_QUERY}`])
|
|
288
|
+
const login = out.ok ? parseLogin(out.stdout) : null
|
|
289
|
+
if (login !== null) loginByHost.set(pin.host, login)
|
|
290
|
+
return login
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// My open tickets in the repo, on any open map or the whiteboard. Fail-closed like the UAT read: a login or a page
|
|
294
|
+
// that fails or is unreadable makes the read null, and the last good list stays; never a partial list.
|
|
295
|
+
const readMine = async ($: Dollar, pin: RepoPin): Promise<readonly MyTicket[] | null> => {
|
|
296
|
+
const login = await readLogin($, pin)
|
|
297
|
+
if (login === null) return null
|
|
298
|
+
const rows: unknown[] = []
|
|
299
|
+
let cursor: string | null = null
|
|
300
|
+
while (rows.length < LIST_LIMIT) {
|
|
301
|
+
const args = ['api', 'graphql', '-F', 'owner={owner}', '-F', 'repo={repo}', '-f', `query=${MINE_QUERY}`, '-f', `login=${login}`]
|
|
302
|
+
const out = await gh($, pin, cursor === null ? args : [...args, '-f', `after=${cursor}`])
|
|
303
|
+
const page = out.ok ? parseIssuesPage(out.stdout) : null
|
|
304
|
+
if (page === null) return null
|
|
305
|
+
rows.push(...page.rows)
|
|
306
|
+
if (!page.hasNextPage) break
|
|
307
|
+
cursor = page.endCursor
|
|
308
|
+
}
|
|
309
|
+
return myTicketsFromRows(rows)
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
const keepMine = async ($: Dollar, mine: readonly MyTicket[] | null) => {
|
|
313
|
+
if (mine !== null && !sameValue(await read($, myTickets), mine)) await update($, myTickets, () => mine)
|
|
314
|
+
}
|
|
315
|
+
|
|
252
316
|
// The pending UAT tickets of one scope (a map label, or the whiteboard) with their bodies, for the parent each
|
|
253
317
|
// one tests. Read through GraphQL (the search index lags a write by seconds, so a follow-up just opened would
|
|
254
318
|
// show without its parent line), all open `trailhead:uat` tickets, the scope filtered here. Fail-closed: a page
|
|
@@ -341,12 +405,13 @@ async function fetchMap($: Dollar, pin: RepoPin, token: number, moves: number, w
|
|
|
341
405
|
await setPane($, { ...EMPTY, maps: before.maps, whiteboard: before.whiteboard, openTotal: before.openTotal, isLoading: true })
|
|
342
406
|
}
|
|
343
407
|
|
|
344
|
-
const [view, list, historyRead, mapsRead, uatParents] = await readList($, pin, target, before.openTotal)
|
|
408
|
+
const [[view, list, historyRead, mapsRead, uatParents], mine] = await Promise.all([readList($, pin, target, before.openTotal), readMine($, pin)])
|
|
345
409
|
const maps = mapsRead.maps ?? before.maps
|
|
346
410
|
const { whiteboard, openTotal } = mapsRead
|
|
347
411
|
await dropClosedView($, mapsRead.maps)
|
|
348
412
|
const now = await $.clock.now()
|
|
349
413
|
if (await isStale()) return
|
|
414
|
+
await keepMine($, mine)
|
|
350
415
|
|
|
351
416
|
const kept = isSameMap ? before : EMPTY
|
|
352
417
|
const failure = !view.ok ? view.reason : !list.ok ? list.reason : null
|
|
@@ -381,14 +446,16 @@ async function fetchDashboard($: Dollar, pin: RepoPin, token: number, moves: num
|
|
|
381
446
|
if (before.fetchedAt === null && !before.isLoading && before.error === null) {
|
|
382
447
|
await update($, dashboardPane, () => ({ ...before, isLoading: true }))
|
|
383
448
|
}
|
|
384
|
-
const [list, index, mapsRead, uatParents] = await Promise.all([
|
|
449
|
+
const [list, index, mapsRead, uatParents, mine] = await Promise.all([
|
|
385
450
|
readOpen($, pin, 'trailhead:whiteboard', 'trailhead:ticket'),
|
|
386
451
|
gh($, pin, ['issue', 'list', '--label', 'trailhead:dashboard', '--state', 'open', '--limit', '10', '--json', DASHBOARD_FIELDS]),
|
|
387
452
|
readMaps($, pin, keptTotal),
|
|
388
453
|
readUat($, pin, 'trailhead:whiteboard'),
|
|
454
|
+
readMine($, pin),
|
|
389
455
|
])
|
|
390
456
|
const now = await $.clock.now()
|
|
391
457
|
if (await isStale()) return
|
|
458
|
+
await keepMine($, mine)
|
|
392
459
|
const shown = await read($, mapPane)
|
|
393
460
|
await setPane($, { ...shown, maps: mapsRead.maps ?? shown.maps, whiteboard: mapsRead.whiteboard, openTotal: mapsRead.openTotal })
|
|
394
461
|
await dropClosedView($, mapsRead.maps)
|
|
@@ -420,6 +487,7 @@ export async function onPinMove($: Dollar, pin: RepoPin | null): Promise<void> {
|
|
|
420
487
|
if (!isOtherRepo) return
|
|
421
488
|
await update($, mapPane, () => EMPTY)
|
|
422
489
|
await update($, dashboardPane, () => EMPTY_DASHBOARD)
|
|
490
|
+
await update($, myTickets, () => [])
|
|
423
491
|
await update($, activeMap, () => null)
|
|
424
492
|
await update($, viewedMap, () => null)
|
|
425
493
|
await update($, isDashboard, () => false)
|
|
@@ -556,6 +624,10 @@ async function restoreAfterClear($: Dollar) {
|
|
|
556
624
|
export const GH_ISSUE_WRITE =
|
|
557
625
|
/\bgh\b[^\n;&|]*\bissue\s+(?:edit|close|create|comment|reopen|delete|transfer)\b|\bgh\b(?=[^\n;&|]*\sapi\s)(?=[^\n;&|]*\brepos\/[^\s;&|]*\/issues\b)(?=[^\n;&|]*(?:\s(?:-X|--method)[\s=]*["']?(?:POST|PATCH|PUT|DELETE)\b|\s(?:-[fF]|--field\b|--raw-field\b|--input\b)))(?![^\n;&|]*\s(?:-X|--method)[\s=]*["']?GET\b)|\btrailhead-issue-body\.js\b/
|
|
558
626
|
|
|
627
|
+
// A tracker write once heredoc bodies are out: a body line is data, never a command (#369). The matcher
|
|
628
|
+
// stays the regex because the host's `command:` takes no function; the handler runs this check first.
|
|
629
|
+
export const isTrackerWrite = (command: string): boolean => GH_ISSUE_WRITE.test(stripHeredocBodies(command))
|
|
630
|
+
|
|
559
631
|
// The host runs the verb in the session's own directory, which a shell `cd` may have moved, and the mod cannot
|
|
560
632
|
// move it back: `out` hands the pinned checkout to the verb, which moves first (#312). A checkout that cannot
|
|
561
633
|
// be read is `unknown`, refused, but not told it is elsewhere.
|
|
@@ -582,7 +654,7 @@ async function placeOf($: Dollar): Promise<{ pin: RepoPin; where: 'in' | 'out' }
|
|
|
582
654
|
// Whether the pinned root still answers as a checkout root, the same test the verb's helper applies.
|
|
583
655
|
async function isCheckoutRoot($: Dollar, root: string): Promise<boolean> {
|
|
584
656
|
try {
|
|
585
|
-
const out = await $.process.run(
|
|
657
|
+
const out = await $.process.run(gitArgv('rev-parse', '--show-toplevel'), { cwd: root, timeoutMs: 5_000 })
|
|
586
658
|
return out.exitCode === 0 && out.stdout.trim() === root
|
|
587
659
|
} catch {
|
|
588
660
|
return false
|
|
@@ -825,12 +897,17 @@ const pressDashboard = ($: Dollar, isOn: boolean): void => {
|
|
|
825
897
|
// so the auto-open runs from a zero-delay timer, a dispatch of its own; `isOpening` keeps two
|
|
826
898
|
// renders from opening twice before the flag is written.
|
|
827
899
|
let isOpening = false
|
|
900
|
+
// Quando l'auto-open ha disegnato il pannello: serve a `/trailhead:panel` per riconoscerne uno mai visto.
|
|
901
|
+
let autoOpenedAt: number | null = null
|
|
828
902
|
// Same for the fill an empty pane asks for from its render.
|
|
829
903
|
let isFilling = false
|
|
830
904
|
|
|
831
|
-
|
|
832
|
-
|
|
905
|
+
// L'auto-open conta come fatto (`paneAutoOpened`) solo quando il pannello è davvero disegnato, o quando la
|
|
906
|
+
// persona è intervenuta (comando, chiusura a mano, bottone close). Un'apertura non richiesta che resta non
|
|
907
|
+
// disegnata non brucia il flag: un trigger successivo (render in fullscreen, `session.attach`) può riprovare.
|
|
908
|
+
async function openPane($: Dollar, isRequested = false) {
|
|
833
909
|
const opened = await $.ui.open({ id: PANE_ID, title: PANE_TITLE })
|
|
910
|
+
if (isRequested || opened.isPlaced) await update($, paneAutoOpened, () => true)
|
|
834
911
|
if (opened.isPlaced) void requestRefresh($)
|
|
835
912
|
return opened
|
|
836
913
|
}
|
|
@@ -839,9 +916,14 @@ async function autoOpen($: Dollar) {
|
|
|
839
916
|
if (isOpening) return
|
|
840
917
|
isOpening = true
|
|
841
918
|
try {
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
919
|
+
const pane = (await $.ui.panes()).find(p => p.id === PANE_ID)
|
|
920
|
+
if (pane === undefined) {
|
|
921
|
+
const opened = await openPane($)
|
|
922
|
+
if (opened.isPlaced) autoOpenedAt = await $.clock.now()
|
|
923
|
+
}
|
|
924
|
+
// Found up and drawn (a reload with the pane showing): nothing to open, and renders stop asking.
|
|
925
|
+
else if (pane.isPlaced) await update($, paneAutoOpened, () => true)
|
|
926
|
+
// Listed but undrawn: the host places it itself when it can; its own render then completes the auto-open.
|
|
845
927
|
} catch (err) {
|
|
846
928
|
debug($, `trailhead mod: map pane auto-open failed: ${String(err)}`)
|
|
847
929
|
} finally {
|
|
@@ -849,21 +931,43 @@ async function autoOpen($: Dollar) {
|
|
|
849
931
|
}
|
|
850
932
|
}
|
|
851
933
|
|
|
852
|
-
//
|
|
853
|
-
|
|
854
|
-
|
|
934
|
+
// Una lettura sola, da un timer a ritardo zero: un render non può scrivere stato.
|
|
935
|
+
async function autoOpenIfDue($: Dollar) {
|
|
936
|
+
if ((await read($, activeMap)) !== null && !(await read($, paneAutoOpened))) $.clock.after(0, () => void autoOpen($))
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
// Il render del pannello scatta solo da disegnato: l'auto-open è completato.
|
|
940
|
+
async function markDrawn($: Dollar) {
|
|
941
|
+
if (!(await read($, paneAutoOpened))) $.clock.after(0, () => void update($, paneAutoOpened, () => true).catch(err => debug($, `trailhead mod: auto-open flag not set: ${String(err)}`)))
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
// Un pannello aperto ma non disegnato (l'auto-open sotto la soglia dei pannelli non richiesti, o su una
|
|
945
|
+
// superficie che non ha ancora comunicato il layout) non è visibile: il comando lo riapre, e una richiesta
|
|
946
|
+
// della persona lo disegna a qualsiasi larghezza. Se nemmeno così viene disegnato NON lo chiude: la persona
|
|
947
|
+
// non l'ha mai visto, chiuderlo risponderebbe "closed" a un pannello mai mostrato. Resta aperto e viene
|
|
948
|
+
// disegnato appena la superficie può collocarlo. Chiude solo un pannello elencato E disegnato. Senza
|
|
949
|
+
// superficie collegata (`no-surface`, l'inizio sessione) la risposta è l'attesa normale.
|
|
950
|
+
const PENDING_MESSAGE = 'Map panel will open as soon as the screen is ready.'
|
|
951
|
+
const NOT_SHOWN_MESSAGE = 'Map panel is open but not shown yet: it appears once the screen can place it.'
|
|
952
|
+
const isNoSurface = (reason: string) => reason.startsWith('no-surface')
|
|
953
|
+
|
|
954
|
+
// Il desktop avvia la sessione al primo invio, non al +: un `/trailhead:panel` come primo messaggio arriva
|
|
955
|
+
// pochi millisecondi dopo l'auto-open. Nessuno fa in tempo a vedere il pannello e a digitare il comando sotto
|
|
956
|
+
// questa soglia, quindi la richiesta è di vederlo, non di chiuderlo.
|
|
957
|
+
const UNSEEN_MS = 2_000
|
|
958
|
+
|
|
855
959
|
async function togglePanel($: Dollar): Promise<string> {
|
|
856
960
|
const pane = (await $.ui.panes()).find(p => p.id === PANE_ID)
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
}
|
|
861
|
-
if (pane !== undefined) {
|
|
961
|
+
const isUnseen = autoOpenedAt !== null && (await $.clock.now()) - autoOpenedAt < UNSEEN_MS
|
|
962
|
+
autoOpenedAt = null
|
|
963
|
+
if (pane?.isPlaced === true && !isUnseen) {
|
|
862
964
|
await $.ui.close({ id: PANE_ID })
|
|
863
965
|
return 'Map panel closed.'
|
|
864
966
|
}
|
|
865
|
-
const opened = await openPane(
|
|
866
|
-
|
|
967
|
+
const opened = await openPane($, true)
|
|
968
|
+
if (opened.isPlaced) return 'Map panel opened.'
|
|
969
|
+
debug($, `trailhead mod: map pane not placed: ${opened.reason}`)
|
|
970
|
+
return isNoSurface(opened.reason) ? PENDING_MESSAGE : NOT_SHOWN_MESSAGE
|
|
867
971
|
}
|
|
868
972
|
|
|
869
973
|
// The close button: the pane goes (its `ui.close` hook stops the timers). The press counts as the
|
|
@@ -919,12 +1023,14 @@ export function registerMapPane(on: On) {
|
|
|
919
1023
|
return result
|
|
920
1024
|
})
|
|
921
1025
|
|
|
922
|
-
// After the call ran: a Bash `gh issue` write changed the tracker.
|
|
1026
|
+
// After the call ran: a Bash `gh issue` write changed the tracker (heredoc bodies not counted).
|
|
923
1027
|
on('tool.call', { tool: 'Bash', command: GH_ISSUE_WRITE }, async ($, e, next) => {
|
|
1028
|
+
// The matcher saw the raw text: a write named only inside a heredoc body is no write.
|
|
1029
|
+
if (typeof e.command !== 'string' || !isTrackerWrite(e.command)) return next(e)
|
|
924
1030
|
const result = await next(e)
|
|
925
1031
|
lastWriteAt = await $.clock.now()
|
|
926
1032
|
void requestRefresh($, { isWrite: true })
|
|
927
|
-
await hintWhiteboard($, e.command, result)
|
|
1033
|
+
await hintWhiteboard($, stripHeredocBodies(e.command), result)
|
|
928
1034
|
return result
|
|
929
1035
|
// This hook never judges the call: if it fails, the call goes on untouched.
|
|
930
1036
|
}).catch((_$, e, next) => next(e))
|
|
@@ -935,7 +1041,14 @@ export function registerMapPane(on: On) {
|
|
|
935
1041
|
// Only in fullscreen, where a pane docks beside the transcript; this bare matcher is
|
|
936
1042
|
// narrower than the band's, and the band's own hook (in register.tsx) draws after it.
|
|
937
1043
|
on('ui.render', { component: 'AbovePrompt', viewport: { isFullscreen: true } }, async ($, e, next) => {
|
|
938
|
-
|
|
1044
|
+
await autoOpenIfDue($)
|
|
1045
|
+
return next(e)
|
|
1046
|
+
})
|
|
1047
|
+
|
|
1048
|
+
// A surface that attaches later (a fresh desktop session) may place the pane the first open could not: with
|
|
1049
|
+
// its layout saying fullscreen, the auto-open tries again. Without a viewport the render trigger decides.
|
|
1050
|
+
on('session.attach', { viewport: { isFullscreen: true } }, async ($, e, next) => {
|
|
1051
|
+
await autoOpenIfDue($)
|
|
939
1052
|
return next(e)
|
|
940
1053
|
})
|
|
941
1054
|
|
|
@@ -948,6 +1061,7 @@ export function registerMapPane(on: On) {
|
|
|
948
1061
|
})
|
|
949
1062
|
|
|
950
1063
|
on('ui.render', { component: 'Pane', requestId: PANE_ID }, async ($, e) => {
|
|
1064
|
+
await markDrawn($)
|
|
951
1065
|
const ui = $.ui.resolve(e)
|
|
952
1066
|
const { Box, Text, Button, Link } = ui
|
|
953
1067
|
// Raster is the terminal surface's only; elsewhere the constructor is not a real element.
|
|
@@ -961,6 +1075,7 @@ export function registerMapPane(on: On) {
|
|
|
961
1075
|
const play = await read($, workState)
|
|
962
1076
|
const orphaned = await read($, isOrphaned)
|
|
963
1077
|
const own = await read($, ownTicket)
|
|
1078
|
+
const mine = await read($, myTickets)
|
|
964
1079
|
const pin = await read($, repoPin)
|
|
965
1080
|
const pending = await read($, pendingWork)
|
|
966
1081
|
const phase = await read($, pendingPhase)
|
|
@@ -976,14 +1091,45 @@ export function registerMapPane(on: On) {
|
|
|
976
1091
|
</Box>
|
|
977
1092
|
)
|
|
978
1093
|
const link = (url: string, label: string) => (url === '' ? label : <Link href={url} label={label} />)
|
|
1094
|
+
// Il filetto di un blocco (riga e righe sotto): sul desktop una barra che si allunga quanto il blocco, nel
|
|
1095
|
+
// terminale un `│` per riga. Occupa sempre GUTTER colonne.
|
|
1096
|
+
const gutter = (key: string, section: SectionKey, lines: number) => {
|
|
1097
|
+
const { color } = SECTION_STYLES[section]
|
|
1098
|
+
return e.surface === 'desktop' ? (
|
|
1099
|
+
<Box key={key} width={GUTTER.length}>
|
|
1100
|
+
<Box key={`${key}-bar`} width={GUTTER_BAR} backgroundColor={color} />
|
|
1101
|
+
</Box>
|
|
1102
|
+
) : (
|
|
1103
|
+
<Box key={key} flexDirection="column" width={GUTTER.length}>
|
|
1104
|
+
{Array.from({ length: lines }, (_, i) => (
|
|
1105
|
+
<Text key={`${key}-${i}`} color={color}>
|
|
1106
|
+
{GUTTER}
|
|
1107
|
+
</Text>
|
|
1108
|
+
))}
|
|
1109
|
+
</Box>
|
|
1110
|
+
)
|
|
1111
|
+
}
|
|
1112
|
+
// Intestazione di sezione: glifo e nome nel colore della sezione, poi un filetto fino al bordo.
|
|
1113
|
+
const sectionHead = (key: SectionKey, label: string) => {
|
|
1114
|
+
const { glyph, color } = SECTION_STYLES[key]
|
|
1115
|
+
const rest = cols - glyph.length - 1 - label.length - 1
|
|
1116
|
+
return (
|
|
1117
|
+
<Box key={`section-head-${key}`}>
|
|
1118
|
+
{text(`glyph-${key}`, { color }, `${glyph} `)}
|
|
1119
|
+
{text(`head-${key}`, { bold: true, color }, label)}
|
|
1120
|
+
{rest > 0 ? text(`rule-${key}`, { color: RULE_COLOR }, ` ${'─'.repeat(rest)}`) : null}
|
|
1121
|
+
</Box>
|
|
1122
|
+
)
|
|
1123
|
+
}
|
|
979
1124
|
|
|
980
1125
|
// The open maps, one row each; shown with several maps open (or with no active map, to pick one).
|
|
981
1126
|
// On the dashboard every map is listed and each row opens it.
|
|
982
1127
|
const mapRow = (m: MapSummary) => {
|
|
983
|
-
const r = mapRowLayout(m, { active, viewed, columns: cols, isDashboard: isBoard })
|
|
1128
|
+
const r = mapRowLayout(m, { active, viewed, columns: cols - GUTTER.length, isDashboard: isBoard })
|
|
984
1129
|
return (
|
|
985
1130
|
<Box key={`map-row-${m.number}`}>
|
|
986
|
-
{
|
|
1131
|
+
{gutter(`map-gutter-${m.number}`, 'maps', 1)}
|
|
1132
|
+
{text(`map-mark-${m.number}`, r.isActive ? { bold: true, color: SECTION_STYLES.maps.color } : {}, r.mark)}
|
|
987
1133
|
{r.isPressable ? (
|
|
988
1134
|
<Button
|
|
989
1135
|
key={`map-${m.number}`}
|
|
@@ -1000,7 +1146,7 @@ export function registerMapPane(on: On) {
|
|
|
1000
1146
|
const isMapsShown = isBoard ? data.maps.length > 0 : data.maps.length > 1 || (active === null && data.maps.length > 0)
|
|
1001
1147
|
const mapsGroup = isMapsShown ? (
|
|
1002
1148
|
<Box key="group-maps" flexDirection="column" marginTop={gap}>
|
|
1003
|
-
{
|
|
1149
|
+
{sectionHead('maps', `Maps · ${data.maps.length}`)}
|
|
1004
1150
|
{data.maps.map(mapRow)}
|
|
1005
1151
|
</Box>
|
|
1006
1152
|
) : null
|
|
@@ -1039,18 +1185,20 @@ export function registerMapPane(on: On) {
|
|
|
1039
1185
|
|
|
1040
1186
|
// This session's own ticket survives a work-state file another session took over; without one, the file decides.
|
|
1041
1187
|
const isInPlay = (t: PaneTicket) => (own !== null ? t.number === own : play !== null && !orphaned && play.ticket === t.number)
|
|
1042
|
-
const row = (t: PaneTicket, hasMarkColumn: boolean) => {
|
|
1043
|
-
const r = rowLayout(t, { isInPlay: isInPlay(t), hasMarkColumn, pendingWork: pending, pendingPhase: phase, columns: cols })
|
|
1188
|
+
const row = (t: PaneTicket, section: SectionKey, hasMarkColumn: boolean, scope: string | null = null) => {
|
|
1189
|
+
const r = rowLayout(t, { isInPlay: isInPlay(t), hasMarkColumn, pendingWork: pending, pendingPhase: phase, columns: cols - GUTTER.length })
|
|
1044
1190
|
const style = r.isInPlay ? accent : r.isDim ? dim : {}
|
|
1045
1191
|
const holder = r.isDim ? dim : {}
|
|
1192
|
+
const typeColor = t.type === null ? undefined : TYPE_COLORS[t.type]
|
|
1193
|
+
const typeStyle = r.isDim || typeColor === undefined ? dim : { color: typeColor }
|
|
1046
1194
|
const line = (
|
|
1047
|
-
<Box key={`row-${t.number}`} width={cols} justifyContent="space-between">
|
|
1195
|
+
<Box key={`row-${t.number}`} width={cols - GUTTER.length} justifyContent="space-between">
|
|
1048
1196
|
<Box>
|
|
1049
1197
|
{r.mark === null ? null : text(`mark-${t.number}`, r.isInPlay ? accent : {}, r.mark)}
|
|
1050
1198
|
<Box key={`ticket-${t.number}`}>
|
|
1051
1199
|
<Text {...style}>{link(t.url, r.label)}</Text>
|
|
1052
1200
|
</Box>
|
|
1053
|
-
{r.type === null ? null : text(`type-${t.number}`,
|
|
1201
|
+
{r.type === null ? null : text(`type-${t.number}`, typeStyle, r.type)}
|
|
1054
1202
|
</Box>
|
|
1055
1203
|
{r.aside?.kind === 'holder' ? text(`holder-${t.number}`, holder, r.aside.text) : null}
|
|
1056
1204
|
{r.aside?.kind === 'self' ? text(`self-${t.number}`, { color: ACCENT }, r.aside.text) : null}
|
|
@@ -1078,19 +1226,24 @@ export function registerMapPane(on: On) {
|
|
|
1078
1226
|
) : null}
|
|
1079
1227
|
</Box>
|
|
1080
1228
|
)
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
<Box key={`entry-${t.number}`}
|
|
1086
|
-
{
|
|
1087
|
-
{
|
|
1229
|
+
// Le righe sotto (genitore UAT, dove sta) rientrano dopo il filetto, che copre tutto il blocco.
|
|
1230
|
+
const parent = uatParentLine(t, cols - GUTTER.length)
|
|
1231
|
+
const lines = 1 + (parent === null ? 0 : 1) + (scope === null ? 0 : 1)
|
|
1232
|
+
return (
|
|
1233
|
+
<Box key={`entry-${t.number}`} width={cols}>
|
|
1234
|
+
{gutter(`gutter-${t.number}`, section, lines)}
|
|
1235
|
+
<Box key={`entry-lines-${t.number}`} flexDirection="column">
|
|
1236
|
+
{line}
|
|
1237
|
+
{parent === null ? null : text(`uat-of-${t.number}`, dim, parent)}
|
|
1238
|
+
{scope === null ? null : text(`scope-of-${t.number}`, dim, scope)}
|
|
1239
|
+
</Box>
|
|
1088
1240
|
</Box>
|
|
1089
1241
|
)
|
|
1090
1242
|
}
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1243
|
+
// `elsewhere`: my tickets of other scopes, listed after the shown scope's with the line naming where each is.
|
|
1244
|
+
const group = (key: Exclude<SectionKey, 'maps'>, name: string, rows: readonly PaneTicket[], elsewhere: readonly MyTicket[] = []) => {
|
|
1245
|
+
if (rows.length + elsewhere.length === 0) return null
|
|
1246
|
+
const hasMarkColumn = [...rows, ...elsewhere].some(isInPlay)
|
|
1094
1247
|
return (
|
|
1095
1248
|
<Box
|
|
1096
1249
|
key={`group-${key}`}
|
|
@@ -1098,17 +1251,19 @@ export function registerMapPane(on: On) {
|
|
|
1098
1251
|
marginTop={gap}
|
|
1099
1252
|
{...(e.surface === 'desktop' ? { rowGap: DESKTOP_ROW_GAP } : {})}
|
|
1100
1253
|
>
|
|
1101
|
-
{
|
|
1102
|
-
{rows.map(t => row(t, hasMarkColumn))}
|
|
1254
|
+
{sectionHead(key, `${name} · ${rows.length + elsewhere.length}`)}
|
|
1255
|
+
{rows.map(t => row(t, key, hasMarkColumn))}
|
|
1256
|
+
{elsewhere.map(t => row(t, key, hasMarkColumn, scopeLine(t, data.maps, cols - GUTTER.length)))}
|
|
1103
1257
|
</Box>
|
|
1104
1258
|
)
|
|
1105
1259
|
}
|
|
1106
|
-
const ticketGroups = (tickets: readonly PaneTicket[]) => {
|
|
1260
|
+
const ticketGroups = (tickets: readonly PaneTicket[], here: TicketScope) => {
|
|
1107
1261
|
const groups = groupTickets(tickets)
|
|
1262
|
+
// In progress first, right under the maps list: what I already hold comes before what to pick up.
|
|
1108
1263
|
return [
|
|
1264
|
+
group('in-progress', 'In progress', groups.inProgress, elsewhereInProgress(mine, here, tickets)),
|
|
1109
1265
|
group('needs-you', 'Needs you', groups.needsYou),
|
|
1110
1266
|
group('frontier', 'Frontier', groups.frontier),
|
|
1111
|
-
group('in-progress', 'In progress', groups.inProgress),
|
|
1112
1267
|
group('uat', 'UAT', groups.uat),
|
|
1113
1268
|
group('paused', 'Paused', groups.paused),
|
|
1114
1269
|
group('blocked', 'Blocked', groups.blocked),
|
|
@@ -1117,7 +1272,7 @@ export function registerMapPane(on: On) {
|
|
|
1117
1272
|
const notes = (d: { unverifiedCount: number; isCapped: boolean }) => [
|
|
1118
1273
|
d.unverifiedCount === 0 ? null : (
|
|
1119
1274
|
<Box key="unverified" marginTop={gap}>
|
|
1120
|
-
<Text {
|
|
1275
|
+
<Text color={AMBER}>{unverifiedLine(d.unverifiedCount)}</Text>
|
|
1121
1276
|
</Box>
|
|
1122
1277
|
),
|
|
1123
1278
|
!d.isCapped ? null : (
|
|
@@ -1201,7 +1356,7 @@ export function registerMapPane(on: On) {
|
|
|
1201
1356
|
{text('head-whiteboard', bold, whiteboardHeading(isLoaded ? board.tickets : null, board.isCapped))}
|
|
1202
1357
|
</Box>
|
|
1203
1358
|
{!isLoaded && board.error === null ? text('loading', dim, 'Loading dashboard…') : null}
|
|
1204
|
-
{ticketGroups(board.tickets)}
|
|
1359
|
+
{ticketGroups(board.tickets, { kind: 'whiteboard' })}
|
|
1205
1360
|
{isEmpty ? text('no-tickets', dim, 'No open whiteboard tickets.') : null}
|
|
1206
1361
|
{notes(board)}
|
|
1207
1362
|
<Box key="spacer" flexGrow={1} />
|
|
@@ -1274,7 +1429,7 @@ export function registerMapPane(on: On) {
|
|
|
1274
1429
|
{whiteboardRow}
|
|
1275
1430
|
{totalLine}
|
|
1276
1431
|
{mapsGroup}
|
|
1277
|
-
{ticketGroups(tickets)}
|
|
1432
|
+
{ticketGroups(tickets, { kind: 'map', number: target })}
|
|
1278
1433
|
{isCurrent && data.error === null && tickets.length === 0 ? (
|
|
1279
1434
|
<Box key="no-tickets" marginTop={gap}>
|
|
1280
1435
|
<Text {...dim}>No open tickets. Fog may remain: /trailhead:map</Text>
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// My tickets in play across the repo: the open tickets assigned to the gh login, on any open map or on the
|
|
2
|
+
// whiteboard, so every view's In progress group shows them, not only the shown scope's. Pure, easy to test.
|
|
3
|
+
import type { MapSummary, MyTicket, PaneTicket, TicketScope } from '../../types'
|
|
4
|
+
import { groupTickets, ticketsFromRows } from './map-pane-view'
|
|
5
|
+
import { fit } from './work-state-view'
|
|
6
|
+
|
|
7
|
+
const MAP_LABEL = /^trailhead:map-(\d+)$/
|
|
8
|
+
const WHITEBOARD_LABEL = 'trailhead:whiteboard'
|
|
9
|
+
|
|
10
|
+
const isRecord = (v: unknown): v is Record<string, unknown> => v !== null && typeof v === 'object' && !Array.isArray(v)
|
|
11
|
+
|
|
12
|
+
const namesOf = (row: unknown): string[] =>
|
|
13
|
+
isRecord(row) && Array.isArray(row.labels)
|
|
14
|
+
? row.labels.flatMap(l => (isRecord(l) && typeof l.name === 'string' ? [l.name] : []))
|
|
15
|
+
: []
|
|
16
|
+
|
|
17
|
+
// One map label, or the whiteboard label alone; none, or contradictory labels, is no scope (never guessed).
|
|
18
|
+
const scopeOf = (names: readonly string[]): TicketScope | null => {
|
|
19
|
+
const maps = names.flatMap(n => {
|
|
20
|
+
const m = MAP_LABEL.exec(n)
|
|
21
|
+
return m === null ? [] : [Number(m[1])]
|
|
22
|
+
})
|
|
23
|
+
const isBoard = names.includes(WHITEBOARD_LABEL)
|
|
24
|
+
if (isBoard) return maps.length === 0 ? { kind: 'whiteboard' } : null
|
|
25
|
+
return maps.length === 1 && Number.isInteger(maps[0]) && (maps[0] as number) > 0 ? { kind: 'map', number: maps[0] as number } : null
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// `{data:{viewer:{login}}}` as the login; any error or an empty login is unreadable (null).
|
|
29
|
+
export const parseLogin = (stdout: string): string | null => {
|
|
30
|
+
let raw: unknown
|
|
31
|
+
try {
|
|
32
|
+
raw = JSON.parse(stdout)
|
|
33
|
+
} catch {
|
|
34
|
+
return null
|
|
35
|
+
}
|
|
36
|
+
if (!isRecord(raw) || 'errors' in raw || !isRecord(raw.data) || !isRecord(raw.data.viewer)) return null
|
|
37
|
+
const { login } = raw.data.viewer
|
|
38
|
+
return typeof login === 'string' && login !== '' ? login : null
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// The rows of the assigned-ticket read (the `gh issue list --json` shape) as tickets tagged with their scope.
|
|
42
|
+
// The ticket parser does the quarantine and the field hygiene; a row with no usable scope is dropped.
|
|
43
|
+
export const myTicketsFromRows = (rows: readonly unknown[]): readonly MyTicket[] => {
|
|
44
|
+
const scopes = new Map(
|
|
45
|
+
rows.flatMap(r => (isRecord(r) && typeof r.number === 'number' ? [[r.number, scopeOf(namesOf(r))] as const] : [])),
|
|
46
|
+
)
|
|
47
|
+
const parsed = [...ticketsFromRows(rows).tickets, ...ticketsFromRows(rows, { isWhiteboard: true }).tickets]
|
|
48
|
+
return parsed
|
|
49
|
+
.flatMap(t => {
|
|
50
|
+
const scope = scopes.get(t.number) ?? null
|
|
51
|
+
return scope === null ? [] : [{ ...t, scope }]
|
|
52
|
+
})
|
|
53
|
+
.sort((a, b) => a.number - b.number)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const isSameScope = (a: TicketScope, b: TicketScope): boolean =>
|
|
57
|
+
a.kind === 'whiteboard' ? b.kind === 'whiteboard' : b.kind === 'map' && a.number === b.number
|
|
58
|
+
|
|
59
|
+
// My in-progress tickets (the same grouping the view uses) outside the shown scope, never one it already lists.
|
|
60
|
+
export const elsewhereInProgress = (
|
|
61
|
+
mine: readonly MyTicket[],
|
|
62
|
+
here: TicketScope,
|
|
63
|
+
shown: readonly PaneTicket[],
|
|
64
|
+
): readonly MyTicket[] => {
|
|
65
|
+
const listed = new Set(shown.map(t => t.number))
|
|
66
|
+
const inProgress = new Set(groupTickets(mine).inProgress.map(t => t.number))
|
|
67
|
+
return mine.filter(t => inProgress.has(t.number) && !isSameScope(t.scope, here) && !listed.has(t.number))
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// The dim line under such a row: `on map #<n> <title>` (the title once the maps read knows it) or `on whiteboard`.
|
|
71
|
+
export const scopeLine = (t: MyTicket, maps: readonly MapSummary[], columns: number): string => {
|
|
72
|
+
if (t.scope.kind === 'whiteboard') return fit('on whiteboard', columns)
|
|
73
|
+
const { number } = t.scope
|
|
74
|
+
const title = maps.find(m => m.number === number)?.title ?? ''
|
|
75
|
+
return fit(title === '' ? `on map #${number}` : `on map #${number} ${title}`, columns)
|
|
76
|
+
}
|