jackherd 1.6.0 → 1.7.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/CHANGELOG.md +31 -0
- package/INSTALL.md +176 -0
- package/README.md +260 -315
- package/assets/branding/README.md +7 -3
- package/assets/branding/jackherd-logo-dark-1024.png +0 -0
- package/assets/branding/jackherd-logo-dark-512.png +0 -0
- package/assets/branding/jackherd-logo-dark.svg +1 -1
- package/assets/icons/bark-dark.svg +1 -0
- package/assets/icons/bark.svg +1 -0
- package/assets/icons/bowl-dark.svg +1 -0
- package/assets/icons/bowl.svg +1 -0
- package/assets/icons/decide-dark.svg +1 -0
- package/assets/icons/decide.svg +1 -0
- package/assets/icons/ear-dark.svg +1 -0
- package/assets/icons/ear.svg +1 -0
- package/assets/icons/lanes-dark.svg +1 -0
- package/assets/icons/lanes.svg +1 -0
- package/assets/icons/plan-dark.svg +1 -0
- package/assets/icons/plan.svg +1 -0
- package/assets/icons/space.svg +1 -0
- package/dist/{chunk-QJSXHHQ3.js → chunk-OBUWTQ6Z.js} +25 -0
- package/dist/config.d.ts +14 -3
- package/dist/config.js +111 -10
- package/dist/plan-format.d.ts +180 -77
- package/dist/plan-format.js +292 -93
- package/docs/lanes-dark.svg +50 -0
- package/docs/lanes.svg +50 -1
- package/docs/skills-dark.svg +65 -0
- package/docs/skills.svg +65 -1
- package/docs/workflow-dark.svg +138 -0
- package/docs/workflow.svg +135 -169
- package/herdr-plugin/herdr-plugin.toml +6 -6
- package/herdr-plugin/open-plans.ts +1 -1
- package/herdr-plugin/restore-plans.ts +7 -7
- package/install.sh +1 -1
- package/kit/hooks/hooks/approval-gate/decision.ts +61 -0
- package/kit/hooks/hooks/approval-gate/herdr-panes.ts +24 -0
- package/kit/hooks/hooks/approval-gate/index.ts +69 -0
- package/kit/hooks/hooks/approval-gate/lane.ts +14 -0
- package/kit/hooks/hooks/approval-gate/request.ts +111 -0
- package/kit/hooks/hooks/approval-gate/run.ts +94 -0
- package/kit/hooks/hooks/approval-gate/settings.ts +26 -0
- package/kit/hooks/hooks/approval-gate/store.ts +70 -0
- package/kit/hooks/hooks/approval-gate/transcripts.ts +40 -0
- package/kit/hooks/hooks/merge-guard/approval.ts +11 -74
- package/kit/hooks/hooks/merge-guard/run.ts +2 -1
- package/kit/hooks/hooks/model-gate/agent-definition.ts +13 -0
- package/kit/hooks/hooks/model-gate/index.ts +58 -0
- package/kit/hooks/hooks/model-gate/run.ts +63 -0
- package/kit/hooks/hooks/model-log/index.ts +60 -0
- package/kit/hooks/hooks/model-log/run.ts +57 -0
- package/kit/hooks/hooks/model-log/store.ts +40 -0
- package/kit/hooks/hooks/model-log/transcript.ts +37 -0
- package/kit/hooks/hooks/shared/checkout.ts +22 -0
- package/kit/hooks/hooks/shared/command.ts +6 -0
- package/kit/hooks/hooks/shared/config.ts +21 -0
- package/kit/hooks/hooks/shared/dialog-answer.ts +5 -0
- package/kit/hooks/hooks/shared/dialog-answers.ts +74 -0
- package/kit/hooks/hooks/shared/files.ts +19 -0
- package/kit/hooks/hooks/shared/fresh.ts +4 -0
- package/kit/hooks/hooks/shared/json.ts +4 -0
- package/kit/hooks/hooks/shared/model-family.ts +48 -0
- package/kit/hooks/hooks/shared/plan-model-table.ts +41 -0
- package/kit/hooks/hooks/shared/private-directory.ts +29 -0
- package/kit/hooks/hooks/shared/repository-directory.ts +14 -0
- package/kit/hooks/hooks/shared/subagent-phase.ts +99 -0
- package/kit/hooks/hooks.json +25 -0
- package/kit/workflow/skills/follow/SKILL.md +4 -3
- package/kit/workflow/skills/implement/SKILL.md +6 -6
- package/kit/workflow/skills/orchestrate/SKILL.md +113 -30
- package/kit/workflow/skills/plan/SKILL.md +5 -5
- package/kit/workflow/skills/start/SKILL.md +2 -2
- package/package.json +4 -1
- package/schemas/jackherd.schema.json +43 -5
- package/src/check/command.ts +31 -4
- package/src/check/deferral-arrows.ts +1 -1
- package/src/check/parallel-report.ts +23 -0
- package/src/check/plan-structure-check.ts +120 -53
- package/src/cli.ts +8 -2
- package/src/config/nested-quantifier.ts +73 -0
- package/src/config/repository-directory.ts +11 -0
- package/src/config/schema.ts +63 -8
- package/src/plan-format/board-prose.ts +36 -0
- package/src/plan-format/dependencies.ts +227 -0
- package/src/plan-format/documentation-gate.ts +16 -0
- package/src/plan-format/index.ts +23 -1
- package/src/plan-format/lanes.ts +35 -1
- package/src/plan-format/model-column.ts +52 -0
- package/src/plan-format/phases.ts +16 -1
- package/src/plan-format/plan-structure.ts +10 -28
- package/src/plan-format/progress.ts +7 -29
- package/src/plans/command.ts +3 -3
- package/src/plans/current-phase.ts +71 -0
- package/src/plans/ensure-panes.ts +2 -2
- package/src/plans/herd-command.ts +4 -0
- package/src/plans/lane-discovery.ts +14 -2
- package/src/plans/lane-tickets.ts +69 -0
- package/src/plans/layout.ts +182 -88
- package/src/plans/live.ts +5 -1
- package/src/plans/phase-models-read.ts +37 -0
- package/src/plans/phase-models.ts +70 -0
- package/src/plans/plan-panes.ts +8 -7
- package/src/plans/pull-requests.ts +78 -4
- package/src/plans/selection.ts +1 -40
- package/src/plans/settings.ts +9 -3
- package/src/plans/shown-with-more.ts +5 -0
- package/src/plans/snapshot.ts +82 -8
- package/src/plans/sprint-html.ts +2 -2
- package/src/plans/supervisor.ts +5 -4
- package/src/plans/symbols.ts +9 -6
- package/src/plans/texts.ts +30 -13
- package/src/plans/view.ts +1 -0
- package/src/sync/herdr-plugin.ts +1 -1
- package/src/sync/next-steps.ts +1 -1
- package/src/sync/render.ts +2 -1
- package/src/sync/setup-questions.ts +2 -2
- package/src/terminal/text.ts +4 -0
- package/src/terminal/types.ts +1 -1
- package/src/vocabulary/index.ts +39 -0
- package/src/watch/approvals.ts +96 -0
- package/src/watch/command.ts +154 -50
- package/src/watch/failed-tests.ts +52 -0
- package/src/watch/failure-read.ts +56 -0
- package/src/watch/failures.ts +107 -0
- package/src/watch/feedback-item.ts +21 -0
- package/src/watch/feedback-read.ts +2 -1
- package/src/watch/feedback.ts +37 -28
- package/src/watch/gh-process.ts +53 -0
- package/src/watch/gh.ts +10 -20
- package/src/watch/heartbeat-progress.ts +1 -1
- package/src/watch/lane-model.ts +54 -0
- package/src/watch/lane-parallel.ts +46 -0
- package/src/watch/lane-progress.ts +28 -4
- package/src/watch/lane-report.ts +62 -9
- package/src/watch/lane-sources.ts +1 -1
- package/src/watch/no-finding.ts +47 -0
- package/src/watch/plan-badges-read.ts +19 -7
- package/src/watch/plan-badges.ts +59 -7
- package/src/watch/plan-views.ts +5 -5
- package/src/watch/previews.ts +3 -9
- package/src/watch/pull-requests.ts +12 -2
- package/src/watch/recorded-reads.ts +25 -0
- package/src/watch/source-fields.ts +4 -0
- package/src/watch/source-tickets.ts +99 -0
- package/src/watch/sources.ts +62 -15
- package/src/watch/texts.ts +21 -1
- package/src/watch/watch-defaults.ts +8 -0
- package/src/watch/watch-events.ts +64 -4
- package/src/watch/watcher-lock.ts +1 -1
- package/src/watch/workflow-runs.ts +5 -7
- package/docs/lanes.mmd +0 -9
- package/docs/skills.mmd +0 -10
package/src/plans/live.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { dirname } from 'node:path'
|
|
|
3
3
|
import { pathToFileURL } from 'node:url'
|
|
4
4
|
import { terminalSafe } from '../plan-format/terminal-text'
|
|
5
5
|
import { openInBrowser } from '../terminal/open'
|
|
6
|
+
import type { LaneMemory } from './current-phase'
|
|
6
7
|
import { errorText } from './error-text'
|
|
7
8
|
import {
|
|
8
9
|
type FetchState,
|
|
@@ -50,6 +51,8 @@ type Live = {
|
|
|
50
51
|
/** This clone's `FETCH_HEAD`, watched beside the plan files; null when git names none. */
|
|
51
52
|
fetchHead: string | null
|
|
52
53
|
reported: Map<string, string | null>
|
|
54
|
+
/** Per lane, the ticks seen at the last read and the phase that moved last; the herd's start empties it. */
|
|
55
|
+
memories: Map<string, LaneMemory>
|
|
53
56
|
pending: Timer | null
|
|
54
57
|
/** The snapshot as read, without the plan chips; `snapshot` is this plus the chips of `page`. */
|
|
55
58
|
read: Snapshot | null
|
|
@@ -185,7 +188,7 @@ const refresh = (live: Live, onChange: () => void): Promise<void> => {
|
|
|
185
188
|
|
|
186
189
|
const readOnce = async (live: Live, onChange: () => void) => {
|
|
187
190
|
try {
|
|
188
|
-
const read = await loadSnapshot(live.source, live.pullRequests)
|
|
191
|
+
const read = await loadSnapshot(live.source, live.pullRequests, live.memories)
|
|
189
192
|
const next = withKnownPage(live, read)
|
|
190
193
|
live.read = read
|
|
191
194
|
if (!live.snapshot) live.view = { ...live.view, selection: startSelection(next, live.source.options) }
|
|
@@ -277,6 +280,7 @@ export const runLive = (source: Source): void => {
|
|
|
277
280
|
watchers: new Map(),
|
|
278
281
|
fetchHead: fetchHeadPath(source.root),
|
|
279
282
|
reported: new Map(),
|
|
283
|
+
memories: new Map(),
|
|
280
284
|
pending: null,
|
|
281
285
|
read: null,
|
|
282
286
|
page: null,
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { lstatSync, readdirSync, readFileSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
import { isPhaseModelRecord, type PhaseModelRecord } from './phase-models'
|
|
4
|
+
|
|
5
|
+
/** The file name the hook gives a record: 16 hex characters of a hash over plan and phase. */
|
|
6
|
+
const RECORD_FILE = /^[0-9a-f]{16}\.json$/
|
|
7
|
+
|
|
8
|
+
/** A record is a line of JSON; anything larger was not written by the hook. */
|
|
9
|
+
const MAX_RECORD_BYTES = 4 * 1024
|
|
10
|
+
|
|
11
|
+
/** A plain file of record size: a link could read elsewhere and a pipe would block the herd, so neither is read. */
|
|
12
|
+
const isRecordFile = (path: string): boolean => {
|
|
13
|
+
const entry = lstatSync(path)
|
|
14
|
+
return entry.isFile() && entry.size < MAX_RECORD_BYTES
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const recordOf = (path: string): PhaseModelRecord[] => {
|
|
18
|
+
try {
|
|
19
|
+
if (!isRecordFile(path)) return []
|
|
20
|
+
const record: unknown = JSON.parse(readFileSync(path, 'utf8'))
|
|
21
|
+
return isPhaseModelRecord(record) ? [record] : []
|
|
22
|
+
} catch {
|
|
23
|
+
return []
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Every record of the model log in the main checkout; nothing when the directory is missing or unreadable. */
|
|
28
|
+
export const readPhaseModels = (root: string): PhaseModelRecord[] => {
|
|
29
|
+
const directory = join(root, '.jackherd', 'models')
|
|
30
|
+
try {
|
|
31
|
+
return readdirSync(directory)
|
|
32
|
+
.filter((name) => RECORD_FILE.test(name))
|
|
33
|
+
.flatMap((name) => recordOf(join(directory, name)))
|
|
34
|
+
} catch {
|
|
35
|
+
return []
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { type LaneProgress, resolvePlanPath } from '../plan-format'
|
|
2
|
+
import { isRecord } from '../watch/json-guards'
|
|
3
|
+
|
|
4
|
+
/** The model families a phase ran on and was recommended, as lower-case words; `fits` null without a recommendation. */
|
|
5
|
+
export type PhaseModel = { used: string; recommended: string | null; fits: boolean | null }
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* A record as the kit's model log hook writes it to `.jackherd/models/<key>.json` in the main checkout: the plan from
|
|
9
|
+
* the repository root, the phase, the model id of the subagent's last answer and when it ended. Same shape as
|
|
10
|
+
* `PhaseModelRecord` in the hook, which this package does not import at run time.
|
|
11
|
+
*/
|
|
12
|
+
export type PhaseModelRecord = PhaseModel & { plan: string; phase: string; model: string; at: number }
|
|
13
|
+
|
|
14
|
+
/** A family as a word of at most 16 lower-case letters, so a record cannot widen the phase row. */
|
|
15
|
+
const FAMILY = /^[a-z]{1,16}$/
|
|
16
|
+
|
|
17
|
+
const isFamily = (value: unknown): value is string => typeof value === 'string' && FAMILY.test(value)
|
|
18
|
+
|
|
19
|
+
const isVerdict = (recommended: unknown, fits: unknown): boolean =>
|
|
20
|
+
(recommended === null && fits === null) || (isFamily(recommended) && typeof fits === 'boolean')
|
|
21
|
+
|
|
22
|
+
export const isPhaseModelRecord = (value: unknown): value is PhaseModelRecord =>
|
|
23
|
+
isRecord(value) &&
|
|
24
|
+
typeof value.plan === 'string' &&
|
|
25
|
+
typeof value.phase === 'string' &&
|
|
26
|
+
typeof value.model === 'string' &&
|
|
27
|
+
isFamily(value.used) &&
|
|
28
|
+
isVerdict(value.recommended, value.fits) &&
|
|
29
|
+
typeof value.at === 'number'
|
|
30
|
+
|
|
31
|
+
/** The key of a phase of a lane's step in `LaneView.models`. */
|
|
32
|
+
export const phaseModelKey = (stepIndex: number, phaseId: string): string => `${stepIndex}:${phaseId}`
|
|
33
|
+
|
|
34
|
+
const recordKey = (plan: string, phase: string): string => `${plan}\n${phase}`
|
|
35
|
+
|
|
36
|
+
/** The newest record per plan and phase. */
|
|
37
|
+
const newestByPhase = (records: readonly PhaseModelRecord[]): Map<string, PhaseModelRecord> => {
|
|
38
|
+
const newest = new Map<string, PhaseModelRecord>()
|
|
39
|
+
for (const record of records) {
|
|
40
|
+
const key = recordKey(record.plan, record.phase)
|
|
41
|
+
const known = newest.get(key)
|
|
42
|
+
if (!known || record.at > known.at) newest.set(key, record)
|
|
43
|
+
}
|
|
44
|
+
return newest
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The model of every phase of the lane's steps that has a record, by `phaseModelKey`. */
|
|
48
|
+
export const laneModelsOf = (
|
|
49
|
+
progress: LaneProgress,
|
|
50
|
+
records: readonly PhaseModelRecord[],
|
|
51
|
+
sprintDirectory: string,
|
|
52
|
+
): Record<string, PhaseModel> => {
|
|
53
|
+
const newest = newestByPhase(records)
|
|
54
|
+
return Object.fromEntries(
|
|
55
|
+
progress.steps.flatMap(({ step, phases }, stepIndex) => {
|
|
56
|
+
const plan = step.planHref ? resolvePlanPath(sprintDirectory, step.planHref) : null
|
|
57
|
+
return phases.flatMap((phase) => {
|
|
58
|
+
const record = plan === null ? undefined : newest.get(recordKey(plan, phase.id))
|
|
59
|
+
return record
|
|
60
|
+
? [
|
|
61
|
+
[
|
|
62
|
+
phaseModelKey(stepIndex, phase.id),
|
|
63
|
+
{ used: record.used, recommended: record.recommended, fits: record.fits },
|
|
64
|
+
],
|
|
65
|
+
]
|
|
66
|
+
: []
|
|
67
|
+
})
|
|
68
|
+
}),
|
|
69
|
+
)
|
|
70
|
+
}
|
package/src/plans/plan-panes.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { basename } from 'node:path'
|
|
2
|
+
import { HERD_COMMAND, HERD_COMMAND_WORDS } from './herd-command'
|
|
2
3
|
|
|
3
|
-
/** One herdr pane as `
|
|
4
|
+
/** One herdr pane as `herd --ensure` sees it: where it is, whether an agent runs in it, its foreground commands. */
|
|
4
5
|
export type PaneState = { pane: string; tab: string; cwd: string; agent: boolean; foreground: string[][] }
|
|
5
6
|
|
|
6
7
|
/** Runs a command in a free shell pane, or splits a pane to the right and runs it in the new one. */
|
|
@@ -79,7 +80,7 @@ export const foregroundOf = (json: string): string[][] => {
|
|
|
79
80
|
|
|
80
81
|
const SHELLS = new Set(['bash', 'zsh', 'sh', 'fish', 'dash', 'ksh'])
|
|
81
82
|
|
|
82
|
-
/** Arguments that make a `
|
|
83
|
+
/** Arguments that make a `herd` call something other than the live view. */
|
|
83
84
|
const NOT_A_VIEW = ['--once', '--ensure']
|
|
84
85
|
|
|
85
86
|
const programOf = (argv: readonly string[]): string => basename(argv[0] ?? '').replace(/^-/, '')
|
|
@@ -92,12 +93,12 @@ const laneOf = (argv: readonly string[]): string | null => {
|
|
|
92
93
|
}
|
|
93
94
|
|
|
94
95
|
const isViewCall = (argv: readonly string[], lane: string | null): boolean =>
|
|
95
|
-
|
|
96
|
+
HERD_COMMAND_WORDS.includes(argv[argv.findIndex(isKitEntry) + 1] ?? '') &&
|
|
96
97
|
argv.some(isKitEntry) &&
|
|
97
98
|
!argv.some((arg) => NOT_A_VIEW.includes(arg)) &&
|
|
98
99
|
laneOf(argv) === lane
|
|
99
100
|
|
|
100
|
-
/** Whether a foreground command is the live
|
|
101
|
+
/** Whether a foreground command is the live herd: of `lane`, or of every lane for null. */
|
|
101
102
|
export const isPlanView = (foreground: readonly (readonly string[])[], lane: string | null): boolean =>
|
|
102
103
|
foreground.some((argv) => isViewCall(argv, lane))
|
|
103
104
|
|
|
@@ -125,14 +126,14 @@ export const missingCoordinatorNote = (panes: readonly PaneState[], root: string
|
|
|
125
126
|
const inRoot = panes.filter((each) => each.agent && each.cwd === root).length
|
|
126
127
|
return inRoot === 0
|
|
127
128
|
? 'coordinator: no agent in the main checkout'
|
|
128
|
-
: `coordinator: ${inRoot} agents in the main checkout and none named ${name}; run
|
|
129
|
+
: `coordinator: ${inRoot} agents in the main checkout and none named ${name}; run herd --ensure in its pane, or herdr agent rename <pane> ${name}`
|
|
129
130
|
}
|
|
130
131
|
|
|
131
132
|
const quoted = (arg: string): string => (/^[\w@%+=:,./-]+$/.test(arg) ? arg : `'${arg.replaceAll("'", `'\\''`)}'`)
|
|
132
133
|
|
|
133
134
|
/** The shell command that starts the view with the CLI `argv` names (runtime and entry), for `lane` or every lane. */
|
|
134
135
|
export const viewCommandOf = (argv: readonly string[], lane: string | null): string =>
|
|
135
|
-
[...argv.slice(0, 2),
|
|
136
|
+
[...argv.slice(0, 2), HERD_COMMAND.name, ...(lane ? ['--lane', lane] : [])].map(quoted).join(' ')
|
|
136
137
|
|
|
137
138
|
/** The new pane of a `herdr pane split` answer; throws on a changed answer. */
|
|
138
139
|
export const splitPaneOf = (json: string): string => {
|
|
@@ -178,7 +179,7 @@ const inMainCheckout = (root: string, worktrees: string) => {
|
|
|
178
179
|
const laneAnchor = (panes: readonly PaneState[], inside: (cwd: string) => boolean): PaneState | undefined =>
|
|
179
180
|
panes.find((each) => inside(each.cwd) && each.agent)
|
|
180
181
|
|
|
181
|
-
/** The
|
|
182
|
+
/** The herd panes that are missing beside the coordinator and each lane, and how to bring each back. */
|
|
182
183
|
export const planPaneActions = ({
|
|
183
184
|
panes,
|
|
184
185
|
coordinator,
|
|
@@ -12,10 +12,26 @@ export type PullRequest = {
|
|
|
12
12
|
branch: string
|
|
13
13
|
draft: boolean
|
|
14
14
|
checks: ChecksState
|
|
15
|
+
/** The names of the checks that failed, each once, in the rollup's order; empty while none failed. */
|
|
16
|
+
failedChecks: string[]
|
|
15
17
|
}
|
|
16
18
|
|
|
17
|
-
/**
|
|
18
|
-
|
|
19
|
+
/**
|
|
20
|
+
* One entry of `statusCheckRollup`: a check run carries a name, status, conclusion and the link to its job
|
|
21
|
+
* (`detailsUrl`), a commit status a context and a state.
|
|
22
|
+
*/
|
|
23
|
+
export type RollupEntry = {
|
|
24
|
+
__typename?: string
|
|
25
|
+
name?: string
|
|
26
|
+
context?: string
|
|
27
|
+
detailsUrl?: string
|
|
28
|
+
status?: string
|
|
29
|
+
conclusion?: string | null
|
|
30
|
+
state?: string
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** A failed check: its name, and the workflow run it belongs to when the link of its job names one. */
|
|
34
|
+
export type FailedCheck = { name: string; runId: number | null }
|
|
19
35
|
|
|
20
36
|
export type GhPullRequest = {
|
|
21
37
|
number: number
|
|
@@ -29,6 +45,18 @@ export type GhPullRequest = {
|
|
|
29
45
|
/** The fields `parsePullRequests` reads; pass them to `gh pr list --json`. */
|
|
30
46
|
export const PULL_REQUEST_FIELDS = 'number,title,url,headRefName,isDraft,statusCheckRollup'
|
|
31
47
|
|
|
48
|
+
const RUN_IN_PATH = /^\/([\w.-]+)\/([\w.-]+)\/actions\/runs\/(\d+)(?:\/|$)/
|
|
49
|
+
|
|
50
|
+
const PULL_REQUEST_PATH = /^\/([\w.-]+)\/([\w.-]+)\/pull\/(\d+)$/
|
|
51
|
+
|
|
52
|
+
/** The longest check name shown or reported; a name comes from the outside. */
|
|
53
|
+
export const CHECK_NAME_LENGTH = 60
|
|
54
|
+
|
|
55
|
+
const DEFAULT_HOST = 'github.com'
|
|
56
|
+
|
|
57
|
+
/** Where a run link must lead: `host`, and when `repository` (`<owner>/<repo>`) is set, that repository only. */
|
|
58
|
+
export type RunScope = { host: string; repository: string | null }
|
|
59
|
+
|
|
32
60
|
const FAILED = new Set(['FAILURE', 'CANCELLED', 'TIMED_OUT', 'ACTION_REQUIRED', 'STARTUP_FAILURE', 'ERROR'])
|
|
33
61
|
const PENDING_STATES = new Set(['PENDING', 'EXPECTED'])
|
|
34
62
|
|
|
@@ -44,7 +72,52 @@ export const checksOf = (rollup: readonly RollupEntry[]): ChecksState => {
|
|
|
44
72
|
return rollup.some(isPending) ? CHECKS.pending : CHECKS.passed
|
|
45
73
|
}
|
|
46
74
|
|
|
47
|
-
|
|
75
|
+
/**
|
|
76
|
+
* The workflow run a link of a check names: `https://<host>/<owner>/<repo>/actions/runs/<id>` with an optional rest.
|
|
77
|
+
* The link comes from the outside and ends up as an argument of `gh run view`, so the host must be `scope.host`
|
|
78
|
+
* (without credentials, over https), the repository `scope.repository` when set, and the id a safe integer.
|
|
79
|
+
*/
|
|
80
|
+
export const runIdOfLink = (
|
|
81
|
+
link: string | undefined,
|
|
82
|
+
scope: RunScope = { host: DEFAULT_HOST, repository: null },
|
|
83
|
+
): number | null => {
|
|
84
|
+
try {
|
|
85
|
+
const url = new URL(link ?? '')
|
|
86
|
+
const match = url.pathname.match(RUN_IN_PATH)
|
|
87
|
+
const own = url.protocol === 'https:' && url.hostname === scope.host && url.username === '' && url.password === ''
|
|
88
|
+
const sameRepository = scope.repository === null || `${match?.[1]}/${match?.[2]}` === scope.repository
|
|
89
|
+
const id = Number(match?.[3])
|
|
90
|
+
return own && match && sameRepository && Number.isSafeInteger(id) ? id : null
|
|
91
|
+
} catch {
|
|
92
|
+
return null
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** The scope of a pull request page: its host and repository, null when the page is not one. */
|
|
97
|
+
const scopeOfPullRequest = (pullRequestUrl: string | undefined): RunScope | undefined => {
|
|
98
|
+
if (pullRequestUrl === undefined) return undefined
|
|
99
|
+
try {
|
|
100
|
+
const url = new URL(pullRequestUrl)
|
|
101
|
+
const path = url.pathname.match(PULL_REQUEST_PATH)
|
|
102
|
+
return { host: url.hostname, repository: path ? `${path[1]}/${path[2]}` : null }
|
|
103
|
+
} catch {
|
|
104
|
+
return undefined
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* The checks of the rollup that failed, each name once with the run of its first failure, in the rollup's order. Names
|
|
110
|
+
* come from the outside and are made terminal-safe; an entry without a name is left out.
|
|
111
|
+
*/
|
|
112
|
+
export const failedChecksOf = (rollup: readonly RollupEntry[], pullRequestUrl?: string): FailedCheck[] => {
|
|
113
|
+
const scope = scopeOfPullRequest(pullRequestUrl)
|
|
114
|
+
const failed = new Map<string, FailedCheck>()
|
|
115
|
+
for (const entry of rollup.filter(isFailed)) {
|
|
116
|
+
const name = terminalSafe(entry.name ?? entry.context ?? '').trim()
|
|
117
|
+
if (name !== '' && !failed.has(name)) failed.set(name, { name, runId: runIdOfLink(entry.detailsUrl, scope) })
|
|
118
|
+
}
|
|
119
|
+
return [...failed.values()]
|
|
120
|
+
}
|
|
48
121
|
|
|
49
122
|
/** True only for `https://<host>/<owner>/<repo>/pull/<number>` of this very pull request: the view opens it on a click. */
|
|
50
123
|
const isPullRequestPage = (url: string, number: number, host: string): boolean => {
|
|
@@ -52,7 +125,7 @@ const isPullRequestPage = (url: string, number: number, host: string): boolean =
|
|
|
52
125
|
const parsed = new URL(url)
|
|
53
126
|
const path = parsed.pathname.match(PULL_REQUEST_PATH)
|
|
54
127
|
const exact = parsed.href === url && parsed.search === '' && parsed.hash === ''
|
|
55
|
-
return exact && parsed.protocol === 'https:' && parsed.hostname === host && Number(path?.[
|
|
128
|
+
return exact && parsed.protocol === 'https:' && parsed.hostname === host && Number(path?.[3]) === number
|
|
56
129
|
} catch {
|
|
57
130
|
return false
|
|
58
131
|
}
|
|
@@ -89,6 +162,7 @@ export const parsePullRequests = (json: string, host = 'github.com'): PullReques
|
|
|
89
162
|
branch: terminalSafe(pullRequest.headRefName),
|
|
90
163
|
draft: pullRequest.isDraft,
|
|
91
164
|
checks: checksOf(pullRequest.statusCheckRollup ?? []),
|
|
165
|
+
failedChecks: failedChecksOf(pullRequest.statusCheckRollup ?? [], pullRequest.url).map(({ name }) => name),
|
|
92
166
|
}))
|
|
93
167
|
}
|
|
94
168
|
|
package/src/plans/selection.ts
CHANGED
|
@@ -12,7 +12,7 @@ export type LaneFold = { status: string | null; open: boolean }
|
|
|
12
12
|
/**
|
|
13
13
|
* The row under the cursor and the expanded phases, both as keys, whether ticked phases and items are
|
|
14
14
|
* listed one by one or summed up in one line, whether the footer shows the symbol legend, and the
|
|
15
|
-
* folds of the lanes by lane name
|
|
15
|
+
* folds of the lanes by lane name.
|
|
16
16
|
*/
|
|
17
17
|
export type Selection = {
|
|
18
18
|
selected: string | null
|
|
@@ -20,7 +20,6 @@ export type Selection = {
|
|
|
20
20
|
showDone: boolean
|
|
21
21
|
showLegend: boolean
|
|
22
22
|
laneFolds: ReadonlyMap<string, LaneFold>
|
|
23
|
-
notesShown: ReadonlySet<string>
|
|
24
23
|
}
|
|
25
24
|
|
|
26
25
|
/** A lane as the folds need it: its name and the status its agent reports, null without an agent. */
|
|
@@ -51,7 +50,6 @@ export const INPUT = {
|
|
|
51
50
|
collapse: ['\x1b[D', '\x1bOD', 'h'],
|
|
52
51
|
showDone: ['a'],
|
|
53
52
|
legend: ['?'],
|
|
54
|
-
notes: ['n'],
|
|
55
53
|
sprintPage: ['s'],
|
|
56
54
|
pageUp: ['\x1b[5~'],
|
|
57
55
|
pageDown: ['\x1b[6~'],
|
|
@@ -73,13 +71,9 @@ export const PLANNED_KEY = 'planned'
|
|
|
73
71
|
|
|
74
72
|
export const phaseKey = (lane: string, stepIndex: number, phaseId: string): string => `${lane}:${stepIndex}:${phaseId}`
|
|
75
73
|
|
|
76
|
-
const NOTES_KEY_PREFIX = 'notes>'
|
|
77
74
|
const FOLLOWING_KEY_PREFIX = 'following>'
|
|
78
75
|
const TICKED_KEY_PREFIX = 'ticked>'
|
|
79
76
|
|
|
80
|
-
/** The key of the row that shows or hides the notes of a lane; selectable, since a key reaches it too. */
|
|
81
|
-
export const notesKey = (lane: string): string => `${NOTES_KEY_PREFIX}${lane}`
|
|
82
|
-
|
|
83
77
|
/** The key of the folded row under a lane that lists the open phases after its own. */
|
|
84
78
|
export const followingKey = (lane: string): string => `${FOLLOWING_KEY_PREFIX}${lane}`
|
|
85
79
|
|
|
@@ -98,12 +92,6 @@ export const itemKey = (phase: string, index: number): string => `${phase}#${ind
|
|
|
98
92
|
/** The phase a task key belongs to, null for any other key. */
|
|
99
93
|
export const itemPhaseOfKey = (key: string): string | null => key.match(ITEM_KEY)?.[1] ?? null
|
|
100
94
|
|
|
101
|
-
const notesLaneOfKey = (key: string): string | null =>
|
|
102
|
-
key.startsWith(NOTES_KEY_PREFIX) ? key.slice(NOTES_KEY_PREFIX.length) : null
|
|
103
|
-
|
|
104
|
-
const followingLaneOfKey = (key: string): string | null =>
|
|
105
|
-
key.startsWith(FOLLOWING_KEY_PREFIX) ? key.slice(FOLLOWING_KEY_PREFIX.length) : null
|
|
106
|
-
|
|
107
95
|
const tickedPhaseOfKey = (key: string): string | null =>
|
|
108
96
|
key.startsWith(TICKED_KEY_PREFIX) ? key.slice(TICKED_KEY_PREFIX.length) : null
|
|
109
97
|
|
|
@@ -114,12 +102,6 @@ export const laneKey = (lane: string): string => `${LANE_KEY_PREFIX}${lane}`
|
|
|
114
102
|
|
|
115
103
|
const laneOfKey = (key: string): string | null => (key.startsWith(LANE_KEY_PREFIX) ? key.slice(1) : null)
|
|
116
104
|
|
|
117
|
-
const PHASE_KEY = /^(.+):\d+:[^:]+$/
|
|
118
|
-
|
|
119
|
-
/** The lane a row belongs to: its own row or one of its phases; null for a pull request row. */
|
|
120
|
-
const laneNameOfKey = (key: string): string | null =>
|
|
121
|
-
laneOfKey(key) ?? notesLaneOfKey(key) ?? followingLaneOfKey(key) ?? key.match(PHASE_KEY)?.[1] ?? null
|
|
122
|
-
|
|
123
105
|
/** Working and blocked lanes show their phases; idle and done ones, and lanes without an agent, fold into one line. */
|
|
124
106
|
export const autoOpen = (status: string | null): boolean =>
|
|
125
107
|
status !== null && status !== AGENT_STATUS.idle && status !== AGENT_STATUS.done
|
|
@@ -153,7 +135,6 @@ export const overviewSelection = (): Selection => ({
|
|
|
153
135
|
showDone: false,
|
|
154
136
|
showLegend: false,
|
|
155
137
|
laneFolds: new Map(),
|
|
156
|
-
notesShown: new Set(),
|
|
157
138
|
})
|
|
158
139
|
|
|
159
140
|
/** The lane view opens its lane and, when it has a current phase, that phase, expanded. */
|
|
@@ -200,13 +181,6 @@ const foldLane = (selection: Selection, lane: string, next: (open: boolean) => b
|
|
|
200
181
|
return { ...selection, laneFolds: new Map(selection.laneFolds).set(lane, { ...fold, open: next(fold.open) }) }
|
|
201
182
|
}
|
|
202
183
|
|
|
203
|
-
const withNotes = (selection: Selection, lane: string, next: (shown: boolean) => boolean): Selection => {
|
|
204
|
-
const notesShown = new Set(selection.notesShown)
|
|
205
|
-
if (next(notesShown.has(lane))) notesShown.add(lane)
|
|
206
|
-
else notesShown.delete(lane)
|
|
207
|
-
return { ...selection, notesShown }
|
|
208
|
-
}
|
|
209
|
-
|
|
210
184
|
/** Opens a task and the phase it belongs to. */
|
|
211
185
|
const openTaskWithPhase = (expanded: Set<string>, phase: string, task: string): void => {
|
|
212
186
|
expanded.add(phase)
|
|
@@ -215,8 +189,6 @@ const openTaskWithPhase = (expanded: Set<string>, phase: string, task: string):
|
|
|
215
189
|
|
|
216
190
|
/** Selects a row and opens it, or closes it when it is already open: a phase shows its items, a lane its phases. */
|
|
217
191
|
export const applyClick = (selection: Selection, key: string): Selection => {
|
|
218
|
-
const notesLane = notesLaneOfKey(key)
|
|
219
|
-
if (notesLane !== null) return { ...withNotes(selection, notesLane, flipped), selected: key }
|
|
220
192
|
const phase = tickedPhaseOfKey(key)
|
|
221
193
|
if (phase !== null) return { ...selection, showDone: !selection.showDone, selected: phase }
|
|
222
194
|
const lane = laneOfKey(key)
|
|
@@ -236,8 +208,6 @@ const toggle = (selection: Selection, keys: readonly string[]): Selection => {
|
|
|
236
208
|
const changeSelected = (selection: Selection, keys: readonly string[], next: (open: boolean) => boolean): Selection => {
|
|
237
209
|
const current = selectedKey(selection, keys)
|
|
238
210
|
if (!current) return selection
|
|
239
|
-
const notesLane = notesLaneOfKey(current)
|
|
240
|
-
if (notesLane !== null) return { ...withNotes(selection, notesLane, next), selected: current }
|
|
241
211
|
const lane = laneOfKey(current)
|
|
242
212
|
if (lane !== null) return { ...foldLane(selection, lane, next), selected: current }
|
|
243
213
|
const expanded = new Set(selection.expanded)
|
|
@@ -253,14 +223,6 @@ const expand = (selection: Selection, keys: readonly string[]): Selection => cha
|
|
|
253
223
|
|
|
254
224
|
const collapse = (selection: Selection, keys: readonly string[]): Selection => changeSelected(selection, keys, closed)
|
|
255
225
|
|
|
256
|
-
/** Lists or hides the notes of the lane under the cursor. */
|
|
257
|
-
const toggleNotes = (selection: Selection, keys: readonly string[]): Selection => {
|
|
258
|
-
const current = selectedKey(selection, keys)
|
|
259
|
-
const lane = current ? laneNameOfKey(current) : null
|
|
260
|
-
if (!lane) return selection
|
|
261
|
-
return withNotes(selection, lane, flipped)
|
|
262
|
-
}
|
|
263
|
-
|
|
264
226
|
export const applyKey = (selection: Selection, input: string, keys: readonly string[]): Selection => {
|
|
265
227
|
if (isInput(INPUT.up, input)) return move(selection, keys, -1)
|
|
266
228
|
if (isInput(INPUT.down, input)) return move(selection, keys, 1)
|
|
@@ -268,7 +230,6 @@ export const applyKey = (selection: Selection, input: string, keys: readonly str
|
|
|
268
230
|
if (isInput(INPUT.expand, input)) return expand(selection, keys)
|
|
269
231
|
if (isInput(INPUT.collapse, input)) return collapse(selection, keys)
|
|
270
232
|
if (isInput(INPUT.showDone, input)) return { ...selection, showDone: !selection.showDone }
|
|
271
|
-
if (isInput(INPUT.notes, input)) return toggleNotes(selection, keys)
|
|
272
233
|
if (isInput(INPUT.legend, input)) return { ...selection, showLegend: !selection.showLegend }
|
|
273
234
|
return selection
|
|
274
235
|
}
|
package/src/plans/settings.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { type JackherdConfig, parseJackherdConfig } from '../config'
|
|
|
4
4
|
import { PROJECT_CONFIG_PATH } from '../config/location'
|
|
5
5
|
import { readConfig } from '../config/read-config'
|
|
6
6
|
import { PLAN_LANGUAGE, type PlanLanguage } from '../vocabulary'
|
|
7
|
+
import type { TicketSource } from '../watch/plan-badges'
|
|
7
8
|
|
|
8
9
|
export const CONFIG_PATH = PROJECT_CONFIG_PATH
|
|
9
10
|
|
|
@@ -15,6 +16,8 @@ export type PlansSettings = Pick<
|
|
|
15
16
|
fetchIntervalMinutes: number
|
|
16
17
|
linkKinds: JackherdConfig['plans']['linkKinds']
|
|
17
18
|
context: JackherdConfig['plans']['context']
|
|
19
|
+
/** The `watch.sources` marked `ticket`, whose entries the view shows as badges. */
|
|
20
|
+
ticketSources: TicketSource[]
|
|
18
21
|
}
|
|
19
22
|
|
|
20
23
|
const CLOCK_LOCALE: Record<PlanLanguage, string> = {
|
|
@@ -22,7 +25,7 @@ const CLOCK_LOCALE: Record<PlanLanguage, string> = {
|
|
|
22
25
|
[PLAN_LANGUAGE.en]: 'en-GB',
|
|
23
26
|
}
|
|
24
27
|
|
|
25
|
-
/** The
|
|
28
|
+
/** The herd's slice of a validated config. */
|
|
26
29
|
export const plansSettingsOf = (config: JackherdConfig): PlansSettings => ({
|
|
27
30
|
planDirectory: config.planDirectory,
|
|
28
31
|
board: config.board,
|
|
@@ -34,9 +37,12 @@ export const plansSettingsOf = (config: JackherdConfig): PlansSettings => ({
|
|
|
34
37
|
fetchIntervalMinutes: config.plans.fetchIntervalMinutes,
|
|
35
38
|
linkKinds: config.plans.linkKinds,
|
|
36
39
|
context: config.plans.context,
|
|
40
|
+
ticketSources: config.watch.sources.flatMap(({ name, lane, ticket }) =>
|
|
41
|
+
ticket ? [{ name, lane: lane ?? null }] : [],
|
|
42
|
+
),
|
|
37
43
|
})
|
|
38
44
|
|
|
39
|
-
/** The
|
|
45
|
+
/** The herd's slice of `.jackherd/config.json`, validated by the shared schema. */
|
|
40
46
|
export const parsePlansSettings = (raw: unknown): PlansSettings => plansSettingsOf(parseJackherdConfig(raw ?? {}))
|
|
41
47
|
|
|
42
48
|
/** Board documents, relative to the main checkout. */
|
|
@@ -63,6 +69,6 @@ export const baseRefOf = ({ baseBranch }: Pick<PlansSettings, 'baseBranch'>): st
|
|
|
63
69
|
|
|
64
70
|
export const clockLocaleOf = (settings: PlansSettings): string => CLOCK_LOCALE[settings.responseLanguage]
|
|
65
71
|
|
|
66
|
-
/** The
|
|
72
|
+
/** The herd's settings from the config `readConfig` finds. */
|
|
67
73
|
export const readSettings = (roots: string[], home: string = homedir()): PlansSettings =>
|
|
68
74
|
plansSettingsOf(readConfig(roots, home))
|