jackherd 0.0.1 → 1.0.1
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 +65 -0
- package/LICENSE +21 -0
- package/NOTICE +38 -0
- package/README.md +1122 -3
- package/assets/branding/README.md +62 -0
- package/assets/branding/jackherd-icon-256.png +0 -0
- package/assets/branding/jackherd-icon-512.png +0 -0
- package/assets/branding/jackherd-icon-64.png +0 -0
- package/assets/branding/jackherd-icon-small-32.png +0 -0
- package/assets/branding/jackherd-icon-small-64.png +0 -0
- package/assets/branding/jackherd-icon-small.svg +1 -0
- package/assets/branding/jackherd-icon.svg +1 -0
- package/assets/branding/jackherd-logo-1024.png +0 -0
- package/assets/branding/jackherd-logo-512.png +0 -0
- 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 -0
- package/assets/branding/jackherd-logo-mark-1024.png +0 -0
- package/assets/branding/jackherd-logo-mark-512.png +0 -0
- package/assets/branding/jackherd-logo-mark.svg +1 -0
- package/assets/branding/jackherd-logo.svg +1 -0
- package/dist/chunk-R6MGHCD7.js +93 -0
- package/dist/config.d.ts +167 -0
- package/dist/config.js +401 -0
- package/dist/plan-format.d.ts +325 -0
- package/dist/plan-format.js +636 -0
- package/docs/lanes.mmd +9 -0
- package/docs/lanes.svg +1 -0
- package/docs/skills.mmd +10 -0
- package/docs/skills.svg +1 -0
- package/docs/workflow.svg +172 -0
- package/herdr-plugin/herdr-plugin.toml +22 -0
- package/herdr-plugin/open-plans.ts +43 -0
- package/herdr-plugin/restore-plans.ts +107 -0
- package/install.sh +157 -0
- package/kit/hooks/hooks/git-guard/decision.ts +438 -0
- package/kit/hooks/hooks/git-guard/index.ts +38 -0
- package/kit/hooks/hooks/git-guard/shell.ts +407 -0
- package/kit/hooks/hooks/watch-guard/background.ts +36 -0
- package/kit/hooks/hooks/watch-guard/decision.ts +53 -0
- package/kit/hooks/hooks/watch-guard/index.ts +11 -0
- package/kit/hooks/hooks/watch-guard/shell-input.ts +32 -0
- package/kit/hooks/hooks/watch-guard/starts.ts +154 -0
- package/kit/hooks/hooks/watch-guard/text-fallback.ts +40 -0
- package/kit/hooks/hooks.json +30 -0
- package/kit/hooks/statusline.sh +72 -0
- package/kit/review/agents/devops.md +11 -0
- package/kit/review/agents/public-content.md +39 -0
- package/kit/review/agents/security-audit.md +11 -0
- package/kit/review/agents/uncle-bob.md +11 -0
- package/kit/review/skills/devops/SKILL.md +50 -0
- package/kit/review/skills/diagnose/SKILL.md +140 -0
- package/kit/review/skills/diagnose/references/feedback-loops.md +59 -0
- package/kit/review/skills/diagnose/scripts/hitl-loop.template.sh +71 -0
- package/kit/review/skills/pre-commit/SKILL.md +100 -0
- package/kit/review/skills/review/SKILL.md +43 -0
- package/kit/review/skills/security-audit/SKILL.md +65 -0
- package/kit/review/skills/uncle-bob/SKILL.md +43 -0
- package/kit/rules/rules/kit/arc42.md +89 -0
- package/kit/rules/rules/kit/architecture-files.md +87 -0
- package/kit/rules/rules/kit/code-style.md +159 -0
- package/kit/rules/rules/kit/pull-requests.md +59 -0
- package/kit/rules/rules/kit/testing.md +91 -0
- package/kit/rules/rules/kit/writing-for-agents.md +110 -0
- package/kit/workflow/agents/explore.md +25 -0
- package/kit/workflow/agents/implement.md +16 -0
- package/kit/workflow/agents/plan.md +13 -0
- package/kit/workflow/agents/spec-review.md +96 -0
- package/kit/workflow/skills/follow/SKILL.md +48 -0
- package/kit/workflow/skills/grill/SKILL.md +60 -0
- package/kit/workflow/skills/implement/SKILL.md +107 -0
- package/kit/workflow/skills/orchestrate/SKILL.md +565 -0
- package/kit/workflow/skills/orchestrate/handover/decision.ts +247 -0
- package/kit/workflow/skills/orchestrate/handover/index.ts +171 -0
- package/kit/workflow/skills/plan/SKILL.md +118 -0
- package/kit/workflow/skills/plan/references/template-English.md +92 -0
- package/kit/workflow/skills/plan/references/template-German.md +92 -0
- package/kit/workflow/skills/start/SKILL.md +47 -0
- package/package.json +63 -5
- package/schemas/jackherd.schema.json +601 -0
- package/src/check/changes.ts +45 -0
- package/src/check/command-line.ts +12 -0
- package/src/check/command.ts +136 -0
- package/src/check/deferral-arrows.ts +41 -0
- package/src/check/execute.ts +29 -0
- package/src/check/glob.ts +15 -0
- package/src/check/plan-structure-check.ts +135 -0
- package/src/check/plan.ts +106 -0
- package/src/check/public-content-check.ts +32 -0
- package/src/check/public-content-facts.ts +177 -0
- package/src/check/public-content.ts +216 -0
- package/src/check/report.ts +55 -0
- package/src/check/result.ts +42 -0
- package/src/check/roots.ts +36 -0
- package/src/check/routing.ts +22 -0
- package/src/check/schema.ts +83 -0
- package/src/check/settings.ts +44 -0
- package/src/claude/paths.ts +14 -0
- package/src/cli.ts +46 -0
- package/src/config/command.ts +45 -0
- package/src/config/detect.ts +196 -0
- package/src/config/index.ts +2 -0
- package/src/config/json-schema.ts +5 -0
- package/src/config/legacy-names.ts +56 -0
- package/src/config/link-kinds.ts +25 -0
- package/src/config/location.ts +46 -0
- package/src/config/read-config.ts +42 -0
- package/src/config/repository-facts.ts +19 -0
- package/src/config/repository-values.ts +46 -0
- package/src/config/schema.ts +302 -0
- package/src/context/command.ts +39 -0
- package/src/context/coordinator-name.ts +16 -0
- package/src/context/fill.ts +69 -0
- package/src/context/index.ts +10 -0
- package/src/context/level.ts +22 -0
- package/src/context/options.ts +29 -0
- package/src/context/report.ts +42 -0
- package/src/context/session-log.ts +91 -0
- package/src/plan-format/drift.ts +69 -0
- package/src/plan-format/header.ts +27 -0
- package/src/plan-format/index.ts +9 -0
- package/src/plan-format/lanes.ts +128 -0
- package/src/plan-format/markdown.ts +98 -0
- package/src/plan-format/phase-tally.ts +55 -0
- package/src/plan-format/phases.ts +224 -0
- package/src/plan-format/plan-structure.ts +150 -0
- package/src/plan-format/progress.ts +202 -0
- package/src/plan-format/sprint-document.ts +69 -0
- package/src/plan-format/terminal-text.ts +28 -0
- package/src/plans/board-choice.ts +60 -0
- package/src/plans/board-pick.ts +72 -0
- package/src/plans/board-rows.ts +62 -0
- package/src/plans/command.ts +60 -0
- package/src/plans/ensure-panes.ts +71 -0
- package/src/plans/error-text.ts +5 -0
- package/src/plans/fetch.ts +96 -0
- package/src/plans/git.ts +145 -0
- package/src/plans/github.ts +35 -0
- package/src/plans/lane-discovery.ts +34 -0
- package/src/plans/lane-plans.ts +80 -0
- package/src/plans/layout.ts +881 -0
- package/src/plans/link-chips.ts +74 -0
- package/src/plans/live.ts +336 -0
- package/src/plans/markdown-html.ts +140 -0
- package/src/plans/options.ts +15 -0
- package/src/plans/phase-source.ts +71 -0
- package/src/plans/plan-chips.ts +75 -0
- package/src/plans/plan-panes.ts +206 -0
- package/src/plans/planned.ts +117 -0
- package/src/plans/pull-requests.ts +107 -0
- package/src/plans/restart-decision.ts +42 -0
- package/src/plans/selection.ts +352 -0
- package/src/plans/settings.ts +68 -0
- package/src/plans/single-flight.ts +25 -0
- package/src/plans/snapshot.ts +300 -0
- package/src/plans/sprint-export.ts +103 -0
- package/src/plans/sprint-html.ts +301 -0
- package/src/plans/sprint-page.ts +287 -0
- package/src/plans/supervisor.ts +124 -0
- package/src/plans/symbols.ts +54 -0
- package/src/plans/texts.ts +103 -0
- package/src/plans/unlisted-lanes.ts +45 -0
- package/src/plans/view.ts +68 -0
- package/src/plans/worked-phases.ts +53 -0
- package/src/sync/checksum.ts +13 -0
- package/src/sync/choice.ts +14 -0
- package/src/sync/command-context.ts +113 -0
- package/src/sync/command.ts +94 -0
- package/src/sync/error-message.ts +1 -0
- package/src/sync/gitignore.ts +45 -0
- package/src/sync/global-kit.ts +176 -0
- package/src/sync/herdr-plugin.ts +175 -0
- package/src/sync/if-changed.ts +109 -0
- package/src/sync/init-command.ts +239 -0
- package/src/sync/init.ts +136 -0
- package/src/sync/kit-hooks.ts +18 -0
- package/src/sync/kit-path.ts +77 -0
- package/src/sync/kit-source.ts +169 -0
- package/src/sync/kit.ts +9 -0
- package/src/sync/legacy-files.ts +116 -0
- package/src/sync/manifest.ts +44 -0
- package/src/sync/next-steps.ts +42 -0
- package/src/sync/plan-file.ts +154 -0
- package/src/sync/plan-hooks.ts +165 -0
- package/src/sync/project-fs.ts +132 -0
- package/src/sync/render.ts +60 -0
- package/src/sync/report.ts +96 -0
- package/src/sync/settings-hooks.ts +90 -0
- package/src/sync/settings-migration.ts +94 -0
- package/src/sync/settings-offer.ts +168 -0
- package/src/sync/setup-questions.ts +70 -0
- package/src/sync/setup.ts +52 -0
- package/src/sync/sync-plan.ts +120 -0
- package/src/sync/sync-result.ts +9 -0
- package/src/sync/sync.ts +307 -0
- package/src/sync/tools.ts +52 -0
- package/src/terminal/herdr.ts +91 -0
- package/src/terminal/index.ts +13 -0
- package/src/terminal/none.ts +7 -0
- package/src/terminal/open.ts +39 -0
- package/src/terminal/text.ts +159 -0
- package/src/terminal/types.ts +30 -0
- package/src/terminal/width.ts +90 -0
- package/src/update/command.ts +65 -0
- package/src/update/package-manager.ts +56 -0
- package/src/update/update.ts +105 -0
- package/src/version/installed-version.ts +32 -0
- package/src/version/release.ts +22 -0
- package/src/version/skill-diff.ts +63 -0
- package/src/version/started-kit.ts +14 -0
- package/src/vocabulary/index.ts +132 -0
- package/src/watch/base-tip.ts +18 -0
- package/src/watch/command.ts +919 -0
- package/src/watch/coordinator-fill.ts +76 -0
- package/src/watch/feedback-read.ts +73 -0
- package/src/watch/feedback.ts +308 -0
- package/src/watch/gh.ts +22 -0
- package/src/watch/github-read.ts +175 -0
- package/src/watch/herdr.ts +11 -0
- package/src/watch/json-guards.ts +9 -0
- package/src/watch/kit-update.ts +18 -0
- package/src/watch/lane-fill.ts +36 -0
- package/src/watch/lane-message.ts +99 -0
- package/src/watch/lane-progress.ts +44 -0
- package/src/watch/lane-report-file.ts +53 -0
- package/src/watch/lane-report.ts +270 -0
- package/src/watch/lane-search.ts +41 -0
- package/src/watch/lane-sources.ts +46 -0
- package/src/watch/lane-watch.ts +121 -0
- package/src/watch/monitor-tool.ts +19 -0
- package/src/watch/options.ts +79 -0
- package/src/watch/plan-badges-read.ts +30 -0
- package/src/watch/plan-badges.ts +66 -0
- package/src/watch/plan-views.ts +59 -0
- package/src/watch/preview-read.ts +60 -0
- package/src/watch/previews.ts +234 -0
- package/src/watch/pull-requests.ts +117 -0
- package/src/watch/single-report.ts +15 -0
- package/src/watch/skill-change.ts +61 -0
- package/src/watch/source-dry-run-command.ts +35 -0
- package/src/watch/source-dry-run.ts +124 -0
- package/src/watch/source-fields.ts +9 -0
- package/src/watch/source-path.ts +40 -0
- package/src/watch/source-run.ts +90 -0
- package/src/watch/sources.ts +344 -0
- package/src/watch/stream-output.ts +14 -0
- package/src/watch/texts.ts +76 -0
- package/src/watch/watch-events.ts +399 -0
- package/src/watch/watcher-lock.ts +145 -0
- package/src/watch/workflow-runs.ts +96 -0
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { homedir } from 'node:os'
|
|
2
|
+
import { TERMINAL_ADAPTER } from '../config/schema'
|
|
3
|
+
import { laneStatusSourceFor } from '../terminal'
|
|
4
|
+
import { startedKit } from '../version/started-kit'
|
|
5
|
+
import { boardFor, foundLanesOf } from './board-pick'
|
|
6
|
+
import { ensurePlanPanes } from './ensure-panes'
|
|
7
|
+
import { mainCheckout, worktreeRoot } from './git'
|
|
8
|
+
import { fetchPullRequests } from './github'
|
|
9
|
+
import { runLive } from './live'
|
|
10
|
+
import { parseOptions } from './options'
|
|
11
|
+
import { isSupervisedChild } from './restart-decision'
|
|
12
|
+
import { clockLocaleOf, readSettings } from './settings'
|
|
13
|
+
import { loadSnapshot, type Source } from './snapshot'
|
|
14
|
+
import { superviseView } from './supervisor'
|
|
15
|
+
import { frameText, paneWidth, renderSnapshot, startSelection } from './view'
|
|
16
|
+
|
|
17
|
+
const printOnce = async (source: Source): Promise<void> => {
|
|
18
|
+
const snapshot = await loadSnapshot(source, await fetchPullRequests(source.root))
|
|
19
|
+
const screen = renderSnapshot(
|
|
20
|
+
snapshot,
|
|
21
|
+
startSelection(snapshot, source.options),
|
|
22
|
+
snapshot.errors,
|
|
23
|
+
paneWidth(),
|
|
24
|
+
clockLocaleOf(source.settings),
|
|
25
|
+
)
|
|
26
|
+
console.log(frameText(screen, Boolean(process.stdout.isTTY)))
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* `jh plans [<board.md>] [--lane <name>] [--once | --ensure]`: a live view of a board's lanes and their plan
|
|
31
|
+
* progress; `--ensure` instead brings back the missing views beside the coordinator and each lane.
|
|
32
|
+
* `home` is where the global config is looked up when the project has none.
|
|
33
|
+
*/
|
|
34
|
+
export const run = async (args: string[], home: string = homedir()): Promise<number> => {
|
|
35
|
+
try {
|
|
36
|
+
const options = parseOptions(args)
|
|
37
|
+
const root = mainCheckout(process.cwd())
|
|
38
|
+
const settings = readSettings([worktreeRoot(process.cwd()), root], home)
|
|
39
|
+
const terminal = laneStatusSourceFor(settings.adapter)
|
|
40
|
+
const picked = boardFor(options.boardPath, [root, worktreeRoot(process.cwd())], settings, () =>
|
|
41
|
+
foundLanesOf(root, settings, () => terminal.listAgents().map((agent) => agent.name)),
|
|
42
|
+
)
|
|
43
|
+
if (options.ensure) {
|
|
44
|
+
const lines =
|
|
45
|
+
settings.adapter === TERMINAL_ADAPTER.herdr
|
|
46
|
+
? ensurePlanPanes(root, settings, picked.file)
|
|
47
|
+
: ['the terminal adapter is not herdr: no panes to bring back']
|
|
48
|
+
for (const line of lines.length > 0 ? lines : ['every plans view is running']) console.log(line)
|
|
49
|
+
return 0
|
|
50
|
+
}
|
|
51
|
+
const source = { root, sprintFile: picked.file, options, settings, terminal, boardNotice: picked.notice }
|
|
52
|
+
if (options.once || !process.stdout.isTTY) await printOnce(source)
|
|
53
|
+
else if (isSupervisedChild(process.env)) runLive(source)
|
|
54
|
+
else return await superviseView(args, await startedKit())
|
|
55
|
+
return 0
|
|
56
|
+
} catch (error) {
|
|
57
|
+
console.error(error instanceof Error ? error.message : String(error))
|
|
58
|
+
return 1
|
|
59
|
+
}
|
|
60
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
2
|
+
import { basename, join } from 'node:path'
|
|
3
|
+
import { defaultCoordinatorName } from '../context/coordinator-name'
|
|
4
|
+
import { parseSprintLanes } from '../plan-format'
|
|
5
|
+
import { parseAgentList, runHerdr } from '../terminal/herdr'
|
|
6
|
+
import { errorText } from './error-text'
|
|
7
|
+
import {
|
|
8
|
+
agentPanesOf,
|
|
9
|
+
coordinatorPaneOf,
|
|
10
|
+
foregroundOf,
|
|
11
|
+
missingCoordinatorNote,
|
|
12
|
+
type PaneAction,
|
|
13
|
+
type PaneState,
|
|
14
|
+
paneListOf,
|
|
15
|
+
planPaneActions,
|
|
16
|
+
splitPaneOf,
|
|
17
|
+
viewCommandOf,
|
|
18
|
+
withAgentPanes,
|
|
19
|
+
} from './plan-panes'
|
|
20
|
+
import type { PlansSettings } from './settings'
|
|
21
|
+
|
|
22
|
+
const panesWithForeground = (agentPanes: readonly string[]): PaneState[] =>
|
|
23
|
+
withAgentPanes(paneListOf(runHerdr(['pane', 'list'])), agentPanes).map((each) => ({
|
|
24
|
+
...each,
|
|
25
|
+
foreground: foregroundOf(runHerdr(['pane', 'process-info', '--pane', each.pane])),
|
|
26
|
+
}))
|
|
27
|
+
|
|
28
|
+
/** The board's lanes that have a worktree, closed ones left out. */
|
|
29
|
+
const lanesOf = (root: string, settings: PlansSettings, boardFile: string) =>
|
|
30
|
+
parseSprintLanes(readFileSync(boardFile, 'utf8'), settings.planLanguage)
|
|
31
|
+
.filter((lane) => !lane.closed)
|
|
32
|
+
.map((lane) => ({ name: lane.name, cwd: join(root, settings.worktreeDirectory, lane.name) }))
|
|
33
|
+
.filter((lane) => existsSync(lane.cwd))
|
|
34
|
+
|
|
35
|
+
/** Carries out one action; a failure becomes the action's line, so the other views are still tried. */
|
|
36
|
+
const apply = (action: PaneAction): string => {
|
|
37
|
+
try {
|
|
38
|
+
const target =
|
|
39
|
+
action.kind === 'split'
|
|
40
|
+
? splitPaneOf(runHerdr(['pane', 'split', action.pane, '--direction', 'right', '--no-focus']))
|
|
41
|
+
: action.pane
|
|
42
|
+
runHerdr(['pane', 'run', target, action.command])
|
|
43
|
+
return `${action.kind === 'split' ? `split ${action.pane}, ` : ''}started the plans view in ${target}`
|
|
44
|
+
} catch (error) {
|
|
45
|
+
return `${action.pane}: ${errorText(error)}`
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* `plans --ensure`: brings back every missing plans view beside the coordinator and each lane of the board, and
|
|
51
|
+
* leaves running ones alone, so it can run after every start. A lane gets its view once its agent runs. One line per
|
|
52
|
+
* action or skipped target, none when every view runs.
|
|
53
|
+
*/
|
|
54
|
+
export const ensurePlanPanes = (root: string, settings: PlansSettings, boardFile: string): string[] => {
|
|
55
|
+
const agents = runHerdr(['agent', 'list'])
|
|
56
|
+
const panes = panesWithForeground(agentPanesOf(agents))
|
|
57
|
+
const coordinatorName = defaultCoordinatorName(basename(root))
|
|
58
|
+
const { actions, notes } = planPaneActions({
|
|
59
|
+
panes,
|
|
60
|
+
coordinator: coordinatorPaneOf(panes, root, {
|
|
61
|
+
named: parseAgentList(agents).find((agent) => agent.name === coordinatorName)?.pane ?? null,
|
|
62
|
+
caller: process.env.HERDR_PANE_ID ?? null,
|
|
63
|
+
}),
|
|
64
|
+
lanes: lanesOf(root, settings, boardFile),
|
|
65
|
+
root,
|
|
66
|
+
worktrees: join(root, settings.worktreeDirectory),
|
|
67
|
+
command: (lane) => viewCommandOf(process.argv, lane),
|
|
68
|
+
missingCoordinator: missingCoordinatorNote(panes, root, coordinatorName),
|
|
69
|
+
})
|
|
70
|
+
return [...actions.map(apply), ...notes]
|
|
71
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import { terminalSafe } from '../plan-format/terminal-text'
|
|
2
|
+
|
|
3
|
+
/** Error text comes from git, gh and the terminal adapter and is printed, so it is made terminal-safe. */
|
|
4
|
+
export const errorText = (error: unknown): string =>
|
|
5
|
+
terminalSafe(error instanceof Error ? error.message : String(error))
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { errorText } from './error-text'
|
|
2
|
+
import type { PullRequestState } from './github'
|
|
3
|
+
|
|
4
|
+
/** The background fetch of the base branch: when it last ran, when it last worked and why it failed. */
|
|
5
|
+
export type FetchState = {
|
|
6
|
+
lastAttempt: number | null
|
|
7
|
+
lastSuccess: Date | null
|
|
8
|
+
failure: string | null
|
|
9
|
+
running: boolean
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export type FetchDecision = {
|
|
13
|
+
now: number
|
|
14
|
+
state: Pick<FetchState, 'lastAttempt' | 'running'>
|
|
15
|
+
intervalMinutes: number
|
|
16
|
+
/** A pull request of a lane is not open any more: it was merged, or closed. */
|
|
17
|
+
pullRequestGone: boolean
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const MINUTE_MS = 60_000
|
|
21
|
+
const FETCH_TIMEOUT_MS = 60_000
|
|
22
|
+
|
|
23
|
+
export const initialFetchState = (): FetchState => ({
|
|
24
|
+
lastAttempt: null,
|
|
25
|
+
lastSuccess: null,
|
|
26
|
+
failure: null,
|
|
27
|
+
running: false,
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
/** Fetch now: never while one runs or when the interval is 0; at once for a gone pull request, else when the interval has passed. */
|
|
31
|
+
export const shouldFetch = ({ now, state, intervalMinutes, pullRequestGone }: FetchDecision): boolean => {
|
|
32
|
+
if (intervalMinutes <= 0 || state.running) return false
|
|
33
|
+
if (pullRequestGone || state.lastAttempt === null) return true
|
|
34
|
+
return now - state.lastAttempt >= intervalMinutes * MINUTE_MS
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The numbers of pull requests of the given lane branches that were open before and are not any more.
|
|
39
|
+
* Pull requests of other branches are none of the view's business.
|
|
40
|
+
*/
|
|
41
|
+
export const goneLanePullRequests = (
|
|
42
|
+
previous: PullRequestState['byBranch'],
|
|
43
|
+
next: PullRequestState['byBranch'],
|
|
44
|
+
laneBranches: ReadonlySet<string>,
|
|
45
|
+
): number[] => {
|
|
46
|
+
const stillOpen = new Set([...next.values()].flat().map(({ number }) => number))
|
|
47
|
+
return [...previous.values()]
|
|
48
|
+
.flat()
|
|
49
|
+
.filter(({ branch, number }) => laneBranches.has(branch) && !stillOpen.has(number))
|
|
50
|
+
.map(({ number }) => number)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The files whose change reloads the view: the plan files, and the main checkout's `FETCH_HEAD`,
|
|
55
|
+
* which every fetch there rewrites (a linked worktree has its own). So a fetch by another process in
|
|
56
|
+
* the main checkout (the lane watcher fetches the base branch as soon as it moved) shows at once
|
|
57
|
+
* instead of on the next refresh, and neither needs the other running.
|
|
58
|
+
*/
|
|
59
|
+
export const watchedFiles = (planFiles: readonly string[], fetchHead: string | null): string[] => [
|
|
60
|
+
...planFiles,
|
|
61
|
+
...(fetchHead ? [fetchHead] : []),
|
|
62
|
+
]
|
|
63
|
+
|
|
64
|
+
/** The footer line after a failed fetch; the view carries on with the ref it has. */
|
|
65
|
+
export const fetchNotice = (state: FetchState, baseRef: string, locale: string): string | null => {
|
|
66
|
+
if (state.failure === null) return null
|
|
67
|
+
const known = state.lastSuccess
|
|
68
|
+
? `last fetch ${state.lastSuccess.toLocaleTimeString(locale)}`
|
|
69
|
+
: 'not fetched since start'
|
|
70
|
+
return `fetch failed (${state.failure}), reading ${baseRef} as it is: ${known}`
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Fetches the base branch; returns the reason when it did not work, null when it did. Never rejects. */
|
|
74
|
+
export const fetchBase = async (root: string, remote: string, branch: string): Promise<string | null> => {
|
|
75
|
+
let timedOut = false
|
|
76
|
+
let timer: Timer | undefined
|
|
77
|
+
try {
|
|
78
|
+
const child = Bun.spawn(['git', '-C', root, 'fetch', '--quiet', remote, branch], {
|
|
79
|
+
stdout: 'ignore',
|
|
80
|
+
stderr: 'pipe',
|
|
81
|
+
env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
|
|
82
|
+
})
|
|
83
|
+
timer = setTimeout(() => {
|
|
84
|
+
timedOut = true
|
|
85
|
+
child.kill()
|
|
86
|
+
}, FETCH_TIMEOUT_MS)
|
|
87
|
+
const [stderr, exitCode] = await Promise.all([new Response(child.stderr).text(), child.exited])
|
|
88
|
+
if (timedOut) return `timed out after ${FETCH_TIMEOUT_MS / 1000} s`
|
|
89
|
+
if (exitCode === 0) return null
|
|
90
|
+
return stderr.trim().split('\n')[0] || `git fetch exited with ${exitCode}`
|
|
91
|
+
} catch (error) {
|
|
92
|
+
return errorText(error)
|
|
93
|
+
} finally {
|
|
94
|
+
clearTimeout(timer)
|
|
95
|
+
}
|
|
96
|
+
}
|
package/src/plans/git.ts
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { existsSync, readdirSync, statSync } from 'node:fs'
|
|
2
|
+
import { dirname } from 'node:path'
|
|
3
|
+
import type { LaneStatus } from './layout'
|
|
4
|
+
import type { BaseRefInfo } from './sprint-page'
|
|
5
|
+
|
|
6
|
+
const gitAsync = async (cwd: string, args: string[]): Promise<string | null> => {
|
|
7
|
+
const child = Bun.spawn(['git', '-C', cwd, ...args], { stdout: 'pipe', stderr: 'ignore' })
|
|
8
|
+
const [output, exitCode] = await Promise.all([new Response(child.stdout).text(), child.exited])
|
|
9
|
+
return exitCode === 0 ? output.trim() : null
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const git = (cwd: string, args: string[]): string | null => {
|
|
13
|
+
const result = Bun.spawnSync(['git', '-C', cwd, ...args], { stdout: 'pipe', stderr: 'pipe' })
|
|
14
|
+
return result.exitCode === 0 ? result.stdout.toString().trim() : null
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** The text of `path` at `ref`, or null when the ref or the file does not exist there. */
|
|
18
|
+
export const showAtRef = (root: string, ref: string, path: string): string | null => {
|
|
19
|
+
const result = Bun.spawnSync(['git', '-C', root, 'show', `${ref}:${path}`], { stdout: 'pipe', stderr: 'pipe' })
|
|
20
|
+
return result.exitCode === 0 ? result.stdout.toString() : null
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** The commit `ref` points at, null when it does not exist. */
|
|
24
|
+
export const commitOf = (root: string, ref: string): string | null =>
|
|
25
|
+
git(root, ['rev-parse', '--verify', '--quiet', `${ref}^{commit}`])
|
|
26
|
+
|
|
27
|
+
/** Reads files as they are at the base ref. */
|
|
28
|
+
export type BaseReader = (path: string) => string | null
|
|
29
|
+
|
|
30
|
+
let cached: { root: string; commit: string; texts: Map<string, string> } | null = null
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* A reader for the files at `ref`, with the commit resolved once. A file at a commit never changes, so what was
|
|
34
|
+
* read is kept for as long as the ref points at the same commit: a re-read of the sprint costs one `git
|
|
35
|
+
* rev-parse` instead of one `git show` per plan.
|
|
36
|
+
*/
|
|
37
|
+
export const baseReaderOf = (root: string, ref: string): BaseReader => {
|
|
38
|
+
const commit = commitOf(root, ref)
|
|
39
|
+
if (commit === null) return () => null
|
|
40
|
+
if (cached?.root !== root || cached.commit !== commit) cached = { root, commit, texts: new Map() }
|
|
41
|
+
const { texts } = cached
|
|
42
|
+
return (path) => {
|
|
43
|
+
const kept = texts.get(path)
|
|
44
|
+
if (kept !== undefined) return kept
|
|
45
|
+
const text = showAtRef(root, commit, path)
|
|
46
|
+
if (text !== null) texts.set(path, text)
|
|
47
|
+
return text
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export const refExists = (root: string, ref: string): boolean => commitOf(root, ref) !== null
|
|
52
|
+
|
|
53
|
+
/** Whether `cwd` lies in a linked worktree (`git worktree add`), not in the main checkout; false outside git. */
|
|
54
|
+
export const isLinkedWorktree = (cwd: string): boolean => {
|
|
55
|
+
const directories = git(cwd, ['rev-parse', '--path-format=absolute', '--git-dir', '--git-common-dir'])?.split('\n')
|
|
56
|
+
return directories?.length === 2 && directories[0] !== directories[1]
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** The main checkout, also when called from inside a linked worktree. */
|
|
60
|
+
export const mainCheckout = (cwd: string): string => {
|
|
61
|
+
const commonDirectory = git(cwd, ['rev-parse', '--path-format=absolute', '--git-common-dir'])
|
|
62
|
+
if (!commonDirectory) throw new Error('not inside a git checkout')
|
|
63
|
+
return dirname(commonDirectory)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** The top of the checkout `cwd` is in: the worktree itself, not the main checkout. */
|
|
67
|
+
export const worktreeRoot = (cwd: string): string => {
|
|
68
|
+
const top = git(cwd, ['rev-parse', '--show-toplevel'])
|
|
69
|
+
if (!top) throw new Error('not inside a git checkout')
|
|
70
|
+
return top
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Branch, commits ahead of `baseRef` and changed files of a worktree; null and 0 when it does not exist. The three
|
|
75
|
+
* git calls run side by side and without holding the event loop, so a key pressed meanwhile is answered at once.
|
|
76
|
+
*/
|
|
77
|
+
export const gitStatusOf = async (
|
|
78
|
+
worktree: string,
|
|
79
|
+
baseRef: string,
|
|
80
|
+
): Promise<Pick<LaneStatus, 'branch' | 'ahead' | 'dirty'>> => {
|
|
81
|
+
if (!existsSync(worktree)) return { branch: null, ahead: null, dirty: 0 }
|
|
82
|
+
const [count, porcelain, branch] = await Promise.all([
|
|
83
|
+
gitAsync(worktree, ['rev-list', '--count', `${baseRef}..HEAD`]),
|
|
84
|
+
gitAsync(worktree, ['status', '--porcelain']),
|
|
85
|
+
gitAsync(worktree, ['branch', '--show-current']),
|
|
86
|
+
])
|
|
87
|
+
const ahead = Number.parseInt(count ?? '', 10)
|
|
88
|
+
return {
|
|
89
|
+
branch: branch || null,
|
|
90
|
+
ahead: Number.isNaN(ahead) ? null : ahead,
|
|
91
|
+
dirty: (porcelain ?? '').split('\n').filter(Boolean).length,
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Where this clone keeps `FETCH_HEAD`, whether it exists yet or not; null outside a git checkout. */
|
|
96
|
+
export const fetchHeadPath = (root: string): string | null =>
|
|
97
|
+
git(root, ['rev-parse', '--path-format=absolute', '--git-path', 'FETCH_HEAD'])
|
|
98
|
+
|
|
99
|
+
/** The base ref's commit and commit time, and when this clone last fetched (the time of `FETCH_HEAD`), as far as git says. */
|
|
100
|
+
export const baseRefInfo = (root: string, ref: string): BaseRefInfo => {
|
|
101
|
+
const fetchHead = fetchHeadPath(root)
|
|
102
|
+
const fetchedAt = fetchHead && existsSync(fetchHead) ? statSync(fetchHead).mtime : null
|
|
103
|
+
return {
|
|
104
|
+
name: ref,
|
|
105
|
+
commit: git(root, ['rev-parse', '--short', `${ref}^{commit}`]),
|
|
106
|
+
committedAt: git(root, ['log', '-1', '--format=%cI', ref]),
|
|
107
|
+
fetchedAt,
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const SSH_REMOTE = /^git@([^:]+):(.+?)(?:\.git)?$/
|
|
112
|
+
|
|
113
|
+
/** The remote as a plain `https://host/path` without credentials, null when it is no http(s) URL. */
|
|
114
|
+
const withoutCredentials = (remote: string): string | null => {
|
|
115
|
+
try {
|
|
116
|
+
const url = new URL(remote)
|
|
117
|
+
if (url.protocol !== 'https:' && url.protocol !== 'http:') return null
|
|
118
|
+
return `${url.protocol}//${url.host}${url.pathname.replace(/\.git$/, '').replace(/\/$/, '')}`
|
|
119
|
+
} catch {
|
|
120
|
+
return null
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** `https://host/owner/repo/blob/<branch>/` for the `origin` remote, null when there is none or it is no URL we know. */
|
|
125
|
+
export const remoteBlobBase = (root: string, branch: string): string | null => {
|
|
126
|
+
const remote = git(root, ['remote', 'get-url', 'origin'])
|
|
127
|
+
if (!remote) return null
|
|
128
|
+
const ssh = SSH_REMOTE.exec(remote)
|
|
129
|
+
const base = ssh ? `https://${ssh[1]}/${ssh[2]}` : withoutCredentials(remote)
|
|
130
|
+
return base ? `${base}/blob/${branch}/` : null
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** The commit time (epoch seconds) of the last commit on `ref` that touched `path`; null when there is none. */
|
|
134
|
+
export const lastCommitTime = (root: string, ref: string, path: string): number | null => {
|
|
135
|
+
const seconds = Number.parseInt(git(root, ['log', '-1', '--format=%ct', ref, '--', path]) ?? '', 10)
|
|
136
|
+
return Number.isNaN(seconds) ? null : seconds
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** The names of the directories under the worktree directory; none when it does not exist. */
|
|
140
|
+
export const worktreeNames = (worktreeDirectory: string): string[] =>
|
|
141
|
+
existsSync(worktreeDirectory)
|
|
142
|
+
? readdirSync(worktreeDirectory, { withFileTypes: true })
|
|
143
|
+
.filter((entry) => entry.isDirectory())
|
|
144
|
+
.map((entry) => entry.name)
|
|
145
|
+
: []
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { errorText } from './error-text'
|
|
2
|
+
import { PULL_REQUEST_FIELDS, type PullRequest, parsePullRequests, pullRequestsByBranch } from './pull-requests'
|
|
3
|
+
|
|
4
|
+
/** Open pull requests by head branch, or none with the reason when `gh` fails. */
|
|
5
|
+
export type PullRequestState = { byBranch: Map<string, PullRequest[]>; errors: string[] }
|
|
6
|
+
|
|
7
|
+
export const noPullRequests = (): PullRequestState => ({ byBranch: new Map(), errors: [] })
|
|
8
|
+
|
|
9
|
+
/** The GitHub host whose pull request links are trusted; `GH_HOST` names an Enterprise server. */
|
|
10
|
+
export const GITHUB_HOST = process.env.GH_HOST ?? 'github.com'
|
|
11
|
+
|
|
12
|
+
const runGh = async (root: string): Promise<{ stdout: string; stderr: string; exitCode: number }> => {
|
|
13
|
+
const child = Bun.spawn(['gh', 'pr', 'list', '--state', 'open', '--limit', '100', '--json', PULL_REQUEST_FIELDS], {
|
|
14
|
+
cwd: root,
|
|
15
|
+
stdout: 'pipe',
|
|
16
|
+
stderr: 'pipe',
|
|
17
|
+
})
|
|
18
|
+
const [stdout, stderr, exitCode] = await Promise.all([
|
|
19
|
+
new Response(child.stdout).text(),
|
|
20
|
+
new Response(child.stderr).text(),
|
|
21
|
+
child.exited,
|
|
22
|
+
])
|
|
23
|
+
return { stdout, stderr, exitCode }
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Open pull requests, or none with the reason; never rejects, also not when `gh` is not installed. */
|
|
27
|
+
export const fetchPullRequests = async (root: string): Promise<PullRequestState> => {
|
|
28
|
+
try {
|
|
29
|
+
const { stdout, stderr, exitCode } = await runGh(root)
|
|
30
|
+
if (exitCode !== 0) throw new Error(`gh pr list: ${stderr.trim() || `exit ${exitCode}`}`)
|
|
31
|
+
return { byBranch: pullRequestsByBranch(parsePullRequests(stdout, GITHUB_HOST)), errors: [] }
|
|
32
|
+
} catch (error) {
|
|
33
|
+
return { byBranch: new Map(), errors: [errorText(error)] }
|
|
34
|
+
}
|
|
35
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { terminalSafe } from '../plan-format/terminal-text'
|
|
2
|
+
|
|
3
|
+
/** What the watcher finds a lane by: the worktree directories, the herdr agent names, the sprint's lane rows. */
|
|
4
|
+
export type LaneSources = {
|
|
5
|
+
worktrees: readonly string[]
|
|
6
|
+
agentNames: readonly string[]
|
|
7
|
+
sprintLanes: readonly string[]
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export type LaneSetChange = { added: string[]; removed: string[] }
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The lanes to watch when none are named: a worktree directory whose name a herdr agent carries or
|
|
14
|
+
* the board's lanes table lists. A worktree with neither (a branch parked in its own checkout) is no
|
|
15
|
+
* lane, and an agent or a row without a worktree is none either.
|
|
16
|
+
*/
|
|
17
|
+
export const discoverLanes = ({ worktrees, agentNames, sprintLanes }: LaneSources): string[] => {
|
|
18
|
+
const claimed = new Set([...agentNames, ...sprintLanes])
|
|
19
|
+
return [...new Set(worktrees)].filter((worktree) => claimed.has(worktree)).sort()
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** The lanes that joined and left between two discoveries, each sorted. */
|
|
23
|
+
export const laneSetChange = (previous: readonly string[], next: readonly string[]): LaneSetChange => ({
|
|
24
|
+
added: next.filter((lane) => !previous.includes(lane)).sort(),
|
|
25
|
+
removed: previous.filter((lane) => !next.includes(lane)).sort(),
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
export const isUnchanged = ({ added, removed }: LaneSetChange): boolean => added.length === 0 && removed.length === 0
|
|
29
|
+
|
|
30
|
+
/** `lane added: <name>` and `lane removed: <name>`, one line per lane. */
|
|
31
|
+
export const laneSetLines = ({ added, removed }: LaneSetChange): string[] => [
|
|
32
|
+
...added.map((lane) => `lane added: ${terminalSafe(lane)}`),
|
|
33
|
+
...removed.map((lane) => `lane removed: ${terminalSafe(lane)}`),
|
|
34
|
+
]
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
import { type PlansByHref, parsePlanPhases, resolvePlanPath, type SprintLane } from '../plan-format'
|
|
4
|
+
import type { BaseReader } from './git'
|
|
5
|
+
import { hasOwnWork, mergePlanPhases } from './phase-source'
|
|
6
|
+
import type { PlansSettings } from './settings'
|
|
7
|
+
|
|
8
|
+
/** Paths that could not be read at the base ref and were read from a checkout instead. */
|
|
9
|
+
export type Fallbacks = string[]
|
|
10
|
+
|
|
11
|
+
export type LaneWork = { worktree: string | null; ahead: number | null; dirty: number }
|
|
12
|
+
|
|
13
|
+
/** What reading a lane's plans needs: the main checkout, the settings, the base ref's reader, the board's directory. */
|
|
14
|
+
export type PlanSources = { root: string; settings: PlansSettings; base: BaseReader; sprintDirectory: string }
|
|
15
|
+
|
|
16
|
+
const isOutsideRepository = (path: string): boolean => path.startsWith('..')
|
|
17
|
+
|
|
18
|
+
/** The file as the base ref has it; null when the ref lacks it or the path leaves the repository. */
|
|
19
|
+
export const textAtBase = (base: BaseReader, path: string): string | null =>
|
|
20
|
+
isOutsideRepository(path) ? null : base(path)
|
|
21
|
+
|
|
22
|
+
/** The checkout's copy of a file the base ref lacks, noted in `fallbacks`; null when the checkout lacks it too. */
|
|
23
|
+
export const readFallback = async (
|
|
24
|
+
path: string,
|
|
25
|
+
fallbackFile: string,
|
|
26
|
+
fallbacks: Fallbacks,
|
|
27
|
+
): Promise<string | null> => {
|
|
28
|
+
if (!existsSync(fallbackFile)) return null
|
|
29
|
+
fallbacks.push(path)
|
|
30
|
+
return Bun.file(fallbackFile).text()
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The text of a repository file as the base ref has it. A file the ref does not have (new, so only in a
|
|
35
|
+
* checkout) is read from `fallbackFile` and noted in `fallbacks`; null when it is nowhere.
|
|
36
|
+
*/
|
|
37
|
+
export const readAtBase = async (
|
|
38
|
+
base: BaseReader,
|
|
39
|
+
path: string,
|
|
40
|
+
fallbackFile: string,
|
|
41
|
+
fallbacks: Fallbacks,
|
|
42
|
+
): Promise<string | null> => textAtBase(base, path) ?? readFallback(path, fallbackFile, fallbacks)
|
|
43
|
+
|
|
44
|
+
/** The lane's worktree, null when it is missing, with the work the lane has there by its git status. */
|
|
45
|
+
export const laneWorkOf = (worktree: string, status: { ahead: number | null; dirty: number }): LaneWork => ({
|
|
46
|
+
worktree: existsSync(worktree) ? worktree : null,
|
|
47
|
+
ahead: status.ahead,
|
|
48
|
+
dirty: status.dirty,
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The plans of a lane. The base ref is the truth; while the lane has work of its own in its worktree, the phases
|
|
53
|
+
* its rows name, and those new there or ticked further, come from the worktree (`mergePlanPhases`). A plan the base ref lacks is read from the lane's worktree, else from the main
|
|
54
|
+
* checkout (a repository that ignores `docs/` keeps plan and board only there). `files` are the plan files to
|
|
55
|
+
* watch for changes.
|
|
56
|
+
*/
|
|
57
|
+
export const readPlans = async (lane: SprintLane, sources: PlanSources, work: LaneWork, fallbacks: Fallbacks) => {
|
|
58
|
+
const { root, settings, sprintDirectory } = sources
|
|
59
|
+
const plans: PlansByHref = {}
|
|
60
|
+
const files: string[] = []
|
|
61
|
+
const texts: Record<string, string> = {}
|
|
62
|
+
for (const href of new Set(lane.steps.flatMap((step) => (step.planHref ? [step.planHref] : [])))) {
|
|
63
|
+
const path = resolvePlanPath(sprintDirectory, href)
|
|
64
|
+
const worktreeFile = work.worktree ? join(work.worktree, path) : null
|
|
65
|
+
const file = worktreeFile && existsSync(worktreeFile) ? worktreeFile : join(root, path)
|
|
66
|
+
const text = await readAtBase(sources.base, path, file, fallbacks)
|
|
67
|
+
if (text === null) continue
|
|
68
|
+
const own = worktreeFile === file && hasOwnWork(work) ? await Bun.file(file).text() : null
|
|
69
|
+
const laneIds = lane.steps.filter((step) => step.planHref === href).flatMap((step) => step.phaseIds)
|
|
70
|
+
plans[href] = mergePlanPhases(
|
|
71
|
+
parsePlanPhases(text, settings.planLanguage),
|
|
72
|
+
own === null ? null : parsePlanPhases(own, settings.planLanguage),
|
|
73
|
+
laneIds,
|
|
74
|
+
work,
|
|
75
|
+
)
|
|
76
|
+
files.push(file)
|
|
77
|
+
texts[href] = text
|
|
78
|
+
}
|
|
79
|
+
return { plans, files, texts }
|
|
80
|
+
}
|