jackherd 1.6.0 → 1.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +31 -0
- package/INSTALL.md +176 -0
- package/README.md +260 -315
- package/assets/branding/README.md +7 -3
- package/assets/branding/jackherd-logo-dark-1024.png +0 -0
- package/assets/branding/jackherd-logo-dark-512.png +0 -0
- package/assets/branding/jackherd-logo-dark.svg +1 -1
- package/assets/icons/bark-dark.svg +1 -0
- package/assets/icons/bark.svg +1 -0
- package/assets/icons/bowl-dark.svg +1 -0
- package/assets/icons/bowl.svg +1 -0
- package/assets/icons/decide-dark.svg +1 -0
- package/assets/icons/decide.svg +1 -0
- package/assets/icons/ear-dark.svg +1 -0
- package/assets/icons/ear.svg +1 -0
- package/assets/icons/lanes-dark.svg +1 -0
- package/assets/icons/lanes.svg +1 -0
- package/assets/icons/plan-dark.svg +1 -0
- package/assets/icons/plan.svg +1 -0
- package/assets/icons/space.svg +1 -0
- package/dist/{chunk-QJSXHHQ3.js → chunk-OBUWTQ6Z.js} +25 -0
- package/dist/config.d.ts +14 -3
- package/dist/config.js +111 -10
- package/dist/plan-format.d.ts +180 -77
- package/dist/plan-format.js +292 -93
- package/docs/lanes-dark.svg +50 -0
- package/docs/lanes.svg +50 -1
- package/docs/skills-dark.svg +65 -0
- package/docs/skills.svg +65 -1
- package/docs/workflow-dark.svg +138 -0
- package/docs/workflow.svg +135 -169
- package/herdr-plugin/herdr-plugin.toml +6 -6
- package/herdr-plugin/open-plans.ts +1 -1
- package/herdr-plugin/restore-plans.ts +7 -7
- package/install.sh +1 -1
- package/kit/hooks/hooks/approval-gate/decision.ts +61 -0
- package/kit/hooks/hooks/approval-gate/herdr-panes.ts +24 -0
- package/kit/hooks/hooks/approval-gate/index.ts +69 -0
- package/kit/hooks/hooks/approval-gate/lane.ts +14 -0
- package/kit/hooks/hooks/approval-gate/request.ts +111 -0
- package/kit/hooks/hooks/approval-gate/run.ts +94 -0
- package/kit/hooks/hooks/approval-gate/settings.ts +26 -0
- package/kit/hooks/hooks/approval-gate/store.ts +70 -0
- package/kit/hooks/hooks/approval-gate/transcripts.ts +40 -0
- package/kit/hooks/hooks/merge-guard/approval.ts +11 -74
- package/kit/hooks/hooks/merge-guard/run.ts +2 -1
- package/kit/hooks/hooks/model-gate/agent-definition.ts +13 -0
- package/kit/hooks/hooks/model-gate/index.ts +58 -0
- package/kit/hooks/hooks/model-gate/run.ts +63 -0
- package/kit/hooks/hooks/model-log/index.ts +60 -0
- package/kit/hooks/hooks/model-log/run.ts +57 -0
- package/kit/hooks/hooks/model-log/store.ts +40 -0
- package/kit/hooks/hooks/model-log/transcript.ts +37 -0
- package/kit/hooks/hooks/shared/checkout.ts +22 -0
- package/kit/hooks/hooks/shared/command.ts +6 -0
- package/kit/hooks/hooks/shared/config.ts +21 -0
- package/kit/hooks/hooks/shared/dialog-answer.ts +5 -0
- package/kit/hooks/hooks/shared/dialog-answers.ts +74 -0
- package/kit/hooks/hooks/shared/files.ts +19 -0
- package/kit/hooks/hooks/shared/fresh.ts +4 -0
- package/kit/hooks/hooks/shared/json.ts +4 -0
- package/kit/hooks/hooks/shared/model-family.ts +48 -0
- package/kit/hooks/hooks/shared/plan-model-table.ts +41 -0
- package/kit/hooks/hooks/shared/private-directory.ts +29 -0
- package/kit/hooks/hooks/shared/repository-directory.ts +14 -0
- package/kit/hooks/hooks/shared/subagent-phase.ts +99 -0
- package/kit/hooks/hooks.json +25 -0
- package/kit/workflow/skills/follow/SKILL.md +4 -3
- package/kit/workflow/skills/implement/SKILL.md +6 -6
- package/kit/workflow/skills/orchestrate/SKILL.md +113 -30
- package/kit/workflow/skills/plan/SKILL.md +5 -5
- package/kit/workflow/skills/start/SKILL.md +2 -2
- package/package.json +4 -1
- package/schemas/jackherd.schema.json +43 -5
- package/src/check/command.ts +31 -4
- package/src/check/deferral-arrows.ts +1 -1
- package/src/check/parallel-report.ts +23 -0
- package/src/check/plan-structure-check.ts +120 -53
- package/src/cli.ts +8 -2
- package/src/config/nested-quantifier.ts +73 -0
- package/src/config/repository-directory.ts +11 -0
- package/src/config/schema.ts +63 -8
- package/src/plan-format/board-prose.ts +36 -0
- package/src/plan-format/dependencies.ts +227 -0
- package/src/plan-format/documentation-gate.ts +16 -0
- package/src/plan-format/index.ts +23 -1
- package/src/plan-format/lanes.ts +35 -1
- package/src/plan-format/model-column.ts +52 -0
- package/src/plan-format/phases.ts +16 -1
- package/src/plan-format/plan-structure.ts +10 -28
- package/src/plan-format/progress.ts +7 -29
- package/src/plans/command.ts +3 -3
- package/src/plans/current-phase.ts +71 -0
- package/src/plans/ensure-panes.ts +2 -2
- package/src/plans/herd-command.ts +4 -0
- package/src/plans/lane-discovery.ts +14 -2
- package/src/plans/lane-tickets.ts +69 -0
- package/src/plans/layout.ts +182 -88
- package/src/plans/live.ts +5 -1
- package/src/plans/phase-models-read.ts +37 -0
- package/src/plans/phase-models.ts +70 -0
- package/src/plans/plan-panes.ts +8 -7
- package/src/plans/pull-requests.ts +78 -4
- package/src/plans/selection.ts +1 -40
- package/src/plans/settings.ts +9 -3
- package/src/plans/shown-with-more.ts +5 -0
- package/src/plans/snapshot.ts +82 -8
- package/src/plans/sprint-html.ts +2 -2
- package/src/plans/supervisor.ts +5 -4
- package/src/plans/symbols.ts +9 -6
- package/src/plans/texts.ts +30 -13
- package/src/plans/view.ts +1 -0
- package/src/sync/herdr-plugin.ts +1 -1
- package/src/sync/next-steps.ts +1 -1
- package/src/sync/render.ts +2 -1
- package/src/sync/setup-questions.ts +2 -2
- package/src/terminal/text.ts +4 -0
- package/src/terminal/types.ts +1 -1
- package/src/vocabulary/index.ts +39 -0
- package/src/watch/approvals.ts +96 -0
- package/src/watch/command.ts +154 -50
- package/src/watch/failed-tests.ts +52 -0
- package/src/watch/failure-read.ts +56 -0
- package/src/watch/failures.ts +107 -0
- package/src/watch/feedback-item.ts +21 -0
- package/src/watch/feedback-read.ts +2 -1
- package/src/watch/feedback.ts +37 -28
- package/src/watch/gh-process.ts +53 -0
- package/src/watch/gh.ts +10 -20
- package/src/watch/heartbeat-progress.ts +1 -1
- package/src/watch/lane-model.ts +54 -0
- package/src/watch/lane-parallel.ts +46 -0
- package/src/watch/lane-progress.ts +28 -4
- package/src/watch/lane-report.ts +62 -9
- package/src/watch/lane-sources.ts +1 -1
- package/src/watch/no-finding.ts +47 -0
- package/src/watch/plan-badges-read.ts +19 -7
- package/src/watch/plan-badges.ts +59 -7
- package/src/watch/plan-views.ts +5 -5
- package/src/watch/previews.ts +3 -9
- package/src/watch/pull-requests.ts +12 -2
- package/src/watch/recorded-reads.ts +25 -0
- package/src/watch/source-fields.ts +4 -0
- package/src/watch/source-tickets.ts +99 -0
- package/src/watch/sources.ts +62 -15
- package/src/watch/texts.ts +21 -1
- package/src/watch/watch-defaults.ts +8 -0
- package/src/watch/watch-events.ts +64 -4
- package/src/watch/watcher-lock.ts +1 -1
- package/src/watch/workflow-runs.ts +5 -7
- package/docs/lanes.mmd +0 -9
- package/docs/skills.mmd +0 -10
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/** The three forms feedback takes on a pull request: a review, an inline review comment, a pull request comment. */
|
|
2
|
+
export const FEEDBACK_FORM = { review: 'review', inline: 'inline', comment: 'comment' } as const
|
|
3
|
+
|
|
4
|
+
export type FeedbackForm = (typeof FEEDBACK_FORM)[keyof typeof FEEDBACK_FORM]
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* One piece of feedback as the watcher reads it; `state` is a review's (`APPROVED`, …), null for a comment. A review's
|
|
8
|
+
* `updatedAt` is when it was submitted: GitHub reports no later edit of a review, so an edited review is not new.
|
|
9
|
+
*/
|
|
10
|
+
export type FeedbackItem = {
|
|
11
|
+
form: FeedbackForm
|
|
12
|
+
id: number
|
|
13
|
+
pullRequest: number
|
|
14
|
+
author: string
|
|
15
|
+
createdAt: string
|
|
16
|
+
updatedAt: string
|
|
17
|
+
state: string | null
|
|
18
|
+
path: string | null
|
|
19
|
+
line: number | null
|
|
20
|
+
body: string
|
|
21
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { errorText } from '../plans/error-text'
|
|
2
|
-
import {
|
|
2
|
+
import { type FeedbackTarget, parseFeedbackLines } from './feedback'
|
|
3
|
+
import { FEEDBACK_FORM, type FeedbackItem } from './feedback-item'
|
|
3
4
|
import { type GhAnswer, runGh } from './gh'
|
|
4
5
|
|
|
5
6
|
export type FeedbackItemsRead = { ok: true; items: FeedbackItem[] } | { ok: false; error: string }
|
package/src/watch/feedback.ts
CHANGED
|
@@ -1,30 +1,10 @@
|
|
|
1
|
+
import { FEEDBACK_FORM, type FeedbackForm, type FeedbackItem } from './feedback-item'
|
|
1
2
|
import { isRecord } from './json-guards'
|
|
3
|
+
import { isNoFindingComment, ROUND_WINDOW_MS } from './no-finding'
|
|
2
4
|
import { PULL_REQUEST_STATE, type WatchedPullRequest } from './pull-requests'
|
|
3
5
|
|
|
4
|
-
/** The three forms feedback takes on a pull request: a review, an inline review comment, a pull request comment. */
|
|
5
|
-
export const FEEDBACK_FORM = { review: 'review', inline: 'inline', comment: 'comment' } as const
|
|
6
|
-
|
|
7
|
-
export type FeedbackForm = (typeof FEEDBACK_FORM)[keyof typeof FEEDBACK_FORM]
|
|
8
|
-
|
|
9
6
|
export const FEEDBACK_EVENT = 'feedback'
|
|
10
7
|
|
|
11
|
-
/**
|
|
12
|
-
* One piece of feedback as the watcher reads it; `state` is a review's (`APPROVED`, …), null for a comment. A review's
|
|
13
|
-
* `updatedAt` is when it was submitted: GitHub reports no later edit of a review, so an edited review is not new.
|
|
14
|
-
*/
|
|
15
|
-
export type FeedbackItem = {
|
|
16
|
-
form: FeedbackForm
|
|
17
|
-
id: number
|
|
18
|
-
pullRequest: number
|
|
19
|
-
author: string
|
|
20
|
-
createdAt: string
|
|
21
|
-
updatedAt: string
|
|
22
|
-
state: string | null
|
|
23
|
-
path: string | null
|
|
24
|
-
line: number | null
|
|
25
|
-
body: string
|
|
26
|
-
}
|
|
27
|
-
|
|
28
8
|
/** An open pull request of a lane whose feedback is read. */
|
|
29
9
|
export type FeedbackTarget = { number: number; lane: string; headCommit: string }
|
|
30
10
|
|
|
@@ -53,8 +33,13 @@ export type FeedbackRead = {
|
|
|
53
33
|
changesRequested: number[]
|
|
54
34
|
}
|
|
55
35
|
|
|
56
|
-
/**
|
|
57
|
-
|
|
36
|
+
/**
|
|
37
|
+
* What does not count as feedback: `own` is the account the lanes push with, `ignore` the project's list of authors,
|
|
38
|
+
* `noFindingTemplates` the texts of a bot's comment that reports no finding (`isNoFindingComment`); none when left out.
|
|
39
|
+
*/
|
|
40
|
+
export type FeedbackFilter = { own: string; ignore: readonly string[]; noFindingTemplates: readonly string[] }
|
|
41
|
+
|
|
42
|
+
type FeedbackConfig = { ignore: readonly string[]; noFindingTemplates: readonly string[] }
|
|
58
43
|
|
|
59
44
|
/** `place` is `<path>:<line>` for an inline comment, else null; `text` the first line of the body. */
|
|
60
45
|
export type FeedbackEntry = { place: string | null; text: string }
|
|
@@ -69,8 +54,11 @@ export type FeedbackEvent = {
|
|
|
69
54
|
entries: FeedbackEntry[]
|
|
70
55
|
}
|
|
71
56
|
|
|
72
|
-
/**
|
|
73
|
-
|
|
57
|
+
/**
|
|
58
|
+
* How far a read reaches back before the last one: room for a clock that differs from GitHub's, and the round of a
|
|
59
|
+
* bot's comment (`ROUND_WINDOW_MS`), so its inline comments are read with it.
|
|
60
|
+
*/
|
|
61
|
+
const SINCE_MARGIN_MS = ROUND_WINDOW_MS
|
|
74
62
|
/** The latest time a JavaScript date holds; a stored `since` past it cannot come from a read. */
|
|
75
63
|
const LATEST_TIME_MS = 8.64e15
|
|
76
64
|
/** GitHub's review states as the REST API spells them. */
|
|
@@ -135,6 +123,20 @@ export const parseFeedbackLines = (text: string, form: FeedbackForm, pullRequest
|
|
|
135
123
|
return itemOf(value, form, number)
|
|
136
124
|
})
|
|
137
125
|
|
|
126
|
+
/**
|
|
127
|
+
* Whose feedback does not count: the account the watcher runs as, the config's ignored logins (`feedback.ignore`) and
|
|
128
|
+
* the preview authors, with the texts of a bot's comment that reports no finding.
|
|
129
|
+
*/
|
|
130
|
+
export const feedbackFilterOf = (
|
|
131
|
+
own: string,
|
|
132
|
+
feedback: Pick<FeedbackConfig, 'ignore' | 'noFindingTemplates'>,
|
|
133
|
+
previewAuthors: readonly string[],
|
|
134
|
+
): FeedbackFilter => ({
|
|
135
|
+
own,
|
|
136
|
+
ignore: [...feedback.ignore, ...previewAuthors],
|
|
137
|
+
noFindingTemplates: feedback.noFindingTemplates,
|
|
138
|
+
})
|
|
139
|
+
|
|
138
140
|
/** A review without text that only carries inline comments; those are reported on their own. */
|
|
139
141
|
const isReviewShell = (item: FeedbackItem): boolean =>
|
|
140
142
|
item.form === FEEDBACK_FORM.review && item.state === REVIEW_STATE.commented && item.body.trim() === ''
|
|
@@ -156,7 +158,10 @@ const requestsChanges = (items: readonly FeedbackItem[]): boolean => {
|
|
|
156
158
|
return [...verdicts.values()].includes(REVIEW_STATE.changesRequested)
|
|
157
159
|
}
|
|
158
160
|
|
|
159
|
-
/**
|
|
161
|
+
/**
|
|
162
|
+
* The read with only the targets' feedback that counts: no own answers, no ignored authors, no empty review shells,
|
|
163
|
+
* no bot comment that reports no finding.
|
|
164
|
+
*/
|
|
160
165
|
export const feedbackReadOf = (
|
|
161
166
|
readAt: number,
|
|
162
167
|
targets: FeedbackTarget[],
|
|
@@ -165,7 +170,11 @@ export const feedbackReadOf = (
|
|
|
165
170
|
): FeedbackRead => {
|
|
166
171
|
const numbers = new Set(targets.map(({ number }) => number))
|
|
167
172
|
const counted = items.filter(
|
|
168
|
-
(item) =>
|
|
173
|
+
(item) =>
|
|
174
|
+
numbers.has(item.pullRequest) &&
|
|
175
|
+
!isFiltered(item.author, filter) &&
|
|
176
|
+
!isReviewShell(item) &&
|
|
177
|
+
!isNoFindingComment(item, items, filter.noFindingTemplates),
|
|
169
178
|
)
|
|
170
179
|
const changesRequested = targets
|
|
171
180
|
.map(({ number }) => number)
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { errorText } from '../plans/error-text'
|
|
2
|
+
|
|
3
|
+
/** The answer of one `gh` call: its output, or the reason it failed (exit code, stderr, gh not installed). */
|
|
4
|
+
export type GhAnswer = { ok: true; stdout: string } | { ok: false; error: string }
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* `timeoutMs` ends a call that has not finished by then; without it the call waits as long as `gh` does.
|
|
8
|
+
* `tailChars` keeps only that many characters from the end of the output: stdout is read as a stream and never held
|
|
9
|
+
* whole, so memory stays near twice `tailChars` however much `gh` prints. `executable` replaces the `gh` that is run,
|
|
10
|
+
* for a test that stands in for it.
|
|
11
|
+
*/
|
|
12
|
+
export type GhOptions = { timeoutMs?: number; tailChars?: number; executable?: string }
|
|
13
|
+
|
|
14
|
+
const readTail = async (stream: ReadableStream<Uint8Array>, tailChars: number): Promise<string> => {
|
|
15
|
+
const decoder = new TextDecoder()
|
|
16
|
+
let tail = ''
|
|
17
|
+
for await (const chunk of stream) {
|
|
18
|
+
tail += decoder.decode(chunk, { stream: true })
|
|
19
|
+
if (tail.length > 2 * tailChars) tail = tail.slice(-tailChars)
|
|
20
|
+
}
|
|
21
|
+
return (tail + decoder.decode()).slice(-tailChars)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const readOutput = (stream: ReadableStream<Uint8Array>, tailChars: number | undefined): Promise<string> =>
|
|
25
|
+
tailChars === undefined ? new Response(stream).text() : readTail(stream, tailChars)
|
|
26
|
+
|
|
27
|
+
/** Runs `gh` in `cwd`; never rejects, so a caller decides what a failed call means. */
|
|
28
|
+
export const runGh = async (args: string[], cwd: string, options: GhOptions = {}): Promise<GhAnswer> => {
|
|
29
|
+
const command = `gh ${args.slice(0, 2).join(' ')}`
|
|
30
|
+
let timedOut = false
|
|
31
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
32
|
+
try {
|
|
33
|
+
const child = Bun.spawn([options.executable ?? 'gh', ...args], { cwd, stdout: 'pipe', stderr: 'pipe' })
|
|
34
|
+
if (options.timeoutMs !== undefined)
|
|
35
|
+
timer = setTimeout(() => {
|
|
36
|
+
timedOut = true
|
|
37
|
+
child.kill()
|
|
38
|
+
}, options.timeoutMs)
|
|
39
|
+
const [stdout, stderr, exitCode] = await Promise.all([
|
|
40
|
+
readOutput(child.stdout, options.tailChars),
|
|
41
|
+
new Response(child.stderr).text(),
|
|
42
|
+
child.exited,
|
|
43
|
+
])
|
|
44
|
+
if (exitCode === 0) return { ok: true, stdout }
|
|
45
|
+
if (timedOut) return { ok: false, error: `${command}: timed out after ${(options.timeoutMs ?? 0) / 1000} s` }
|
|
46
|
+
const reason = stderr.trim().replace(/\s*\n\s*/g, ' ') || `exit ${exitCode}`
|
|
47
|
+
return { ok: false, error: `${command}: ${reason}` }
|
|
48
|
+
} catch (error) {
|
|
49
|
+
return { ok: false, error: `${command}: ${errorText(error)}` }
|
|
50
|
+
} finally {
|
|
51
|
+
clearTimeout(timer)
|
|
52
|
+
}
|
|
53
|
+
}
|
package/src/watch/gh.ts
CHANGED
|
@@ -1,22 +1,12 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { type GhAnswer, type GhOptions, runGh as runGhProcess } from './gh-process'
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
export type GhAnswer = { ok: true; stdout: string } | { ok: false; error: string }
|
|
3
|
+
export type { GhAnswer, GhOptions }
|
|
5
4
|
|
|
6
|
-
/**
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
child.exited,
|
|
15
|
-
])
|
|
16
|
-
if (exitCode === 0) return { ok: true, stdout }
|
|
17
|
-
const reason = stderr.trim().replace(/\s*\n\s*/g, ' ') || `exit ${exitCode}`
|
|
18
|
-
return { ok: false, error: `${command}: ${reason}` }
|
|
19
|
-
} catch (error) {
|
|
20
|
-
return { ok: false, error: `${command}: ${errorText(error)}` }
|
|
21
|
-
}
|
|
22
|
-
}
|
|
5
|
+
/**
|
|
6
|
+
* The seam the watcher's readers call `gh` through, and the module their tests replace with `mock.module('./gh')`.
|
|
7
|
+
* Bun keeps such a mock for the rest of the process and rewrites the exports of a module already loaded, a re-export
|
|
8
|
+
* included, so `runGh` here is a binding of its own and the real runner in `gh-process.ts` stays reachable for its
|
|
9
|
+
* test whatever file ran before.
|
|
10
|
+
*/
|
|
11
|
+
export const runGh = (args: string[], cwd: string, options?: GhOptions): Promise<GhAnswer> =>
|
|
12
|
+
runGhProcess(args, cwd, options)
|
|
@@ -2,7 +2,7 @@ import { currentPhase, type LaneProgress, PHASE_ID_PREFIX, type PhaseProgress }
|
|
|
2
2
|
import { AGENT_STATUS } from '../terminal/types'
|
|
3
3
|
import type { LaneSnapshot } from './lane-watch'
|
|
4
4
|
|
|
5
|
-
/** The current phase of a lane and how many of its checkboxes are ticked, as the
|
|
5
|
+
/** The current phase of a lane and how many of its checkboxes are ticked, as the herd counts them. */
|
|
6
6
|
export type PhaseCount = Pick<PhaseProgress, 'id' | 'done' | 'total'>
|
|
7
7
|
|
|
8
8
|
/** What a heartbeat adds to a working lane, by lane name: `P12 7/12`, without the middle dot of `shortTitle`, which the heartbeat's separator rewrite would turn into a slash. */
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { differsFromColumn } from '../plan-format'
|
|
2
|
+
import { isRecord } from './json-guards'
|
|
3
|
+
|
|
4
|
+
export const LANE_MODEL_EVENT = 'model'
|
|
5
|
+
|
|
6
|
+
/** Lane → the differing model last reported for its agent; a lane without an entry runs a fitting model or is unread. */
|
|
7
|
+
export type LaneModels = Record<string, string>
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The model of a lane's last answer (null without one), whether its agent is idle (`done` or `idle`: neither working
|
|
11
|
+
* nor waiting for an answer, so it can be switched), and its board row's model column.
|
|
12
|
+
*/
|
|
13
|
+
export type LaneModelReading = { model: string | null; idle: boolean; column: string }
|
|
14
|
+
|
|
15
|
+
export type LaneModelEvent = { kind: typeof LANE_MODEL_EVENT; lane: string; model: string; column: string }
|
|
16
|
+
|
|
17
|
+
const differs = (reading: LaneModelReading): reading is LaneModelReading & { model: string } =>
|
|
18
|
+
reading.model !== null && differsFromColumn(reading.column, reading.model)
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* One event per idle lane whose model fits none of its column and differs from the one last reported: once per
|
|
22
|
+
* deviation, again only after the model changed. A lane that is not idle waits, since a lane is switched only while idle.
|
|
23
|
+
*/
|
|
24
|
+
export const laneModelEvents = (previous: LaneModels, readings: Record<string, LaneModelReading>): LaneModelEvent[] =>
|
|
25
|
+
Object.entries(readings).flatMap(([lane, reading]) => {
|
|
26
|
+
return differs(reading) && reading.idle && reading.model !== previous[lane]
|
|
27
|
+
? [{ kind: LANE_MODEL_EVENT, lane, model: reading.model, column: reading.column }]
|
|
28
|
+
: []
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
const recordedModel = (reported: string | undefined, reading: LaneModelReading | undefined): string | undefined => {
|
|
32
|
+
if (!reading || reading.model === null) return reported
|
|
33
|
+
if (!differs(reading)) return undefined
|
|
34
|
+
return reading.idle ? reading.model : reported
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The models after a read: an idle lane keeps the differing model it reported, a fitting model clears the entry,
|
|
39
|
+
* a lane that is not idle and one without a reading keep what they had; a lane no longer watched is dropped.
|
|
40
|
+
*/
|
|
41
|
+
export const recordLaneModels = (
|
|
42
|
+
previous: LaneModels,
|
|
43
|
+
readings: Record<string, LaneModelReading>,
|
|
44
|
+
lanes: readonly string[],
|
|
45
|
+
): LaneModels =>
|
|
46
|
+
Object.fromEntries(
|
|
47
|
+
lanes.flatMap((lane) => {
|
|
48
|
+
const model = recordedModel(previous[lane], readings[lane])
|
|
49
|
+
return model === undefined ? [] : [[lane, model]]
|
|
50
|
+
}),
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
export const isLaneModels = (value: unknown): value is LaneModels =>
|
|
54
|
+
isRecord(value) && Object.values(value).every((model) => typeof model === 'string')
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { isRecord } from './json-guards'
|
|
2
|
+
|
|
3
|
+
export const LANE_PARALLEL_EVENT = 'parallel'
|
|
4
|
+
|
|
5
|
+
/** Lane → the phases last reported as able to run together; a lane without an entry has none to report. */
|
|
6
|
+
export type LaneParallels = Record<string, string[]>
|
|
7
|
+
|
|
8
|
+
export type LaneParallelEvent = { kind: typeof LANE_PARALLEL_EVENT; lane: string; phases: string[] }
|
|
9
|
+
|
|
10
|
+
const MIN_PHASES = 2
|
|
11
|
+
|
|
12
|
+
/** The first group of `parallelGroups` with at least two phases, which is what the watcher reports; empty without one. */
|
|
13
|
+
export const parallelBatchOf = (groups: readonly string[][]): string[] =>
|
|
14
|
+
groups.find((group) => group.length >= MIN_PHASES) ?? []
|
|
15
|
+
|
|
16
|
+
const sameAs = (left: readonly string[] | undefined, right: readonly string[]): boolean =>
|
|
17
|
+
left !== undefined && left.length === right.length && left.every((id, index) => id === right[index])
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* One event per lane whose plan has two phases or more that can run together and that differ from the ones last
|
|
21
|
+
* reported: once per set, again only after the set changed or after a read without any.
|
|
22
|
+
*/
|
|
23
|
+
export const laneParallelEvents = (previous: LaneParallels, readings: Record<string, string[]>): LaneParallelEvent[] =>
|
|
24
|
+
Object.entries(readings).flatMap(([lane, phases]) =>
|
|
25
|
+
phases.length >= MIN_PHASES && !sameAs(previous[lane], phases) ? [{ kind: LANE_PARALLEL_EVENT, lane, phases }] : [],
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The phases after a read: a lane with two phases or more keeps them, a shorter reading clears the entry, a lane
|
|
30
|
+
* without a reading keeps what it had; a lane no longer watched is dropped.
|
|
31
|
+
*/
|
|
32
|
+
export const recordLaneParallels = (
|
|
33
|
+
previous: LaneParallels,
|
|
34
|
+
readings: Record<string, string[]>,
|
|
35
|
+
lanes: readonly string[],
|
|
36
|
+
): LaneParallels =>
|
|
37
|
+
Object.fromEntries(
|
|
38
|
+
lanes.flatMap((lane) => {
|
|
39
|
+
const phases = readings[lane] ?? previous[lane]
|
|
40
|
+
return phases !== undefined && phases.length >= MIN_PHASES ? [[lane, phases]] : []
|
|
41
|
+
}),
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
export const isLaneParallels = (value: unknown): value is LaneParallels =>
|
|
45
|
+
isRecord(value) &&
|
|
46
|
+
Object.values(value).every((phases) => Array.isArray(phases) && phases.every((id) => typeof id === 'string'))
|
|
@@ -1,13 +1,15 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
2
|
+
import { dirname, join } from 'node:path'
|
|
3
|
+
import { type LaneProgress, parallelGroupsOfPlan, resolvePlanPath, type SprintLane } from '../plan-format'
|
|
3
4
|
import { boardRowsOf } from '../plans/board-rows'
|
|
4
5
|
import { baseReaderOf, gitStatusOf } from '../plans/git'
|
|
5
6
|
import { laneWorkOf, readPlans, textAtBase } from '../plans/lane-plans'
|
|
6
7
|
import { baseRefOf, boardRepoPathOf, type PlansSettings, worktreeOf } from '../plans/settings'
|
|
7
8
|
import { workedLaneProgress } from '../plans/worked-phases'
|
|
9
|
+
import { parallelBatchOf } from './lane-parallel'
|
|
8
10
|
|
|
9
11
|
/**
|
|
10
|
-
* The progress the watcher reports for a lane, from the same reading as the
|
|
12
|
+
* The progress the watcher reports for a lane, from the same reading as the herd: the base ref's plans, the
|
|
11
13
|
* phases the lane works on from its worktree (also without a board row), so both name the same open phase.
|
|
12
14
|
*/
|
|
13
15
|
export const watchedLaneProgress = async (
|
|
@@ -30,7 +32,29 @@ export const watchedLaneProgress = async (
|
|
|
30
32
|
}
|
|
31
33
|
|
|
32
34
|
/**
|
|
33
|
-
* The
|
|
35
|
+
* The phases of a lane's plan (the plan of its newest row, as the lane's worktree has it, else as the base ref has
|
|
36
|
+
* it) that can run together, by the plan's dependency table and ticks; empty without a plan, without a table or
|
|
37
|
+
* without two such phases.
|
|
38
|
+
*/
|
|
39
|
+
export const watchedLaneParallel = (
|
|
40
|
+
root: string,
|
|
41
|
+
settings: PlansSettings,
|
|
42
|
+
boardFile: string,
|
|
43
|
+
lane: SprintLane,
|
|
44
|
+
): string[] => {
|
|
45
|
+
const href = lane.steps.filter((step) => step.planHref !== null).at(-1)?.planHref
|
|
46
|
+
if (!href) return []
|
|
47
|
+
const path = resolvePlanPath(dirname(boardRepoPathOf(root, settings, boardFile)), href)
|
|
48
|
+
const worktreeFile = join(worktreeOf(root, settings, lane.name), path)
|
|
49
|
+
const text = existsSync(worktreeFile)
|
|
50
|
+
? readFileSync(worktreeFile, 'utf8')
|
|
51
|
+
: textAtBase(baseReaderOf(root, baseRefOf(settings)), path)
|
|
52
|
+
const groups = text === null ? null : parallelGroupsOfPlan(text, settings.planLanguage)
|
|
53
|
+
return groups === null ? [] : parallelBatchOf(groups)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The rows the checkout's board file (`checkoutText`) has beyond the board at the base ref, counted as the herd
|
|
34
58
|
* adds them; 0 when the base ref lacks the board, which is then read from the checkout as a whole.
|
|
35
59
|
*/
|
|
36
60
|
export const boardCheckoutRows = (
|
package/src/watch/lane-report.ts
CHANGED
|
@@ -1,10 +1,14 @@
|
|
|
1
1
|
import { CONTEXT_LEVEL, type ContextLevel, contextLabel } from '../context'
|
|
2
|
-
import { currentPhase, isTickedUnmerged, type LaneProgress } from '../plan-format'
|
|
2
|
+
import { currentPhase, isTickedUnmerged, type LaneProgress, phasesText } from '../plan-format'
|
|
3
3
|
import { terminalSafe } from '../plan-format/terminal-text'
|
|
4
|
-
import type
|
|
4
|
+
import { CHECK_NAME_LENGTH, type ChecksState } from '../plans/pull-requests'
|
|
5
|
+
import { shownWithMore } from '../plans/shown-with-more'
|
|
6
|
+
import type { ApprovalEvent } from './approvals'
|
|
5
7
|
import type { CoordinatorRead } from './coordinator-fill'
|
|
6
8
|
import { type FeedbackEvent, firstLine } from './feedback'
|
|
7
9
|
import type { LaneFillEvent } from './lane-fill'
|
|
10
|
+
import type { LaneModelEvent } from './lane-model'
|
|
11
|
+
import type { LaneParallelEvent } from './lane-parallel'
|
|
8
12
|
import { AGENT_STATUS, type LaneSnapshot, NO_AGENT } from './lane-watch'
|
|
9
13
|
import { PULL_REQUEST_STATE, type WatchedPullRequest } from './pull-requests'
|
|
10
14
|
import { SOURCE_FIELD } from './source-fields'
|
|
@@ -44,6 +48,8 @@ const REPORT_INDENT = ' '
|
|
|
44
48
|
const SHORT_SHA_LENGTH = 7
|
|
45
49
|
const NO_REMINDER: BoardReminder = { term: '', checkoutRows: 0 }
|
|
46
50
|
const OTHER_PULL_REQUEST_LIMIT = 5
|
|
51
|
+
const FAILED_TEST_LENGTH = 100
|
|
52
|
+
const FAILED_TESTS_IN_LINE = 5
|
|
47
53
|
|
|
48
54
|
const pullRequestSegment = (pullRequest: LaneReport['pullRequest'], texts: WatchTexts): string => {
|
|
49
55
|
if (!pullRequest) return texts.noPullRequest
|
|
@@ -90,6 +96,18 @@ const deployOwner = (event: DeployEvent): string => {
|
|
|
90
96
|
return event.pullRequest === undefined ? 'manual' : `#${event.pullRequest}`
|
|
91
97
|
}
|
|
92
98
|
|
|
99
|
+
/** `: test, lint (3 tests: a, b, c +1)` after a red pull request's header; empty without a failed check's name. */
|
|
100
|
+
const failedChecksSuffix = (event: PullRequestEvent): string => {
|
|
101
|
+
if (event.kind !== WATCH_EVENT.checksFailed || !event.failedChecks?.length) return ''
|
|
102
|
+
const checks = event.failedChecks.map((name) => terminalSafe(name).slice(0, CHECK_NAME_LENGTH)).join(', ')
|
|
103
|
+
const tests = event.failedTests ?? []
|
|
104
|
+
if (tests.length === 0) return `: ${checks}`
|
|
105
|
+
const { shown, more } = shownWithMore(tests, FAILED_TESTS_IN_LINE)
|
|
106
|
+
const names = shown.map((name) => terminalSafe(name).slice(0, FAILED_TEST_LENGTH)).join(', ')
|
|
107
|
+
const count = `${tests.length} ${tests.length === 1 ? 'test' : 'tests'}`
|
|
108
|
+
return `: ${checks} (${count}: ${names}${more > 0 ? ` +${more}` : ''})`
|
|
109
|
+
}
|
|
110
|
+
|
|
93
111
|
/**
|
|
94
112
|
* `other PRs: #<n> passed · #<m> none`, in the words of `texts`: the open pull requests among `own` (the user's own without a
|
|
95
113
|
* lane), newest first, at most `OTHER_PULL_REQUEST_LIMIT` and `+<k>` for the rest; null when there is none.
|
|
@@ -103,13 +121,14 @@ export const otherPullRequestsLine = (
|
|
|
103
121
|
.filter(({ number, state }) => state === PULL_REQUEST_STATE.open && own.includes(number))
|
|
104
122
|
.sort((left, right) => right.number - left.number)
|
|
105
123
|
if (open.length === 0) return null
|
|
106
|
-
const
|
|
107
|
-
const
|
|
108
|
-
return `${texts.otherPullRequests}: ${[...
|
|
124
|
+
const { shown, more } = shownWithMore(open, OTHER_PULL_REQUEST_LIMIT)
|
|
125
|
+
const named = shown.map(({ number, checks }) => `#${number} ${checks}`)
|
|
126
|
+
return `${texts.otherPullRequests}: ${[...named, ...(more > 0 ? [`+${more}`] : [])].join(' · ')}`
|
|
109
127
|
}
|
|
110
128
|
|
|
111
129
|
/**
|
|
112
|
-
* The header line of one event: `=== merged #<number> (lane)`, `=== checks failed #<number> (lane|branch <name>)`,
|
|
130
|
+
* The header line of one event: `=== merged #<number> (lane)`, `=== checks failed #<number> (lane|branch <name>): <checks> (<k> tests: <names>)`,
|
|
131
|
+
* the checks and tests only when the event carries them,
|
|
113
132
|
* `=== deploy success run 9 (push, lane|#<number>) target stage`, `=== heartbeat`, `=== gh unavailable: <reason>`,
|
|
114
133
|
* `=== coordinator warn 62 %`, `=== lane <name> alert 47 %`, `=== preview #<number> (lane) <url> · <sha7>`.
|
|
115
134
|
* `target` is the deploy environment read from the run, null when none was found.
|
|
@@ -126,8 +145,15 @@ export const watchEventLine = (event: WatchEvent, target: string | null): string
|
|
|
126
145
|
return `=== ${event.kind} #${event.number} (${terminalSafe(event.lane)}) ${terminalSafe(event.url)} · ${terminalSafe(event.headCommit.slice(0, SHORT_SHA_LENGTH))}`
|
|
127
146
|
if (event.kind === WATCH_EVENT.laneFill)
|
|
128
147
|
return `=== ${event.kind} ${terminalSafe(event.lane)} ${event.level} ${contextLabel(event.share)}`
|
|
148
|
+
if (event.kind === WATCH_EVENT.laneModel)
|
|
149
|
+
return `=== ${event.kind} ${terminalSafe(event.lane)} ${terminalSafe(event.model)} · board ${terminalSafe(event.column)}`
|
|
150
|
+
if (event.kind === WATCH_EVENT.laneParallel)
|
|
151
|
+
return `=== ${event.kind} ${terminalSafe(event.lane)} ${phasesText(event.phases)}`
|
|
152
|
+
if (event.kind === WATCH_EVENT.approval)
|
|
153
|
+
return `=== ${event.kind} ${terminalSafe(event.lane)} ${event.id} ${terminalSafe(event.tool)} · ${terminalSafe(event.summary)}`
|
|
129
154
|
if (event.kind === WATCH_EVENT.coordinator) return `=== ${event.kind} ${event.level} ${contextLabel(event.share)}`
|
|
130
|
-
if (isPullRequestEvent(event))
|
|
155
|
+
if (isPullRequestEvent(event))
|
|
156
|
+
return `=== ${event.kind} #${event.number} (${pullRequestOwner(event)})${failedChecksSuffix(event)}`
|
|
131
157
|
if ('number' in event) return `=== ${event.kind} #${event.number} (${terminalSafe(event.lane)})`
|
|
132
158
|
return `=== ${event.kind}`
|
|
133
159
|
}
|
|
@@ -138,11 +164,11 @@ export const watchEventLine = (event: WatchEvent, target: string | null): string
|
|
|
138
164
|
*/
|
|
139
165
|
export const feedbackEventLines = (event: FeedbackEvent): string[] => {
|
|
140
166
|
const header = `=== ${event.kind} #${event.number} (${terminalSafe(event.lane)}) ${terminalSafe(event.author)} ${terminalSafe(event.label)} · ${event.entries.length} new`
|
|
141
|
-
const
|
|
167
|
+
const { shown: shownEntries, more } = shownWithMore(event.entries, FEEDBACK_ENTRY_LIMIT)
|
|
168
|
+
const entries = shownEntries.map(({ place, text }) => {
|
|
142
169
|
const line = terminalSafe(place === null ? text : `${place} ${text}`).slice(0, REPORT_TEXT_LENGTH)
|
|
143
170
|
return `${REPORT_INDENT}${line}`
|
|
144
171
|
})
|
|
145
|
-
const more = event.entries.length - FEEDBACK_ENTRY_LIMIT
|
|
146
172
|
return [header, ...entries, ...(more > 0 ? [`${REPORT_INDENT}… ${more} more`] : [])]
|
|
147
173
|
}
|
|
148
174
|
|
|
@@ -246,6 +272,33 @@ export const laneFillDutyLines = (events: readonly WatchEvent[], texts: WatchTex
|
|
|
246
272
|
.filter(isLaneAlert)
|
|
247
273
|
.map(({ lane }) => `${terminalSafe(lane)} context ${CONTEXT_LEVEL.alert}: ${texts.laneAlertDuty}`)
|
|
248
274
|
|
|
275
|
+
const isLaneModel = (event: WatchEvent): event is LaneModelEvent => event.kind === WATCH_EVENT.laneModel
|
|
276
|
+
|
|
277
|
+
/** What a lane whose model fits none of its model column asks of the coordinator (`orchestrate` § 2): one line per such lane. */
|
|
278
|
+
export const laneModelDutyLines = (events: readonly WatchEvent[], texts: WatchTexts): string[] =>
|
|
279
|
+
events
|
|
280
|
+
.filter(isLaneModel)
|
|
281
|
+
.map(({ lane, model }) => `${terminalSafe(lane)} model ${terminalSafe(model)}: ${texts.laneModelDuty}`)
|
|
282
|
+
|
|
283
|
+
const isLaneParallel = (event: WatchEvent): event is LaneParallelEvent => event.kind === WATCH_EVENT.laneParallel
|
|
284
|
+
|
|
285
|
+
/** What phases of one lane that can run together ask of the coordinator (`orchestrate` § 4): one line per such lane. */
|
|
286
|
+
export const parallelDutyLines = (events: readonly WatchEvent[], texts: WatchTexts): string[] =>
|
|
287
|
+
events
|
|
288
|
+
.filter(isLaneParallel)
|
|
289
|
+
.map(({ lane, phases }) => `${terminalSafe(lane)} parallel: ${texts.parallelDuty(phasesText(phases))}`)
|
|
290
|
+
|
|
291
|
+
const isApproval = (event: WatchEvent): event is ApprovalEvent => event.kind === WATCH_EVENT.approval
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* What a lane's call waiting for approval asks of the coordinator (`orchestrate` § 4): one line per request, naming the
|
|
295
|
+
* mark the question carries and the two labels the hook matches.
|
|
296
|
+
*/
|
|
297
|
+
export const approvalDutyLines = (events: readonly WatchEvent[], texts: WatchTexts): string[] =>
|
|
298
|
+
events
|
|
299
|
+
.filter(isApproval)
|
|
300
|
+
.map(({ lane, id, hash }) => `${terminalSafe(lane)} approval ${id}: ${texts.approvalDuty(id, hash)}`)
|
|
301
|
+
|
|
249
302
|
/**
|
|
250
303
|
* What a blocked lane asks of the coordinator (`orchestrate` § 4), one line per stopped lane that
|
|
251
304
|
* sits in `blocked`: vet the question, answer it in the lane or ask the user, never hand the pane
|
|
@@ -33,7 +33,7 @@ export const branchOf = (worktree: string): string | null =>
|
|
|
33
33
|
|
|
34
34
|
/**
|
|
35
35
|
* Fetches the base branch when the remote moved past `origin/<branch>`, so every reader of that ref
|
|
36
|
-
* (the
|
|
36
|
+
* (the herd watches `FETCH_HEAD`) sees a merge as soon as the watcher does. Asks the remote
|
|
37
37
|
* first, so an unchanged base costs no fetch; a failure leaves the ref as it is.
|
|
38
38
|
*/
|
|
39
39
|
export const fetchBaseWhenMoved = async (root: string, branch: string): Promise<void> => {
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { FEEDBACK_FORM, type FeedbackItem } from './feedback-item'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* How far apart a bot's comment and its inline comments may be and still belong to one round: a review posts its
|
|
5
|
+
* inline comments and its summary within moments, and a read reaches back by the same margin.
|
|
6
|
+
*/
|
|
7
|
+
export const ROUND_WINDOW_MS = 5 * 60_000
|
|
8
|
+
|
|
9
|
+
const BOT_SUFFIX = '[bot]'
|
|
10
|
+
|
|
11
|
+
/** GitHub names the login of every app account with the suffix `[bot]`; logins ignore case. */
|
|
12
|
+
export const isBotLogin = (login: string): boolean => login.toLowerCase().endsWith(BOT_SUFFIX)
|
|
13
|
+
|
|
14
|
+
/** Trimmed, every run of whitespace (line breaks included) as one space. */
|
|
15
|
+
export const normalisedText = (text: string): string => text.trim().replace(/\s+/g, ' ')
|
|
16
|
+
|
|
17
|
+
const isInRound = (comment: FeedbackItem, other: FeedbackItem): boolean => {
|
|
18
|
+
const distance = Math.abs(Date.parse(comment.createdAt) - Date.parse(other.createdAt))
|
|
19
|
+
return distance <= ROUND_WINDOW_MS
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const hasInlineInRound = (comment: FeedbackItem, items: readonly FeedbackItem[]): boolean =>
|
|
23
|
+
items.some(
|
|
24
|
+
(other) =>
|
|
25
|
+
other.form === FEEDBACK_FORM.inline &&
|
|
26
|
+
other.pullRequest === comment.pullRequest &&
|
|
27
|
+
other.author.toLowerCase() === comment.author.toLowerCase() &&
|
|
28
|
+
isInRound(comment, other),
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* True for a pull request comment of a bot that reports no finding: its text equals one of `templates` once both are
|
|
33
|
+
* normalised, and the same author left no inline comment on the pull request in the same round, which is the time
|
|
34
|
+
* window `ROUND_WINDOW_MS` around the comment's creation. A comment whose time cannot be read is never one: the guess
|
|
35
|
+
* would drop feedback. `items` are all the feedback read, the inline comments among them.
|
|
36
|
+
*/
|
|
37
|
+
export const isNoFindingComment = (
|
|
38
|
+
comment: FeedbackItem,
|
|
39
|
+
items: readonly FeedbackItem[],
|
|
40
|
+
templates: readonly string[],
|
|
41
|
+
): boolean => {
|
|
42
|
+
if (comment.form !== FEEDBACK_FORM.comment || !isBotLogin(comment.author)) return false
|
|
43
|
+
if (Number.isNaN(Date.parse(comment.createdAt))) return false
|
|
44
|
+
const text = normalisedText(comment.body)
|
|
45
|
+
const matches = text !== '' && templates.some((template) => normalisedText(template) === text)
|
|
46
|
+
return matches && !hasInlineInRound(comment, items)
|
|
47
|
+
}
|
|
@@ -1,6 +1,13 @@
|
|
|
1
1
|
import { readFileSync } from 'node:fs'
|
|
2
2
|
import { join } from 'node:path'
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
sourceTicketsOf,
|
|
5
|
+
type TicketSource,
|
|
6
|
+
WATCH_HEALTH,
|
|
7
|
+
type WatchReading,
|
|
8
|
+
watchBadgesOf,
|
|
9
|
+
watchHealthOf,
|
|
10
|
+
} from './plan-badges'
|
|
4
11
|
import { parseWatchState, type WatchState } from './watch-events'
|
|
5
12
|
import { type CommandLineOf, isWatcherRunning, WATCHER_FILES, watcherDirectoryOf } from './watcher-lock'
|
|
6
13
|
|
|
@@ -13,11 +20,16 @@ const readState = (file: string): WatchState | null => {
|
|
|
13
20
|
}
|
|
14
21
|
|
|
15
22
|
/**
|
|
16
|
-
* What the watcher of the repository at `root` gives the
|
|
17
|
-
* its badges, and its health, so the view can say why there are none. A missing or
|
|
18
|
-
* view goes on without them.
|
|
23
|
+
* What the watcher of the repository at `root` gives the herd, read from its state file without a GitHub call:
|
|
24
|
+
* its badges, the tickets of `ticketSources`, and its health, so the view can say why there are none. A missing or
|
|
25
|
+
* damaged file shows no badges; the view goes on without them.
|
|
19
26
|
*/
|
|
20
|
-
export const readWatchReading = (
|
|
27
|
+
export const readWatchReading = (
|
|
28
|
+
root: string,
|
|
29
|
+
now: number,
|
|
30
|
+
ticketSources: readonly TicketSource[],
|
|
31
|
+
lookUp?: CommandLineOf,
|
|
32
|
+
): WatchReading => {
|
|
21
33
|
const directory = watcherDirectoryOf(root)
|
|
22
34
|
const state = readState(join(directory, WATCHER_FILES.state))
|
|
23
35
|
const health = watchHealthOf(
|
|
@@ -25,6 +37,6 @@ export const readWatchReading = (root: string, now: number, lookUp?: CommandLine
|
|
|
25
37
|
isWatcherRunning(join(directory, WATCHER_FILES.lock), lookUp),
|
|
26
38
|
now,
|
|
27
39
|
)
|
|
28
|
-
|
|
29
|
-
return { badges, health }
|
|
40
|
+
if (!state || health.kind !== WATCH_HEALTH.live) return { badges: {}, tickets: [], health }
|
|
41
|
+
return { badges: watchBadgesOf(state, now), tickets: sourceTicketsOf(state, ticketSources, now), health }
|
|
30
42
|
}
|