jackherd 1.10.0 → 1.11.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +26 -0
- package/README.md +24 -16
- package/dist/{chunk-5MQBCUQ5.js → chunk-RMMXD4G5.js} +9 -2
- package/dist/config.d.ts +60 -0
- package/dist/config.js +39 -4
- package/dist/plan-format.d.ts +7 -1
- package/dist/plan-format.js +4 -2
- package/docs/follow-dark.svg +48 -0
- package/docs/follow.svg +48 -0
- package/herdr-plugin/herdr-plugin.toml +1 -1
- package/install.sh +1 -1
- package/kit/hooks/hooks/open-question/run.ts +8 -3
- package/kit/rules/rules/kit/pull-requests.md +2 -0
- package/kit/workflow/skills/follow/SKILL.md +3 -3
- package/kit/workflow/skills/orchestrate/SKILL.md +11 -3
- package/package.json +2 -2
- package/recipes/sources.md +4 -0
- package/schemas/jackherd.schema.json +80 -0
- package/src/cli.ts +1 -0
- package/src/config/schema.ts +24 -1
- package/src/config/tones.ts +27 -0
- package/src/lane-prompt/additions.ts +14 -0
- package/src/lane-prompt/command.ts +8 -2
- package/src/lane-prompt/input.ts +44 -3
- package/src/lane-prompt/options.ts +2 -0
- package/src/mermaid/command.ts +36 -0
- package/src/mermaid/problems.ts +48 -0
- package/src/plan-format/dependencies.ts +2 -2
- package/src/plan-format/waiting.ts +15 -2
- package/src/plans/appointment-rows.ts +34 -10
- package/src/plans/ask-coordinator.ts +46 -12
- package/src/plans/ask-target.ts +13 -2
- package/src/plans/board-close.ts +85 -0
- package/src/plans/frame.ts +5 -12
- package/src/plans/git.ts +35 -0
- package/src/plans/groomed.ts +27 -0
- package/src/plans/layout-phase.ts +7 -4
- package/src/plans/layout.ts +258 -48
- package/src/plans/link-chips.ts +22 -4
- package/src/plans/live.ts +73 -5
- package/src/plans/next-rows.ts +44 -0
- package/src/plans/planned.ts +22 -0
- 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 +14 -1
- package/src/plans/settings.ts +10 -0
- package/src/plans/snapshot.ts +79 -21
- 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/texts.ts +28 -3
- package/src/plans/tones.ts +30 -0
- package/src/plans/view.ts +8 -2
- package/src/plans/waiting-click.ts +129 -24
- package/src/terminal/text.ts +8 -0
- package/src/terminal/types.ts +4 -0
- package/src/vocabulary/index.ts +10 -0
- package/src/watch/plan-badges-read.ts +9 -2
- package/src/watch/plan-badges.ts +10 -2
- package/src/watch/report-questions.ts +4 -3
- 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 +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
|
|
24
|
-
|
|
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
|
-
/**
|
|
32
|
-
|
|
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
|
|
89
|
-
const
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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 }
|
|
140
|
-
|
|
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 =
|
|
150
|
-
const
|
|
151
|
-
|
|
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
|
|
190
|
-
|
|
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,
|
|
194
|
-
|
|
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,
|
package/src/terminal/text.ts
CHANGED
|
@@ -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
|
|
package/src/terminal/types.ts
CHANGED
|
@@ -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
|
|
package/src/vocabulary/index.ts
CHANGED
|
@@ -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 {
|
|
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
|
}
|
package/src/watch/plan-badges.ts
CHANGED
|
@@ -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 = {
|
|
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 =>
|
|
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 =
|
|
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.
|
|
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
|
+
}
|
|
@@ -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
|
-
|
|
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
|
|
package/src/watch/sources.ts
CHANGED
|
@@ -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') &&
|