jackherd 1.10.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.
Files changed (64) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/README.md +17 -2
  3. package/dist/{chunk-5MQBCUQ5.js → chunk-RMMXD4G5.js} +9 -2
  4. package/dist/config.d.ts +60 -0
  5. package/dist/config.js +39 -4
  6. package/dist/plan-format.d.ts +7 -1
  7. package/dist/plan-format.js +4 -2
  8. package/herdr-plugin/herdr-plugin.toml +1 -1
  9. package/install.sh +1 -1
  10. package/kit/hooks/hooks/open-question/run.ts +8 -3
  11. package/kit/rules/rules/kit/pull-requests.md +2 -0
  12. package/kit/workflow/skills/follow/SKILL.md +3 -3
  13. package/kit/workflow/skills/orchestrate/SKILL.md +10 -2
  14. package/package.json +1 -1
  15. package/recipes/sources.md +4 -0
  16. package/schemas/jackherd.schema.json +80 -0
  17. package/src/cli.ts +1 -0
  18. package/src/config/schema.ts +24 -1
  19. package/src/config/tones.ts +27 -0
  20. package/src/lane-prompt/additions.ts +14 -0
  21. package/src/lane-prompt/command.ts +8 -2
  22. package/src/lane-prompt/input.ts +44 -3
  23. package/src/lane-prompt/options.ts +2 -0
  24. package/src/mermaid/command.ts +36 -0
  25. package/src/mermaid/problems.ts +48 -0
  26. package/src/plan-format/dependencies.ts +2 -2
  27. package/src/plan-format/waiting.ts +15 -2
  28. package/src/plans/appointment-rows.ts +34 -10
  29. package/src/plans/ask-coordinator.ts +46 -12
  30. package/src/plans/ask-target.ts +13 -2
  31. package/src/plans/board-close.ts +85 -0
  32. package/src/plans/frame.ts +5 -12
  33. package/src/plans/git.ts +35 -0
  34. package/src/plans/groomed.ts +27 -0
  35. package/src/plans/layout-phase.ts +7 -4
  36. package/src/plans/layout.ts +258 -48
  37. package/src/plans/link-chips.ts +22 -4
  38. package/src/plans/live.ts +73 -5
  39. package/src/plans/next-rows.ts +44 -0
  40. package/src/plans/planned.ts +22 -0
  41. package/src/plans/row-actions.ts +52 -0
  42. package/src/plans/row-focus.ts +148 -0
  43. package/src/plans/row-targets.ts +7 -0
  44. package/src/plans/selection.ts +14 -1
  45. package/src/plans/settings.ts +10 -0
  46. package/src/plans/snapshot.ts +79 -21
  47. package/src/plans/source-asked.ts +19 -0
  48. package/src/plans/source-prefix.ts +17 -0
  49. package/src/plans/source-rows.ts +188 -0
  50. package/src/plans/texts.ts +28 -3
  51. package/src/plans/tones.ts +30 -0
  52. package/src/plans/view.ts +8 -2
  53. package/src/plans/waiting-click.ts +129 -24
  54. package/src/terminal/text.ts +8 -0
  55. package/src/terminal/types.ts +4 -0
  56. package/src/vocabulary/index.ts +10 -0
  57. package/src/watch/plan-badges-read.ts +9 -2
  58. package/src/watch/plan-badges.ts +10 -2
  59. package/src/watch/report-questions.ts +4 -3
  60. package/src/watch/source-entries.ts +55 -0
  61. package/src/watch/source-fields.ts +1 -0
  62. package/src/watch/source-statuses.ts +60 -0
  63. package/src/watch/source-tickets.ts +21 -1
  64. package/src/watch/sources.ts +41 -1
@@ -1,8 +1,10 @@
1
+ import type { ToneName } from '../config/tones'
1
2
  import { WAITING_KIND, type WaitingItem } from '../plan-format/waiting'
2
3
  import { fit, padVisible, wrap } from '../terminal/text'
3
4
  import { textWidth } from '../terminal/width'
4
5
  import { ASK_KIND, type AskTarget } from './ask-target'
5
6
  import { plain } from './layout-phase'
7
+ import { DONE_KIND, type DoneTarget } from './row-targets'
6
8
  import { MOUSE_BUTTON, type MouseEvent } from './selection'
7
9
  import { shortTitle } from './task-name'
8
10
 
@@ -20,16 +22,31 @@ export type BlockRow = {
20
22
  url: string | null
21
23
  /** What the coordinator can be asked to put: a dialog question or a row of the board; null for files and appointments. */
22
24
  ask: AskTarget | null
23
- /** The screen column (1-based) `[ask]` starts in on the first line, five cells wide; null for a row without one. */
24
- askColumn: number | null
25
+ /** The buttons at the right edge of the first line, `[ask]` first, each with the screen column (1-based) it starts in. */
26
+ buttons: PlacedButton[]
25
27
  /** What the folded block shows of the row: the time column, if any, and the short title, without the mark. */
26
28
  preview: string
27
29
  /** Per line, the index in it where the part behind the short title starts; the line's length where none of it is. */
28
30
  dimFrom: number[]
31
+ /** The tone of the mark when it differs from the block's; absent for most rows. */
32
+ tone?: ToneName
33
+ /** The key in the expanded set that a click on the row flips, whatever its text or address; absent for most rows. */
34
+ toggle?: string
35
+ /** The row is dim from its text on; absent for most rows. */
36
+ dim?: boolean
29
37
  }
30
38
 
31
- /** What a click does: expand or fold the row (its key), open an address, ask the coordinator to put the row, or nothing (null). */
32
- export type BlockClick = { toggle: string } | { open: string } | { ask: AskTarget }
39
+ /**
40
+ * What a click does: expand or fold the row (its key), open an address, ask the coordinator (to put the row to the
41
+ * user, to run an appointment now, to ask for a new time), close the row (`done`), or nothing (null).
42
+ */
43
+ export type BlockClick = { toggle: string } | { open: string } | { ask: AskTarget } | { done: DoneTarget }
44
+
45
+ /** A button of a row: its label as drawn and what a click on it does. */
46
+ export type BlockButton = { label: string; click: BlockClick }
47
+
48
+ /** A button with the screen column (1-based) its label starts in. */
49
+ export type PlacedButton = BlockButton & { column: number }
33
50
 
34
51
  /**
35
52
  * One row of a block of the herd before it is drawn: the key it expands by, the `mark` that starts it (kept apart so
@@ -45,6 +62,13 @@ export type BlockItem = {
45
62
  title?: string
46
63
  url: string | null
47
64
  ask: AskTarget | null
65
+ /** The buttons after `[ask]`, in the order they are drawn. */
66
+ buttons?: readonly BlockButton[]
67
+ tone?: ToneName
68
+ /** The key in the expanded set that a click on the row flips, whatever its text or address. */
69
+ toggle?: string
70
+ /** The row is dim from its text on (an entry already asked about). */
71
+ dim?: boolean
48
72
  }
49
73
 
50
74
  /**
@@ -65,12 +89,22 @@ export const SHOWN_BLOCK_ROWS = 5
65
89
  export const WAITING_MORE_KEY = 'more>waiting'
66
90
  /** The same for the appointment block. */
67
91
  export const APPOINTMENT_MORE_KEY = 'more>appointment'
92
+ /** The same for the next block. */
93
+ export const NEXT_MORE_KEY = 'more>next'
94
+ /** The same for the sources block. */
95
+ export const SOURCE_MORE_KEY = 'more>source'
68
96
  /** The bar and the blank that start every line of a for-you row. */
69
97
  export const WAITING_MARK = '▌'
70
98
 
71
99
  /** The button at the right edge of a question or a board row; a click on it, and only on it, asks the coordinator. */
72
100
  export const ASK_BUTTON = '[ask]'
101
+ /** The button that closes a row: the board row gets its status, a sent file or a question its record. */
102
+ export const DONE_BUTTON = '[done]'
103
+ /** The appointment buttons that ask the coordinator to run the row now, and to ask the user for a new time. */
104
+ export const RUN_BUTTON = '[run]'
105
+ export const MOVE_BUTTON = '[move]'
73
106
  const ASK_GAP = 1
107
+ const BUTTON_GAP = 1
74
108
  const FILE_MARK = '📎'
75
109
  const QUESTION_MARK = '?'
76
110
  const OPENABLE = /^(https?|file):\/\//
@@ -85,13 +119,34 @@ const BAR_WIDTH = textWidth(`${WAITING_MARK} `)
85
119
 
86
120
  const roomOf = (width: number): number => Math.max(width - BAR_WIDTH, 1)
87
121
 
88
- /** The cells a row's text may take and the screen column `[ask]` starts in; no column for a row without a button. */
89
- const askLayout = (ask: AskTarget | null, width: number): { room: number; askColumn: number | null } => {
90
- if (!ask) return { room: roomOf(width), askColumn: null }
91
- const room = Math.max(roomOf(width) - (textWidth(ASK_BUTTON) + ASK_GAP), 1)
92
- return { room, askColumn: BAR_WIDTH + room + ASK_GAP + 1 }
122
+ /** The buttons of an item, `[ask]` first when the item can be asked. */
123
+ const buttonsOf = ({ ask, buttons = [] }: Pick<BlockItem, 'ask' | 'buttons'>): BlockButton[] => [
124
+ ...(ask ? [{ label: ASK_BUTTON, click: { ask } }] : []),
125
+ ...buttons,
126
+ ]
127
+
128
+ const buttonsWidth = (buttons: readonly BlockButton[]): number =>
129
+ buttons.reduce((sum, { label }) => sum + textWidth(label), 0) + Math.max(buttons.length - 1, 0) * BUTTON_GAP
130
+
131
+ /** The cells a row's text may take and the buttons with the screen columns they start in; none for a row without one. */
132
+ const buttonLayout = (items: readonly BlockButton[], width: number): { room: number; placed: PlacedButton[] } => {
133
+ if (items.length === 0) return { room: roomOf(width), placed: [] }
134
+ const room = Math.max(roomOf(width) - (buttonsWidth(items) + ASK_GAP), 1)
135
+ let column = BAR_WIDTH + room + ASK_GAP + 1
136
+ const placed = items.map((button) => {
137
+ const at = column
138
+ column += textWidth(button.label) + BUTTON_GAP
139
+ return { ...button, column: at }
140
+ })
141
+ return { room, placed }
93
142
  }
94
143
 
144
+ /** The labels of a row's buttons as drawn after its text, one blank apart, each painted with `paintLabel`. */
145
+ export const buttonsText = (
146
+ buttons: readonly BlockButton[],
147
+ paintLabel: (label: string) => string = (label) => label,
148
+ ): string => buttons.map(({ label }) => paintLabel(label)).join(' ')
149
+
95
150
  const askOf = (item: WaitingItem): AskTarget | null => {
96
151
  if (item.kind === WAITING_KIND.file) return null
97
152
  if (item.kind === WAITING_KIND.question) return item.id ? { kind: ASK_KIND.question, id: item.id } : null
@@ -126,29 +181,58 @@ const dimStarts = (lines: readonly string[], start: string, titleLength: number)
126
181
  })
127
182
  }
128
183
 
184
+ /** What `[done]` closes: the record of a file or a question, else the row of the board; none for a record-less question. */
185
+ const doneOf = (item: WaitingItem): DoneTarget | null => {
186
+ if (item.id) return { kind: DONE_KIND.record, id: item.id }
187
+ return item.kind === undefined ? { kind: DONE_KIND.boardRow, since: item.since, item: item.item } : null
188
+ }
189
+
190
+ const doneButtons = (item: WaitingItem): BlockButton[] => {
191
+ const done = doneOf(item)
192
+ return done ? [{ label: DONE_BUTTON, click: { done } }] : []
193
+ }
194
+
195
+ const withPrefix = (prefix: string | undefined, text: string): string => (prefix ? `${prefix}: ${text}` : text)
196
+
129
197
  const itemOf = (item: WaitingItem): BlockItem => ({
198
+ buttons: doneButtons(item),
130
199
  key: waitingKey(item),
131
200
  mark: item.kind === WAITING_KIND.file ? FILE_MARK : QUESTION_MARK,
132
- text: plain(item.item),
133
- title: shortTitle(plain(item.item)),
201
+ text: withPrefix(item.prefix, plain(item.item)),
202
+ title: withPrefix(item.prefix, shortTitle(plain(item.item))),
134
203
  url: item.kind === WAITING_KIND.file && item.url && OPENABLE.test(item.url) ? item.url : null,
135
204
  ask: askOf(item),
136
205
  })
137
206
 
138
- const rowOf = (
139
- { key, mark, lead, text, title = text, url, ask }: BlockItem,
140
- expandedKeys: ReadonlySet<string>,
141
- width: number,
142
- ): BlockRow => {
143
- const { room, askColumn } = askLayout(ask, width)
207
+ const rowOf = (item: BlockItem, expandedKeys: ReadonlySet<string>, width: number): BlockRow => {
208
+ const { key, mark, lead, text, title = text, url, ask, tone, toggle, dim } = item
209
+ const { room, placed } = buttonLayout(buttonsOf(item), width)
144
210
  const leadText = lead === undefined ? '' : `${lead} `
145
211
  const start = `${mark} ${leadText}`
146
212
  const cut = title !== text || textWidth(`${start}${text}`) > room
147
213
  const expanded = cut && expandedKeys.has(key)
148
214
  const [first, ...rest] = expanded ? hangingLines(start, text, room) : [fit(`${start}${title}`, room)]
149
- const lines = ask ? [padVisible(first, room + ASK_GAP), ...rest] : [first, ...rest]
150
- const dimFrom = dimStarts([first, ...rest], start, expanded && title !== text ? title.length : 0)
151
- return { key, mark, lines, cut, expanded, url, ask, askColumn, preview: `${leadText}${title}`, dimFrom }
215
+ const lines = placed.length > 0 ? [padVisible(first, room + ASK_GAP), ...rest] : [first, ...rest]
216
+ const lineParts = [first, ...rest]
217
+ const dimFrom = dim
218
+ ? lineParts.map((_, index) => (index === 0 ? mark.length + 1 : 0))
219
+ : dimStarts(lineParts, start, expanded && title !== text ? title.length : 0)
220
+ const preview = `${leadText}${title}`
221
+ return {
222
+ key,
223
+ mark,
224
+ lines,
225
+ cut,
226
+ expanded,
227
+ url,
228
+ ask,
229
+ buttons: placed,
230
+ preview,
231
+ dimFrom,
232
+ ...(tone && { tone }),
233
+ ...(toggle && { toggle }),
234
+ ...(dim && { dim }),
235
+ }
152
236
  }
153
237
 
154
238
  /**
@@ -163,6 +247,13 @@ export const blockRows = (
163
247
  ): BlockRow[] =>
164
248
  (expandedKeys.has(moreKey) ? items : items.slice(0, SHOWN_BLOCK_ROWS)).map((item) => rowOf(item, expandedKeys, width))
165
249
 
250
+ /** Every item as a row, for a block that limits its rows itself (the sources block, five entries per source). */
251
+ export const allBlockRows = (
252
+ items: readonly BlockItem[],
253
+ expandedKeys: ReadonlySet<string>,
254
+ width: number,
255
+ ): BlockRow[] => items.map((item) => rowOf(item, expandedKeys, width))
256
+
166
257
  /** The rows the herd draws for what waits on the user. */
167
258
  export const waitingRows = (
168
259
  items: readonly WaitingItem[],
@@ -186,16 +277,30 @@ const rowAt = (
186
277
  return null
187
278
  }
188
279
 
189
- const onAskButton = ({ askColumn }: BlockRow, column: number | undefined): boolean =>
190
- askColumn !== null && column !== undefined && column >= askColumn && column < askColumn + textWidth(ASK_BUTTON)
280
+ const buttonAt = ({ buttons }: BlockRow, column: number | undefined): PlacedButton | undefined =>
281
+ column === undefined
282
+ ? undefined
283
+ : buttons.find((button) => column >= button.column && column < button.column + textWidth(button.label))
191
284
 
192
285
  const clickOn = (row: BlockRow, onFirstLine: boolean, column: number | undefined): BlockClick | null => {
193
- const { key, cut, expanded, url, ask } = row
194
- if (ask && onFirstLine && onAskButton(row, column)) return { ask }
286
+ const { key, cut, expanded, url, toggle } = row
287
+ const button = onFirstLine ? buttonAt(row, column) : undefined
288
+ if (button) return button.click
289
+ if (toggle) return { toggle }
195
290
  if (url) return cut && !expanded ? { toggle: key } : { open: url }
196
291
  return cut ? { toggle: key } : null
197
292
  }
198
293
 
294
+ /**
295
+ * What Enter does on a row: the question is asked where the row has an `[ask]` button, else the row opens its address,
296
+ * expands when cut off, or does nothing. Never a `[done]` or `[run]`: those close or start something, so only a click on
297
+ * the button does that.
298
+ */
299
+ export const mainClickOf = (row: BlockRow): BlockClick | null => {
300
+ const ask = row.buttons.find((button) => button.label === ASK_BUTTON)
301
+ return ask ? ask.click : clickOn(row, true, undefined)
302
+ }
303
+
199
304
  /** Whether a plain left click hit the head line of a block, which folds and opens it. `headLine` is 0-based. */
200
305
  export const blockHeadClick = (
201
306
  headLine: number,
@@ -47,6 +47,7 @@ export const paint = {
47
47
  red: style('31'),
48
48
  green: style('32'),
49
49
  yellow: style('33'),
50
+ blue: style('34'),
50
51
  magenta: style('35'),
51
52
  teal: style(tealCodeOf(process.env)),
52
53
  cyan: style('36'),
@@ -75,6 +76,13 @@ export const link = (url: string, text: string): string =>
75
76
  export const badge = (url: string, text: string, hint = '', tone: BadgeTone = BADGE_TONE.grey): string =>
76
77
  `${ESC}[${tone}m ${link(url, text)}${hint ? `${FAINT_ON} ${hint}${FAINT_OFF}` : ''} ${RESET}`
77
78
 
79
+ /**
80
+ * `text` as a button in square brackets, like `[ask]`: the text linked to `url` in the normal brightness, with `hint`
81
+ * after it inside the brackets, faint. A square bracket means a click does something.
82
+ */
83
+ export const bracket = (url: string, text: string, hint = ''): string =>
84
+ `[${link(url, text)}${hint ? `${FAINT_ON} ${hint}${FAINT_OFF}` : ''}]`
85
+
78
86
  /** `text` as a badge without a link, for what no click opens: the background of `tone`, a cell of padding either side. */
79
87
  export const box = (text: string, tone: BadgeTone = BADGE_TONE.grey): string => `${ESC}[${tone}m ${text} ${RESET}`
80
88
 
@@ -6,6 +6,10 @@ export type AgentStatus = (typeof AGENT_STATUS)[keyof typeof AGENT_STATUS]
6
6
  export const isAgentStatus = (value: string): value is AgentStatus =>
7
7
  (Object.values(AGENT_STATUS) as string[]).includes(value)
8
8
 
9
+ /** The states of an agent that rests: it has finished or waits for the next task, and is not working or asking. */
10
+ export const isRestingAgent = (agent: string | null): boolean =>
11
+ agent === AGENT_STATUS.idle || agent === AGENT_STATUS.done
12
+
9
13
  /** A name to list the unnamed agent of one pane under. */
10
14
  export type AdoptedName = { pane: string; name: string }
11
15
 
@@ -87,6 +87,8 @@ export const APPOINTMENT_VOCABULARY: Record<
87
87
  /** Further header names that count as the status column. */
88
88
  statusAlso: readonly string[]
89
89
  open: readonly string[]
90
+ /** The lower-case word an action starts with when it is the weekly board grooming, which the herd's header counts from. */
91
+ grooming: string
90
92
  }
91
93
  > = {
92
94
  [PLAN_LANGUAGE.de]: {
@@ -94,15 +96,23 @@ export const APPOINTMENT_VOCABULARY: Record<
94
96
  columns: { when: 'Wann', action: 'Aktion', origin: 'Herkunft', status: 'Stand' },
95
97
  statusAlso: ['Status'],
96
98
  open: ['offen'],
99
+ grooming: 'board-pflege',
97
100
  },
98
101
  [PLAN_LANGUAGE.en]: {
99
102
  heading: 'Appointments',
100
103
  columns: { when: 'When', action: 'Action', origin: 'Origin', status: 'Status' },
101
104
  statusAlso: [],
102
105
  open: ['open'],
106
+ grooming: 'board grooming',
103
107
  },
104
108
  }
105
109
 
110
+ /** The heading of the board section of what the coordinator does next, a numbered list of at most three lines. */
111
+ export const NEXT_VOCABULARY: Record<PlanLanguage, { heading: string }> = {
112
+ [PLAN_LANGUAGE.de]: { heading: 'Als Nächstes' },
113
+ [PLAN_LANGUAGE.en]: { heading: 'Next' },
114
+ }
115
+
106
116
  /**
107
117
  * The board section of what waits on the user's answer (a sent preview, an open question, a report to accept): its
108
118
  * heading, the header names of its columns (since, item, origin, status, found by name in any order; the status
@@ -8,6 +8,7 @@ import {
8
8
  watchBadgesOf,
9
9
  watchHealthOf,
10
10
  } from './plan-badges'
11
+ import { sourceStatusesOf } from './source-statuses'
11
12
  import { parseWatchState, type WatchState } from './watch-events'
12
13
  import { type CommandLineOf, isWatcherRunning, WATCHER_FILES, watcherDirectoryOf } from './watcher-lock'
13
14
 
@@ -23,7 +24,7 @@ const readState = (file: string): WatchState | null => {
23
24
 
24
25
  /**
25
26
  * What the watcher of the repository at `root` gives the herd, read from its state file without a GitHub call:
26
- * its badges, the tickets of `ticketSources`, and its health, so the view can say why there are none. A missing or
27
+ * its badges, the tickets of `ticketSources`, the status of the sources in `statusNames`, and its health, so the view can say why there are none. A missing or
27
28
  * damaged file shows no badges; the view goes on without them. Nothing is read unless both levels of the watcher
28
29
  * directory are private (`isPrivateLevels`), and only regular files, so a directory, a file or a FIFO someone else
29
30
  * planted neither feeds the view nor blocks it.
@@ -34,6 +35,7 @@ export const readWatchReading = (
34
35
  ticketSources: readonly TicketSource[],
35
36
  lookUp?: CommandLineOf,
36
37
  system: LocationSystem = systemLocation(),
38
+ statusNames: readonly string[] = [],
37
39
  ): WatchReading => {
38
40
  const directory = watcherDirectoryOf(root, system)
39
41
  const isPrivate = isPrivateLevels(dirname(directory), directory, system)
@@ -44,5 +46,10 @@ export const readWatchReading = (
44
46
  now,
45
47
  )
46
48
  if (!state || health.kind !== WATCH_HEALTH.live) return { badges: {}, tickets: [], health }
47
- return { badges: watchBadgesOf(state, now), tickets: sourceTicketsOf(state, ticketSources, now), health }
49
+ return {
50
+ badges: watchBadgesOf(state, now),
51
+ tickets: sourceTicketsOf(state, ticketSources, now),
52
+ health,
53
+ ...(statusNames.length > 0 ? { sources: sourceStatusesOf(state, statusNames, now) } : {}),
54
+ }
48
55
  }
@@ -3,6 +3,7 @@ import { sanitisedFailedTestPlaces, sanitisedFailedTests } from './failed-tests'
3
3
  import { failedTestPlacesOf, failedTestsOf } from './failures'
4
4
  import { previewUrlOf } from './previews'
5
5
  import { PULL_REQUEST_STATE } from './pull-requests'
6
+ import type { SourceStatus } from './source-statuses'
6
7
  import { lineText, type TicketRecord } from './sources'
7
8
  import type { WatchState } from './watch-events'
8
9
 
@@ -45,7 +46,13 @@ export type TicketBadge = { id: string; url: string | null; status: string | nul
45
46
  export type SourceTicket = TicketBadge & { lane: string | null }
46
47
 
47
48
  /** The badges the view draws, the tickets of the marked sources, and why there may be none. */
48
- export type WatchReading = { badges: WatchBadges; tickets: SourceTicket[]; health: WatchHealth }
49
+ export type WatchReading = {
50
+ badges: WatchBadges
51
+ tickets: SourceTicket[]
52
+ health: WatchHealth
53
+ /** The status of the sources the herd lists; absent when none was asked for. */
54
+ sources?: SourceStatus[]
55
+ }
49
56
 
50
57
  /** Three missed GitHub reads of the watcher (one a minute): an older state is not shown (D96). */
51
58
  export const STALE_AFTER_MS = 180_000
@@ -58,7 +65,8 @@ const MINUTE_MS = 60_000
58
65
  const shownFailedTestsOf = (state: WatchState, number: string, headCommit: string): string[] =>
59
66
  sanitisedFailedTests(failedTestsOf(state.failures, Number(number), headCommit))
60
67
 
61
- const isFresh = (savedAt: number | null, now: number): boolean => savedAt !== null && now - savedAt <= STALE_AFTER_MS
68
+ export const isFresh = (savedAt: number | null, now: number): boolean =>
69
+ savedAt !== null && now - savedAt <= STALE_AFTER_MS
62
70
 
63
71
  /**
64
72
  * The health of a watcher from whether it runs and when it last saved its state. A watcher that runs but has not
@@ -8,8 +8,9 @@ const CODE_FENCE = /^\s*(```|~~~)/
8
8
  const LIST_ITEM = /^(?:\d+[.)]|[-*])\s/
9
9
  const CONTINUATION_LINE = /^\s/
10
10
  const LIST_MARKER = /^(?:\d+[.)]|[-*])\s*/
11
- /** What a section writes when it has nothing to ask: "Keine.", "None.", "Keine offenen Fragen.", a dash. */
12
- const NO_QUESTIONS = /^(?:(?:keine|none|no|nein)(?:\s+(?:offenen\s+)?(?:fragen?|questions?))?\.?|n\/a|[—–])$/i
11
+ /** What a section writes when it has nothing to ask: "Keine.", "None.", "Keine offenen Fragen.", "Keine offenen.", a dash. */
12
+ const NO_QUESTIONS =
13
+ /^(?:(?:keine|none|no|nein)(?:\s+(?:(?:offen(?:en?)?|open)(?:\s+ones?)?|(?:offenen\s+)?(?:fragen?|questions?)))?\.?|n\/a|[—–])$/i
13
14
 
14
15
  /** What the questions part of a message may take: whole up to these limits, else cut question by question. */
15
16
  const QUESTION_LINES = 20
@@ -30,7 +31,7 @@ const headings = (lines: readonly string[]): { index: number; level: number; isQ
30
31
  /** True when every line of the body, ignoring blank lines, a list marker and bold, says there is nothing to ask. */
31
32
  const asksNothing = (body: readonly string[]): boolean =>
32
33
  body
33
- .map((line) => line.trim().replace(LIST_MARKER, '').replaceAll('**', '').trim())
34
+ .map((line) => line.replaceAll('**', '').trim().replace(LIST_MARKER, '').trim())
34
35
  .filter((text) => text !== '')
35
36
  .every((text) => NO_QUESTIONS.test(text))
36
37
 
@@ -0,0 +1,55 @@
1
+ import {
2
+ lineText,
3
+ reportsItem,
4
+ type SeenEntry,
5
+ type SourceEntry,
6
+ type SourceItem,
7
+ type SourceRecord,
8
+ type SourceRun,
9
+ type SourceSettings,
10
+ seenMapOf,
11
+ storedValue,
12
+ } from './sources'
13
+
14
+ /** The most entries a source keeps for the herd. */
15
+ export const ENTRY_LIMIT = 20
16
+
17
+ const shownAt = ({ changedAt, seenAt }: SourceEntry): number => changedAt ?? seenAt
18
+
19
+ const entryOf = (
20
+ item: SourceItem,
21
+ known: SourceEntry | undefined,
22
+ seen: ReadonlyMap<string, SeenEntry> | null,
23
+ settings: SourceSettings,
24
+ now: number,
25
+ ): SourceEntry => ({
26
+ id: item.id,
27
+ title: storedValue(lineText(item.title)),
28
+ url: item.url,
29
+ count: item.count,
30
+ seenAt: known?.seenAt ?? now,
31
+ changedAt: seen && reportsItem(seen, item, settings) ? now : (known?.changedAt ?? null),
32
+ })
33
+
34
+ /**
35
+ * The entries a source that is no ticket keeps after `run`: those of a successful run, at most `ENTRY_LIMIT`, newest
36
+ * change first, each with the time the watcher first saw it and the time it last reported a change of it (none for the
37
+ * baseline run). A failed run keeps what the source had; a ticket source keeps none (its tickets are the list).
38
+ */
39
+ export const entriesAfter = (
40
+ previous: SourceRecord | undefined,
41
+ run: SourceRun | undefined,
42
+ settings: SourceSettings | undefined,
43
+ now: number,
44
+ ): SourceEntry[] | undefined => {
45
+ if (!settings || settings.ticket) return undefined
46
+ if (!run?.result.ok) return previous?.entries
47
+ const seen = seenMapOf(previous)
48
+ const known = new Map((previous?.entries ?? []).map((entry) => [entry.id, entry]))
49
+ return run.result.items
50
+ .map((item) => entryOf(item, known.get(item.id), seen, settings, now))
51
+ .map((entry, index) => ({ entry, index }))
52
+ .sort((a, b) => shownAt(b.entry) - shownAt(a.entry) || a.index - b.index)
53
+ .map(({ entry }) => entry)
54
+ .slice(0, ENTRY_LIMIT)
55
+ }
@@ -7,6 +7,7 @@ export const SOURCE_FIELD = {
7
7
  body: 'body',
8
8
  author: 'author',
9
9
  status: 'status',
10
+ count: 'count',
10
11
  } as const
11
12
 
12
13
  /** The fields a reader sees of an entry: a change of one of them is a change of the entry (`contentFingerprint`). */
@@ -0,0 +1,60 @@
1
+ import { isFresh, NEW_FOR_MS } from './plan-badges'
2
+ import { previewUrlOf } from './previews'
3
+ import { lineText, type SourceEntry, type SourceRecord } from './sources'
4
+ import type { WatchState } from './watch-events'
5
+
6
+ /** The newest entry a source reported, as the herd draws it; `isNew` while it is younger than `NEW_FOR_MS`. */
7
+ export type LatestStatus = { title: string; url: string | null; isNew: boolean }
8
+
9
+ /** An entry as the herd lists it under its source: `at` is when it last changed, else when the watcher first saw it; `isNew` only for a reported change younger than `NEW_FOR_MS`. */
10
+ export type EntryStatus = {
11
+ id: string
12
+ title: string
13
+ url: string | null
14
+ count: string | null
15
+ at: number
16
+ isNew: boolean
17
+ }
18
+
19
+ /** What the herd shows of a source: when it ran last, since when it fails, its newest report and its entries, newest change first. */
20
+ export type SourceStatus = {
21
+ name: string
22
+ ranAt: number | null
23
+ failingSince: number | null
24
+ latest: LatestStatus | null
25
+ entries: EntryStatus[]
26
+ }
27
+
28
+ const entryStatusOf = (entry: SourceEntry, now: number): EntryStatus => ({
29
+ id: entry.id,
30
+ title: lineText(entry.title),
31
+ url: entry.url === null ? null : previewUrlOf(entry.url, undefined),
32
+ count: entry.count,
33
+ at: entry.changedAt ?? entry.seenAt,
34
+ isNew: entry.changedAt !== null && now - entry.changedAt < NEW_FOR_MS,
35
+ })
36
+
37
+ const latestOf = (latest: SourceRecord['latest'], now: number): LatestStatus | null =>
38
+ latest
39
+ ? {
40
+ title: lineText(latest.title),
41
+ url: latest.url === null ? null : previewUrlOf(latest.url, undefined),
42
+ isNew: now - latest.at < NEW_FOR_MS,
43
+ }
44
+ : null
45
+
46
+ const statusOf = (name: string, record: SourceRecord | undefined, now: number): SourceStatus => ({
47
+ name,
48
+ ranAt: record?.ranAt ?? null,
49
+ failingSince: record?.failingSince ?? null,
50
+ latest: latestOf(record?.latest, now),
51
+ entries: (record?.entries ?? []).map((entry) => entryStatusOf(entry, now)),
52
+ })
53
+
54
+ /**
55
+ * The status of each source in `names`, in their order, from the watcher's saved state alone. A name the watcher has
56
+ * not run yet shows without a run; a title is drawn as one line and the URL is checked again, as for the tickets, and a
57
+ * state older than `STALE_AFTER_MS` shows none.
58
+ */
59
+ export const sourceStatusesOf = (state: WatchState, names: readonly string[], now: number): SourceStatus[] =>
60
+ isFresh(state.savedAt, now) ? names.map((name) => statusOf(name, state.sources[name], now)) : []
@@ -1,4 +1,7 @@
1
+ import { entriesAfter } from './source-entries'
1
2
  import {
3
+ type LatestReport,
4
+ lineText,
2
5
  recordSources,
3
6
  reportsItem,
4
7
  SEEN_LIMIT,
@@ -9,6 +12,7 @@ import {
9
12
  type SourceSettings,
10
13
  type SourceState,
11
14
  seenMapOf,
15
+ storedValue,
12
16
  type TicketRecord,
13
17
  } from './sources'
14
18
 
@@ -39,6 +43,19 @@ const ticketsAfter = (
39
43
  )
40
44
  }
41
45
 
46
+ /** The newest entry this run reported (the last of them), else what was recorded; failures and baselines record nothing. */
47
+ const latestAfter = (
48
+ previous: SourceRecord | undefined,
49
+ run: SourceRun | undefined,
50
+ settings: SourceSettings | undefined,
51
+ now: number,
52
+ ): LatestReport | undefined => {
53
+ const seen = seenMapOf(previous)
54
+ if (!seen || !settings || !run?.result.ok) return previous?.latest
55
+ const reported = run.result.items.filter((item) => reportsItem(seen, item, settings)).at(-1)
56
+ return reported ? { title: storedValue(lineText(reported.title)), url: reported.url, at: now } : previous?.latest
57
+ }
58
+
42
59
  const withoutTickets = ({ tickets: _tickets, ...record }: SourceRecord): SourceRecord => record
43
60
 
44
61
  /** The tickets a source keeps: those of a successful run, else the ones it had; none for a source not marked. */
@@ -71,7 +88,10 @@ const recordTickets = (
71
88
  const source = settings.find((candidate) => candidate.name === name)
72
89
  const run = runs.find((candidate) => candidate.name === name)
73
90
  const tickets = ticketsOf(previous[name], run, source, now)
74
- return [name, tickets ? { ...withoutTickets(record), tickets } : withoutTickets(record)]
91
+ const latest = latestAfter(previous[name], run, source, now)
92
+ const entries = entriesAfter(previous[name], run, source, now)
93
+ const base = { ...withoutTickets(record), ...(latest ? { latest } : {}), ...(entries ? { entries } : {}) }
94
+ return [name, tickets ? { ...base, tickets } : base]
75
95
  }),
76
96
  )
77
97
 
@@ -67,6 +67,8 @@ export type SourceItem = {
67
67
  author: string | null
68
68
  /** `map.status` as the state keeps it (`storedValue`), null when not mapped or empty. */
69
69
  status: string | null
70
+ /** `map.count` as one line (how often an error happened); null when not mapped or empty. */
71
+ count: string | null
70
72
  fingerprint: string
71
73
  /** The `reportOn` fields as the state keeps them (`storedValue`); empty without `reportOn`. */
72
74
  values: Record<string, string>
@@ -86,6 +88,22 @@ export type SeenEntry = [string, string] | [string, string, Record<string, strin
86
88
  */
87
89
  export type TicketRecord = { url: string | null; status: string | null; changedAt: number | null }
88
90
 
91
+ /** The newest entry a source reported: its title as one line, its checked URL or null, and the epoch milliseconds of the run that reported it. */
92
+ export type LatestReport = { title: string; url: string | null; at: number }
93
+
94
+ /**
95
+ * An entry of a source as the herd lists it: its title as one line, its checked URL, its count, when the watcher first
96
+ * saw it (`seenAt`) and when it last reported a change of it (`changedAt`, null while only the baseline knows it).
97
+ */
98
+ export type SourceEntry = {
99
+ id: string
100
+ title: string
101
+ url: string | null
102
+ count: string | null
103
+ seenAt: number
104
+ changedAt: number | null
105
+ }
106
+
89
107
  /**
90
108
  * What the watcher keeps per source: `seen` holds a `SeenEntry` per id, the entries seen most recently last, or null
91
109
  * before the first successful run (which is a baseline); `failingSince` is the epoch milliseconds of the first failed
@@ -98,6 +116,10 @@ export type SourceRecord = {
98
116
  ranAt?: number
99
117
  /** Of a ticket source only: the entries of its last successful run by id. */
100
118
  tickets?: Record<string, TicketRecord>
119
+ /** The newest entry the source reported, kept for the herd's row of the source; absent until one was reported. */
120
+ latest?: LatestReport
121
+ /** Of a source that is no ticket: the entries of its last successful run for the herd's expanded row, newest change first. */
122
+ entries?: SourceEntry[]
101
123
  }
102
124
 
103
125
  export type SourceState = Record<string, SourceRecord>
@@ -145,7 +167,7 @@ const scalarText = (value: unknown): string | null => {
145
167
  export const textAt = (entry: unknown, path: SourcePath | undefined): string | null =>
146
168
  path === undefined ? null : scalarText(selectPath(entry, path))
147
169
 
148
- type MappedFields = Omit<SourceItem, 'fingerprint' | 'values' | 'status'>
170
+ type MappedFields = Omit<SourceItem, 'fingerprint' | 'values' | 'status' | 'count'>
149
171
 
150
172
  const hashOf = (text: string, length: number): string =>
151
173
  createHash('sha256').update(text).digest('hex').slice(0, length)
@@ -191,6 +213,9 @@ const valuesOf = (
191
213
  const statusOf = (entry: unknown, map: SourceMap): string | null =>
192
214
  storedValue(lineText(textAt(entry, map.status) ?? '')) || null
193
215
 
216
+ const countOf = (entry: unknown, map: SourceMap): string | null =>
217
+ storedValue(lineText(textAt(entry, map.count) ?? '')) || null
218
+
194
219
  /** One entry after `map`, or null when its id, or a mapped updatedAt, is missing or no text; unmapped it is `''`. */
195
220
  const sourceItemOf = (entry: unknown, settings: Pick<SourceSettings, 'map' | 'reportOn'>): SourceItem | null => {
196
221
  const { map } = settings
@@ -209,6 +234,7 @@ const sourceItemOf = (entry: unknown, settings: Pick<SourceSettings, 'map' | 're
209
234
  return {
210
235
  ...fields,
211
236
  status: statusOf(entry, map),
237
+ count: countOf(entry, map),
212
238
  fingerprint: contentFingerprint(fields, map),
213
239
  values: valuesOf(entry, fields, settings),
214
240
  }
@@ -383,8 +409,22 @@ const isTicketRecord = (value: unknown): boolean =>
383
409
  isTextOrNull(value.status) &&
384
410
  (value.changedAt === null || typeof value.changedAt === 'number')
385
411
 
412
+ const isLatestReport = (value: unknown): boolean =>
413
+ isRecord(value) && typeof value.title === 'string' && isTextOrNull(value.url) && typeof value.at === 'number'
414
+
415
+ const isSourceEntry = (value: unknown): boolean =>
416
+ isRecord(value) &&
417
+ typeof value.id === 'string' &&
418
+ typeof value.title === 'string' &&
419
+ isTextOrNull(value.url) &&
420
+ isTextOrNull(value.count) &&
421
+ typeof value.seenAt === 'number' &&
422
+ (value.changedAt === null || typeof value.changedAt === 'number')
423
+
386
424
  const isSourceRecord = (value: unknown): boolean =>
387
425
  isRecord(value) &&
426
+ (value.entries === undefined || (Array.isArray(value.entries) && value.entries.every(isSourceEntry))) &&
427
+ (value.latest === undefined || isLatestReport(value.latest)) &&
388
428
  (value.seen === null || (Array.isArray(value.seen) && value.seen.every(isSeenEntry))) &&
389
429
  (value.failingSince === null || typeof value.failingSince === 'number') &&
390
430
  (value.ranAt === undefined || typeof value.ranAt === 'number') &&