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.
Files changed (250) hide show
  1. package/CHANGELOG.md +65 -0
  2. package/LICENSE +21 -0
  3. package/NOTICE +38 -0
  4. package/README.md +1122 -3
  5. package/assets/branding/README.md +62 -0
  6. package/assets/branding/jackherd-icon-256.png +0 -0
  7. package/assets/branding/jackherd-icon-512.png +0 -0
  8. package/assets/branding/jackherd-icon-64.png +0 -0
  9. package/assets/branding/jackherd-icon-small-32.png +0 -0
  10. package/assets/branding/jackherd-icon-small-64.png +0 -0
  11. package/assets/branding/jackherd-icon-small.svg +1 -0
  12. package/assets/branding/jackherd-icon.svg +1 -0
  13. package/assets/branding/jackherd-logo-1024.png +0 -0
  14. package/assets/branding/jackherd-logo-512.png +0 -0
  15. package/assets/branding/jackherd-logo-dark-1024.png +0 -0
  16. package/assets/branding/jackherd-logo-dark-512.png +0 -0
  17. package/assets/branding/jackherd-logo-dark.svg +1 -0
  18. package/assets/branding/jackherd-logo-mark-1024.png +0 -0
  19. package/assets/branding/jackherd-logo-mark-512.png +0 -0
  20. package/assets/branding/jackherd-logo-mark.svg +1 -0
  21. package/assets/branding/jackherd-logo.svg +1 -0
  22. package/dist/chunk-R6MGHCD7.js +93 -0
  23. package/dist/config.d.ts +167 -0
  24. package/dist/config.js +401 -0
  25. package/dist/plan-format.d.ts +325 -0
  26. package/dist/plan-format.js +636 -0
  27. package/docs/lanes.mmd +9 -0
  28. package/docs/lanes.svg +1 -0
  29. package/docs/skills.mmd +10 -0
  30. package/docs/skills.svg +1 -0
  31. package/docs/workflow.svg +172 -0
  32. package/herdr-plugin/herdr-plugin.toml +22 -0
  33. package/herdr-plugin/open-plans.ts +43 -0
  34. package/herdr-plugin/restore-plans.ts +107 -0
  35. package/install.sh +157 -0
  36. package/kit/hooks/hooks/git-guard/decision.ts +438 -0
  37. package/kit/hooks/hooks/git-guard/index.ts +38 -0
  38. package/kit/hooks/hooks/git-guard/shell.ts +407 -0
  39. package/kit/hooks/hooks/watch-guard/background.ts +36 -0
  40. package/kit/hooks/hooks/watch-guard/decision.ts +53 -0
  41. package/kit/hooks/hooks/watch-guard/index.ts +11 -0
  42. package/kit/hooks/hooks/watch-guard/shell-input.ts +32 -0
  43. package/kit/hooks/hooks/watch-guard/starts.ts +154 -0
  44. package/kit/hooks/hooks/watch-guard/text-fallback.ts +40 -0
  45. package/kit/hooks/hooks.json +30 -0
  46. package/kit/hooks/statusline.sh +72 -0
  47. package/kit/review/agents/devops.md +11 -0
  48. package/kit/review/agents/public-content.md +39 -0
  49. package/kit/review/agents/security-audit.md +11 -0
  50. package/kit/review/agents/uncle-bob.md +11 -0
  51. package/kit/review/skills/devops/SKILL.md +50 -0
  52. package/kit/review/skills/diagnose/SKILL.md +140 -0
  53. package/kit/review/skills/diagnose/references/feedback-loops.md +59 -0
  54. package/kit/review/skills/diagnose/scripts/hitl-loop.template.sh +71 -0
  55. package/kit/review/skills/pre-commit/SKILL.md +100 -0
  56. package/kit/review/skills/review/SKILL.md +43 -0
  57. package/kit/review/skills/security-audit/SKILL.md +65 -0
  58. package/kit/review/skills/uncle-bob/SKILL.md +43 -0
  59. package/kit/rules/rules/kit/arc42.md +89 -0
  60. package/kit/rules/rules/kit/architecture-files.md +87 -0
  61. package/kit/rules/rules/kit/code-style.md +159 -0
  62. package/kit/rules/rules/kit/pull-requests.md +59 -0
  63. package/kit/rules/rules/kit/testing.md +91 -0
  64. package/kit/rules/rules/kit/writing-for-agents.md +110 -0
  65. package/kit/workflow/agents/explore.md +25 -0
  66. package/kit/workflow/agents/implement.md +16 -0
  67. package/kit/workflow/agents/plan.md +13 -0
  68. package/kit/workflow/agents/spec-review.md +96 -0
  69. package/kit/workflow/skills/follow/SKILL.md +48 -0
  70. package/kit/workflow/skills/grill/SKILL.md +60 -0
  71. package/kit/workflow/skills/implement/SKILL.md +107 -0
  72. package/kit/workflow/skills/orchestrate/SKILL.md +565 -0
  73. package/kit/workflow/skills/orchestrate/handover/decision.ts +247 -0
  74. package/kit/workflow/skills/orchestrate/handover/index.ts +171 -0
  75. package/kit/workflow/skills/plan/SKILL.md +118 -0
  76. package/kit/workflow/skills/plan/references/template-English.md +92 -0
  77. package/kit/workflow/skills/plan/references/template-German.md +92 -0
  78. package/kit/workflow/skills/start/SKILL.md +47 -0
  79. package/package.json +63 -5
  80. package/schemas/jackherd.schema.json +601 -0
  81. package/src/check/changes.ts +45 -0
  82. package/src/check/command-line.ts +12 -0
  83. package/src/check/command.ts +136 -0
  84. package/src/check/deferral-arrows.ts +41 -0
  85. package/src/check/execute.ts +29 -0
  86. package/src/check/glob.ts +15 -0
  87. package/src/check/plan-structure-check.ts +135 -0
  88. package/src/check/plan.ts +106 -0
  89. package/src/check/public-content-check.ts +32 -0
  90. package/src/check/public-content-facts.ts +177 -0
  91. package/src/check/public-content.ts +216 -0
  92. package/src/check/report.ts +55 -0
  93. package/src/check/result.ts +42 -0
  94. package/src/check/roots.ts +36 -0
  95. package/src/check/routing.ts +22 -0
  96. package/src/check/schema.ts +83 -0
  97. package/src/check/settings.ts +44 -0
  98. package/src/claude/paths.ts +14 -0
  99. package/src/cli.ts +46 -0
  100. package/src/config/command.ts +45 -0
  101. package/src/config/detect.ts +196 -0
  102. package/src/config/index.ts +2 -0
  103. package/src/config/json-schema.ts +5 -0
  104. package/src/config/legacy-names.ts +56 -0
  105. package/src/config/link-kinds.ts +25 -0
  106. package/src/config/location.ts +46 -0
  107. package/src/config/read-config.ts +42 -0
  108. package/src/config/repository-facts.ts +19 -0
  109. package/src/config/repository-values.ts +46 -0
  110. package/src/config/schema.ts +302 -0
  111. package/src/context/command.ts +39 -0
  112. package/src/context/coordinator-name.ts +16 -0
  113. package/src/context/fill.ts +69 -0
  114. package/src/context/index.ts +10 -0
  115. package/src/context/level.ts +22 -0
  116. package/src/context/options.ts +29 -0
  117. package/src/context/report.ts +42 -0
  118. package/src/context/session-log.ts +91 -0
  119. package/src/plan-format/drift.ts +69 -0
  120. package/src/plan-format/header.ts +27 -0
  121. package/src/plan-format/index.ts +9 -0
  122. package/src/plan-format/lanes.ts +128 -0
  123. package/src/plan-format/markdown.ts +98 -0
  124. package/src/plan-format/phase-tally.ts +55 -0
  125. package/src/plan-format/phases.ts +224 -0
  126. package/src/plan-format/plan-structure.ts +150 -0
  127. package/src/plan-format/progress.ts +202 -0
  128. package/src/plan-format/sprint-document.ts +69 -0
  129. package/src/plan-format/terminal-text.ts +28 -0
  130. package/src/plans/board-choice.ts +60 -0
  131. package/src/plans/board-pick.ts +72 -0
  132. package/src/plans/board-rows.ts +62 -0
  133. package/src/plans/command.ts +60 -0
  134. package/src/plans/ensure-panes.ts +71 -0
  135. package/src/plans/error-text.ts +5 -0
  136. package/src/plans/fetch.ts +96 -0
  137. package/src/plans/git.ts +145 -0
  138. package/src/plans/github.ts +35 -0
  139. package/src/plans/lane-discovery.ts +34 -0
  140. package/src/plans/lane-plans.ts +80 -0
  141. package/src/plans/layout.ts +881 -0
  142. package/src/plans/link-chips.ts +74 -0
  143. package/src/plans/live.ts +336 -0
  144. package/src/plans/markdown-html.ts +140 -0
  145. package/src/plans/options.ts +15 -0
  146. package/src/plans/phase-source.ts +71 -0
  147. package/src/plans/plan-chips.ts +75 -0
  148. package/src/plans/plan-panes.ts +206 -0
  149. package/src/plans/planned.ts +117 -0
  150. package/src/plans/pull-requests.ts +107 -0
  151. package/src/plans/restart-decision.ts +42 -0
  152. package/src/plans/selection.ts +352 -0
  153. package/src/plans/settings.ts +68 -0
  154. package/src/plans/single-flight.ts +25 -0
  155. package/src/plans/snapshot.ts +300 -0
  156. package/src/plans/sprint-export.ts +103 -0
  157. package/src/plans/sprint-html.ts +301 -0
  158. package/src/plans/sprint-page.ts +287 -0
  159. package/src/plans/supervisor.ts +124 -0
  160. package/src/plans/symbols.ts +54 -0
  161. package/src/plans/texts.ts +103 -0
  162. package/src/plans/unlisted-lanes.ts +45 -0
  163. package/src/plans/view.ts +68 -0
  164. package/src/plans/worked-phases.ts +53 -0
  165. package/src/sync/checksum.ts +13 -0
  166. package/src/sync/choice.ts +14 -0
  167. package/src/sync/command-context.ts +113 -0
  168. package/src/sync/command.ts +94 -0
  169. package/src/sync/error-message.ts +1 -0
  170. package/src/sync/gitignore.ts +45 -0
  171. package/src/sync/global-kit.ts +176 -0
  172. package/src/sync/herdr-plugin.ts +175 -0
  173. package/src/sync/if-changed.ts +109 -0
  174. package/src/sync/init-command.ts +239 -0
  175. package/src/sync/init.ts +136 -0
  176. package/src/sync/kit-hooks.ts +18 -0
  177. package/src/sync/kit-path.ts +77 -0
  178. package/src/sync/kit-source.ts +169 -0
  179. package/src/sync/kit.ts +9 -0
  180. package/src/sync/legacy-files.ts +116 -0
  181. package/src/sync/manifest.ts +44 -0
  182. package/src/sync/next-steps.ts +42 -0
  183. package/src/sync/plan-file.ts +154 -0
  184. package/src/sync/plan-hooks.ts +165 -0
  185. package/src/sync/project-fs.ts +132 -0
  186. package/src/sync/render.ts +60 -0
  187. package/src/sync/report.ts +96 -0
  188. package/src/sync/settings-hooks.ts +90 -0
  189. package/src/sync/settings-migration.ts +94 -0
  190. package/src/sync/settings-offer.ts +168 -0
  191. package/src/sync/setup-questions.ts +70 -0
  192. package/src/sync/setup.ts +52 -0
  193. package/src/sync/sync-plan.ts +120 -0
  194. package/src/sync/sync-result.ts +9 -0
  195. package/src/sync/sync.ts +307 -0
  196. package/src/sync/tools.ts +52 -0
  197. package/src/terminal/herdr.ts +91 -0
  198. package/src/terminal/index.ts +13 -0
  199. package/src/terminal/none.ts +7 -0
  200. package/src/terminal/open.ts +39 -0
  201. package/src/terminal/text.ts +159 -0
  202. package/src/terminal/types.ts +30 -0
  203. package/src/terminal/width.ts +90 -0
  204. package/src/update/command.ts +65 -0
  205. package/src/update/package-manager.ts +56 -0
  206. package/src/update/update.ts +105 -0
  207. package/src/version/installed-version.ts +32 -0
  208. package/src/version/release.ts +22 -0
  209. package/src/version/skill-diff.ts +63 -0
  210. package/src/version/started-kit.ts +14 -0
  211. package/src/vocabulary/index.ts +132 -0
  212. package/src/watch/base-tip.ts +18 -0
  213. package/src/watch/command.ts +919 -0
  214. package/src/watch/coordinator-fill.ts +76 -0
  215. package/src/watch/feedback-read.ts +73 -0
  216. package/src/watch/feedback.ts +308 -0
  217. package/src/watch/gh.ts +22 -0
  218. package/src/watch/github-read.ts +175 -0
  219. package/src/watch/herdr.ts +11 -0
  220. package/src/watch/json-guards.ts +9 -0
  221. package/src/watch/kit-update.ts +18 -0
  222. package/src/watch/lane-fill.ts +36 -0
  223. package/src/watch/lane-message.ts +99 -0
  224. package/src/watch/lane-progress.ts +44 -0
  225. package/src/watch/lane-report-file.ts +53 -0
  226. package/src/watch/lane-report.ts +270 -0
  227. package/src/watch/lane-search.ts +41 -0
  228. package/src/watch/lane-sources.ts +46 -0
  229. package/src/watch/lane-watch.ts +121 -0
  230. package/src/watch/monitor-tool.ts +19 -0
  231. package/src/watch/options.ts +79 -0
  232. package/src/watch/plan-badges-read.ts +30 -0
  233. package/src/watch/plan-badges.ts +66 -0
  234. package/src/watch/plan-views.ts +59 -0
  235. package/src/watch/preview-read.ts +60 -0
  236. package/src/watch/previews.ts +234 -0
  237. package/src/watch/pull-requests.ts +117 -0
  238. package/src/watch/single-report.ts +15 -0
  239. package/src/watch/skill-change.ts +61 -0
  240. package/src/watch/source-dry-run-command.ts +35 -0
  241. package/src/watch/source-dry-run.ts +124 -0
  242. package/src/watch/source-fields.ts +9 -0
  243. package/src/watch/source-path.ts +40 -0
  244. package/src/watch/source-run.ts +90 -0
  245. package/src/watch/sources.ts +344 -0
  246. package/src/watch/stream-output.ts +14 -0
  247. package/src/watch/texts.ts +76 -0
  248. package/src/watch/watch-events.ts +399 -0
  249. package/src/watch/watcher-lock.ts +145 -0
  250. package/src/watch/workflow-runs.ts +96 -0
@@ -0,0 +1,121 @@
1
+ export type HerdrAgent = { name?: string; pane_id: string; agent_status: string; state_change_seq?: number }
2
+
3
+ /** The herdr agent states; herdr's JSON delivers them as plain strings. */
4
+ export const AGENT_STATUS = { working: 'working', blocked: 'blocked', done: 'done', idle: 'idle' } as const
5
+
6
+ /** The state a report gives a lane whose agent left the herdr listing. */
7
+ export const LANE_GONE = 'no longer listed'
8
+
9
+ /** The state a report gives a lane that has no agent in herdr. */
10
+ export const NO_AGENT = 'no agent'
11
+
12
+ export type AgentStatus = (typeof AGENT_STATUS)[keyof typeof AGENT_STATUS]
13
+
14
+ export type LaneState = { status: string; seq: number | null }
15
+
16
+ export type LaneSnapshot = Record<string, LaneState>
17
+
18
+ /** The last `state_change_seq` the watcher saw per lane, kept across runs; no entry means never seen. */
19
+ export type SeenSeqs = Record<string, number | null>
20
+
21
+ const STOP_STATUSES = new Set<string>([AGENT_STATUS.blocked, AGENT_STATUS.done, AGENT_STATUS.idle])
22
+
23
+ export const isAgentStatus = (value: string): value is AgentStatus =>
24
+ (Object.values(AGENT_STATUS) as string[]).includes(value)
25
+
26
+ /** The state of each named lane in a `herdr agent list` answer; unnamed and other agents are left out. */
27
+ export const toSnapshot = (agents: readonly HerdrAgent[], lanes: readonly string[]): LaneSnapshot =>
28
+ Object.fromEntries(
29
+ agents
30
+ .filter((agent) => agent.name && lanes.includes(agent.name))
31
+ .map((agent) => [agent.name, { status: agent.agent_status, seq: agent.state_change_seq ?? null }]),
32
+ )
33
+
34
+ const isHerdrAgent = (value: unknown): value is HerdrAgent => {
35
+ if (typeof value !== 'object' || value === null) return false
36
+ const candidate = value as Record<string, unknown>
37
+ return (
38
+ typeof candidate.pane_id === 'string' &&
39
+ typeof candidate.agent_status === 'string' &&
40
+ (candidate.name === undefined || typeof candidate.name === 'string') &&
41
+ (candidate.state_change_seq === undefined || typeof candidate.state_change_seq === 'number')
42
+ )
43
+ }
44
+
45
+ /** Reads the JSON of `herdr agent list`; throws on anything else, so a changed format is seen, not guessed. */
46
+ export const agentsOf = (json: string): HerdrAgent[] => {
47
+ const agents = (JSON.parse(json) as { result?: { agents?: unknown } }).result?.agents
48
+ if (!Array.isArray(agents) || !agents.every(isHerdrAgent)) throw new Error('herdr agent list: unexpected answer')
49
+ return agents
50
+ }
51
+
52
+ /** The sidebar titles that differ from the ones last reported, in the order of `titles`; null clears one. */
53
+ export const changedTitles = (
54
+ titles: ReadonlyMap<string, string | null>,
55
+ reported: ReadonlyMap<string, string | null>,
56
+ ): [string, string | null][] => [...titles].filter(([pane, title]) => reported.get(pane) !== title)
57
+
58
+ const isWaiting = (state: LaneState): boolean => STOP_STATUSES.has(state.status)
59
+
60
+ const hasSeen = (seen: SeenSeqs, lane: string): boolean => Object.hasOwn(seen, lane)
61
+
62
+ /**
63
+ * The baseline a new run starts from. A lane never seen before takes its current state, so a freshly
64
+ * started lane does not wake the coordinator. A waiting lane seen earlier with another sequence is
65
+ * set back to that sequence, so a stop between the last return and the re-arm fires on the first
66
+ * poll instead of being swallowed. Lanes that are working start from their current state. A lane
67
+ * seen earlier that is missing now stays in the baseline, so its exit fires.
68
+ */
69
+ export const armBaseline = (current: LaneSnapshot, seen: SeenSeqs): LaneSnapshot => {
70
+ const gone = Object.keys(seen)
71
+ .filter((lane) => !(lane in current))
72
+ .map((lane) => [lane, { status: LANE_GONE, seq: seen[lane] ?? null }])
73
+ const listed = Object.entries(current).map(([lane, state]) => [
74
+ lane,
75
+ isWaiting(state) && hasSeen(seen, lane) && seen[lane] !== state.seq ? { ...state, seq: seen[lane] ?? null } : state,
76
+ ])
77
+ return Object.fromEntries([...gone, ...listed])
78
+ }
79
+
80
+ /**
81
+ * The lanes that need the coordinator: a lane fires when it sits in `blocked`, `done` or `idle` with
82
+ * a `state_change_seq` other than the one in the baseline. A lane the last run already reported keeps
83
+ * its sequence and does not fire again, so re-arming right after handling a lane never returns at
84
+ * once. A lane that left the listing (its agent exited) fires as well; one never listed does not.
85
+ */
86
+ export const firedLanes = (baseline: LaneSnapshot, current: LaneSnapshot, lanes: readonly string[]): string[] =>
87
+ lanes.filter((lane) => {
88
+ const now = current[lane]
89
+ if (!now) return baseline[lane] !== undefined
90
+ return isWaiting(now) && baseline[lane]?.seq !== now.seq
91
+ })
92
+
93
+ /** The sequence of every observed lane; saved on each return so "no entry" means "never seen". */
94
+ export const seenSeqsOf = (current: LaneSnapshot): SeenSeqs =>
95
+ Object.fromEntries(Object.entries(current).map(([lane, state]) => [lane, state.seq]))
96
+
97
+ /** Moves the baseline along for lanes that are working, so their next stop counts as a change. */
98
+ export const advanceBaseline = (baseline: LaneSnapshot, current: LaneSnapshot): LaneSnapshot =>
99
+ Object.fromEntries(
100
+ Object.entries(current).map(([lane, state]) => [
101
+ lane,
102
+ state.status === AGENT_STATUS.working ? state : (baseline[lane] ?? state),
103
+ ]),
104
+ )
105
+
106
+ /**
107
+ * The baseline once agents showed up: a lane whose agent is listed but has no baseline entry takes
108
+ * its current state, as on a fresh start, so an agent that starts out idle does not wake the
109
+ * coordinator. Entries already there stay, including those of lanes whose agent left.
110
+ */
111
+ export const baselineWithNewAgents = (baseline: LaneSnapshot, current: LaneSnapshot): LaneSnapshot => ({
112
+ ...current,
113
+ ...baseline,
114
+ })
115
+
116
+ /**
117
+ * The lanes that left the watched set together with their agent: they were seen, and nothing is
118
+ * listed for them now. They fire as gone once, although they are no longer watched.
119
+ */
120
+ export const departedLanes = (removed: readonly string[], baseline: LaneSnapshot, current: LaneSnapshot): string[] =>
121
+ removed.filter((lane) => lane in baseline && !(lane in current))
@@ -0,0 +1,19 @@
1
+ type Environment = Readonly<Record<string, string | undefined>>
2
+
3
+ const PROVIDER_SWITCHES = ['CLAUDE_CODE_USE_BEDROCK', 'CLAUDE_CODE_USE_VERTEX', 'CLAUDE_CODE_USE_FOUNDRY']
4
+
5
+ /** Read only for being set at all: any non-empty value, `0` included, switches the behaviour on. */
6
+ const PRESENCE_SWITCHES = ['DISABLE_TELEMETRY', 'CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC']
7
+
8
+ const ON_VALUES = ['1', 'true', 'yes', 'on']
9
+
10
+ /**
11
+ * Whether a Claude Code session started in this environment has the Monitor tool. Source: Claude Code docs,
12
+ * tools reference, "Monitor tool" (code.claude.com/docs/en/tools-reference#monitor-tool): not available on
13
+ * Amazon Bedrock, Google Cloud's Agent Platform (Vertex) or Microsoft Foundry, nor when `DISABLE_TELEMETRY` or
14
+ * `CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC` is set. The provider switches take `1`/`true`/`yes`/`on` in any
15
+ * casing (env-vars reference); the two others count when set to anything non-empty.
16
+ */
17
+ export const monitorToolAvailable = (env: Environment): boolean =>
18
+ !PROVIDER_SWITCHES.some((name) => ON_VALUES.includes((env[name] ?? '').toLowerCase())) &&
19
+ !PRESENCE_SWITCHES.some((name) => (env[name] ?? '') !== '')
@@ -0,0 +1,79 @@
1
+ import { CLI_NAME } from '../version/release'
2
+
3
+ /**
4
+ * `lanes` null finds the lanes instead of taking them from the command line. `coordinator` names the
5
+ * coordinator's agent; null leaves it to the agent in the watcher's own pane. `stream` keeps the
6
+ * watcher running after a report instead of returning.
7
+ */
8
+ export type WatchOptions = {
9
+ lanes: string[] | null
10
+ heartbeatMinutes: number
11
+ boardPath: string | null
12
+ coordinator: string | null
13
+ stream: boolean
14
+ /** `watch source <target> --dry-run [--raw]`: run one source once instead of watching (`SourceDryRun`). */
15
+ sourceDryRun: SourceDryRun | null
16
+ }
17
+
18
+ /** `target` is a configured name, a draft file or `-` for stdin; `raw` also prints the command's output. */
19
+ export type SourceDryRun = { target: string; raw: boolean }
20
+
21
+ export const DEFAULT_HEARTBEAT_MINUTES = 15
22
+
23
+ const VALUE_OPTIONS = ['--heartbeat', '--board', '--coordinator']
24
+ const SOURCE_SUBCOMMAND = 'source'
25
+ const DRY_RUN_FLAG = '--dry-run'
26
+ const RAW_FLAG = '--raw'
27
+ const STREAM_FLAG = '--stream'
28
+
29
+ const USAGE = `usage: ${CLI_NAME} watch [--stream] [--heartbeat <minutes>] [--board <board.md>] [--coordinator <name>] [<lane> ...]\n ${CLI_NAME} watch source <name|draft.json|-> --dry-run [--raw]`
30
+
31
+ const optionValue = (args: readonly string[], option: string): string | null => {
32
+ const index = args.indexOf(option)
33
+ if (index === -1) return null
34
+ const value = args[index + 1]
35
+ if (value === undefined || value.startsWith('--')) throw new Error(`${option} needs a value\n${USAGE}`)
36
+ return value
37
+ }
38
+
39
+ const heartbeatOf = (value: string | null): number => {
40
+ if (value === null) return DEFAULT_HEARTBEAT_MINUTES
41
+ const minutes = Number(value)
42
+ if (!Number.isFinite(minutes) || minutes <= 0)
43
+ throw new Error(`--heartbeat needs a positive number of minutes\n${USAGE}`)
44
+ return minutes
45
+ }
46
+
47
+ /** `source <name|draft.json|-> --dry-run [--raw]` and nothing else; a source is never watched from the command line. */
48
+ const sourceDryRunOf = (args: readonly string[]): SourceDryRun => {
49
+ const [, target, flag, ...rest] = args
50
+ const raw = rest.length === 1 && rest[0] === RAW_FLAG
51
+ if (target === undefined || target.startsWith('--') || flag !== DRY_RUN_FLAG || (rest.length > 0 && !raw))
52
+ throw new Error(`watch source runs one source once: source <name|draft.json|-> --dry-run [--raw]\n${USAGE}`)
53
+ return { target, raw }
54
+ }
55
+
56
+ /** Reads the watcher's command line; throws with the usage on an unknown option or a missing value. */
57
+ export const parseWatchOptions = (args: readonly string[]): WatchOptions => {
58
+ if (args[0] === SOURCE_SUBCOMMAND)
59
+ return {
60
+ lanes: null,
61
+ heartbeatMinutes: DEFAULT_HEARTBEAT_MINUTES,
62
+ boardPath: null,
63
+ coordinator: null,
64
+ stream: false,
65
+ sourceDryRun: sourceDryRunOf(args),
66
+ }
67
+ const unknown = args.find((arg) => arg.startsWith('--') && !VALUE_OPTIONS.includes(arg) && arg !== STREAM_FLAG)
68
+ if (unknown) throw new Error(`unknown option ${unknown}\n${USAGE}`)
69
+ const valueIndexes = new Set(VALUE_OPTIONS.map((option) => args.indexOf(option) + 1).filter((index) => index > 0))
70
+ const lanes = args.filter((arg, index) => !arg.startsWith('--') && !valueIndexes.has(index))
71
+ return {
72
+ lanes: lanes.length > 0 ? lanes : null,
73
+ heartbeatMinutes: heartbeatOf(optionValue(args, '--heartbeat')),
74
+ boardPath: optionValue(args, '--board'),
75
+ coordinator: optionValue(args, '--coordinator'),
76
+ stream: args.includes(STREAM_FLAG),
77
+ sourceDryRun: null,
78
+ }
79
+ }
@@ -0,0 +1,30 @@
1
+ import { readFileSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+ import { WATCH_HEALTH, type WatchReading, watchBadgesOf, watchHealthOf } from './plan-badges'
4
+ import { parseWatchState, type WatchState } from './watch-events'
5
+ import { type CommandLineOf, isWatcherRunning, WATCHER_FILES, watcherDirectoryOf } from './watcher-lock'
6
+
7
+ const readState = (file: string): WatchState | null => {
8
+ try {
9
+ return parseWatchState(readFileSync(file, 'utf8'))
10
+ } catch {
11
+ return null
12
+ }
13
+ }
14
+
15
+ /**
16
+ * What the watcher of the repository at `root` gives the plans view, read from its state file without a GitHub call:
17
+ * its badges, and its health, so the view can say why there are none. A missing or damaged file shows no badges; the
18
+ * view goes on without them.
19
+ */
20
+ export const readWatchReading = (root: string, now: number, lookUp?: CommandLineOf): WatchReading => {
21
+ const directory = watcherDirectoryOf(root)
22
+ const state = readState(join(directory, WATCHER_FILES.state))
23
+ const health = watchHealthOf(
24
+ state?.savedAt ?? null,
25
+ isWatcherRunning(join(directory, WATCHER_FILES.lock), lookUp),
26
+ now,
27
+ )
28
+ const badges = state && health.kind === WATCH_HEALTH.live ? watchBadgesOf(state, now) : {}
29
+ return { badges, health }
30
+ }
@@ -0,0 +1,66 @@
1
+ import { PULL_REQUEST_STATE } from './pull-requests'
2
+ import type { WatchState } from './watch-events'
3
+
4
+ /**
5
+ * What the plans view shows beside a pull request: the checked URL of its preview, the feedback since its last push,
6
+ * and whether a reviewer requests changes.
7
+ */
8
+ export type PullRequestBadges = { preview: string | null; feedback: number; changesRequested: boolean }
9
+
10
+ /** The badges of a pull request the watcher shows nothing for. */
11
+ export const NO_BADGES: PullRequestBadges = { preview: null, feedback: 0, changesRequested: false }
12
+
13
+ /** Pull request number → its badges; a pull request without an entry has none. */
14
+ export type WatchBadges = Record<string, PullRequestBadges>
15
+
16
+ /** Whether the watcher's state can be shown: no watcher runs, its state is too old, or it is live. */
17
+ export const WATCH_HEALTH = { live: 'live', off: 'off', stale: 'stale' } as const
18
+
19
+ /** `minutes` is the whole minutes since the stale state was saved. */
20
+ export type WatchHealth =
21
+ | { kind: typeof WATCH_HEALTH.live }
22
+ | { kind: typeof WATCH_HEALTH.off }
23
+ | { kind: typeof WATCH_HEALTH.stale; minutes: number }
24
+
25
+ /** The badges the view draws and why there may be none. */
26
+ export type WatchReading = { badges: WatchBadges; health: WatchHealth }
27
+
28
+ /** Three missed GitHub reads of the watcher (one a minute): an older state is not shown (D96). */
29
+ export const STALE_AFTER_MS = 180_000
30
+
31
+ const MINUTE_MS = 60_000
32
+
33
+ const isFresh = (savedAt: number | null, now: number): boolean => savedAt !== null && now - savedAt <= STALE_AFTER_MS
34
+
35
+ /**
36
+ * The health of a watcher from whether it runs and when it last saved its state. A watcher that runs but has not
37
+ * saved after a GitHub read yet is live: it only just started.
38
+ */
39
+ export const watchHealthOf = (savedAt: number | null, running: boolean, now: number): WatchHealth => {
40
+ if (!running) return { kind: WATCH_HEALTH.off }
41
+ if (savedAt === null || isFresh(savedAt, now)) return { kind: WATCH_HEALTH.live }
42
+ return { kind: WATCH_HEALTH.stale, minutes: Math.floor((now - savedAt) / MINUTE_MS) }
43
+ }
44
+
45
+ /**
46
+ * The badges of the open pull requests the watcher follows, from its saved state alone: the preview reported for the
47
+ * head the watcher knows, the feedback counted since that head, and the requested changes, which a push keeps. A value
48
+ * of an older head, a preview recorded without its URL, and a state older than `STALE_AFTER_MS` show nothing.
49
+ */
50
+ export const watchBadgesOf = (state: WatchState, now: number): WatchBadges => {
51
+ if (!isFresh(state.savedAt, now)) return {}
52
+ return Object.fromEntries(
53
+ Object.entries(state.pullRequests).flatMap(([number, { state: prState, headCommit }]) => {
54
+ if (prState !== PULL_REQUEST_STATE.open) return []
55
+ const preview = state.previews.pullRequests[number]
56
+ const feedback = state.feedback.pullRequests[number]
57
+ const badges = {
58
+ preview: preview?.reportedHead === headCommit ? (preview.url ?? null) : null,
59
+ feedback: feedback?.headCommit === headCommit ? feedback.count : 0,
60
+ changesRequested: feedback?.changesRequested === true,
61
+ }
62
+ const shown = badges.preview !== null || badges.feedback > 0 || badges.changesRequested
63
+ return shown ? [[number, badges]] : []
64
+ }),
65
+ )
66
+ }
@@ -0,0 +1,59 @@
1
+ import { TERMINAL_ADAPTER } from '../config/schema'
2
+ import { errorText } from '../plans/error-text'
3
+ import type { PlansSettings } from '../plans/settings'
4
+ import type { LaneSnapshot } from './lane-watch'
5
+
6
+ export const ENSURE_TRIGGER = { start: 'start', poll: 'poll' } as const
7
+
8
+ /**
9
+ * Why the watcher may bring back plans views: its own start, or a poll that found lanes added to the watched set
10
+ * or watched lanes whose agent appeared (a lane gets its view only once its agent runs).
11
+ */
12
+ export type EnsureTrigger =
13
+ | { kind: typeof ENSURE_TRIGGER.start }
14
+ | { kind: typeof ENSURE_TRIGGER.poll; added: string[]; withNewAgent: string[] }
15
+
16
+ /** Whether the last failure of `plans --ensure` was already printed; a run that works clears it. */
17
+ export type PlanViews = { failureReported: boolean }
18
+
19
+ type Ensured = { ok: true; lines: string[] } | { ok: false; error: string }
20
+
21
+ type Effects = { ensure: () => string[]; log: (line: string) => void }
22
+
23
+ export const insideHerdr = (adapter: PlansSettings['adapter'], env: Record<string, string | undefined>): boolean =>
24
+ adapter === TERMINAL_ADAPTER.herdr && env.HERDR_ENV === '1'
25
+
26
+ /** The watched lanes that had no agent at the last poll and have one now. */
27
+ export const lanesWithNewAgent = (previous: LaneSnapshot, next: LaneSnapshot, lanes: readonly string[]): string[] =>
28
+ lanes.filter((lane) => !previous[lane] && next[lane])
29
+
30
+ /** At the start, and after a poll that added a lane or saw a lane's agent appear; a lane that only left needs no view. */
31
+ export const ensureDue = (trigger: EnsureTrigger): boolean =>
32
+ trigger.kind === ENSURE_TRIGGER.start || trigger.added.length > 0 || trigger.withNewAgent.length > 0
33
+
34
+ /** The lines one run leaves in the watcher's output: what it brought back, or a failure once per streak. */
35
+ export const ensuredLines = (ensured: Ensured, views: PlanViews): { lines: string[]; views: PlanViews } => {
36
+ if (ensured.ok)
37
+ return { lines: ensured.lines.map((line) => `plans --ensure: ${line}`), views: { failureReported: false } }
38
+ if (views.failureReported) return { lines: [], views }
39
+ return { lines: [`plans --ensure failed, still watching: ${ensured.error}`], views: { failureReported: true } }
40
+ }
41
+
42
+ const ensuredOf = (ensure: () => string[]): Ensured => {
43
+ try {
44
+ return { ok: true, lines: ensure() }
45
+ } catch (error) {
46
+ return { ok: false, error: errorText(error) }
47
+ }
48
+ }
49
+
50
+ /**
51
+ * Brings back missing plans views when `trigger` calls for it, as `jh plans --ensure` does. A failure
52
+ * never ends the watch; it is printed once until a run works again.
53
+ */
54
+ export const keepPlanViews = (trigger: EnsureTrigger, views: PlanViews, effects: Effects): PlanViews => {
55
+ if (!ensureDue(trigger)) return views
56
+ const { lines, views: next } = ensuredLines(ensuredOf(effects.ensure), views)
57
+ for (const line of lines) effects.log(line)
58
+ return next
59
+ }
@@ -0,0 +1,60 @@
1
+ import { errorText } from '../plans/error-text'
2
+ import type { FeedbackTarget } from './feedback'
3
+ import { runGh } from './gh'
4
+ import {
5
+ deploymentCandidate,
6
+ type PreviewCandidate,
7
+ parseDeploymentLines,
8
+ parseDeploymentStatus,
9
+ previewDeployments,
10
+ } from './previews'
11
+
12
+ export type PreviewCandidatesRead = { ok: true; candidates: PreviewCandidate[] } | { ok: false; error: string }
13
+
14
+ type HeadRead = { ok: true; candidate: PreviewCandidate | null } | { ok: false; error: string }
15
+
16
+ const DEPLOYMENT_LIMIT = 30
17
+ const DEPLOYMENT_FIELDS = '.[] | {id, environment, production: (.production_environment // false)}'
18
+ /** A deployment without a status yet answers `null`, read as an empty state. */
19
+ const STATUS_FIELDS = '.[0] | {state: (.state // ""), url: (.environment_url // "")}'
20
+
21
+ /**
22
+ * The newest preview deployment of the target's head as a candidate once its latest status succeeded with a URL, else
23
+ * none: a head costs one list and at most one status call per read. An older deployment of the same head that
24
+ * succeeded is not asked, the newest one stands for the head.
25
+ */
26
+ const readHeadPreview = async (
27
+ root: string,
28
+ target: FeedbackTarget,
29
+ deployEnvironments: readonly string[],
30
+ ): Promise<HeadRead> => {
31
+ const listPath = `repos/{owner}/{repo}/deployments?sha=${target.headCommit}&per_page=${DEPLOYMENT_LIMIT}`
32
+ const listed = await runGh(['api', listPath, '--jq', DEPLOYMENT_FIELDS], root)
33
+ if (!listed.ok) return listed
34
+ try {
35
+ const [newest] = previewDeployments(parseDeploymentLines(listed.stdout), deployEnvironments)
36
+ if (!newest) return { ok: true, candidate: null }
37
+ const statusPath = `repos/{owner}/{repo}/deployments/${newest.id}/statuses?per_page=1`
38
+ const status = await runGh(['api', statusPath, '--jq', STATUS_FIELDS], root)
39
+ if (!status.ok) return status
40
+ return { ok: true, candidate: deploymentCandidate(target, newest.id, parseDeploymentStatus(status.stdout)) }
41
+ } catch (error) {
42
+ return { ok: false, error: errorText(error) }
43
+ }
44
+ }
45
+
46
+ /**
47
+ * The preview deployments of the targets' head commits over REST, one list per target and the status of its newest
48
+ * preview deployment. The deployments of `deployEnvironments` are the project's own deploys, never a
49
+ * preview. No target, no call; the first failure is the reason.
50
+ */
51
+ export const readDeploymentCandidates = async (
52
+ root: string,
53
+ targets: readonly FeedbackTarget[],
54
+ deployEnvironments: readonly string[],
55
+ ): Promise<PreviewCandidatesRead> => {
56
+ const reads = await Promise.all(targets.map((target) => readHeadPreview(root, target, deployEnvironments)))
57
+ const failed = reads.find((read) => !read.ok)
58
+ if (failed && !failed.ok) return { ok: false, error: failed.error }
59
+ return { ok: true, candidates: reads.flatMap((read) => (read.ok && read.candidate ? [read.candidate] : [])) }
60
+ }
@@ -0,0 +1,234 @@
1
+ import { FEEDBACK_FORM, type FeedbackFilter, type FeedbackItem, type FeedbackTarget, isReadTime } from './feedback'
2
+ import { isRecord } from './json-guards'
3
+
4
+ export const PREVIEW_EVENT = 'preview'
5
+
6
+ /** Where a preview is read: a deployment of the head commit, or a comment of a configured author. */
7
+ export const PREVIEW_SOURCE = { deployment: 'deployment', comment: FEEDBACK_FORM.comment } as const
8
+
9
+ export type PreviewSource = (typeof PREVIEW_SOURCE)[keyof typeof PREVIEW_SOURCE]
10
+
11
+ /**
12
+ * A preview as one source shows it, with what changes when it is updated: `key` names the comment or deployment
13
+ * (`<source>:<id>`), `stamp` its version (a comment's `updated_at`, a deployment's `success`). `url` is null when the
14
+ * source names no usable link yet; `headCommit` is the head a deployment was read for, null for a comment, which
15
+ * names no commit.
16
+ */
17
+ export type PreviewCandidate = {
18
+ pullRequest: number
19
+ source: PreviewSource
20
+ key: string
21
+ stamp: string
22
+ url: string | null
23
+ headCommit: string | null
24
+ }
25
+
26
+ /**
27
+ * `reportedHead` is the head a preview was last reported (or, at the first sighting, found) for, `url` that preview's
28
+ * checked URL (missing in a state written before the plans view showed it); `stamps` maps `key` to `stamp`.
29
+ */
30
+ export type PreviewRecord = { reportedHead: string | null; stamps: Record<string, string>; url?: string | null }
31
+
32
+ /** `since` is the epoch milliseconds of the last read, null before the first. */
33
+ export type PreviewState = { since: number | null; pullRequests: Record<string, PreviewRecord> }
34
+
35
+ export type PreviewRead = { readAt: number; targets: FeedbackTarget[]; candidates: PreviewCandidate[] }
36
+
37
+ /** Which comments name a preview: their authors' logins, and optionally a pattern whose first group is the URL. */
38
+ export type PreviewSettings = { authors: readonly string[]; urlPattern?: string }
39
+
40
+ export type PreviewEvent = { kind: typeof PREVIEW_EVENT; lane: string; number: number; url: string; headCommit: string }
41
+
42
+ /** A deployment of a commit as `gh api --jq` lists it, newest first. */
43
+ export type Deployment = { id: number; environment: string; production: boolean }
44
+
45
+ /** A deployment's latest status; both empty when it has none. */
46
+ export type DeploymentStatus = { state: string; url: string }
47
+
48
+ const DEPLOYMENT_SUCCESS = 'success'
49
+ /** A plain https link up to the first space, control character or Markdown and table delimiter. */
50
+ const FIRST_HTTPS_LINK = /https:\/\/[^\s<>()[\]{}"'`|\p{Cc}]+/u
51
+ const TRAILING_PUNCTUATION = /[.,;:!?]+$/
52
+ const URL_MAX_LENGTH = 200
53
+
54
+ export const emptyPreviewState = (): PreviewState => ({ since: null, pullRequests: {} })
55
+
56
+ /** The URL a text names, as a checked https URL: the first group of `pattern` (or its match), else the first https link. */
57
+ export const previewUrlOf = (text: string, pattern: string | undefined): string | null => {
58
+ const match = pattern === undefined ? FIRST_HTTPS_LINK.exec(text) : new RegExp(pattern).exec(text)
59
+ const raw = (match?.[1] ?? match?.[0] ?? '').replace(TRAILING_PUNCTUATION, '')
60
+ const linked = FIRST_HTTPS_LINK.exec(raw)?.[0]
61
+ if (linked === undefined || linked !== raw || raw.length > URL_MAX_LENGTH) return null
62
+ try {
63
+ const url = new URL(raw)
64
+ const withCredentials = url.username !== '' || url.password !== ''
65
+ return url.protocol === 'https:' && !withCredentials ? url.href : null
66
+ } catch {
67
+ return null
68
+ }
69
+ }
70
+
71
+ /** GitHub logins ignore case. */
72
+ export const isPreviewAuthor = (author: string, authors: readonly string[]): boolean =>
73
+ authors.some((name) => name.toLowerCase() === author.toLowerCase())
74
+
75
+ /** Whose comments are no feedback: the account the watcher runs as, the ignored logins and the preview authors. */
76
+ export const feedbackFilterOf = (
77
+ own: string,
78
+ ignore: readonly string[],
79
+ previews: Pick<PreviewSettings, 'authors'> | undefined,
80
+ ): FeedbackFilter => ({ own, ignore: [...ignore, ...(previews?.authors ?? [])] })
81
+
82
+ /**
83
+ * Whether the feedback read, which reached back from its own last read, also covers everything since the previews'
84
+ * last read; a first preview read is a baseline and needs no history.
85
+ */
86
+ export const feedbackReadCovers = (feedbackSince: number | null, previewsSince: number | null): boolean =>
87
+ previewsSince === null || (feedbackSince !== null && feedbackSince <= previewsSince)
88
+
89
+ /** The pull request comments of the configured authors as candidates; a comment's text gives nothing but its URL. */
90
+ export const commentCandidates = (items: readonly FeedbackItem[], settings: PreviewSettings): PreviewCandidate[] =>
91
+ items
92
+ .filter((item) => item.form === FEEDBACK_FORM.comment && isPreviewAuthor(item.author, settings.authors))
93
+ .map((item) => ({
94
+ pullRequest: item.pullRequest,
95
+ source: PREVIEW_SOURCE.comment,
96
+ key: `${PREVIEW_SOURCE.comment}:${item.id}`,
97
+ stamp: item.updatedAt,
98
+ url: previewUrlOf(item.body, settings.urlPattern),
99
+ headCommit: null,
100
+ }))
101
+
102
+ const isDeployment = (value: unknown): value is Deployment =>
103
+ isRecord(value) &&
104
+ Number.isSafeInteger(value.id) &&
105
+ (value.id as number) > 0 &&
106
+ typeof value.environment === 'string' &&
107
+ typeof value.production === 'boolean'
108
+
109
+ /** Reads the lines of `gh api --jq '.[] | {id, environment, production}'`; throws on any other shape. */
110
+ export const parseDeploymentLines = (text: string): Deployment[] =>
111
+ text
112
+ .split('\n')
113
+ .filter((line) => line.trim() !== '')
114
+ .map((line) => {
115
+ const value: unknown = JSON.parse(line)
116
+ if (!isDeployment(value)) throw new Error('gh api deployments: unexpected answer')
117
+ return { id: value.id, environment: value.environment, production: value.production }
118
+ })
119
+
120
+ /** Reads `{state, url}` of a deployment's latest status; throws on any other shape. */
121
+ export const parseDeploymentStatus = (text: string): DeploymentStatus => {
122
+ const value: unknown = JSON.parse(text)
123
+ if (!isRecord(value) || typeof value.state !== 'string' || typeof value.url !== 'string')
124
+ throw new Error('gh api deployment status: unexpected answer')
125
+ return { state: value.state, url: value.url }
126
+ }
127
+
128
+ /** The deployments that may hold a preview: no production one and none of the project's deploy environments. */
129
+ export const previewDeployments = (deployments: readonly Deployment[], deployEnvironments: readonly string[]) =>
130
+ deployments.filter(({ production, environment }) => !production && !deployEnvironments.includes(environment))
131
+
132
+ /** A deployment of `target`'s head as a candidate once its latest status succeeded with an https URL. */
133
+ export const deploymentCandidate = (
134
+ target: FeedbackTarget,
135
+ id: number,
136
+ status: DeploymentStatus,
137
+ ): PreviewCandidate | null => {
138
+ const url = status.state === DEPLOYMENT_SUCCESS ? previewUrlOf(status.url, undefined) : null
139
+ return url === null
140
+ ? null
141
+ : {
142
+ pullRequest: target.number,
143
+ source: PREVIEW_SOURCE.deployment,
144
+ key: `${PREVIEW_SOURCE.deployment}:${id}`,
145
+ stamp: DEPLOYMENT_SUCCESS,
146
+ url,
147
+ headCommit: target.headCommit,
148
+ }
149
+ }
150
+
151
+ export const previewReadOf = (
152
+ readAt: number,
153
+ targets: FeedbackTarget[],
154
+ candidates: readonly PreviewCandidate[],
155
+ ): PreviewRead => {
156
+ const numbers = new Set(targets.map(({ number }) => number))
157
+ return { readAt, targets, candidates: candidates.filter(({ pullRequest }) => numbers.has(pullRequest)) }
158
+ }
159
+
160
+ /** A candidate with a URL that may stand for the target's current head: a comment, or a deployment of that head. */
161
+ const isUsable = (candidate: PreviewCandidate, target: FeedbackTarget): boolean =>
162
+ candidate.url !== null && (candidate.headCommit === null || candidate.headCommit === target.headCommit)
163
+
164
+ /** Deployments before comments: a deployment names its commit, a comment does not. */
165
+ const byDeploymentFirst = (a: PreviewCandidate, b: PreviewCandidate): number =>
166
+ Number(a.source !== PREVIEW_SOURCE.deployment) - Number(b.source !== PREVIEW_SOURCE.deployment)
167
+
168
+ const candidatesOf = (read: PreviewRead, target: FeedbackTarget): PreviewCandidate[] =>
169
+ read.candidates.filter(({ pullRequest }) => pullRequest === target.number).sort(byDeploymentFirst)
170
+
171
+ /** The usable candidate that changed since `record`, when the head has had no preview reported yet. */
172
+ const freshPreview = (record: PreviewRecord, target: FeedbackTarget, candidates: readonly PreviewCandidate[]) =>
173
+ target.headCommit === record.reportedHead
174
+ ? undefined
175
+ : candidates.find((candidate) => record.stamps[candidate.key] !== candidate.stamp && isUsable(candidate, target))
176
+
177
+ /**
178
+ * One event per pull request whose head is new since its last reported preview and whose preview source changed
179
+ * since. A pull request without a record is a baseline: what it shows at its first read fires nothing.
180
+ */
181
+ export const previewEvents = (previous: PreviewState, read: PreviewRead): PreviewEvent[] =>
182
+ read.targets.flatMap((target) => {
183
+ const record = previous.pullRequests[String(target.number)]
184
+ const fresh = record ? freshPreview(record, target, candidatesOf(read, target)) : undefined
185
+ return fresh?.url
186
+ ? [
187
+ {
188
+ kind: PREVIEW_EVENT,
189
+ lane: target.lane,
190
+ number: target.number,
191
+ url: fresh.url,
192
+ headCommit: target.headCommit,
193
+ },
194
+ ]
195
+ : []
196
+ })
197
+
198
+ const recordOf = (record: PreviewRecord | undefined, target: FeedbackTarget, candidates: PreviewCandidate[]) => {
199
+ const stamps = Object.fromEntries(candidates.map(({ key, stamp }) => [key, stamp]))
200
+ const shown = record
201
+ ? freshPreview(record, target, candidates)
202
+ : candidates.find((candidate) => isUsable(candidate, target))
203
+ const reportedHead = shown ? target.headCommit : (record?.reportedHead ?? null)
204
+ const url = shown ? shown.url : (record?.url ?? null)
205
+ return { reportedHead, stamps: { ...record?.stamps, ...stamps }, url }
206
+ }
207
+
208
+ /** The state after `read`: each target's head marked as reported when it fired (or showed a preview at its first read). */
209
+ export const recordPreviews = (previous: PreviewState, read: PreviewRead): PreviewState => ({
210
+ since: read.readAt,
211
+ pullRequests: Object.fromEntries(
212
+ read.targets.map((target) => [
213
+ String(target.number),
214
+ recordOf(previous.pullRequests[String(target.number)], target, candidatesOf(read, target)),
215
+ ]),
216
+ ),
217
+ })
218
+
219
+ /** The targets whose current head has no preview reported yet; only they need their deployments read. */
220
+ export const unreportedTargets = (state: PreviewState, targets: readonly FeedbackTarget[]): FeedbackTarget[] =>
221
+ targets.filter(({ number, headCommit }) => state.pullRequests[String(number)]?.reportedHead !== headCommit)
222
+
223
+ const isPreviewRecord = (value: unknown): boolean =>
224
+ isRecord(value) &&
225
+ (value.reportedHead === null || typeof value.reportedHead === 'string') &&
226
+ isRecord(value.stamps) &&
227
+ Object.values(value.stamps).every((stamp) => typeof stamp === 'string') &&
228
+ (value.url === undefined || value.url === null || typeof value.url === 'string')
229
+
230
+ export const isPreviewState = (value: unknown): value is PreviewState =>
231
+ isRecord(value) &&
232
+ isReadTime(value.since) &&
233
+ isRecord(value.pullRequests) &&
234
+ Object.values(value.pullRequests).every(isPreviewRecord)