@marcomigozzi/trailhead 0.16.0 → 0.17.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 +67 -18
- package/README.md +67 -18
- package/bin/capture-action.test.js +167 -0
- package/bin/capture-docs.test.js +32 -0
- package/bin/config-dir-templates-docs.test.js +52 -0
- package/bin/decision-builds-docs.test.js +75 -0
- package/bin/engine-route-docs.test.js +148 -0
- package/bin/fog-vs-ticket-docs.test.js +56 -0
- package/bin/grill-model-docs.test.js +155 -0
- package/bin/grilling-holistic-review-docs.test.js +7 -3
- package/bin/lib/codex-projection.js +30 -14
- package/bin/lib/codex-projection.test.js +81 -17
- package/bin/lib/host-descriptor.js +15 -2
- package/bin/lib/host-descriptor.test.js +24 -0
- package/bin/lib/runtime-command-literals.test.js +184 -0
- package/bin/report-status-docs.test.js +11 -3
- package/bin/session-trust-docs.test.js +37 -0
- package/bin/surfaced-scope-docs.test.js +57 -0
- package/bin/trailhead.js +7 -5
- package/bin/trailhead.test.js +53 -11
- package/bin/unblock-docs.test.js +56 -0
- package/package.json +1 -1
- package/plugins/trailhead/.claude-plugin/plugin.json +1 -1
- package/plugins/trailhead/agents/trailhead-code-review.md +2 -2
- package/plugins/trailhead/agents/trailhead-codebase-map.md +1 -1
- package/plugins/trailhead/agents/trailhead-grill.md +41 -0
- package/plugins/trailhead/agents/trailhead-verify.md +2 -2
- 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/inherit-session.js +17 -0
- package/plugins/trailhead/hooks/lib/issue-body.test.js +84 -0
- package/plugins/trailhead/hooks/lib/model-defaults-review.js +72 -22
- package/plugins/trailhead/hooks/lib/model-defaults-review.test.js +113 -0
- 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/report-status.test.js +124 -2
- package/plugins/trailhead/hooks/lib/shell-scan.js +25 -0
- package/plugins/trailhead/hooks/lib/statusline-context.test.js +22 -1
- package/plugins/trailhead/hooks/lib/unmodelled.js +6 -1
- package/plugins/trailhead/hooks/mod/README.md +24 -10
- package/plugins/trailhead/hooks/mod/map-pane.tsx +124 -44
- package/plugins/trailhead/hooks/mod/my-tickets.ts +76 -0
- package/plugins/trailhead/hooks/mod/repo-pin.ts +8 -5
- package/plugins/trailhead/hooks/trailhead-check-update.js +17 -1
- package/plugins/trailhead/hooks/trailhead-check-update.test.js +60 -0
- package/plugins/trailhead/hooks/trailhead-secret-read-guard.js +40 -1
- package/plugins/trailhead/hooks/trailhead-secret-read-guard.test.js +68 -0
- package/plugins/trailhead/skills/_shared/capture-core.md +118 -0
- package/plugins/trailhead/skills/_shared/choices.md +2 -2
- package/plugins/trailhead/skills/_shared/configuration-reference.md +20 -18
- package/plugins/trailhead/skills/_shared/configuration.md +1 -1
- package/plugins/trailhead/skills/_shared/session-handoff.md +2 -2
- package/plugins/trailhead/skills/_shared/substrate-commands.md +8 -4
- package/plugins/trailhead/skills/_shared/substrate.md +1 -1
- package/plugins/trailhead/skills/_shared/surfaced-scope.md +23 -0
- package/plugins/trailhead/skills/_shared/teamwork.md +9 -5
- package/plugins/trailhead/skills/_shared/techniques/acceptance-testing.md +1 -1
- package/plugins/trailhead/skills/_shared/techniques/code-review.md +5 -2
- package/plugins/trailhead/skills/_shared/techniques/codebase-map.md +4 -1
- package/plugins/trailhead/skills/_shared/techniques/debug.md +4 -1
- package/plugins/trailhead/skills/_shared/techniques/grilling.md +26 -1
- package/plugins/trailhead/skills/_shared/techniques/research.md +4 -1
- package/plugins/trailhead/skills/_shared/techniques/verify.md +4 -2
- package/plugins/trailhead/skills/_shared/techniques.md +7 -5
- 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 +1 -1
- package/plugins/trailhead/skills/trailhead-chart/references/charting.md +5 -5
- package/plugins/trailhead/skills/trailhead-chart/references/inbox.md +3 -3
- package/plugins/trailhead/skills/trailhead-manage/references/auditing.md +5 -4
- package/plugins/trailhead/skills/trailhead-work/SKILL.md +13 -11
- package/plugins/trailhead/skills/trailhead-work/references/auto.md +1 -1
- package/plugins/trailhead/skills/trailhead-work/references/ticket-engines.md +11 -10
- package/plugins/trailhead/templates/statusline-trailhead.sh +3 -3
- package/plugins/trailhead/templates/trailhead-capture.js +418 -0
- package/plugins/trailhead/templates/trailhead-checkout-find.js +256 -0
- package/plugins/trailhead/templates/trailhead-checkout-in.js +76 -20
- package/plugins/trailhead/templates/trailhead-dashboard.js +15 -2
- package/plugins/trailhead/templates/trailhead-issue-body.js +69 -6
- package/plugins/trailhead/templates/trailhead-report-status.js +123 -11
- package/plugins/trailhead/tests/map-pane-ui.test.ts +299 -37
- package/plugins/trailhead/tests/my-tickets.test.ts +66 -0
- package/plugins/trailhead/tests/repo-pin.test.ts +20 -5
- 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,6 +48,7 @@ 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'
|
|
52
53
|
import { isMainTurnRunning } from './turn-state'
|
|
53
54
|
import { createdIssueNumber, isWhiteboardCreate, nextFollow, whiteboardHint } from './whiteboard-follow'
|
|
@@ -79,6 +80,9 @@ const TICKETS_QUERY = `query($owner:String!,$repo:String!,$label:String!,$after:
|
|
|
79
80
|
// most UAT_MAX_PAGES pages). The scope is filtered by the caller: GraphQL's `labels` is any-of.
|
|
80
81
|
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
82
|
const UAT_MAX_PAGES = 10
|
|
83
|
+
// The gh account's login, then its open tickets on any map or the whiteboard (paged like TICKETS_QUERY).
|
|
84
|
+
const LOGIN_QUERY = 'query{viewer{login}}'
|
|
85
|
+
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
86
|
const DASHBOARD_FIELDS = 'number,url'
|
|
83
87
|
// Same colours as the band.
|
|
84
88
|
const ACCENT = '#7aa2f7'
|
|
@@ -123,6 +127,8 @@ export const viewedMap = atom({ plugin: 'trailhead', key: 'viewedMap' } as const
|
|
|
123
127
|
export const isDashboard = atom({ plugin: 'trailhead', key: 'isDashboard' } as const, false)
|
|
124
128
|
export const dashboardPane = atom({ plugin: 'trailhead', key: 'dashboardPane' } as const, EMPTY_DASHBOARD)
|
|
125
129
|
// The ticket whose `work` or `resume` press is queued; one at a time.
|
|
130
|
+
// My open tickets across every map and the whiteboard (the last good read), for every view's In progress group.
|
|
131
|
+
export const myTickets = atom({ plugin: 'trailhead', key: 'myTickets' } as const, [] as readonly MyTicket[])
|
|
126
132
|
export const pendingWork = atom({ plugin: 'trailhead', key: 'pendingWork' } as const, null as number | null)
|
|
127
133
|
// Whether that press waits for the running turn to end (cancellable) or was sent to the host.
|
|
128
134
|
export const pendingPhase = atom({ plugin: 'trailhead', key: 'pendingPhase' } as const, null as 'queued' | 'starting' | null)
|
|
@@ -159,8 +165,12 @@ function debug($: Dollar, message: string): void {
|
|
|
159
165
|
// A hot reload resets it, which costs at most one early refresh.
|
|
160
166
|
let queue: Promise<void> = Promise.resolve()
|
|
161
167
|
let generation = 0
|
|
168
|
+
// Bumped by every pin move, same repo or not: a read that spans one is stale even when `generation` held.
|
|
169
|
+
let pinMoves = 0
|
|
162
170
|
let lastStartedAt: number | null = null
|
|
163
|
-
|
|
171
|
+
// When the last tracker write finished: a turn end that finds it newer than the last read start reads at once.
|
|
172
|
+
let lastWriteAt: number | null = null
|
|
173
|
+
let trailing: Tracked | null = null
|
|
164
174
|
let ticker: Tracked | null = null
|
|
165
175
|
// What /clear would take from the pane, kept across it (module variables survive it, atoms do not).
|
|
166
176
|
type Carried = { isAutoOpened: boolean; viewed: number | null; active: number | null; isDashboard: boolean }
|
|
@@ -245,6 +255,40 @@ const readOpen = async ($: Dollar, pin: RepoPin, label: string, also?: string):
|
|
|
245
255
|
const readOpenFallback = ($: Dollar, pin: RepoPin, label: string, also?: string) =>
|
|
246
256
|
gh($, pin, ['issue', 'list', ...(also === undefined ? [] : ['--label', also]), '--label', label, '--state', 'open', '--limit', String(LIST_LIMIT), '--json', TICKET_FIELDS])
|
|
247
257
|
|
|
258
|
+
// The gh login per host, read once: the account does not change under a running session.
|
|
259
|
+
const loginByHost = new Map<string, string>()
|
|
260
|
+
const readLogin = async ($: Dollar, pin: RepoPin): Promise<string | null> => {
|
|
261
|
+
const known = loginByHost.get(pin.host)
|
|
262
|
+
if (known !== undefined) return known
|
|
263
|
+
const out = await gh($, pin, ['api', 'graphql', '-f', `query=${LOGIN_QUERY}`])
|
|
264
|
+
const login = out.ok ? parseLogin(out.stdout) : null
|
|
265
|
+
if (login !== null) loginByHost.set(pin.host, login)
|
|
266
|
+
return login
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// My open tickets in the repo, on any open map or the whiteboard. Fail-closed like the UAT read: a login or a page
|
|
270
|
+
// that fails or is unreadable makes the read null, and the last good list stays; never a partial list.
|
|
271
|
+
const readMine = async ($: Dollar, pin: RepoPin): Promise<readonly MyTicket[] | null> => {
|
|
272
|
+
const login = await readLogin($, pin)
|
|
273
|
+
if (login === null) return null
|
|
274
|
+
const rows: unknown[] = []
|
|
275
|
+
let cursor: string | null = null
|
|
276
|
+
while (rows.length < LIST_LIMIT) {
|
|
277
|
+
const args = ['api', 'graphql', '-F', 'owner={owner}', '-F', 'repo={repo}', '-f', `query=${MINE_QUERY}`, '-f', `login=${login}`]
|
|
278
|
+
const out = await gh($, pin, cursor === null ? args : [...args, '-f', `after=${cursor}`])
|
|
279
|
+
const page = out.ok ? parseIssuesPage(out.stdout) : null
|
|
280
|
+
if (page === null) return null
|
|
281
|
+
rows.push(...page.rows)
|
|
282
|
+
if (!page.hasNextPage) break
|
|
283
|
+
cursor = page.endCursor
|
|
284
|
+
}
|
|
285
|
+
return myTicketsFromRows(rows)
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const keepMine = async ($: Dollar, mine: readonly MyTicket[] | null) => {
|
|
289
|
+
if (mine !== null && !sameValue(await read($, myTickets), mine)) await update($, myTickets, () => mine)
|
|
290
|
+
}
|
|
291
|
+
|
|
248
292
|
// The pending UAT tickets of one scope (a map label, or the whiteboard) with their bodies, for the parent each
|
|
249
293
|
// one tests. Read through GraphQL (the search index lags a write by seconds, so a follow-up just opened would
|
|
250
294
|
// show without its parent line), all open `trailhead:uat` tickets, the scope filtered here. Fail-closed: a page
|
|
@@ -307,18 +351,18 @@ async function dropClosedView($: Dollar, maps: readonly MapSummary[] | null) {
|
|
|
307
351
|
}
|
|
308
352
|
|
|
309
353
|
// One read of the viewed (else active) map. The result is dropped when the pane closed meanwhile
|
|
310
|
-
// (generation) or the target moved on, checked before every write; a moved
|
|
311
|
-
async function fetchMap($: Dollar, pin: RepoPin, token: number, wantsAnother: () => void) {
|
|
354
|
+
// (generation) or the target or pin moved on, checked before every write; a moved one is read again.
|
|
355
|
+
async function fetchMap($: Dollar, pin: RepoPin, token: number, moves: number, wantsAnother: () => void) {
|
|
312
356
|
const target = await readTarget($)
|
|
313
357
|
if (token !== generation) return
|
|
314
|
-
// Checked before every write: the last test is the token, with no await after
|
|
315
|
-
// another repo (even with the same map number there) makes the read stale; the
|
|
316
|
-
// under the new pin for it.
|
|
358
|
+
// Checked before every write: the last test is the token, then the pin-move count, with no await after
|
|
359
|
+
// them. A pin that moved to another repo (even with the same map number there) makes the read stale; the
|
|
360
|
+
// target is not read under the new pin for it. A move that landed during the checks shows in the count.
|
|
317
361
|
const isStale = async () => {
|
|
318
362
|
if (token !== generation) return true
|
|
319
363
|
const isMoved = !isSamePin(await read($, repoPin), pin) || (await read($, isDashboard)) || (await readTarget($)) !== target
|
|
320
364
|
if (token !== generation) return true
|
|
321
|
-
if (!isMoved) return false
|
|
365
|
+
if (!isMoved && moves === pinMoves) return false
|
|
322
366
|
wantsAnother()
|
|
323
367
|
return true
|
|
324
368
|
}
|
|
@@ -337,12 +381,13 @@ async function fetchMap($: Dollar, pin: RepoPin, token: number, wantsAnother: ()
|
|
|
337
381
|
await setPane($, { ...EMPTY, maps: before.maps, whiteboard: before.whiteboard, openTotal: before.openTotal, isLoading: true })
|
|
338
382
|
}
|
|
339
383
|
|
|
340
|
-
const [view, list, historyRead, mapsRead, uatParents] = await readList($, pin, target, before.openTotal)
|
|
384
|
+
const [[view, list, historyRead, mapsRead, uatParents], mine] = await Promise.all([readList($, pin, target, before.openTotal), readMine($, pin)])
|
|
341
385
|
const maps = mapsRead.maps ?? before.maps
|
|
342
386
|
const { whiteboard, openTotal } = mapsRead
|
|
343
387
|
await dropClosedView($, mapsRead.maps)
|
|
344
388
|
const now = await $.clock.now()
|
|
345
389
|
if (await isStale()) return
|
|
390
|
+
await keepMine($, mine)
|
|
346
391
|
|
|
347
392
|
const kept = isSameMap ? before : EMPTY
|
|
348
393
|
const failure = !view.ok ? view.reason : !list.ok ? list.reason : null
|
|
@@ -362,13 +407,13 @@ async function fetchMap($: Dollar, pin: RepoPin, token: number, wantsAnother: ()
|
|
|
362
407
|
|
|
363
408
|
// The whiteboard's open tickets (map-less) and the pinned index, plus the open maps. Same drop rule
|
|
364
409
|
// as a map read: a result is not committed once the pane closed or the dashboard was left.
|
|
365
|
-
async function fetchDashboard($: Dollar, pin: RepoPin, token: number, wantsAnother: () => void) {
|
|
410
|
+
async function fetchDashboard($: Dollar, pin: RepoPin, token: number, moves: number, wantsAnother: () => void) {
|
|
366
411
|
const isStale = async () => {
|
|
367
412
|
if (token !== generation) return true
|
|
368
413
|
const isOther = !isSamePin(await read($, repoPin), pin)
|
|
369
414
|
const isLeft = !(await read($, isDashboard))
|
|
370
415
|
if (token !== generation) return true
|
|
371
|
-
if (!isOther && !isLeft) return false
|
|
416
|
+
if (!isOther && !isLeft && moves === pinMoves) return false
|
|
372
417
|
wantsAnother()
|
|
373
418
|
return true
|
|
374
419
|
}
|
|
@@ -377,14 +422,16 @@ async function fetchDashboard($: Dollar, pin: RepoPin, token: number, wantsAnoth
|
|
|
377
422
|
if (before.fetchedAt === null && !before.isLoading && before.error === null) {
|
|
378
423
|
await update($, dashboardPane, () => ({ ...before, isLoading: true }))
|
|
379
424
|
}
|
|
380
|
-
const [list, index, mapsRead, uatParents] = await Promise.all([
|
|
425
|
+
const [list, index, mapsRead, uatParents, mine] = await Promise.all([
|
|
381
426
|
readOpen($, pin, 'trailhead:whiteboard', 'trailhead:ticket'),
|
|
382
427
|
gh($, pin, ['issue', 'list', '--label', 'trailhead:dashboard', '--state', 'open', '--limit', '10', '--json', DASHBOARD_FIELDS]),
|
|
383
428
|
readMaps($, pin, keptTotal),
|
|
384
429
|
readUat($, pin, 'trailhead:whiteboard'),
|
|
430
|
+
readMine($, pin),
|
|
385
431
|
])
|
|
386
432
|
const now = await $.clock.now()
|
|
387
433
|
if (await isStale()) return
|
|
434
|
+
await keepMine($, mine)
|
|
388
435
|
const shown = await read($, mapPane)
|
|
389
436
|
await setPane($, { ...shown, maps: mapsRead.maps ?? shown.maps, whiteboard: mapsRead.whiteboard, openTotal: mapsRead.openTotal })
|
|
390
437
|
await dropClosedView($, mapsRead.maps)
|
|
@@ -404,16 +451,19 @@ async function fetchDashboard($: Dollar, pin: RepoPin, token: number, wantsAnoth
|
|
|
404
451
|
// holds for the old one (and drops a read of it still in flight), so its tickets never show under the new.
|
|
405
452
|
let paneRepo: string | null = null
|
|
406
453
|
// Called by refreshPin right before the pin atom changes, from the pane's refresh and the band's alike. A move
|
|
407
|
-
// to another repo drops the reads in flight and empties what the pane holds for
|
|
408
|
-
// fresh read (a closed pane makes that a no-op), so its tickets never show under the
|
|
454
|
+
// to another repo (or to no pin) drops the reads in flight and, to another repo, empties what the pane holds for
|
|
455
|
+
// the old one, then asks for a fresh read (a closed pane makes that a no-op), so its tickets never show under the
|
|
456
|
+
// new repo. A move inside the same repo leaves the read in flight to its own stale check, which reads again.
|
|
409
457
|
export async function onPinMove($: Dollar, pin: RepoPin | null): Promise<void> {
|
|
410
|
-
|
|
458
|
+
pinMoves += 1
|
|
411
459
|
const key = pin === null ? null : pinKey(pin)
|
|
412
460
|
const isOtherRepo = paneRepo !== null && key !== null && key !== paneRepo
|
|
461
|
+
if (isOtherRepo || pin === null) generation += 1
|
|
413
462
|
paneRepo = key ?? paneRepo
|
|
414
463
|
if (!isOtherRepo) return
|
|
415
464
|
await update($, mapPane, () => EMPTY)
|
|
416
465
|
await update($, dashboardPane, () => EMPTY_DASHBOARD)
|
|
466
|
+
await update($, myTickets, () => [])
|
|
417
467
|
await update($, activeMap, () => null)
|
|
418
468
|
await update($, viewedMap, () => null)
|
|
419
469
|
await update($, isDashboard, () => false)
|
|
@@ -435,25 +485,28 @@ function stopTimers() {
|
|
|
435
485
|
generation += 1
|
|
436
486
|
// A fill timer dropped with the pane must not leave the next pane unfillable.
|
|
437
487
|
isFilling = false
|
|
438
|
-
trailing?.cancel()
|
|
488
|
+
trailing?.timer.cancel()
|
|
439
489
|
trailing = null
|
|
490
|
+
lastWriteAt = null
|
|
440
491
|
ticker?.timer.cancel()
|
|
441
492
|
ticker = null
|
|
442
493
|
}
|
|
443
494
|
|
|
444
|
-
//
|
|
445
|
-
//
|
|
495
|
+
// A timer past its due time by more than the grace never fired: drop it, so the caller starts another.
|
|
496
|
+
// Both the slow timer and the trailing refresh are tracked, as the host may silently drop either.
|
|
446
497
|
const liveOrNull = (t: Tracked | null, now: number): Tracked | null => {
|
|
447
498
|
if (t === null || !isTimerLate(now, t.dueAt, TIMER_GRACE_MS)) return t
|
|
448
499
|
t.timer.cancel()
|
|
449
500
|
return null
|
|
450
501
|
}
|
|
451
502
|
|
|
452
|
-
const startTrailing = ($: Dollar, waitMs: number):
|
|
453
|
-
$.clock.after(waitMs, () => {
|
|
503
|
+
const startTrailing = ($: Dollar, now: number, waitMs: number): Tracked => ({
|
|
504
|
+
timer: $.clock.after(waitMs, () => {
|
|
454
505
|
trailing = null
|
|
455
506
|
void requestRefresh($)
|
|
456
|
-
})
|
|
507
|
+
}),
|
|
508
|
+
dueAt: now + waitMs,
|
|
509
|
+
})
|
|
457
510
|
|
|
458
511
|
const startTicker = ($: Dollar, now: number): Tracked => ({
|
|
459
512
|
timer: $.clock.every(TIMER_MS, () => void requestRefresh($, { isTick: true })),
|
|
@@ -464,7 +517,9 @@ const startTicker = ($: Dollar, now: number): Tracked => ({
|
|
|
464
517
|
// `isWrite`: a tracker write just ran; a read that starts at once is followed by one more at the floor's end,
|
|
465
518
|
// by when the search index (history, the list fallback) has caught up.
|
|
466
519
|
// `isTick`: the slow timer fired, so its next due time moves on.
|
|
467
|
-
|
|
520
|
+
// `isTurnEnd`: the turn is over; if a write landed after the last read started, the read runs inside the floor
|
|
521
|
+
// too, so the pane shows the turn's final tracker state without waiting on a timer the host may have dropped.
|
|
522
|
+
type Request = { bypassFloor?: boolean; isWrite?: boolean; isTick?: boolean; isTurnEnd?: boolean }
|
|
468
523
|
|
|
469
524
|
// Throttled: inside the floor it keeps ONE trailing refresh for the rest of it, so an event
|
|
470
525
|
// just after a refresh still shows.
|
|
@@ -472,24 +527,32 @@ async function decide($: Dollar, request: Request) {
|
|
|
472
527
|
if (!(await isPaneUp($))) return
|
|
473
528
|
// Nothing pinned: no repo to read, so no call.
|
|
474
529
|
const pin = await syncPin($)
|
|
530
|
+
const moves = pinMoves
|
|
475
531
|
if (pin === null) return
|
|
476
532
|
const now = await $.clock.now()
|
|
477
533
|
if (request.isTick === true && ticker !== null) ticker = { ...ticker, dueAt: now + TIMER_MS }
|
|
478
534
|
// A timer the host dropped is started again here, else the pane would wait on it for good.
|
|
479
535
|
ticker = liveOrNull(ticker, now) ?? startTicker($, now)
|
|
480
|
-
|
|
536
|
+
// Same for the trailing refresh: a dead handle would keep `??=` below from arming a live one.
|
|
537
|
+
trailing = liveOrNull(trailing, now)
|
|
538
|
+
// Strictly newer: a read that started at the write's own instant already saw it.
|
|
539
|
+
const isFlush = request.isTurnEnd === true && lastWriteAt !== null && (lastStartedAt === null || lastWriteAt > lastStartedAt)
|
|
540
|
+
const decision = request.bypassFloor === true || isFlush ? { run: true as const } : refreshDecision(now, lastStartedAt, MIN_REFRESH_MS)
|
|
481
541
|
if (!decision.run) {
|
|
482
|
-
trailing ??= startTrailing($, decision.waitMs)
|
|
542
|
+
trailing ??= startTrailing($, now, decision.waitMs)
|
|
483
543
|
return
|
|
484
544
|
}
|
|
485
545
|
lastStartedAt = now
|
|
486
|
-
trailing
|
|
487
|
-
|
|
546
|
+
// A flush leaves the trailing refresh alone: it still owes the read after the search index caught up.
|
|
547
|
+
if (!isFlush) {
|
|
548
|
+
trailing?.timer.cancel()
|
|
549
|
+
trailing = null
|
|
550
|
+
}
|
|
488
551
|
// Never bypassFloor: a burst of writes still costs one read per floor plus this one trailing.
|
|
489
|
-
if (request.isWrite === true) trailing = startTrailing($, MIN_REFRESH_MS)
|
|
552
|
+
if (request.isWrite === true) trailing = startTrailing($, now, MIN_REFRESH_MS)
|
|
490
553
|
const again = () => void requestRefresh($, { bypassFloor: true })
|
|
491
|
-
if (await read($, isDashboard)) await fetchDashboard($, pin, generation, again)
|
|
492
|
-
else await fetchMap($, pin, generation, again)
|
|
554
|
+
if (await read($, isDashboard)) await fetchDashboard($, pin, generation, moves, again)
|
|
555
|
+
else await fetchMap($, pin, generation, moves, again)
|
|
493
556
|
}
|
|
494
557
|
|
|
495
558
|
// Does nothing while the pane is closed: the data exists only for the pane.
|
|
@@ -832,19 +895,26 @@ async function autoOpen($: Dollar) {
|
|
|
832
895
|
|
|
833
896
|
// Un pannello aperto ma non disegnato (l'auto-open sotto la soglia dei pannelli non richiesti, tipico di
|
|
834
897
|
// una sessione appena aperta) non è visibile: il comando lo riapre, e una richiesta della persona lo
|
|
835
|
-
// disegna a qualsiasi larghezza. Se nemmeno così viene disegnato
|
|
898
|
+
// disegna a qualsiasi larghezza. Se nemmeno così viene disegnato lo chiude, tranne quando non c'è ancora
|
|
899
|
+
// nessuna superficie (`no-surface`): è l'attesa normale dell'inizio sessione, il pannello resta aperto e
|
|
900
|
+
// viene disegnato appena una superficie si collega (il suo render chiede la prima lettura).
|
|
901
|
+
const PENDING_MESSAGE = 'Map panel will open as soon as the screen is ready.'
|
|
902
|
+
const isNoSurface = (reason: string) => reason.startsWith('no-surface')
|
|
903
|
+
|
|
836
904
|
async function togglePanel($: Dollar): Promise<string> {
|
|
837
905
|
const pane = (await $.ui.panes()).find(p => p.id === PANE_ID)
|
|
838
906
|
if (pane?.isPlaced === false) {
|
|
839
907
|
const reopened = await openPane($)
|
|
840
908
|
if (reopened.isPlaced) return 'Map panel opened.'
|
|
909
|
+
if (isNoSurface(reopened.reason)) return PENDING_MESSAGE
|
|
841
910
|
}
|
|
842
911
|
if (pane !== undefined) {
|
|
843
912
|
await $.ui.close({ id: PANE_ID })
|
|
844
913
|
return 'Map panel closed.'
|
|
845
914
|
}
|
|
846
915
|
const opened = await openPane($)
|
|
847
|
-
|
|
916
|
+
if (opened.isPlaced) return 'Map panel opened.'
|
|
917
|
+
return isNoSurface(opened.reason) ? PENDING_MESSAGE : `Map panel not shown: ${opened.reason}`
|
|
848
918
|
}
|
|
849
919
|
|
|
850
920
|
// The close button: the pane goes (its `ui.close` hook stops the timers). The press counts as the
|
|
@@ -876,7 +946,7 @@ export function registerMapPane(on: On) {
|
|
|
876
946
|
const result = await next(e)
|
|
877
947
|
await safeSyncPin($)
|
|
878
948
|
await safeReadActiveMap($)
|
|
879
|
-
void requestRefresh(
|
|
949
|
+
void requestRefresh($, { isTurnEnd: true })
|
|
880
950
|
// The band's hook marks the turn over; the timer runs after the whole chain, and releases a held press.
|
|
881
951
|
if (e.agentId === undefined) $.clock.after(0, () => releaseHeld($))
|
|
882
952
|
return result
|
|
@@ -903,6 +973,7 @@ export function registerMapPane(on: On) {
|
|
|
903
973
|
// After the call ran: a Bash `gh issue` write changed the tracker.
|
|
904
974
|
on('tool.call', { tool: 'Bash', command: GH_ISSUE_WRITE }, async ($, e, next) => {
|
|
905
975
|
const result = await next(e)
|
|
976
|
+
lastWriteAt = await $.clock.now()
|
|
906
977
|
void requestRefresh($, { isWrite: true })
|
|
907
978
|
await hintWhiteboard($, e.command, result)
|
|
908
979
|
return result
|
|
@@ -941,6 +1012,7 @@ export function registerMapPane(on: On) {
|
|
|
941
1012
|
const play = await read($, workState)
|
|
942
1013
|
const orphaned = await read($, isOrphaned)
|
|
943
1014
|
const own = await read($, ownTicket)
|
|
1015
|
+
const mine = await read($, myTickets)
|
|
944
1016
|
const pin = await read($, repoPin)
|
|
945
1017
|
const pending = await read($, pendingWork)
|
|
946
1018
|
const phase = await read($, pendingPhase)
|
|
@@ -1019,7 +1091,7 @@ export function registerMapPane(on: On) {
|
|
|
1019
1091
|
|
|
1020
1092
|
// This session's own ticket survives a work-state file another session took over; without one, the file decides.
|
|
1021
1093
|
const isInPlay = (t: PaneTicket) => (own !== null ? t.number === own : play !== null && !orphaned && play.ticket === t.number)
|
|
1022
|
-
const row = (t: PaneTicket, hasMarkColumn: boolean) => {
|
|
1094
|
+
const row = (t: PaneTicket, hasMarkColumn: boolean, scope: string | null = null) => {
|
|
1023
1095
|
const r = rowLayout(t, { isInPlay: isInPlay(t), hasMarkColumn, pendingWork: pending, pendingPhase: phase, columns: cols })
|
|
1024
1096
|
const style = r.isInPlay ? accent : r.isDim ? dim : {}
|
|
1025
1097
|
const holder = r.isDim ? dim : {}
|
|
@@ -1059,18 +1131,20 @@ export function registerMapPane(on: On) {
|
|
|
1059
1131
|
</Box>
|
|
1060
1132
|
)
|
|
1061
1133
|
const parent = uatParentLine(t, cols)
|
|
1062
|
-
return parent === null ? (
|
|
1134
|
+
return parent === null && scope === null ? (
|
|
1063
1135
|
line
|
|
1064
1136
|
) : (
|
|
1065
1137
|
<Box key={`entry-${t.number}`} flexDirection="column">
|
|
1066
1138
|
{line}
|
|
1067
|
-
{text(`uat-of-${t.number}`, dim, parent)}
|
|
1139
|
+
{parent === null ? null : text(`uat-of-${t.number}`, dim, parent)}
|
|
1140
|
+
{scope === null ? null : text(`scope-of-${t.number}`, dim, scope)}
|
|
1068
1141
|
</Box>
|
|
1069
1142
|
)
|
|
1070
1143
|
}
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1144
|
+
// `elsewhere`: my tickets of other scopes, listed after the shown scope's with the line naming where each is.
|
|
1145
|
+
const group = (key: string, name: string, rows: readonly PaneTicket[], elsewhere: readonly MyTicket[] = []) => {
|
|
1146
|
+
if (rows.length + elsewhere.length === 0) return null
|
|
1147
|
+
const hasMarkColumn = [...rows, ...elsewhere].some(isInPlay)
|
|
1074
1148
|
return (
|
|
1075
1149
|
<Box
|
|
1076
1150
|
key={`group-${key}`}
|
|
@@ -1078,17 +1152,19 @@ export function registerMapPane(on: On) {
|
|
|
1078
1152
|
marginTop={gap}
|
|
1079
1153
|
{...(e.surface === 'desktop' ? { rowGap: DESKTOP_ROW_GAP } : {})}
|
|
1080
1154
|
>
|
|
1081
|
-
{text(`head-${key}`, bold, `${name} · ${rows.length}`)}
|
|
1155
|
+
{text(`head-${key}`, bold, `${name} · ${rows.length + elsewhere.length}`)}
|
|
1082
1156
|
{rows.map(t => row(t, hasMarkColumn))}
|
|
1157
|
+
{elsewhere.map(t => row(t, hasMarkColumn, scopeLine(t, data.maps, cols)))}
|
|
1083
1158
|
</Box>
|
|
1084
1159
|
)
|
|
1085
1160
|
}
|
|
1086
|
-
const ticketGroups = (tickets: readonly PaneTicket[]) => {
|
|
1161
|
+
const ticketGroups = (tickets: readonly PaneTicket[], here: TicketScope) => {
|
|
1087
1162
|
const groups = groupTickets(tickets)
|
|
1163
|
+
// In progress first, right under the maps list: what I already hold comes before what to pick up.
|
|
1088
1164
|
return [
|
|
1165
|
+
group('in-progress', 'In progress', groups.inProgress, elsewhereInProgress(mine, here, tickets)),
|
|
1089
1166
|
group('needs-you', 'Needs you', groups.needsYou),
|
|
1090
1167
|
group('frontier', 'Frontier', groups.frontier),
|
|
1091
|
-
group('in-progress', 'In progress', groups.inProgress),
|
|
1092
1168
|
group('uat', 'UAT', groups.uat),
|
|
1093
1169
|
group('paused', 'Paused', groups.paused),
|
|
1094
1170
|
group('blocked', 'Blocked', groups.blocked),
|
|
@@ -1146,8 +1222,12 @@ export function registerMapPane(on: On) {
|
|
|
1146
1222
|
|
|
1147
1223
|
// A list older than one slow-timer period missed its reads (the pane undrawn when they came, or the
|
|
1148
1224
|
// timer dropped): the render asks for one, as for an empty view. A failing read keeps its slow pace.
|
|
1225
|
+
// A trailing refresh past its due time by more than the grace was dropped by the host: the render asks for
|
|
1226
|
+
// the read it would have made (the request replaces the dead handle).
|
|
1227
|
+
const isTrailingLate = trailing !== null && isTimerLate(now, trailing.dueAt, TIMER_GRACE_MS)
|
|
1149
1228
|
const isOverdue = (d: { fetchedAt: number | null; error: string | null; isLoading: boolean }) =>
|
|
1150
|
-
|
|
1229
|
+
isTrailingLate ||
|
|
1230
|
+
(d.fetchedAt !== null && d.error === null && !d.isLoading && isTimerLate(now, d.fetchedAt + TIMER_MS, TIMER_GRACE_MS))
|
|
1151
1231
|
|
|
1152
1232
|
// No GitHub repo was ever pinned: say so rather than a `gh` failure; nothing is read.
|
|
1153
1233
|
if (pin === null) {
|
|
@@ -1177,7 +1257,7 @@ export function registerMapPane(on: On) {
|
|
|
1177
1257
|
{text('head-whiteboard', bold, whiteboardHeading(isLoaded ? board.tickets : null, board.isCapped))}
|
|
1178
1258
|
</Box>
|
|
1179
1259
|
{!isLoaded && board.error === null ? text('loading', dim, 'Loading dashboard…') : null}
|
|
1180
|
-
{ticketGroups(board.tickets)}
|
|
1260
|
+
{ticketGroups(board.tickets, { kind: 'whiteboard' })}
|
|
1181
1261
|
{isEmpty ? text('no-tickets', dim, 'No open whiteboard tickets.') : null}
|
|
1182
1262
|
{notes(board)}
|
|
1183
1263
|
<Box key="spacer" flexGrow={1} />
|
|
@@ -1250,7 +1330,7 @@ export function registerMapPane(on: On) {
|
|
|
1250
1330
|
{whiteboardRow}
|
|
1251
1331
|
{totalLine}
|
|
1252
1332
|
{mapsGroup}
|
|
1253
|
-
{ticketGroups(tickets)}
|
|
1333
|
+
{ticketGroups(tickets, { kind: 'map', number: target })}
|
|
1254
1334
|
{isCurrent && data.error === null && tickets.length === 0 ? (
|
|
1255
1335
|
<Box key="no-tickets" marginTop={gap}>
|
|
1256
1336
|
<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
|
+
}
|
|
@@ -46,13 +46,16 @@ export const nextPin = (current: RepoPin | null, repo: RepoRead | null, toplevel
|
|
|
46
46
|
}
|
|
47
47
|
|
|
48
48
|
// The pinned checkout as the trailing word of a verb's args: `--in=` plus base64url of the root (alphabet
|
|
49
|
-
// [A-Za-z0-9_-], no padding), so a path with spaces, quotes or a leading `-` stays one shell-safe word
|
|
50
|
-
//
|
|
49
|
+
// [A-Za-z0-9_-], no padding), so a path with spaces, quotes or a leading `-` stays one shell-safe word, then `.` and
|
|
50
|
+
// base64url of `host/owner/name` (`.` is outside the alphabet, so it separates unambiguously). The verb decodes it
|
|
51
|
+
// with templates/trailhead-checkout-in.js and moves there first; when the root is gone, the repo part lets the
|
|
52
|
+
// helper find the checkout by its remote (#312, #329).
|
|
51
53
|
// No Buffer in the mod's runtime: UTF-8 bytes through btoa, then the url-safe alphabet.
|
|
52
|
-
|
|
53
|
-
const binary = Array.from(new TextEncoder().encode(
|
|
54
|
-
return
|
|
54
|
+
const base64url = (text: string): string => {
|
|
55
|
+
const binary = Array.from(new TextEncoder().encode(text), byte => String.fromCharCode(byte)).join('')
|
|
56
|
+
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
|
|
55
57
|
}
|
|
58
|
+
export const checkoutArg = (pin: RepoPin): string => `--in=${base64url(pin.root)}.${base64url(pinKey(pin))}`
|
|
56
59
|
|
|
57
60
|
// A repo-relative path under the pinned root, absolute whatever the session cwd is.
|
|
58
61
|
export const pinPath = (pin: RepoPin, relative: string): string => `${pin.root.replace(/\/+$/, '')}/${relative}`
|
|
@@ -186,6 +186,21 @@ function labelGuardNotice(cwd) {
|
|
|
186
186
|
}
|
|
187
187
|
}
|
|
188
188
|
|
|
189
|
+
// Preflight dell'auth di gh (#328): un GH_TOKEN/GITHUB_TOKEN non valido che
|
|
190
|
+
// oscura il login gh funzionante fa fallire ogni chiamata gh; lo segnaliamo
|
|
191
|
+
// una volta, all'avvio (la lib salta resume e compact). Come reviewNotice: il
|
|
192
|
+
// require e' nel try, ogni errore -> null.
|
|
193
|
+
function authNotice(input) {
|
|
194
|
+
try {
|
|
195
|
+
return require('./lib/gh-auth-preflight.js').check({
|
|
196
|
+
env: process.env,
|
|
197
|
+
source: input && typeof input.source === 'string' ? input.source : undefined,
|
|
198
|
+
}) || null;
|
|
199
|
+
} catch {
|
|
200
|
+
return null;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
189
204
|
// Legge il JSON di SessionStart da stdin in modo asincrono, con timeout, come
|
|
190
205
|
// gli altri hook: una lettura sincrona di fd 0 si bloccherebbe per sempre su
|
|
191
206
|
// un TTY o su una pipe mai chiusa. Su TTY o input assente si prosegue con {}.
|
|
@@ -212,11 +227,12 @@ function readInput(done) {
|
|
|
212
227
|
function main(input) {
|
|
213
228
|
const cwd = (input && typeof input.cwd === 'string' && input.cwd) || process.cwd();
|
|
214
229
|
|
|
230
|
+
const auth = authNotice(input);
|
|
215
231
|
const review = reviewNotice(cwd);
|
|
216
232
|
const guard = labelGuardNotice(cwd);
|
|
217
233
|
const update = updateNotice();
|
|
218
234
|
|
|
219
|
-
const parts = [review, guard, update].filter(Boolean);
|
|
235
|
+
const parts = [auth, review, guard, update].filter(Boolean);
|
|
220
236
|
if (!parts.length) return;
|
|
221
237
|
|
|
222
238
|
process.stdout.write(JSON.stringify({
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Test dell'hook SessionStart trailhead-check-update.js (notice auth di gh).
|
|
3
|
+
// Run: node trailhead-check-update.test.js
|
|
4
|
+
// Un gh finto in una bin dir tmp (prima nel PATH) applica la precedenza reale.
|
|
5
|
+
const assert = require('assert');
|
|
6
|
+
const { execFileSync } = require('child_process');
|
|
7
|
+
const path = require('path');
|
|
8
|
+
const fs = require('fs');
|
|
9
|
+
const os = require('os');
|
|
10
|
+
|
|
11
|
+
let passed = 0;
|
|
12
|
+
const ok = (name, cond) => { assert.ok(cond, name); passed++; };
|
|
13
|
+
|
|
14
|
+
const hookPath = path.join(__dirname, 'trailhead-check-update.js');
|
|
15
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'trailhead-check-update-test-'));
|
|
16
|
+
const bin = path.join(tmp, 'bin');
|
|
17
|
+
fs.mkdirSync(bin);
|
|
18
|
+
const fakeGh = `#!${process.execPath}
|
|
19
|
+
const a = process.argv.slice(2);
|
|
20
|
+
if (a[0] !== 'api' || a[1] !== 'user') process.exit(1); // altre chiamate (update check): innocue
|
|
21
|
+
const t = process.env.GH_TOKEN || process.env.GITHUB_TOKEN;
|
|
22
|
+
const v = t ? t : (process.env.FAKE_GH_STORED || 'bad');
|
|
23
|
+
if (v === 'good') { console.log('octo'); process.exit(0); }
|
|
24
|
+
console.log('{"message":"Bad credentials","status":"401"}');
|
|
25
|
+
console.error('gh: Bad credentials (HTTP 401)');
|
|
26
|
+
process.exit(1);
|
|
27
|
+
`;
|
|
28
|
+
fs.writeFileSync(path.join(bin, 'gh'), fakeGh, { mode: 0o755 });
|
|
29
|
+
|
|
30
|
+
const repo = path.join(tmp, 'repo');
|
|
31
|
+
execFileSync('git', ['init', '-q', repo]);
|
|
32
|
+
|
|
33
|
+
function run(source, extraEnv) {
|
|
34
|
+
const env = { ...process.env, ...extraEnv };
|
|
35
|
+
delete env.GH_TOKEN; delete env.GITHUB_TOKEN;
|
|
36
|
+
Object.assign(env, extraEnv, {
|
|
37
|
+
PATH: `${bin}${path.delimiter}${process.env.PATH}`,
|
|
38
|
+
XDG_CACHE_HOME: path.join(tmp, 'cache'),
|
|
39
|
+
CLAUDE_CONFIG_DIR: path.join(tmp, 'cfg'),
|
|
40
|
+
});
|
|
41
|
+
const input = { cwd: repo };
|
|
42
|
+
if (source) input.source = source;
|
|
43
|
+
const out = execFileSync('node', [hookPath], { input: JSON.stringify(input), encoding: 'utf8', env });
|
|
44
|
+
return out ? JSON.parse(out).hookSpecificOutput.additionalContext : '';
|
|
45
|
+
}
|
|
46
|
+
const AUTH = 'GitHub rejects the';
|
|
47
|
+
|
|
48
|
+
for (const source of ['startup', 'clear', undefined]) {
|
|
49
|
+
const ctx = run(source, { GITHUB_TOKEN: 'bad', FAKE_GH_STORED: 'good' });
|
|
50
|
+
ok(`source ${source}: auth notice surfaced`, ctx.includes(`${AUTH} GITHUB_TOKEN`) && ctx.includes('GITHUB_TOKEN= gh'));
|
|
51
|
+
ok(`source ${source}: token value not leaked`, !ctx.includes('bad'));
|
|
52
|
+
}
|
|
53
|
+
for (const source of ['resume', 'compact']) {
|
|
54
|
+
ok(`source ${source}: no auth notice`, !run(source, { GITHUB_TOKEN: 'bad', FAKE_GH_STORED: 'good' }).includes(AUTH));
|
|
55
|
+
}
|
|
56
|
+
ok('no token var: no auth notice', !run('startup', { FAKE_GH_STORED: 'good' }).includes(AUTH));
|
|
57
|
+
ok('valid token: no auth notice', !run('startup', { GITHUB_TOKEN: 'good' }).includes(AUTH));
|
|
58
|
+
|
|
59
|
+
fs.rmSync(tmp, { recursive: true, force: true });
|
|
60
|
+
console.log(`trailhead-check-update: ${passed} passed`);
|