jackherd 1.9.0 → 1.11.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 +49 -0
- package/INSTALL.md +1 -1
- package/README.md +65 -32
- package/dist/{chunk-MCP4XJQH.js → chunk-RMMXD4G5.js} +18 -6
- package/dist/config.d.ts +99 -4
- package/dist/config.js +105 -29
- package/dist/plan-format.d.ts +51 -6
- package/dist/plan-format.js +49 -7
- package/docs/workflow-dark.svg +67 -63
- package/docs/workflow.svg +67 -63
- package/herdr-plugin/herdr-plugin.toml +1 -1
- package/install.sh +1 -1
- package/kit/hooks/ask-rules.json +15 -0
- package/kit/hooks/hooks/ask-hint/decision.ts +82 -14
- package/kit/hooks/hooks/ask-hint/index.ts +15 -19
- package/kit/hooks/hooks/ask-hint/settings.ts +3 -18
- package/kit/hooks/hooks/git-guard/decision.ts +50 -102
- package/kit/hooks/hooks/merge-guard/always-ask.ts +63 -0
- package/kit/hooks/hooks/merge-guard/{plan-approval-settings.ts → board-settings.ts} +21 -67
- package/kit/hooks/hooks/merge-guard/check-free.ts +20 -0
- package/kit/hooks/hooks/merge-guard/decision.ts +127 -187
- package/kit/hooks/hooks/merge-guard/index.ts +6 -3
- package/kit/hooks/hooks/merge-guard/lane-plan.ts +79 -0
- package/kit/hooks/hooks/merge-guard/run.ts +59 -54
- package/kit/hooks/hooks/merge-guard/settings.ts +13 -1
- package/kit/hooks/hooks/merge-guard/standing-approval.ts +142 -0
- package/kit/hooks/hooks/open-question/index.ts +26 -0
- package/kit/hooks/hooks/open-question/record.ts +135 -0
- package/kit/hooks/hooks/open-question/run.ts +57 -0
- package/kit/hooks/hooks/open-question/store.ts +39 -0
- package/kit/hooks/hooks/sent-file/store.ts +3 -12
- package/kit/hooks/hooks/shared/config.ts +7 -0
- package/kit/hooks/hooks/shared/dialog-answers.ts +9 -9
- package/kit/hooks/hooks/shared/files.ts +7 -0
- package/kit/hooks/hooks/shared/lane.ts +1 -1
- package/kit/hooks/hooks/shared/newest-verdict.ts +10 -0
- package/kit/hooks/hooks/shared/private-directory.ts +56 -9
- package/kit/hooks/hooks/shared/program-position.ts +2 -75
- package/kit/hooks/hooks/shared/shell-aliases.ts +1 -71
- package/kit/hooks/hooks/{ask-hint → shared}/transcript.ts +37 -6
- package/kit/hooks/hooks/{merge-guard → shared}/xargs-program.ts +10 -8
- package/kit/hooks/hooks/task-hint/decision.ts +49 -0
- package/kit/hooks/hooks/task-hint/index.ts +79 -0
- package/kit/hooks/hooks/task-hint/run.ts +62 -0
- package/kit/hooks/hooks/task-hint/store.ts +51 -0
- package/kit/hooks/hooks.json +13 -5
- package/kit/review/agents/skill-review.md +34 -0
- package/kit/review/skills/pre-commit/SKILL.md +1 -1
- package/kit/rules/rules/kit/pull-requests.md +2 -0
- package/kit/workflow/skills/follow/SKILL.md +3 -10
- package/kit/workflow/skills/implement/SKILL.md +8 -6
- package/kit/workflow/skills/orchestrate/SKILL.md +218 -433
- package/kit/workflow/skills/orchestrate/handover/decision.ts +43 -8
- package/kit/workflow/skills/orchestrate/handover/index.ts +32 -4
- package/kit/workflow/skills/orchestrate/handover/standing-approval.ts +84 -0
- package/kit/workflow/skills/orchestrate/references/acceptance.md +15 -0
- package/kit/workflow/skills/orchestrate/references/close-lane.md +17 -0
- package/kit/workflow/skills/orchestrate/references/coordinator-handover.md +43 -17
- package/kit/workflow/skills/orchestrate/references/draft-review.md +19 -0
- package/kit/workflow/skills/orchestrate/references/events.md +86 -0
- package/kit/workflow/skills/orchestrate/references/herd.md +6 -6
- package/kit/workflow/skills/orchestrate/references/lane-handover.md +18 -0
- package/kit/workflow/skills/orchestrate/references/lane-session.md +25 -0
- package/kit/workflow/skills/orchestrate/references/lane-setup.md +37 -0
- package/kit/workflow/skills/orchestrate/references/plan-commit.md +22 -10
- package/kit/workflow/skills/orchestrate/references/sensitive-input.md +5 -7
- package/kit/workflow/skills/plan/SKILL.md +6 -5
- package/kit/workflow/skills/plan/references/template-English.md +4 -4
- package/kit/workflow/skills/plan/references/template-German.md +4 -4
- package/package.json +3 -1
- package/recipes/README.md +2 -1
- package/recipes/sources.md +5 -1
- package/schemas/jackherd.schema.json +139 -5
- package/src/check/agent-text-reviewers.ts +23 -0
- package/src/check/command.ts +2 -0
- package/src/check/plan-structure-check.ts +49 -2
- package/src/check/plan.ts +8 -1
- package/src/check/schema.ts +9 -0
- package/src/check/skill-budget-defaults.ts +55 -0
- package/src/check/skill-budgets.ts +185 -0
- package/src/cli.ts +2 -0
- package/src/config/auto-window.ts +8 -0
- package/src/config/link-kinds.ts +7 -1
- package/src/config/schema.ts +82 -25
- package/src/config/tones.ts +27 -0
- package/src/lane/close-board.ts +106 -0
- package/src/lane/close-check.ts +183 -0
- package/src/lane/close-command-line.ts +11 -0
- package/src/lane/close-command.ts +217 -0
- package/src/lane/close-options.ts +43 -0
- package/src/lane/close-survey.ts +83 -0
- package/src/lane/close.ts +141 -0
- package/src/lane/command.ts +13 -0
- package/src/lane-prompt/additions.ts +14 -0
- package/src/lane-prompt/build.ts +74 -0
- package/src/lane-prompt/command.ts +93 -0
- package/src/lane-prompt/input.ts +157 -0
- package/src/lane-prompt/options.ts +70 -0
- package/src/lane-prompt/texts.ts +101 -0
- package/src/mermaid/command.ts +36 -0
- package/src/mermaid/problems.ts +48 -0
- package/src/plan-format/clock.ts +8 -0
- package/src/plan-format/dependencies.ts +9 -6
- package/src/plan-format/index.ts +1 -0
- package/src/plan-format/short-names.ts +71 -0
- package/src/plan-format/waiting.ts +22 -4
- package/src/plans/appointment-rows.ts +83 -0
- package/src/plans/ask-coordinator.ts +111 -0
- package/src/plans/ask-target.ts +36 -0
- package/src/plans/board-close.ts +85 -0
- package/src/plans/child-stop.ts +51 -0
- package/src/plans/closable-records.ts +34 -0
- package/src/plans/command.ts +3 -2
- package/src/plans/fetch.ts +26 -3
- package/src/plans/frame.ts +5 -12
- package/src/plans/git.ts +54 -5
- package/src/plans/groomed.ts +27 -0
- package/src/plans/herd-claim.ts +173 -0
- package/src/plans/herd-memory.ts +36 -25
- package/src/plans/lane-plans.ts +44 -13
- package/src/plans/lane-summary.ts +253 -45
- package/src/plans/layout-phase.ts +36 -11
- package/src/plans/layout-summary.ts +89 -18
- package/src/plans/layout.ts +502 -98
- package/src/plans/link-chips.ts +23 -4
- package/src/plans/live.ts +178 -23
- package/src/plans/next-rows.ts +44 -0
- package/src/plans/phase-ids.ts +8 -0
- package/src/plans/phase-source.ts +15 -0
- package/src/plans/plan-cache.ts +28 -0
- package/src/plans/plan-heads.ts +54 -0
- package/src/plans/planned.ts +22 -0
- package/src/plans/pull-requests.ts +17 -2
- package/src/plans/question-records.ts +48 -0
- package/src/plans/questions-read.ts +13 -0
- package/src/plans/read-cache.ts +8 -0
- package/src/plans/record-id.ts +4 -0
- package/src/plans/restart-decision.ts +13 -4
- package/src/plans/row-actions.ts +52 -0
- package/src/plans/row-focus.ts +148 -0
- package/src/plans/row-targets.ts +7 -0
- package/src/plans/selection.ts +111 -18
- package/src/plans/sent-files-read.ts +6 -15
- package/src/plans/sent-files.ts +7 -14
- package/src/plans/settings.ts +12 -1
- package/src/plans/signalled-phase.ts +42 -0
- package/src/plans/signals-read.ts +12 -4
- package/src/plans/snapshot.ts +162 -81
- package/src/plans/source-asked.ts +19 -0
- package/src/plans/source-prefix.ts +17 -0
- package/src/plans/source-rows.ts +188 -0
- package/src/plans/sprint-export.ts +11 -11
- package/src/plans/sprint-page.ts +5 -5
- package/src/plans/subagent-phases.ts +95 -0
- package/src/plans/subagent-tasks.ts +51 -0
- package/src/plans/subagents-read.ts +66 -17
- package/src/plans/subagents.ts +64 -6
- package/src/plans/supervisor.ts +14 -4
- package/src/plans/symbols.ts +3 -0
- package/src/plans/task-name.ts +34 -0
- package/src/plans/texts.ts +48 -6
- package/src/plans/tones.ts +30 -0
- package/src/plans/view.ts +25 -11
- package/src/plans/waiting-click.ts +295 -35
- package/src/plans/waiting-records.ts +39 -0
- package/src/plans/waiting-window.ts +8 -0
- package/src/preview/command.ts +25 -3
- package/src/shared/private-location.ts +89 -0
- package/src/shared/stat-cache.ts +48 -0
- package/src/sync/kit-source.ts +29 -10
- package/src/sync/kit.ts +2 -0
- package/src/sync/manifest.ts +2 -0
- package/src/sync/plan-ask-rules.ts +94 -0
- package/src/sync/plan-hooks.ts +2 -0
- package/src/sync/render.ts +3 -3
- package/src/sync/settings-hooks.ts +4 -1
- package/src/sync/sync-plan.ts +1 -0
- package/src/sync/sync.ts +24 -3
- package/src/task-signals/claude-read.ts +27 -10
- package/src/terminal/text.ts +16 -3
- package/src/terminal/types.ts +4 -0
- package/src/version/skill-diff.ts +46 -0
- package/src/vocabulary/index.ts +26 -6
- package/src/waiting/command.ts +22 -7
- package/src/watch/appointments.ts +1 -2
- package/src/watch/asking-lanes.ts +40 -0
- package/src/watch/base-tip.ts +11 -0
- package/src/watch/command.ts +330 -105
- package/src/watch/heartbeat-progress.ts +6 -18
- package/src/watch/kit-update.ts +28 -9
- package/src/watch/lane-name.ts +6 -0
- package/src/watch/lane-progress.ts +40 -8
- package/src/watch/lane-report-file.ts +20 -5
- package/src/watch/lane-report.ts +32 -4
- package/src/watch/lane-sources.ts +31 -3
- package/src/watch/main-checkout.ts +34 -0
- package/src/watch/plan-badges-read.ts +22 -9
- package/src/watch/plan-badges.ts +10 -2
- package/src/watch/pull-requests.ts +6 -2
- package/src/watch/reference-paths.ts +87 -0
- package/src/watch/report-questions.ts +4 -3
- package/src/watch/running-subagents.ts +43 -0
- package/src/watch/skill-change.ts +124 -32
- package/src/watch/source-change.ts +48 -0
- package/src/watch/source-entries.ts +55 -0
- package/src/watch/source-fields.ts +1 -0
- package/src/watch/source-statuses.ts +60 -0
- package/src/watch/source-tickets.ts +21 -1
- package/src/watch/sources.ts +47 -4
- package/src/watch/stream-output.ts +4 -3
- package/src/watch/task-parallel-auto.ts +208 -0
- package/src/watch/task-parallel-data-paths.ts +46 -0
- package/src/watch/task-parallel-input.ts +5 -15
- package/src/watch/task-parallel-read.ts +3 -1
- package/src/watch/task-parallel.ts +36 -7
- package/src/watch/task-signal-missing.ts +69 -0
- package/src/watch/task-signal-read.ts +29 -0
- package/src/watch/texts.ts +10 -0
- package/src/watch/update-available.ts +6 -3
- package/src/watch/watch-events.ts +23 -0
- package/src/watch/watcher-lock.ts +25 -29
- package/kit/hooks/hooks/merge-guard/plan-approval-record.ts +0 -23
- package/kit/hooks/hooks/merge-guard/plan-approval-sources.ts +0 -196
- package/kit/hooks/hooks/merge-guard/plan-approval.ts +0 -168
- package/kit/hooks/hooks/merge-guard/plan-growth.ts +0 -40
- package/kit/hooks/hooks/merge-guard/plan-pointers.ts +0 -194
- package/kit/hooks/hooks/shared/command-text-runners.ts +0 -71
- package/kit/hooks/hooks/shared/plan-open-phases.ts +0 -114
package/src/plans/link-chips.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { ARTIFACT_LINK_KIND, type LinkKind } from '../config/link-kinds'
|
|
2
2
|
|
|
3
3
|
/** A link of a plan that matches a link kind, as the sprint page shows it. */
|
|
4
4
|
export type LinkChip = { url: string; label: string; icon: string; private: boolean }
|
|
@@ -14,6 +14,7 @@ const MARKS = /\*\*|`/g
|
|
|
14
14
|
const CONTROL = /\p{Cc}/gu
|
|
15
15
|
|
|
16
16
|
const linksOf = (line: string): Found[] => {
|
|
17
|
+
if (!line.includes('http')) return []
|
|
17
18
|
const found: Found[] = []
|
|
18
19
|
const take = (url: string, text: string | null, index: number) =>
|
|
19
20
|
found.push({ url, text, before: line.slice(0, index), line })
|
|
@@ -26,9 +27,27 @@ const linksOf = (line: string): Found[] => {
|
|
|
26
27
|
return found.sort((left, right) => line.indexOf(left.url) - line.indexOf(right.url))
|
|
27
28
|
}
|
|
28
29
|
|
|
29
|
-
const
|
|
30
|
-
|
|
31
|
-
|
|
30
|
+
const LIST_LEAD = /^\s*(?:[-*>]\s*)*(?:\[[ xX]\]\s*)?/
|
|
31
|
+
const LEFTOVER_LINK = /\bhttps?:\/\/\S+/g
|
|
32
|
+
const TITLE_END = /[\s:,;.\-–>]+$/
|
|
33
|
+
|
|
34
|
+
/** What a line says besides its links, without list marks, checkbox and emphasis: the title of a bare artifact link. */
|
|
35
|
+
const lineTitleOf = ({ line }: Found): string =>
|
|
36
|
+
line
|
|
37
|
+
.replace(MARKDOWN_LINK, '$1')
|
|
38
|
+
.replace(AUTOLINK, '')
|
|
39
|
+
.replace(LEFTOVER_LINK, '')
|
|
40
|
+
.replace(LIST_LEAD, '')
|
|
41
|
+
.replace(MARKS, '')
|
|
42
|
+
.replace(CONTROL, '')
|
|
43
|
+
.replace(TITLE_END, '')
|
|
44
|
+
.trim()
|
|
45
|
+
|
|
46
|
+
const labelOf = (found: Found, kind: LinkKind): string => {
|
|
47
|
+
const written = found.text ?? LABEL_BEFORE.exec(found.before)?.[1]
|
|
48
|
+
const own = written?.replace(MARKS, '').replace(CONTROL, '').trim()
|
|
49
|
+
if (own) return own
|
|
50
|
+
return (kind.pattern === ARTIFACT_LINK_KIND.pattern && lineTitleOf(found)) || kind.label
|
|
32
51
|
}
|
|
33
52
|
|
|
34
53
|
/**
|
package/src/plans/live.ts
CHANGED
|
@@ -1,14 +1,20 @@
|
|
|
1
1
|
import { existsSync, type FSWatcher, watch } from 'node:fs'
|
|
2
|
-
import { dirname } from 'node:path'
|
|
2
|
+
import { basename, dirname } from 'node:path'
|
|
3
3
|
import { pathToFileURL } from 'node:url'
|
|
4
4
|
import { terminalSafe } from '../plan-format/terminal-text'
|
|
5
|
+
import { runHerdr } from '../terminal/herdr'
|
|
5
6
|
import { openInBrowser } from '../terminal/open'
|
|
7
|
+
import { stripAnsi } from '../terminal/text'
|
|
8
|
+
import { closeWaiting } from '../waiting/command'
|
|
9
|
+
import { appointmentKeys } from './appointment-rows'
|
|
10
|
+
import { askCoordinator } from './ask-coordinator'
|
|
6
11
|
import type { LaneMemory } from './current-phase'
|
|
7
12
|
import { errorText } from './error-text'
|
|
8
13
|
import {
|
|
9
14
|
type FetchState,
|
|
10
15
|
fetchBase,
|
|
11
16
|
fetchNotice,
|
|
17
|
+
fetchPlanHeads,
|
|
12
18
|
goneLanePullRequests,
|
|
13
19
|
initialFetchState,
|
|
14
20
|
shouldFetch,
|
|
@@ -17,18 +23,31 @@ import {
|
|
|
17
23
|
import { fetchHeadPath } from './git'
|
|
18
24
|
import { fetchPullRequests, noPullRequests, type PullRequestState } from './github'
|
|
19
25
|
import { recallLanes, rememberLanes } from './herd-memory'
|
|
20
|
-
import type
|
|
26
|
+
import { type Screen, settledSelection } from './layout'
|
|
27
|
+
import { nextKeys } from './next-rows'
|
|
21
28
|
import { type KnownPage, pageAfterWrite, pageKey, pageNeedsWriting, planAnchorOn, withPlanChips } from './plan-chips'
|
|
29
|
+
import { planPullRequests } from './plan-heads'
|
|
22
30
|
import { herdLabel } from './plan-panes'
|
|
23
31
|
import { pullRequestUrl } from './pull-requests'
|
|
32
|
+
import { newReadCache, type ReadCache } from './read-cache'
|
|
33
|
+
import { TAKEN_OVER_CODE, TAKEOVER_SIGNAL } from './restart-decision'
|
|
34
|
+
import { closeRow } from './row-actions'
|
|
35
|
+
import { blockRowByKey, bracketAt, hoveredKeyAt, lineIndexAt, withHovered } from './row-focus'
|
|
24
36
|
import {
|
|
25
37
|
inputOutcome,
|
|
38
|
+
isActivateKey,
|
|
39
|
+
isMotion,
|
|
26
40
|
isSprintPageKey,
|
|
41
|
+
type MouseEvent,
|
|
27
42
|
overviewSelection,
|
|
28
43
|
parseMouse,
|
|
44
|
+
settledBlockFolds,
|
|
45
|
+
showDoneNotice,
|
|
29
46
|
splitKeys,
|
|
30
47
|
syncLaneFolds,
|
|
31
|
-
|
|
48
|
+
TOP_BLOCK,
|
|
49
|
+
type TopBlockId,
|
|
50
|
+
toggledBlockFold,
|
|
32
51
|
toggledExpansion,
|
|
33
52
|
type View,
|
|
34
53
|
windowTop,
|
|
@@ -36,10 +55,21 @@ import {
|
|
|
36
55
|
import { BASE_REMOTE, baseRefOf, boardRepoPathOf, CLOCK_LOCALE } from './settings'
|
|
37
56
|
import { singleFlight } from './single-flight'
|
|
38
57
|
import { loadSnapshot, type Snapshot, type Source } from './snapshot'
|
|
58
|
+
import { sourceKeys } from './source-rows'
|
|
39
59
|
import { exportSprintPage, writeSprintPageInSteps } from './sprint-export'
|
|
40
60
|
import { boardTexts } from './texts'
|
|
41
|
-
import {
|
|
42
|
-
|
|
61
|
+
import {
|
|
62
|
+
bodyRows,
|
|
63
|
+
clockOf,
|
|
64
|
+
frameOutput,
|
|
65
|
+
frameToWrite,
|
|
66
|
+
paneWidth,
|
|
67
|
+
renderSnapshot,
|
|
68
|
+
startSelection,
|
|
69
|
+
TERMINAL_OFF,
|
|
70
|
+
TERMINAL_ON,
|
|
71
|
+
} from './view'
|
|
72
|
+
import { type BlockClick, type BlockHit, blockClick, blockHeadClick, mainClickOf, waitingKeys } from './waiting-click'
|
|
43
73
|
|
|
44
74
|
/** The state of the running view; only the functions below `runLive` change it. */
|
|
45
75
|
type Live = {
|
|
@@ -58,6 +88,8 @@ type Live = {
|
|
|
58
88
|
reported: Map<string, string | null>
|
|
59
89
|
/** Per lane, the ticks seen at the last read, the phase that moved last and when each moved; kept across herds (`rememberLanes`). */
|
|
60
90
|
memories: Map<string, LaneMemory>
|
|
91
|
+
/** Session logs and plans parsed at the last read, kept while they did not change. */
|
|
92
|
+
cache: ReadCache
|
|
61
93
|
pending: Timer | null
|
|
62
94
|
/** The snapshot as read, without the plan chips; `snapshot` is this plus the chips of `page`. */
|
|
63
95
|
read: Snapshot | null
|
|
@@ -66,6 +98,8 @@ type Live = {
|
|
|
66
98
|
pageWriting: boolean
|
|
67
99
|
flight: (() => Promise<void>) | null
|
|
68
100
|
pageError: string | null
|
|
101
|
+
/** The frame last written to the terminal; null while its content is unknown, such as after a resize. */
|
|
102
|
+
written: string | null
|
|
69
103
|
}
|
|
70
104
|
|
|
71
105
|
const REFRESH_MS = 5_000
|
|
@@ -143,7 +177,10 @@ const redraw = (live: Live) => {
|
|
|
143
177
|
const top = windowTop(live.view, screen.body.cursorLine, screen.body.lines.length, bodyRows(screen))
|
|
144
178
|
live.screen = screen
|
|
145
179
|
live.view = { ...live.view, top }
|
|
146
|
-
|
|
180
|
+
const output = frameToWrite(live.written, frameOutput(screen, top, paneWidth(), bodyRows(screen)))
|
|
181
|
+
if (output === null) return
|
|
182
|
+
live.written = output
|
|
183
|
+
process.stdout.write(output)
|
|
147
184
|
}
|
|
148
185
|
|
|
149
186
|
/** The whole sprint, also while the view shows one lane: the snapshot is read again without the lane filter. */
|
|
@@ -193,16 +230,29 @@ const refresh = (live: Live, onChange: () => void): Promise<void> => {
|
|
|
193
230
|
|
|
194
231
|
const readOnce = async (live: Live, onChange: () => void) => {
|
|
195
232
|
try {
|
|
196
|
-
const read = await loadSnapshot(live.source, live.pullRequests, live.memories)
|
|
233
|
+
const read = await loadSnapshot(live.source, live.pullRequests, live.memories, live.cache)
|
|
197
234
|
rememberLanes(live.source.root, live.memories, read.documents.laneOrder)
|
|
198
235
|
const next = withKnownPage(live, read)
|
|
199
236
|
live.read = read
|
|
200
|
-
if (!live.snapshot)
|
|
237
|
+
if (!live.snapshot)
|
|
238
|
+
live.view = {
|
|
239
|
+
...live.view,
|
|
240
|
+
selection: startSelection(next, live.source.options),
|
|
241
|
+
}
|
|
201
242
|
const folded = syncLaneFolds(
|
|
202
243
|
live.view.selection,
|
|
203
|
-
next.views.map(({ progress, status }) => ({
|
|
244
|
+
next.views.map(({ progress, status }) => ({
|
|
245
|
+
lane: progress.lane.name,
|
|
246
|
+
status: status.agent,
|
|
247
|
+
})),
|
|
204
248
|
)
|
|
205
|
-
|
|
249
|
+
const settled = settledBlockFolds(folded, {
|
|
250
|
+
waiting: waitingKeys(next.waiting ?? []),
|
|
251
|
+
next: nextKeys(next.next?.rows ?? []),
|
|
252
|
+
appointments: appointmentKeys(next.appointments?.rows ?? []),
|
|
253
|
+
sources: sourceKeys(next.sourceBlock?.sources ?? []),
|
|
254
|
+
})
|
|
255
|
+
live.view = { ...live.view, selection: settled }
|
|
206
256
|
live.snapshot = next
|
|
207
257
|
const fetchLine = fetchNotice(live.fetch, baseRefOf(live.source.settings), CLOCK_LOCALE)
|
|
208
258
|
live.errors = [
|
|
@@ -246,39 +296,133 @@ const openPlan = (live: Live, snapshot: Snapshot, address: string) => {
|
|
|
246
296
|
live.notice = reason === null ? texts.pageOpened(live.page.path) : texts.pageNotOpenedReason(reason)
|
|
247
297
|
}
|
|
248
298
|
|
|
249
|
-
/**
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
if (!outcome) return false
|
|
299
|
+
/**
|
|
300
|
+
* What a hit on a row of a block of the herd does: expands or folds it, asks the coordinator to put a question or a board row
|
|
301
|
+
* (the result shows in the footer) or opens its address (a failure shows there). An appointment row only ever expands.
|
|
302
|
+
*/
|
|
303
|
+
const applyBlockClick = (live: Live, outcome: BlockClick): void => {
|
|
255
304
|
if ('toggle' in outcome) {
|
|
256
|
-
live.view = {
|
|
257
|
-
|
|
305
|
+
live.view = {
|
|
306
|
+
...live.view,
|
|
307
|
+
selection: toggledExpansion(live.view.selection, outcome.toggle),
|
|
308
|
+
}
|
|
309
|
+
return
|
|
310
|
+
}
|
|
311
|
+
if ('ask' in outcome) {
|
|
312
|
+
const { root, settings } = live.source
|
|
313
|
+
live.notice = askCoordinator(outcome.ask, {
|
|
314
|
+
language: settings.responseLanguage,
|
|
315
|
+
adapter: settings.adapter,
|
|
316
|
+
repository: basename(root),
|
|
317
|
+
run: runHerdr,
|
|
318
|
+
})
|
|
319
|
+
return
|
|
320
|
+
}
|
|
321
|
+
if ('done' in outcome) {
|
|
322
|
+
live.notice = closeRow(outcome.done, {
|
|
323
|
+
sprintFile: live.source.sprintFile,
|
|
324
|
+
language: live.source.settings.planLanguage,
|
|
325
|
+
now: new Date(),
|
|
326
|
+
closeRecords: (ids) => closeWaiting(live.source.root, ids, Date.now()),
|
|
327
|
+
})
|
|
328
|
+
return
|
|
258
329
|
}
|
|
259
330
|
const reason = openInBrowser(outcome.open)
|
|
260
331
|
if (reason) live.notice = boardTextsOf(live).notOpened(terminalSafe(reason))
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* A plain click on a block on top of the herd, matched against `hit` as it was drawn: the head folds or opens it, a
|
|
336
|
+
* cut-off row expands, a file or Artifact opens, a question or a board row asks the coordinator; the failure or the result
|
|
337
|
+
* shows in the footer. An appointment row has nothing to open or ask.
|
|
338
|
+
*/
|
|
339
|
+
const clickBlock = (live: Live, block: TopBlockId, hit: BlockHit | null, input: string): boolean => {
|
|
340
|
+
const mouse = parseMouse(input)
|
|
341
|
+
if (!mouse || !hit) return false
|
|
342
|
+
if (blockHeadClick(hit.firstLine - 1, mouse)) {
|
|
343
|
+
live.view = {
|
|
344
|
+
...live.view,
|
|
345
|
+
selection: toggledBlockFold(live.view.selection, block, hit.keys),
|
|
346
|
+
}
|
|
347
|
+
return true
|
|
348
|
+
}
|
|
349
|
+
const outcome = blockClick(hit.rows, hit.firstLine, mouse, hit.moreKey)
|
|
350
|
+
if (!outcome) return false
|
|
351
|
+
applyBlockClick(live, outcome)
|
|
261
352
|
return true
|
|
262
353
|
}
|
|
263
354
|
|
|
355
|
+
/** The mouse moved: the row under it is drawn like the selected one, and the screen is redrawn only when that row changes. */
|
|
356
|
+
const hover = (live: Live, screen: Screen, mouse: MouseEvent): void => {
|
|
357
|
+
const frame = { headerRows: screen.header.length, bodyRows: bodyRows(screen), top: live.view.top }
|
|
358
|
+
const key = hoveredKeyAt(screen.focusHits, mouse, frame)
|
|
359
|
+
const line = lineIndexAt(mouse, frame)
|
|
360
|
+
const text = line === null ? '' : (screen.header[line] ?? screen.body.lines[line - frame.headerRows] ?? '')
|
|
361
|
+
const selection = withHovered(
|
|
362
|
+
live.view.selection,
|
|
363
|
+
key,
|
|
364
|
+
key === null ? null : bracketAt(stripAnsi(text), mouse.column - 1),
|
|
365
|
+
)
|
|
366
|
+
if (selection === live.view.selection) return
|
|
367
|
+
live.view = { ...live.view, selection }
|
|
368
|
+
redraw(live)
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* Enter or space on the selected row of a block on top does its main action (`mainClickOf`: ask, open, expand); true
|
|
373
|
+
* when the input was for such a row, whether or not it had anything to do.
|
|
374
|
+
*/
|
|
375
|
+
const activateBlockRow = (live: Live, screen: Screen, input: string): boolean => {
|
|
376
|
+
if (!isActivateKey(input)) return false
|
|
377
|
+
const key = live.view.selection.selected
|
|
378
|
+
const row = key === null ? null : blockRowByKey(blocksOf(screen), key)
|
|
379
|
+
if (!row) return false
|
|
380
|
+
const click = mainClickOf(row)
|
|
381
|
+
if (click) applyBlockClick(live, click)
|
|
382
|
+
return true
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
const blocksOf = ({ waitingBlock, nextBlock, appointmentBlock, sourceBlock }: Screen): (BlockHit | null)[] => [
|
|
386
|
+
waitingBlock,
|
|
387
|
+
nextBlock,
|
|
388
|
+
appointmentBlock,
|
|
389
|
+
sourceBlock,
|
|
390
|
+
]
|
|
391
|
+
|
|
264
392
|
const handleInput = (live: Live, input: string) => {
|
|
265
393
|
const { snapshot, screen } = live
|
|
266
394
|
if (!snapshot || !screen) return
|
|
395
|
+
const mouse = parseMouse(input)
|
|
396
|
+
if (mouse && isMotion(mouse)) {
|
|
397
|
+
hover(live, screen, mouse)
|
|
398
|
+
return
|
|
399
|
+
}
|
|
267
400
|
live.notice = null
|
|
401
|
+
live.view = { ...live.view, selection: settledSelection(live.view.selection, screen) }
|
|
402
|
+
if (activateBlockRow(live, screen, input)) {
|
|
403
|
+
redraw(live)
|
|
404
|
+
return
|
|
405
|
+
}
|
|
268
406
|
if (isSprintPageKey(input)) {
|
|
269
407
|
void writeSprintPage(live, snapshot)
|
|
270
408
|
return
|
|
271
409
|
}
|
|
272
|
-
if (
|
|
410
|
+
if (
|
|
411
|
+
clickBlock(live, TOP_BLOCK.waiting, screen.waitingBlock, input) ||
|
|
412
|
+
clickBlock(live, TOP_BLOCK.next, screen.nextBlock, input) ||
|
|
413
|
+
clickBlock(live, TOP_BLOCK.appointments, screen.appointmentBlock, input) ||
|
|
414
|
+
clickBlock(live, TOP_BLOCK.sources, screen.sourceBlock, input)
|
|
415
|
+
) {
|
|
273
416
|
redraw(live)
|
|
274
417
|
return
|
|
275
418
|
}
|
|
276
419
|
const context = {
|
|
277
|
-
keys:
|
|
420
|
+
keys: screen.rowKeys,
|
|
278
421
|
targets: screen.body.targets,
|
|
279
422
|
headerRows: screen.header.length,
|
|
280
423
|
bodyRows: bodyRows(screen),
|
|
281
424
|
}
|
|
425
|
+
live.notice = showDoneNotice(live.view.selection, input, context.keys)
|
|
282
426
|
const pullRequests = snapshot.views.flatMap((view) => view.pullRequests)
|
|
283
427
|
const outcome = inputOutcome(live.view, input, context, (key) => pullRequestUrl(pullRequests, key))
|
|
284
428
|
live.view = outcome.view
|
|
@@ -306,12 +450,14 @@ export const runLive = (source: Source): void => {
|
|
|
306
450
|
fetchHead: fetchHeadPath(source.root),
|
|
307
451
|
reported: new Map(),
|
|
308
452
|
memories: recallLanes(source.root),
|
|
453
|
+
cache: newReadCache(),
|
|
309
454
|
pending: null,
|
|
310
455
|
read: null,
|
|
311
456
|
page: null,
|
|
312
457
|
pageWriting: false,
|
|
313
458
|
flight: null,
|
|
314
459
|
pageError: null,
|
|
460
|
+
written: null,
|
|
315
461
|
}
|
|
316
462
|
const schedule = () => {
|
|
317
463
|
if (live.pending) clearTimeout(live.pending)
|
|
@@ -320,6 +466,8 @@ export const runLive = (source: Source): void => {
|
|
|
320
466
|
const fetchBaseBranch = async () => {
|
|
321
467
|
live.fetch = { ...live.fetch, lastAttempt: Date.now(), running: true }
|
|
322
468
|
const failure = await fetchBase(source.root, BASE_REMOTE, source.settings.baseBranch)
|
|
469
|
+
const planHeads = planPullRequests([...live.pullRequests.byBranch.values()].flat(), source.settings.planDirectory)
|
|
470
|
+
if (failure === null) await fetchPlanHeads(source.root, BASE_REMOTE, planHeads)
|
|
323
471
|
live.fetch = {
|
|
324
472
|
...live.fetch,
|
|
325
473
|
running: false,
|
|
@@ -346,17 +494,24 @@ export const runLive = (source: Source): void => {
|
|
|
346
494
|
fetchWhenDue(readable && gone.length > 0)
|
|
347
495
|
schedule()
|
|
348
496
|
}
|
|
349
|
-
const
|
|
497
|
+
const leave = (code: number) => {
|
|
350
498
|
clearTitles(source, live.reported)
|
|
351
499
|
source.terminal.labelOwnPane(null)
|
|
352
500
|
for (const watcher of live.watchers.values()) watcher.close()
|
|
353
501
|
process.stdout.write(TERMINAL_OFF)
|
|
354
|
-
process.exit(
|
|
502
|
+
process.exit(code)
|
|
355
503
|
}
|
|
504
|
+
const quit = () => leave(0)
|
|
505
|
+
const takenOver = () => leave(TAKEN_OVER_CODE)
|
|
356
506
|
const onInput = (input: string) => (QUIT_KEYS.has(input) ? quit() : handleInput(live, input))
|
|
357
507
|
|
|
358
508
|
source.terminal.labelOwnPane(herdLabel(source.options.lane))
|
|
359
|
-
|
|
509
|
+
process.removeAllListeners(TAKEOVER_SIGNAL)
|
|
510
|
+
process.on(TAKEOVER_SIGNAL, takenOver)
|
|
511
|
+
enterTerminal(onInput, quit, () => {
|
|
512
|
+
live.written = null
|
|
513
|
+
redraw(live)
|
|
514
|
+
})
|
|
360
515
|
setInterval(() => {
|
|
361
516
|
fetchWhenDue(false)
|
|
362
517
|
void refresh(live, schedule)
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { sectionLines } from '../plan-format/markdown'
|
|
2
|
+
import { NEXT_VOCABULARY, type PlanLanguage } from '../vocabulary'
|
|
3
|
+
import { shortTitle } from './task-name'
|
|
4
|
+
import type { BlockItem } from './waiting-click'
|
|
5
|
+
|
|
6
|
+
/** A line of the board's next section as the herd shows it. */
|
|
7
|
+
export type NextRow = { text: string }
|
|
8
|
+
|
|
9
|
+
/** The next block: the section's lines without the ones the for-you block already shows. */
|
|
10
|
+
export type NextBlock = { rows: NextRow[] }
|
|
11
|
+
|
|
12
|
+
const MAX_NEXT_ROWS = 3
|
|
13
|
+
|
|
14
|
+
const LIST_LINE = /^(?:\d+[.)]|[-*])\s+(.+)$/
|
|
15
|
+
|
|
16
|
+
const sameText = (left: string, right: string): boolean => left.trim().toLowerCase() === right.trim().toLowerCase()
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The next section of the board, up to three list lines in the order written; the parser cuts after three, as the board
|
|
20
|
+
* says. A line equal to one of `shown` (the texts of the for-you block) is left out: it stands above already.
|
|
21
|
+
*/
|
|
22
|
+
export const nextBlock = (board: string, language: PlanLanguage, shown: readonly string[]): NextBlock => ({
|
|
23
|
+
rows: sectionLines(board, NEXT_VOCABULARY[language].heading)
|
|
24
|
+
.flatMap((line) => LIST_LINE.exec(line.trim())?.[1] ?? [])
|
|
25
|
+
.slice(0, MAX_NEXT_ROWS)
|
|
26
|
+
.filter((text) => !shown.some((known) => sameText(known, text)))
|
|
27
|
+
.map((text) => ({ text })),
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
const nextKey = ({ text }: NextRow): string => `next>${text}`
|
|
31
|
+
|
|
32
|
+
/** The rows as the block draws them, numbered; a click on one only expands it. */
|
|
33
|
+
export const nextItems = (rows: readonly NextRow[]): BlockItem[] =>
|
|
34
|
+
rows.map((row, index) => ({
|
|
35
|
+
key: nextKey(row),
|
|
36
|
+
mark: String(index + 1),
|
|
37
|
+
text: row.text,
|
|
38
|
+
title: shortTitle(row.text),
|
|
39
|
+
url: null,
|
|
40
|
+
ask: null,
|
|
41
|
+
}))
|
|
42
|
+
|
|
43
|
+
/** The keys of all rows: what the fold of the block remembers. */
|
|
44
|
+
export const nextKeys = (rows: readonly NextRow[]): string[] => rows.map(nextKey)
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
const SUB_PHASE_LETTERS = /[a-z]+$/i
|
|
2
|
+
|
|
3
|
+
/** The main phase a sub-phase belongs to: `10c` is part of `10`; a main phase is its own. */
|
|
4
|
+
export const mainPhaseIdOf = (id: string): string => id.replace(SUB_PHASE_LETTERS, '')
|
|
5
|
+
|
|
6
|
+
/** Whether `named` names the phase: by its own id, or by its main phase when it is a sub-phase (`21b` by `21`). */
|
|
7
|
+
export const isNamedBy = (id: string, named: ReadonlySet<string>): boolean =>
|
|
8
|
+
named.has(id) || named.has(mainPhaseIdOf(id))
|
|
@@ -35,6 +35,21 @@ const tickCount = (phase: PlanPhase): number => phase.items.filter((item) => ite
|
|
|
35
35
|
const tickedFurther = (worktreePhase: PlanPhase, basePhase: PlanPhase): boolean =>
|
|
36
36
|
tickCount(worktreePhase) > tickCount(basePhase)
|
|
37
37
|
|
|
38
|
+
/**
|
|
39
|
+
* The phases of `base` with each phase replaced by the copy's where the copy ticked it further (`tickedFurther`), and
|
|
40
|
+
* the phases only the copy has after them. Nothing is marked touched: the copy is someone's branch, not the lane's own
|
|
41
|
+
* work, so it never makes a phase the lane's current one.
|
|
42
|
+
*/
|
|
43
|
+
export const newerPhases = (base: readonly PlanPhase[], copy: readonly PlanPhase[]): PlanPhase[] => {
|
|
44
|
+
const copyById = new Map(copy.map((phase) => [phase.id, phase]))
|
|
45
|
+
const baseIds = new Set(base.map((phase) => phase.id))
|
|
46
|
+
const inBaseOrder = base.map((basePhase) => {
|
|
47
|
+
const copied = copyById.get(basePhase.id)
|
|
48
|
+
return copied && tickedFurther(copied, basePhase) ? copied : basePhase
|
|
49
|
+
})
|
|
50
|
+
return [...inBaseOrder, ...copy.filter((phase) => !baseIds.has(phase.id))]
|
|
51
|
+
}
|
|
52
|
+
|
|
38
53
|
/** A phase read from the worktree is touched when its ticks differ from the base's, or the base lacks it. */
|
|
39
54
|
const markTouched = (phase: PlanPhase, basePhase: PlanPhase | undefined): PlanPhase =>
|
|
40
55
|
basePhase && sameTicks(phase, basePhase) ? phase : { ...phase, touched: true }
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { type PlanLanguage, type PlanPhase, parsePlanPhases } from '../plan-format'
|
|
2
|
+
import type { CacheRound } from '../shared/stat-cache'
|
|
3
|
+
|
|
4
|
+
type ParsedPlan = { text: string; language: PlanLanguage; phases: PlanPhase[] }
|
|
5
|
+
|
|
6
|
+
/** The phases of each plan by key, kept while the plan's text and language stay the same. */
|
|
7
|
+
export type PlanCache = Map<string, ParsedPlan>
|
|
8
|
+
|
|
9
|
+
export type PlanRound = CacheRound<ParsedPlan>
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* `parsePlanPhases` of the text, asked again only when the text or the language under `key` changed since the cache
|
|
13
|
+
* saw it. The text decides, not a modification time: most plans come from the base ref, which has none.
|
|
14
|
+
*/
|
|
15
|
+
export const parsedPlanPhases = (
|
|
16
|
+
round: PlanRound | undefined,
|
|
17
|
+
key: string,
|
|
18
|
+
text: string,
|
|
19
|
+
language: PlanLanguage,
|
|
20
|
+
): PlanPhase[] => {
|
|
21
|
+
if (!round) return parsePlanPhases(text, language)
|
|
22
|
+
round.used.add(key)
|
|
23
|
+
const kept = round.entries.get(key)
|
|
24
|
+
if (kept && kept.language === language && kept.text === text) return kept.phases
|
|
25
|
+
const phases = parsePlanPhases(text, language)
|
|
26
|
+
round.entries.set(key, { text, language, phases })
|
|
27
|
+
return phases
|
|
28
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { BaseReader } from './git'
|
|
2
|
+
import type { PullRequest } from './pull-requests'
|
|
3
|
+
|
|
4
|
+
/** What choosing the heads needs of an open pull request: the herd's and the watcher's lists both carry it. */
|
|
5
|
+
export type PlanPullRequest = Pick<PullRequest, 'number' | 'branch' | 'files'>
|
|
6
|
+
|
|
7
|
+
/** An open pull request that changes a plan, with a reader of the files at its head. */
|
|
8
|
+
export type PlanHead = { number: number; files: readonly string[]; read: BaseReader }
|
|
9
|
+
|
|
10
|
+
/** Where the herd keeps the fetched heads of open pull requests: its own namespace, apart from branches and remotes. */
|
|
11
|
+
const PULL_HEAD_NAMESPACE = 'refs/jackherd/pr'
|
|
12
|
+
|
|
13
|
+
export const pullHeadRef = (number: number): string => `${PULL_HEAD_NAMESPACE}/${number}`
|
|
14
|
+
|
|
15
|
+
/** The refspec that fetches a pull request's head from `refs/pull/<n>/head`, which GitHub keeps for forks too. */
|
|
16
|
+
export const pullHeadRefspec = (number: number): string => `+refs/pull/${number}/head:${pullHeadRef(number)}`
|
|
17
|
+
|
|
18
|
+
const changesPlan = (pullRequest: PlanPullRequest, planDirectory: string): boolean =>
|
|
19
|
+
(pullRequest.files ?? []).some((path) => path.startsWith(`${planDirectory}/`))
|
|
20
|
+
|
|
21
|
+
/** The open pull requests that change a file under `planDirectory`, by number, so several heads merge in a fixed order. */
|
|
22
|
+
export const planPullRequests = <Open extends PlanPullRequest>(
|
|
23
|
+
pullRequests: readonly Open[],
|
|
24
|
+
planDirectory: string,
|
|
25
|
+
): Open[] =>
|
|
26
|
+
pullRequests
|
|
27
|
+
.filter((pullRequest) => changesPlan(pullRequest, planDirectory))
|
|
28
|
+
.sort((first, second) => first.number - second.number)
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* `readerOf` asked once per ref, the reader kept for every later ask: build it once per read of all lanes, so a
|
|
32
|
+
* head costs one `git rev-parse` however many lanes read it.
|
|
33
|
+
*/
|
|
34
|
+
export const readerOncePerRef = (readerOf: (ref: string) => BaseReader): ((ref: string) => BaseReader) => {
|
|
35
|
+
const readers = new Map<string, BaseReader>()
|
|
36
|
+
return (ref) => {
|
|
37
|
+
const kept = readers.get(ref) ?? readerOf(ref)
|
|
38
|
+
readers.set(ref, kept)
|
|
39
|
+
return kept
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The heads a lane's plans are read from besides the base: every plan pull request but the one of the lane's own
|
|
45
|
+
* branch, whose ticks the lane's worktree holds already.
|
|
46
|
+
*/
|
|
47
|
+
export const laneHeads = (
|
|
48
|
+
pullRequests: readonly PlanPullRequest[],
|
|
49
|
+
ownBranch: string | null,
|
|
50
|
+
readerOf: (ref: string) => BaseReader,
|
|
51
|
+
): PlanHead[] =>
|
|
52
|
+
pullRequests
|
|
53
|
+
.filter(({ branch }) => branch !== ownBranch)
|
|
54
|
+
.map(({ number, files }) => ({ number, files: files ?? [], read: readerOf(pullHeadRef(number)) }))
|
package/src/plans/planned.ts
CHANGED
|
@@ -19,8 +19,15 @@ export type PlannedPlan = {
|
|
|
19
19
|
phaseIds: string[]
|
|
20
20
|
done: number
|
|
21
21
|
total: number
|
|
22
|
+
/** Set when the plan file was last changed `STALE_PLAN_DAYS` or more days ago; absent otherwise or when git does not know. */
|
|
23
|
+
stale?: true
|
|
22
24
|
}
|
|
23
25
|
|
|
26
|
+
/** Days without a change to its plan file from which a planned phase reads `stale?`. */
|
|
27
|
+
export const STALE_PLAN_DAYS = 14
|
|
28
|
+
|
|
29
|
+
const DAY_MS = 86_400_000
|
|
30
|
+
|
|
24
31
|
/** A lane of the sprint and what its agent reports, null when it has none. */
|
|
25
32
|
export type PlannedLane = { lane: SprintLane; agent: string | null }
|
|
26
33
|
|
|
@@ -76,9 +83,24 @@ export const plannedPlans = (input: PlannedInput): PlannedPlan[] => {
|
|
|
76
83
|
})
|
|
77
84
|
}
|
|
78
85
|
|
|
86
|
+
/** The plans with `stale` set on those whose file `touchedAt` says was last changed `STALE_PLAN_DAYS` or more days before `now`. */
|
|
87
|
+
export const markStale = (
|
|
88
|
+
plans: readonly PlannedPlan[],
|
|
89
|
+
touchedAt: (path: string) => number | null,
|
|
90
|
+
now: number,
|
|
91
|
+
): PlannedPlan[] =>
|
|
92
|
+
plans.map((plan) => {
|
|
93
|
+
const touched = touchedAt(plan.path)
|
|
94
|
+
return touched !== null && now - touched >= STALE_PLAN_DAYS * DAY_MS ? { ...plan, stale: true } : plan
|
|
95
|
+
})
|
|
96
|
+
|
|
79
97
|
/** `P3 P4 P5` */
|
|
80
98
|
export const phaseList = (plan: Pick<PlannedPlan, 'phaseIds'>): string => phaseLabels(plan.phaseIds, ' ')
|
|
81
99
|
|
|
100
|
+
/** `P3`: the phase of the plan that comes next, the first open one in the order of the plan. */
|
|
101
|
+
export const plannedNextLabel = (plan: Pick<PlannedPlan, 'phaseIds' | 'stale'>): string =>
|
|
102
|
+
`${phaseLabels(plan.phaseIds.slice(0, 1), ' ')}${plan.stale ? ' stale?' : ''}`
|
|
103
|
+
|
|
82
104
|
/** How many phases the plans list together. */
|
|
83
105
|
export const plannedPhaseCount = (planned: readonly Pick<PlannedPlan, 'phaseIds'>[]): number =>
|
|
84
106
|
planned.reduce((count, plan) => count + plan.phaseIds.length, 0)
|
|
@@ -14,6 +14,8 @@ export type PullRequest = {
|
|
|
14
14
|
checks: ChecksState
|
|
15
15
|
/** The names of the checks that failed, each once, in the rollup's order; empty while none failed. */
|
|
16
16
|
failedChecks: string[]
|
|
17
|
+
/** The paths the pull request changes, as `gh` lists them (at most 100); absent when the answer has none. */
|
|
18
|
+
files?: string[]
|
|
17
19
|
}
|
|
18
20
|
|
|
19
21
|
/**
|
|
@@ -40,10 +42,11 @@ export type GhPullRequest = {
|
|
|
40
42
|
headRefName: string
|
|
41
43
|
isDraft: boolean
|
|
42
44
|
statusCheckRollup: RollupEntry[] | null
|
|
45
|
+
files?: { path: string }[]
|
|
43
46
|
}
|
|
44
47
|
|
|
45
48
|
/** The fields `parsePullRequests` reads; pass them to `gh pr list --json`. */
|
|
46
|
-
export const PULL_REQUEST_FIELDS = 'number,title,url,headRefName,isDraft,statusCheckRollup'
|
|
49
|
+
export const PULL_REQUEST_FIELDS = 'number,title,url,headRefName,isDraft,statusCheckRollup,files'
|
|
47
50
|
|
|
48
51
|
const RUN_IN_PATH = /^\/([\w.-]+)\/([\w.-]+)\/actions\/runs\/(\d+)(?:\/|$)/
|
|
49
52
|
|
|
@@ -131,6 +134,16 @@ const isPullRequestPage = (url: string, number: number, host: string): boolean =
|
|
|
131
134
|
}
|
|
132
135
|
}
|
|
133
136
|
|
|
137
|
+
const isFileList = (value: unknown): boolean =>
|
|
138
|
+
Array.isArray(value) &&
|
|
139
|
+
value.every(
|
|
140
|
+
(file) => typeof file === 'object' && file !== null && typeof (file as { path?: unknown }).path === 'string',
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
/** The `files` of a `gh` answer as plain paths, to spread into a pull request; nothing when the answer has none. */
|
|
144
|
+
export const filePathsOf = ({ files }: Pick<GhPullRequest, 'files'>): { files?: string[] } =>
|
|
145
|
+
files ? { files: files.map(({ path }) => path) } : {}
|
|
146
|
+
|
|
134
147
|
/** One entry of `gh pr list --json <PULL_REQUEST_FIELDS>` whose link is the pull request's own page on `host`. */
|
|
135
148
|
export const isGhPullRequest = (value: unknown, host: string): value is GhPullRequest => {
|
|
136
149
|
if (typeof value !== 'object' || value === null) return false
|
|
@@ -142,7 +155,8 @@ export const isGhPullRequest = (value: unknown, host: string): value is GhPullRe
|
|
|
142
155
|
isPullRequestPage(candidate.url, candidate.number, host) &&
|
|
143
156
|
typeof candidate.headRefName === 'string' &&
|
|
144
157
|
typeof candidate.isDraft === 'boolean' &&
|
|
145
|
-
(candidate.statusCheckRollup === null || Array.isArray(candidate.statusCheckRollup))
|
|
158
|
+
(candidate.statusCheckRollup === null || Array.isArray(candidate.statusCheckRollup)) &&
|
|
159
|
+
(candidate.files === undefined || isFileList(candidate.files))
|
|
146
160
|
)
|
|
147
161
|
}
|
|
148
162
|
|
|
@@ -163,6 +177,7 @@ export const parsePullRequests = (json: string, host = 'github.com'): PullReques
|
|
|
163
177
|
draft: pullRequest.isDraft,
|
|
164
178
|
checks: checksOf(pullRequest.statusCheckRollup ?? []),
|
|
165
179
|
failedChecks: failedChecksOf(pullRequest.statusCheckRollup ?? [], pullRequest.url).map(({ name }) => name),
|
|
180
|
+
...filePathsOf(pullRequest),
|
|
166
181
|
}))
|
|
167
182
|
}
|
|
168
183
|
|