jackherd 0.0.0-stage → 1.0.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 +61 -0
- package/LICENSE +21 -0
- package/NOTICE +38 -0
- package/README.md +1123 -2
- 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 +65 -4
- 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,74 @@
|
|
|
1
|
+
import type { LinkKind } from '../config/link-kinds'
|
|
2
|
+
|
|
3
|
+
/** A link of a plan that matches a link kind, as the sprint page shows it. */
|
|
4
|
+
export type LinkChip = { url: string; label: string; icon: string; private: boolean }
|
|
5
|
+
|
|
6
|
+
type Found = { url: string; text: string | null; before: string; line: string }
|
|
7
|
+
|
|
8
|
+
const MARKDOWN_LINK = /\[([^\]]+)\]\((https?:\/\/[^)\s]+)\)/g
|
|
9
|
+
const AUTOLINK = /<(https?:\/\/[^>\s]+)>/g
|
|
10
|
+
const BARE_LINK = /(?<![(<\w"])(https?:\/\/[^\s)>\]]+)/g
|
|
11
|
+
const LABEL_BEFORE = /\*\*([^*]+?):?\*\*:?\s*(?:[-–>]\s*)?$/
|
|
12
|
+
const PRIVATE = /\bprivat/i
|
|
13
|
+
const MARKS = /\*\*|`/g
|
|
14
|
+
const CONTROL = /\p{Cc}/gu
|
|
15
|
+
|
|
16
|
+
const linksOf = (line: string): Found[] => {
|
|
17
|
+
const found: Found[] = []
|
|
18
|
+
const take = (url: string, text: string | null, index: number) =>
|
|
19
|
+
found.push({ url, text, before: line.slice(0, index), line })
|
|
20
|
+
for (const match of line.matchAll(MARKDOWN_LINK)) take(match[2], match[1], match.index)
|
|
21
|
+
for (const match of line.matchAll(AUTOLINK)) take(match[1], null, match.index)
|
|
22
|
+
const rest = line
|
|
23
|
+
.replace(MARKDOWN_LINK, (whole) => ' '.repeat(whole.length))
|
|
24
|
+
.replace(AUTOLINK, (whole) => ' '.repeat(whole.length))
|
|
25
|
+
for (const match of rest.matchAll(BARE_LINK)) take(match[1].replace(/[.,;]+$/, ''), null, match.index)
|
|
26
|
+
return found.sort((left, right) => line.indexOf(left.url) - line.indexOf(right.url))
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const labelOf = ({ text, before }: Found, kind: LinkKind): string => {
|
|
30
|
+
const written = text ?? LABEL_BEFORE.exec(before)?.[1]
|
|
31
|
+
return written ? written.replace(MARKS, '').replace(CONTROL, '').trim() || kind.label : kind.label
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The links of a plan's text that match one of the kinds, once each and in the order they appear. The chip's
|
|
36
|
+
* text is the link text, else the bold label written before the link (`**Mockup:** <url>`), else the kind's
|
|
37
|
+
* label; a link on a line that says it is private carries that. Nothing is fetched.
|
|
38
|
+
*/
|
|
39
|
+
export const extractLinkChips = (markdown: string, kinds: readonly LinkKind[]): LinkChip[] => {
|
|
40
|
+
const compiled = kinds.map((kind) => ({ kind, pattern: new RegExp(kind.pattern) }))
|
|
41
|
+
const chips = new Map<string, LinkChip>()
|
|
42
|
+
for (const line of markdown.split('\n')) {
|
|
43
|
+
for (const link of linksOf(line)) {
|
|
44
|
+
const match = compiled.find(({ pattern }) => pattern.test(link.url))
|
|
45
|
+
if (!match || chips.has(link.url)) continue
|
|
46
|
+
chips.set(link.url, {
|
|
47
|
+
url: link.url,
|
|
48
|
+
label: labelOf(link, match.kind),
|
|
49
|
+
icon: match.kind.icon,
|
|
50
|
+
private: PRIVATE.test(link.line),
|
|
51
|
+
})
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return [...chips.values()]
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const CHIP_KEY = /^chip>(https?:\/\/.+)$/
|
|
58
|
+
const PLAN_KEY = /^plan>(file:\/\/\/.+)$/
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The click target of a chip. A `file:` address is the sprint page's own (the plan chip) and gets a key of its
|
|
62
|
+
* own, so no link of a plan's text can ever be taken for it; the keys cannot collide with a lane key (`@`), a
|
|
63
|
+
* phase key or a pull request key.
|
|
64
|
+
*/
|
|
65
|
+
export const chipKey = (url: string): string => (url.startsWith('file:') ? `plan>${url}` : `chip>${url}`)
|
|
66
|
+
|
|
67
|
+
/** The address behind the click target of a link of a plan's text: http(s) only, null for any other key. */
|
|
68
|
+
export const chipUrlOf = (key: string): string | null => CHIP_KEY.exec(key)?.[1] ?? null
|
|
69
|
+
|
|
70
|
+
/** The `file:` address behind the plan chip's click target; the caller opens it only when it is the page. */
|
|
71
|
+
export const planUrlOf = (key: string): string | null => PLAN_KEY.exec(key)?.[1] ?? null
|
|
72
|
+
|
|
73
|
+
/** Whether a click target belongs to a chip, of either kind. */
|
|
74
|
+
export const isChipKey = (key: string): boolean => chipUrlOf(key) !== null || planUrlOf(key) !== null
|
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
import { existsSync, type FSWatcher, watch } from 'node:fs'
|
|
2
|
+
import { dirname } from 'node:path'
|
|
3
|
+
import { pathToFileURL } from 'node:url'
|
|
4
|
+
import { terminalSafe } from '../plan-format/terminal-text'
|
|
5
|
+
import { openInBrowser } from '../terminal/open'
|
|
6
|
+
import { errorText } from './error-text'
|
|
7
|
+
import {
|
|
8
|
+
type FetchState,
|
|
9
|
+
fetchBase,
|
|
10
|
+
fetchNotice,
|
|
11
|
+
goneLanePullRequests,
|
|
12
|
+
initialFetchState,
|
|
13
|
+
shouldFetch,
|
|
14
|
+
watchedFiles,
|
|
15
|
+
} from './fetch'
|
|
16
|
+
import { fetchHeadPath } from './git'
|
|
17
|
+
import { fetchPullRequests, noPullRequests, type PullRequestState } from './github'
|
|
18
|
+
import type { Screen } from './layout'
|
|
19
|
+
import { type KnownPage, pageAfterWrite, pageKey, pageNeedsWriting, planAnchorOn, withPlanChips } from './plan-chips'
|
|
20
|
+
import { pullRequestUrl } from './pull-requests'
|
|
21
|
+
import {
|
|
22
|
+
inputOutcome,
|
|
23
|
+
isSprintPageKey,
|
|
24
|
+
overviewSelection,
|
|
25
|
+
splitKeys,
|
|
26
|
+
syncLaneFolds,
|
|
27
|
+
targetKeys,
|
|
28
|
+
type View,
|
|
29
|
+
windowTop,
|
|
30
|
+
} from './selection'
|
|
31
|
+
import { BASE_REMOTE, baseRefOf, boardRepoPathOf, clockLocaleOf } from './settings'
|
|
32
|
+
import { singleFlight } from './single-flight'
|
|
33
|
+
import { loadSnapshot, type Snapshot, type Source } from './snapshot'
|
|
34
|
+
import { exportSprintPage, writeSprintPageInSteps } from './sprint-export'
|
|
35
|
+
import { boardTexts } from './texts'
|
|
36
|
+
import { bodyRows, clockOf, draw, paneWidth, renderSnapshot, startSelection, TERMINAL_OFF, TERMINAL_ON } from './view'
|
|
37
|
+
|
|
38
|
+
/** The state of the running view; only the functions below `runLive` change it. */
|
|
39
|
+
type Live = {
|
|
40
|
+
source: Source
|
|
41
|
+
snapshot: Snapshot | null
|
|
42
|
+
screen: Screen | null
|
|
43
|
+
view: View
|
|
44
|
+
errors: string[]
|
|
45
|
+
pullRequests: PullRequestState
|
|
46
|
+
fetch: FetchState
|
|
47
|
+
/** One footer line about the last action of the person, such as where the sprint page was written; cleared by the next key. */
|
|
48
|
+
notice: string | null
|
|
49
|
+
watchers: Map<string, FSWatcher>
|
|
50
|
+
/** This clone's `FETCH_HEAD`, watched beside the plan files; null when git names none. */
|
|
51
|
+
fetchHead: string | null
|
|
52
|
+
reported: Map<string, string | null>
|
|
53
|
+
pending: Timer | null
|
|
54
|
+
/** The snapshot as read, without the plan chips; `snapshot` is this plus the chips of `page`. */
|
|
55
|
+
read: Snapshot | null
|
|
56
|
+
/** The sprint page last written, and the key of the plans it was built from; a click on a plan chip opens it. */
|
|
57
|
+
page: KnownPage | null
|
|
58
|
+
pageWriting: boolean
|
|
59
|
+
flight: (() => Promise<void>) | null
|
|
60
|
+
pageError: string | null
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const REFRESH_MS = 5_000
|
|
64
|
+
const PULL_REQUEST_REFRESH_MS = 60_000
|
|
65
|
+
const DEBOUNCE_MS = 150
|
|
66
|
+
const QUIT_KEYS = new Set(['\x03', 'q'])
|
|
67
|
+
|
|
68
|
+
/** The sidebar titles that differ from the ones last reported, in the order of `titles`; null clears one. */
|
|
69
|
+
export const changedTitles = (
|
|
70
|
+
titles: ReadonlyMap<string, string | null>,
|
|
71
|
+
reported: ReadonlyMap<string, string | null>,
|
|
72
|
+
): [string, string | null][] => [...titles].filter(([pane, title]) => reported.get(pane) !== title)
|
|
73
|
+
|
|
74
|
+
/** Reports the titles that changed since the last frame; a failed report is retried on the next one. */
|
|
75
|
+
const reportTitles = (source: Source, titles: Map<string, string | null>, reported: Map<string, string | null>) => {
|
|
76
|
+
const errors: string[] = []
|
|
77
|
+
for (const [pane, title] of changedTitles(titles, reported)) {
|
|
78
|
+
const error = source.terminal.setTitle(pane, title)
|
|
79
|
+
if (error) errors.push(`sidebar title not set: ${terminalSafe(error)}`)
|
|
80
|
+
else reported.set(pane, title)
|
|
81
|
+
}
|
|
82
|
+
return errors
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const clearTitles = (source: Source, reported: Map<string, string | null>) => {
|
|
86
|
+
for (const [pane, title] of reported) {
|
|
87
|
+
if (title) source.terminal.setTitle(pane, null)
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const syncWatchers = (watchers: Map<string, FSWatcher>, files: string[], onChange: () => void) => {
|
|
92
|
+
for (const [file, watcher] of watchers) {
|
|
93
|
+
if (files.includes(file)) continue
|
|
94
|
+
watcher.close()
|
|
95
|
+
watchers.delete(file)
|
|
96
|
+
}
|
|
97
|
+
for (const file of files.filter((candidate) => !watchers.has(candidate))) {
|
|
98
|
+
try {
|
|
99
|
+
const watcher = watch(file, (event) => {
|
|
100
|
+
if (event === 'rename') {
|
|
101
|
+
watcher.close()
|
|
102
|
+
watchers.delete(file)
|
|
103
|
+
}
|
|
104
|
+
onChange()
|
|
105
|
+
})
|
|
106
|
+
watchers.set(file, watcher)
|
|
107
|
+
} catch {
|
|
108
|
+
// A file that cannot be watched right now is retried on the next refresh; the 5 s refresh covers it meanwhile.
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Alternate screen, hidden cursor, mouse reports and raw keys; `quit` restores the terminal. */
|
|
114
|
+
const enterTerminal = (onInput: (input: string) => void, quit: () => void, onResize: () => void) => {
|
|
115
|
+
process.stdout.write(TERMINAL_ON)
|
|
116
|
+
process.on('SIGINT', quit)
|
|
117
|
+
process.on('SIGTERM', quit)
|
|
118
|
+
process.stdout.on('resize', onResize)
|
|
119
|
+
if (!process.stdin.isTTY) return
|
|
120
|
+
process.stdin.setRawMode(true)
|
|
121
|
+
process.stdin.on('data', (chunk) => {
|
|
122
|
+
for (const input of splitKeys(chunk.toString())) onInput(input)
|
|
123
|
+
})
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const redraw = (live: Live) => {
|
|
127
|
+
if (!live.snapshot) return
|
|
128
|
+
const screen = renderSnapshot(
|
|
129
|
+
live.snapshot,
|
|
130
|
+
live.view.selection,
|
|
131
|
+
[...live.errors, ...(live.pageError ? [live.pageError] : []), ...(live.notice ? [live.notice] : [])],
|
|
132
|
+
paneWidth(),
|
|
133
|
+
clockLocaleOf(live.source.settings),
|
|
134
|
+
)
|
|
135
|
+
const top = windowTop(live.view, screen.body.cursorLine, screen.body.lines.length, bodyRows(screen))
|
|
136
|
+
live.screen = screen
|
|
137
|
+
live.view = { ...live.view, top }
|
|
138
|
+
draw(screen, top)
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** The whole sprint, also while the view shows one lane: the snapshot is read again without the lane filter. */
|
|
142
|
+
const wholeSprint = async (live: Live, snapshot: Snapshot): Promise<Snapshot> =>
|
|
143
|
+
live.source.options.lane
|
|
144
|
+
? loadSnapshot({ ...live.source, options: { ...live.source.options, lane: null } }, live.pullRequests)
|
|
145
|
+
: snapshot
|
|
146
|
+
|
|
147
|
+
const boardTextsOf = (live: Live) => boardTexts(live.source.settings.board.term)
|
|
148
|
+
|
|
149
|
+
const sprintDirectoryOf = ({ root, sprintFile, settings }: Source): string =>
|
|
150
|
+
dirname(boardRepoPathOf(root, settings, sprintFile))
|
|
151
|
+
|
|
152
|
+
/** The snapshot as read with the plan chips of the page written so far, none before the first one. */
|
|
153
|
+
const withKnownPage = (live: Live, read: Snapshot): Snapshot =>
|
|
154
|
+
live.page ? withPlanChips(read, live.page, sprintDirectoryOf(live.source)) : read
|
|
155
|
+
|
|
156
|
+
const isCurrent = (live: Live, read: Snapshot): boolean =>
|
|
157
|
+
live.page !== null && !pageNeedsWriting(live.page, pageKey(read), existsSync(live.page.path))
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Writes the sprint page, so a plan chip's link points at a file that exists. Only when the plans it is built
|
|
161
|
+
* from changed or the file is gone: building it takes seconds, with the event loop blocked for all of them.
|
|
162
|
+
*/
|
|
163
|
+
const syncSprintPage = async (live: Live, read: Snapshot) => {
|
|
164
|
+
const key = pageKey(read)
|
|
165
|
+
if (live.pageWriting || !pageNeedsWriting(live.page, key, live.page !== null && existsSync(live.page.path))) return
|
|
166
|
+
live.pageWriting = true
|
|
167
|
+
try {
|
|
168
|
+
const written = await writeSprintPageInSteps(live.source, await wholeSprint(live, read))
|
|
169
|
+
live.pageError = 'error' in written ? boardTextsOf(live).pageNotWritten(written.error) : null
|
|
170
|
+
live.page = pageAfterWrite(live.page, written, key)
|
|
171
|
+
} catch (error) {
|
|
172
|
+
live.pageError = boardTextsOf(live).pageNotWritten(errorText(error))
|
|
173
|
+
} finally {
|
|
174
|
+
live.pageWriting = false
|
|
175
|
+
}
|
|
176
|
+
if (live.read) live.snapshot = withKnownPage(live, live.read)
|
|
177
|
+
redraw(live)
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** A re-read never overlaps another: one asked for meanwhile runs once after the running one, so the screen cannot go back to an older read. */
|
|
181
|
+
const refresh = (live: Live, onChange: () => void): Promise<void> => {
|
|
182
|
+
live.flight ??= singleFlight(() => readOnce(live, onChange))
|
|
183
|
+
return live.flight()
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const readOnce = async (live: Live, onChange: () => void) => {
|
|
187
|
+
try {
|
|
188
|
+
const read = await loadSnapshot(live.source, live.pullRequests)
|
|
189
|
+
const next = withKnownPage(live, read)
|
|
190
|
+
live.read = read
|
|
191
|
+
if (!live.snapshot) live.view = { ...live.view, selection: startSelection(next, live.source.options) }
|
|
192
|
+
const folded = syncLaneFolds(
|
|
193
|
+
live.view.selection,
|
|
194
|
+
next.views.map(({ progress, status }) => ({ lane: progress.lane.name, status: status.agent })),
|
|
195
|
+
)
|
|
196
|
+
live.view = { ...live.view, selection: folded }
|
|
197
|
+
live.snapshot = next
|
|
198
|
+
const fetchLine = fetchNotice(live.fetch, baseRefOf(live.source.settings), clockLocaleOf(live.source.settings))
|
|
199
|
+
live.errors = [
|
|
200
|
+
...next.errors,
|
|
201
|
+
...(fetchLine ? [fetchLine] : []),
|
|
202
|
+
...reportTitles(live.source, next.titles, live.reported),
|
|
203
|
+
]
|
|
204
|
+
syncWatchers(live.watchers, watchedFiles(next.files, live.fetchHead), onChange)
|
|
205
|
+
} catch (error) {
|
|
206
|
+
const at = clockOf(clockLocaleOf(live.source.settings))
|
|
207
|
+
live.errors = [`refresh failed at ${at}, showing the last good state: ${errorText(error)}`]
|
|
208
|
+
}
|
|
209
|
+
redraw(live)
|
|
210
|
+
const { read } = live
|
|
211
|
+
if (read) setTimeout(() => void syncSprintPage(live, read), 0)
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const writeSprintPage = async (live: Live, snapshot: Snapshot, anchor: string | null = null) => {
|
|
215
|
+
const { source } = live
|
|
216
|
+
try {
|
|
217
|
+
live.notice = exportSprintPage(source, await wholeSprint(live, snapshot), undefined, anchor)
|
|
218
|
+
} catch (error) {
|
|
219
|
+
live.notice = boardTextsOf(live).pageNotWritten(errorText(error))
|
|
220
|
+
}
|
|
221
|
+
redraw(live)
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* A click on a plan chip. A page built from the current plans is opened at once; otherwise it is written again
|
|
226
|
+
* first. Any other `file:` address is refused.
|
|
227
|
+
*/
|
|
228
|
+
const openPlan = (live: Live, snapshot: Snapshot, address: string) => {
|
|
229
|
+
const anchor = planAnchorOn(live.page?.path ?? null, address)
|
|
230
|
+
if (!anchor || !live.page) return
|
|
231
|
+
if (!live.read || !isCurrent(live, live.read)) {
|
|
232
|
+
void writeSprintPage(live, snapshot, anchor)
|
|
233
|
+
return
|
|
234
|
+
}
|
|
235
|
+
const reason = openInBrowser(`${pathToFileURL(live.page.path).href}#${anchor}`)
|
|
236
|
+
const texts = boardTextsOf(live)
|
|
237
|
+
live.notice = reason === null ? texts.pageOpened(live.page.path) : texts.pageNotOpenedReason(reason)
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const handleInput = (live: Live, input: string) => {
|
|
241
|
+
const { snapshot, screen } = live
|
|
242
|
+
if (!snapshot || !screen) return
|
|
243
|
+
live.notice = null
|
|
244
|
+
if (isSprintPageKey(input)) {
|
|
245
|
+
void writeSprintPage(live, snapshot)
|
|
246
|
+
return
|
|
247
|
+
}
|
|
248
|
+
const context = {
|
|
249
|
+
keys: targetKeys(screen.body.targets),
|
|
250
|
+
targets: screen.body.targets,
|
|
251
|
+
headerRows: screen.header.length,
|
|
252
|
+
bodyRows: bodyRows(screen),
|
|
253
|
+
}
|
|
254
|
+
const pullRequests = snapshot.views.flatMap((view) => view.pullRequests)
|
|
255
|
+
const outcome = inputOutcome(live.view, input, context, (key) => pullRequestUrl(pullRequests, key))
|
|
256
|
+
live.view = outcome.view
|
|
257
|
+
if (outcome.open?.startsWith('file:')) {
|
|
258
|
+
openPlan(live, snapshot, outcome.open)
|
|
259
|
+
redraw(live)
|
|
260
|
+
return
|
|
261
|
+
}
|
|
262
|
+
const error = outcome.open ? openInBrowser(outcome.open) : null
|
|
263
|
+
if (error) live.errors = [...live.errors, error]
|
|
264
|
+
redraw(live)
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
export const runLive = (source: Source): void => {
|
|
268
|
+
const live: Live = {
|
|
269
|
+
source,
|
|
270
|
+
snapshot: null,
|
|
271
|
+
screen: null,
|
|
272
|
+
view: { selection: overviewSelection(), top: 0, follow: true },
|
|
273
|
+
errors: [],
|
|
274
|
+
pullRequests: noPullRequests(),
|
|
275
|
+
fetch: initialFetchState(),
|
|
276
|
+
notice: null,
|
|
277
|
+
watchers: new Map(),
|
|
278
|
+
fetchHead: fetchHeadPath(source.root),
|
|
279
|
+
reported: new Map(),
|
|
280
|
+
pending: null,
|
|
281
|
+
read: null,
|
|
282
|
+
page: null,
|
|
283
|
+
pageWriting: false,
|
|
284
|
+
flight: null,
|
|
285
|
+
pageError: null,
|
|
286
|
+
}
|
|
287
|
+
const schedule = () => {
|
|
288
|
+
if (live.pending) clearTimeout(live.pending)
|
|
289
|
+
live.pending = setTimeout(() => refresh(live, schedule), DEBOUNCE_MS)
|
|
290
|
+
}
|
|
291
|
+
const fetchBaseBranch = async () => {
|
|
292
|
+
live.fetch = { ...live.fetch, lastAttempt: Date.now(), running: true }
|
|
293
|
+
const failure = await fetchBase(source.root, BASE_REMOTE, source.settings.baseBranch)
|
|
294
|
+
live.fetch = {
|
|
295
|
+
...live.fetch,
|
|
296
|
+
running: false,
|
|
297
|
+
failure,
|
|
298
|
+
lastSuccess: failure === null ? new Date() : live.fetch.lastSuccess,
|
|
299
|
+
}
|
|
300
|
+
schedule()
|
|
301
|
+
}
|
|
302
|
+
const fetchWhenDue = (pullRequestGone: boolean) => {
|
|
303
|
+
const due = shouldFetch({
|
|
304
|
+
now: Date.now(),
|
|
305
|
+
state: live.fetch,
|
|
306
|
+
intervalMinutes: source.settings.fetchIntervalMinutes,
|
|
307
|
+
pullRequestGone,
|
|
308
|
+
})
|
|
309
|
+
if (due) void fetchBaseBranch()
|
|
310
|
+
}
|
|
311
|
+
const refreshPullRequests = async () => {
|
|
312
|
+
const previous = live.pullRequests
|
|
313
|
+
live.pullRequests = await fetchPullRequests(source.root)
|
|
314
|
+
const readable = previous.errors.length === 0 && live.pullRequests.errors.length === 0
|
|
315
|
+
const laneBranches = new Set(live.snapshot?.views.flatMap(({ status }) => (status.branch ? [status.branch] : [])))
|
|
316
|
+
const gone = goneLanePullRequests(previous.byBranch, live.pullRequests.byBranch, laneBranches)
|
|
317
|
+
fetchWhenDue(readable && gone.length > 0)
|
|
318
|
+
schedule()
|
|
319
|
+
}
|
|
320
|
+
const quit = () => {
|
|
321
|
+
clearTitles(source, live.reported)
|
|
322
|
+
for (const watcher of live.watchers.values()) watcher.close()
|
|
323
|
+
process.stdout.write(TERMINAL_OFF)
|
|
324
|
+
process.exit(0)
|
|
325
|
+
}
|
|
326
|
+
const onInput = (input: string) => (QUIT_KEYS.has(input) ? quit() : handleInput(live, input))
|
|
327
|
+
|
|
328
|
+
enterTerminal(onInput, quit, () => redraw(live))
|
|
329
|
+
setInterval(() => {
|
|
330
|
+
fetchWhenDue(false)
|
|
331
|
+
void refresh(live, schedule)
|
|
332
|
+
}, REFRESH_MS)
|
|
333
|
+
setInterval(refreshPullRequests, PULL_REQUEST_REFRESH_MS)
|
|
334
|
+
void refresh(live, schedule)
|
|
335
|
+
void refreshPullRequests()
|
|
336
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { dirname, join, posix } from 'node:path'
|
|
2
|
+
import { pathToFileURL } from 'node:url'
|
|
3
|
+
import { Marked, type Tokens } from 'marked'
|
|
4
|
+
|
|
5
|
+
/** Where a plan's links can go: the other plans on the page, the repository, the local checkout. */
|
|
6
|
+
export type LinkContext = {
|
|
7
|
+
/** The repository path of the plan the markdown comes from. */
|
|
8
|
+
fromPath: string
|
|
9
|
+
/** Repository path of a plan on the page → the id of its section. */
|
|
10
|
+
anchors: ReadonlyMap<string, string>
|
|
11
|
+
/** `https://host/owner/repo/blob/<branch>/` or null when the remote is unknown. */
|
|
12
|
+
blobBase: string | null
|
|
13
|
+
/** The main checkout, for `file:` links when there is no remote. */
|
|
14
|
+
root: string
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const escapeHtml = (text: string): string =>
|
|
18
|
+
text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''')
|
|
19
|
+
|
|
20
|
+
/** The id of a heading as GitHub makes it: lower case, punctuation gone, spaces to hyphens. */
|
|
21
|
+
export const headingSlug = (text: string): string =>
|
|
22
|
+
text
|
|
23
|
+
.toLowerCase()
|
|
24
|
+
.trim()
|
|
25
|
+
.replace(/[^\p{L}\p{N}\s-]/gu, '')
|
|
26
|
+
.replace(/\s/g, '-')
|
|
27
|
+
|
|
28
|
+
const SCHEME = /^[a-z][a-z0-9+.-]*:/i
|
|
29
|
+
const WEB = /^https?:\/\//i
|
|
30
|
+
const MAX_HEADING = 6
|
|
31
|
+
const HEADING_SHIFT = 2
|
|
32
|
+
|
|
33
|
+
/** The id of the section of a plan, from its repository path. */
|
|
34
|
+
export const planAnchorOf = (repoPath: string): string =>
|
|
35
|
+
`plan-${headingSlug(posix.basename(repoPath, '.md')).replace(/-+/g, '-')}`
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Where a link of a plan goes on the page: another plan of the page becomes its anchor, a link inside the
|
|
39
|
+
* plan becomes the id of that heading, any other file of the repository goes to GitHub (or to the checkout),
|
|
40
|
+
* a web link stays. Anything else, a script or a path out of the repository, has no target.
|
|
41
|
+
*/
|
|
42
|
+
export const resolveLink = (href: string, context: LinkContext, ownAnchor: string): string | null => {
|
|
43
|
+
if (href === '') return null
|
|
44
|
+
if (href.startsWith('#')) return `#${ownAnchor}--${href.slice(1)}`
|
|
45
|
+
if (WEB.test(href)) return href
|
|
46
|
+
if (SCHEME.test(href)) return null
|
|
47
|
+
const [path, fragment] = href.split('#')
|
|
48
|
+
const target = posix.normalize(posix.join(dirname(context.fromPath), path))
|
|
49
|
+
if (target.startsWith('..')) return null
|
|
50
|
+
const anchor = context.anchors.get(target)
|
|
51
|
+
if (anchor) return `#${anchor}${fragment ? `--${fragment}` : ''}`
|
|
52
|
+
const base = context.blobBase ? `${context.blobBase}${target}` : pathToFileURL(join(context.root, target)).href
|
|
53
|
+
return fragment ? `${base}#${fragment}` : base
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Every table in its own container that scrolls sideways, so a wide one is not cut off. */
|
|
57
|
+
export const scrollable = (html: string): string =>
|
|
58
|
+
html.replace(/<table/g, '<div class="table-scroll"><table').replace(/<\/table>/g, '</table></div>')
|
|
59
|
+
|
|
60
|
+
// The header fields (`**Date:**`, `**Status:**`, `**Scope:**`) sit on consecutive lines, which markdown folds
|
|
61
|
+
// into one paragraph; a trailing double space keeps each on its own line.
|
|
62
|
+
const hardBreakFields = (markdown: string): string =>
|
|
63
|
+
markdown.replace(/^(\*\*\w+:\*\*[^\n]*)\n(?=\*\*\w+:\*\*)/gm, '$1 \n')
|
|
64
|
+
|
|
65
|
+
const renderUncached = (
|
|
66
|
+
markdown: string,
|
|
67
|
+
context: LinkContext,
|
|
68
|
+
ownAnchor: string,
|
|
69
|
+
/** The title the section already shows; a first level 1 heading with that text is left out. */
|
|
70
|
+
shownTitle: string | null = null,
|
|
71
|
+
): string => {
|
|
72
|
+
const seen = new Map<string, number>()
|
|
73
|
+
const marked = new Marked({
|
|
74
|
+
gfm: true,
|
|
75
|
+
renderer: {
|
|
76
|
+
html: ({ text }: Tokens.HTML | Tokens.Tag) => escapeHtml(text),
|
|
77
|
+
image: ({ text }: Tokens.Image) => escapeHtml(text),
|
|
78
|
+
checkbox: ({ checked }: Tokens.Checkbox) => (checked ? '☑' : '☐'),
|
|
79
|
+
link(this: { parser: { parseInline: (tokens: Tokens.Generic[]) => string } }, { href, tokens }: Tokens.Link) {
|
|
80
|
+
const label = this.parser.parseInline(tokens)
|
|
81
|
+
const target = resolveLink(href, context, ownAnchor)
|
|
82
|
+
return target === null ? label : `<a href="${escapeHtml(target)}">${label}</a>`
|
|
83
|
+
},
|
|
84
|
+
heading(
|
|
85
|
+
this: { parser: { parseInline: (tokens: Tokens.Generic[]) => string } },
|
|
86
|
+
{ tokens, depth, text }: Tokens.Heading,
|
|
87
|
+
) {
|
|
88
|
+
if (depth === 1 && shownTitle !== null && text.trim() === shownTitle.trim()) return ''
|
|
89
|
+
const slug = headingSlug(text)
|
|
90
|
+
const count = seen.get(slug) ?? 0
|
|
91
|
+
seen.set(slug, count + 1)
|
|
92
|
+
const id = `${ownAnchor}--${count === 0 ? slug : `${slug}-${count}`}`
|
|
93
|
+
const level = Math.min(depth + HEADING_SHIFT, MAX_HEADING)
|
|
94
|
+
return `<h${level} id="${escapeHtml(id)}">${this.parser.parseInline(tokens)}</h${level}>\n`
|
|
95
|
+
},
|
|
96
|
+
},
|
|
97
|
+
})
|
|
98
|
+
return scrollable(marked.parse(hardBreakFields(markdown), { async: false }))
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const rendered = new Map<string, string>()
|
|
102
|
+
const RENDERED_MAX = 64
|
|
103
|
+
|
|
104
|
+
const renderKey = (markdown: string, context: LinkContext, ownAnchor: string, shownTitle: string | null): string =>
|
|
105
|
+
JSON.stringify([
|
|
106
|
+
Bun.hash(markdown).toString(36),
|
|
107
|
+
markdown.length,
|
|
108
|
+
context.fromPath,
|
|
109
|
+
[...context.anchors],
|
|
110
|
+
context.blobBase,
|
|
111
|
+
context.root,
|
|
112
|
+
ownAnchor,
|
|
113
|
+
shownTitle,
|
|
114
|
+
])
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* A plan's markdown as HTML for the page. Raw HTML in the text is shown as text and images as their alt text;
|
|
118
|
+
* headings get ids under the plan's anchor and move two levels down; task items are ☐ and ☑. Rendering takes
|
|
119
|
+
* about two milliseconds per kilobyte, so what was rendered is kept: the same text under the same links yields
|
|
120
|
+
* the same HTML, and a page built again after one plan changed renders that plan only.
|
|
121
|
+
*/
|
|
122
|
+
export const renderPlanMarkdown = (
|
|
123
|
+
markdown: string,
|
|
124
|
+
context: LinkContext,
|
|
125
|
+
ownAnchor: string,
|
|
126
|
+
/** The title the section already shows; a first level 1 heading with that text is left out. */
|
|
127
|
+
shownTitle: string | null = null,
|
|
128
|
+
): string => {
|
|
129
|
+
const key = renderKey(markdown, context, ownAnchor, shownTitle)
|
|
130
|
+
const kept = rendered.get(key)
|
|
131
|
+
if (kept !== undefined) {
|
|
132
|
+
rendered.delete(key)
|
|
133
|
+
rendered.set(key, kept)
|
|
134
|
+
return kept
|
|
135
|
+
}
|
|
136
|
+
const html = renderUncached(markdown, context, ownAnchor, shownTitle)
|
|
137
|
+
if (rendered.size >= RENDERED_MAX) rendered.delete(rendered.keys().next().value as string)
|
|
138
|
+
rendered.set(key, html)
|
|
139
|
+
return html
|
|
140
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export type Options = { boardPath: string | null; lane: string | null; once: boolean; ensure: boolean }
|
|
2
|
+
|
|
3
|
+
const KNOWN_OPTIONS = ['--lane', '--once', '--ensure']
|
|
4
|
+
|
|
5
|
+
export const parseOptions = (args: string[]): Options => {
|
|
6
|
+
const unknown = args.find((arg) => arg.startsWith('--') && !KNOWN_OPTIONS.includes(arg))
|
|
7
|
+
if (unknown) throw new Error(`unknown option ${unknown} (known: ${KNOWN_OPTIONS.join(', ')})`)
|
|
8
|
+
const laneIndex = args.indexOf('--lane')
|
|
9
|
+
const lane = laneIndex === -1 ? null : args[laneIndex + 1]
|
|
10
|
+
if (lane === undefined || lane?.startsWith('--')) throw new Error('--lane needs a lane name')
|
|
11
|
+
const positional = args.filter((arg, index) => !arg.startsWith('--') && (laneIndex === -1 || index !== laneIndex + 1))
|
|
12
|
+
const ensure = args.includes('--ensure')
|
|
13
|
+
if (ensure && (lane !== null || args.includes('--once'))) throw new Error('--ensure takes no --lane or --once')
|
|
14
|
+
return { boardPath: positional[0] ?? null, lane, once: args.includes('--once'), ensure }
|
|
15
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import type { PlanPhase } from '../plan-format'
|
|
2
|
+
|
|
3
|
+
/** What a lane has done in its own worktree: commits ahead of the base ref (null when unknown) and changed files. */
|
|
4
|
+
export type OwnWork = { ahead: number | null; dirty: number }
|
|
5
|
+
|
|
6
|
+
export const PHASE_SOURCE = { base: 'base', worktree: 'worktree' } as const
|
|
7
|
+
|
|
8
|
+
export type PhaseSource = (typeof PHASE_SOURCE)[keyof typeof PHASE_SOURCE]
|
|
9
|
+
|
|
10
|
+
export const hasOwnWork = ({ ahead, dirty }: OwnWork): boolean => (ahead ?? 0) > 0 || dirty > 0
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Where a phase that a row of the lane names is read from: its worktree while the lane has unmerged work of
|
|
14
|
+
* its own there, where the lane ticks it, else the base ref. `mergePlanPhases` adds the phases no row names.
|
|
15
|
+
*/
|
|
16
|
+
export const phaseSource = (work: OwnWork, ownedByLane: boolean): PhaseSource =>
|
|
17
|
+
ownedByLane && hasOwnWork(work) ? PHASE_SOURCE.worktree : PHASE_SOURCE.base
|
|
18
|
+
|
|
19
|
+
const LETTER_SUFFIX = /[a-z]$/
|
|
20
|
+
|
|
21
|
+
/** A sprint phase `4b` belongs to the plan phase `4` as well as to `4b`. */
|
|
22
|
+
export const belongsToLane = (planPhaseId: string, laneIds: readonly string[]): boolean =>
|
|
23
|
+
laneIds.some((id) => id === planPhaseId || id.replace(LETTER_SUFFIX, '') === planPhaseId)
|
|
24
|
+
|
|
25
|
+
const doneStates = (phase: PlanPhase): boolean[] => phase.items.map((item) => item.done)
|
|
26
|
+
|
|
27
|
+
const sameTicks = (left: PlanPhase, right: PlanPhase): boolean => {
|
|
28
|
+
const [leftStates, rightStates] = [doneStates(left), doneStates(right)]
|
|
29
|
+
return leftStates.length === rightStates.length && leftStates.every((done, index) => done === rightStates[index])
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const tickCount = (phase: PlanPhase): number => phase.items.filter((item) => item.done).length
|
|
33
|
+
|
|
34
|
+
/** The lane ticked further than the base: the worktree's copy is newer, not an old copy of another lane's work. */
|
|
35
|
+
const tickedFurther = (worktreePhase: PlanPhase, basePhase: PlanPhase): boolean =>
|
|
36
|
+
tickCount(worktreePhase) > tickCount(basePhase)
|
|
37
|
+
|
|
38
|
+
/** A phase read from the worktree is touched when its ticks differ from the base's, or the base lacks it. */
|
|
39
|
+
const markTouched = (phase: PlanPhase, basePhase: PlanPhase | undefined): PlanPhase =>
|
|
40
|
+
basePhase && sameTicks(phase, basePhase) ? phase : { ...phase, touched: true }
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The phases of a plan as the lane sees them: the base ref's phases in their order, each replaced by the
|
|
44
|
+
* worktree's version when `phaseSource` says so, or, for a phase no row of the lane names, when the lane has
|
|
45
|
+
* work of its own and ticked it further than the base; a phase only the worktree has follows. A phase the
|
|
46
|
+
* worktree ticked less stays at the base: the lane holds an old copy of what another lane finished. Only the
|
|
47
|
+
* count is compared, so a tick of the lane's own stays hidden while the base has more ticks in that phase.
|
|
48
|
+
*/
|
|
49
|
+
export const mergePlanPhases = (
|
|
50
|
+
base: readonly PlanPhase[],
|
|
51
|
+
worktree: readonly PlanPhase[] | null,
|
|
52
|
+
laneIds: readonly string[],
|
|
53
|
+
work: OwnWork,
|
|
54
|
+
): PlanPhase[] => {
|
|
55
|
+
const laneHasWork = hasOwnWork(work)
|
|
56
|
+
const worktreeById = new Map((worktree ?? []).map((phase) => [phase.id, phase]))
|
|
57
|
+
const baseIds = new Set(base.map((phase) => phase.id))
|
|
58
|
+
const readsWorktree = (id: string) => phaseSource(work, belongsToLane(id, laneIds)) === PHASE_SOURCE.worktree
|
|
59
|
+
const readsWorktreeCopy = (worktreePhase: PlanPhase, basePhase: PlanPhase) =>
|
|
60
|
+
readsWorktree(basePhase.id) || (laneHasWork && tickedFurther(worktreePhase, basePhase))
|
|
61
|
+
const inBaseOrder = base.map((basePhase) => {
|
|
62
|
+
const worktreePhase = worktreeById.get(basePhase.id)
|
|
63
|
+
return worktreePhase && readsWorktreeCopy(worktreePhase, basePhase)
|
|
64
|
+
? markTouched(worktreePhase, basePhase)
|
|
65
|
+
: basePhase
|
|
66
|
+
})
|
|
67
|
+
const added = laneHasWork
|
|
68
|
+
? [...worktreeById.values()].filter((phase) => !baseIds.has(phase.id)).map((phase) => markTouched(phase, undefined))
|
|
69
|
+
: []
|
|
70
|
+
return [...inBaseOrder, ...added]
|
|
71
|
+
}
|