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
package/src/watch/command.ts
CHANGED
|
@@ -17,8 +17,8 @@
|
|
|
17
17
|
* under Claude Code's Monitor tool, which turns every line into a message. Without lane names it watches every
|
|
18
18
|
* worktree whose name a herdr agent carries or the board's lanes table lists, and prints `lane added: <name>` /
|
|
19
19
|
* `lane removed: <name>` as that set changes. With every GitHub read it fetches `origin/<baseBranch>` once the
|
|
20
|
-
* remote moved past it, so the
|
|
21
|
-
* it brings back missing
|
|
20
|
+
* remote moved past it, so the herd, which watches `FETCH_HEAD`, shows a merged lane row at once. Inside herdr
|
|
21
|
+
* it brings back missing herd panes of the board's lanes like `jh herd --ensure`: at its start, for every
|
|
22
22
|
* added lane and for every lane whose agent appeared.
|
|
23
23
|
*
|
|
24
24
|
* `watch source <name> --dry-run` runs one source once instead and prints what it would report (`source-dry-run-command.ts`).
|
|
@@ -35,7 +35,7 @@ import { defaultCoordinatorName } from '../context/coordinator-name'
|
|
|
35
35
|
import { coordinatorSettings } from '../context/options'
|
|
36
36
|
import { contextReport } from '../context/report'
|
|
37
37
|
import { agentLog } from '../context/session-log'
|
|
38
|
-
import { type LaneProgress, parseSprintLanes } from '../plan-format'
|
|
38
|
+
import { type LaneProgress, parseSprintLanes, type SprintLane } from '../plan-format'
|
|
39
39
|
import { terminalSafe } from '../plan-format/terminal-text'
|
|
40
40
|
import { boardFor, foundLanesOf } from '../plans/board-pick'
|
|
41
41
|
import { ensurePlanPanes } from '../plans/ensure-panes'
|
|
@@ -48,6 +48,7 @@ import { defaultUpdateCheck } from '../sync/if-changed'
|
|
|
48
48
|
import { herdrLaneStatusSource } from '../terminal/herdr'
|
|
49
49
|
import { AGENT_STATUS } from '../terminal/types'
|
|
50
50
|
import { startedKit } from '../version/started-kit'
|
|
51
|
+
import { type ApprovalRequestFile, approvalEvents, readApprovalRequests, recordApprovals } from './approvals'
|
|
51
52
|
import {
|
|
52
53
|
type CoordinatorRead,
|
|
53
54
|
contextReadingOf,
|
|
@@ -57,16 +58,18 @@ import {
|
|
|
57
58
|
explainedRead,
|
|
58
59
|
readingOf,
|
|
59
60
|
} from './coordinator-fill'
|
|
61
|
+
import { readFailedTests } from './failure-read'
|
|
62
|
+
import { type FailureReading, failureReadingOf, failureTargets, recordFailures } from './failures'
|
|
60
63
|
import {
|
|
61
|
-
type FeedbackItem,
|
|
62
64
|
type FeedbackRead,
|
|
63
65
|
type FeedbackTarget,
|
|
64
66
|
feedbackEvents,
|
|
67
|
+
feedbackFilterOf,
|
|
65
68
|
feedbackReadOf,
|
|
66
69
|
feedbackSince,
|
|
67
70
|
feedbackTargets,
|
|
68
|
-
recordFeedback,
|
|
69
71
|
} from './feedback'
|
|
72
|
+
import { type FeedbackItem } from './feedback-item'
|
|
70
73
|
import { type FeedbackItemsRead, readCommentItems, readFeedbackItems, readOwnLogin } from './feedback-read'
|
|
71
74
|
import { answered, deployTargetOf, type GithubRead, readGithub, targetText } from './github-read'
|
|
72
75
|
import { currentPhaseCountsOf, type PhaseCount, progressNotes } from './heartbeat-progress'
|
|
@@ -74,8 +77,11 @@ import { agentScreen, listAgents } from './herdr'
|
|
|
74
77
|
import { endedByKitUpdate } from './kit-update'
|
|
75
78
|
import { type LaneReadings, laneFillEvents, recordLaneLevels } from './lane-fill'
|
|
76
79
|
import { laneMessage, rememberedStatusLines, statusBlock } from './lane-message'
|
|
77
|
-
import {
|
|
80
|
+
import { type LaneModelReading, laneModelEvents, recordLaneModels } from './lane-model'
|
|
81
|
+
import { laneParallelEvents, recordLaneParallels } from './lane-parallel'
|
|
82
|
+
import { boardCheckoutRows, watchedLaneParallel, watchedLaneProgress } from './lane-progress'
|
|
78
83
|
import {
|
|
84
|
+
approvalDutyLines,
|
|
79
85
|
armLine,
|
|
80
86
|
blockedDutyLines,
|
|
81
87
|
coordinatorStatusLine,
|
|
@@ -83,9 +89,11 @@ import {
|
|
|
83
89
|
ghUnavailableStatusLine,
|
|
84
90
|
type LaneReport,
|
|
85
91
|
laneFillDutyLines,
|
|
92
|
+
laneModelDutyLines,
|
|
86
93
|
laneReportLine,
|
|
87
94
|
mergeDutyLines,
|
|
88
95
|
otherPullRequestsLine,
|
|
96
|
+
parallelDutyLines,
|
|
89
97
|
sourceEventLines,
|
|
90
98
|
sourceFailingStatusLine,
|
|
91
99
|
watchEventLine,
|
|
@@ -117,27 +125,20 @@ import {
|
|
|
117
125
|
import { readDeploymentCandidates } from './preview-read'
|
|
118
126
|
import {
|
|
119
127
|
commentCandidates,
|
|
120
|
-
feedbackFilterOf,
|
|
121
128
|
feedbackReadCovers,
|
|
122
129
|
type PreviewRead,
|
|
123
130
|
previewEvents,
|
|
124
131
|
previewReadOf,
|
|
125
|
-
recordPreviews,
|
|
126
132
|
unreportedTargets,
|
|
127
133
|
} from './previews'
|
|
128
134
|
import { latestPullRequestOf, type WatchedPullRequest } from './pull-requests'
|
|
135
|
+
import { recordedReadsOf } from './recorded-reads'
|
|
129
136
|
import { withSingleReportNotice } from './single-report'
|
|
130
137
|
import { readOrchestrateSkill, skillLineFor } from './skill-change'
|
|
131
138
|
import { sourceDryRun } from './source-dry-run-command'
|
|
132
139
|
import { runSource, SOURCE_LIMITS } from './source-run'
|
|
133
|
-
import {
|
|
134
|
-
|
|
135
|
-
recordSources,
|
|
136
|
-
type SourceRun,
|
|
137
|
-
type SourceSettings,
|
|
138
|
-
type SourceState,
|
|
139
|
-
sourceEvents,
|
|
140
|
-
} from './sources'
|
|
140
|
+
import { recordWatchedSources } from './source-tickets'
|
|
141
|
+
import { isSourceDue, type SourceRun, type SourceSettings, type SourceState, sourceEvents } from './sources'
|
|
141
142
|
import { streamHeartbeatLine } from './stream-output'
|
|
142
143
|
import { WATCH_TEXTS, type WatchTexts } from './texts'
|
|
143
144
|
import { updateAnnouncer } from './update-available'
|
|
@@ -174,6 +175,8 @@ type Files = { state: string; lock: string }
|
|
|
174
175
|
/** The logins whose feedback does not count, or false when the project turned feedback off. */
|
|
175
176
|
type FeedbackConfig = JackherdConfig['watch']['feedback']
|
|
176
177
|
|
|
178
|
+
type ChecksConfig = JackherdConfig['watch']['checks']
|
|
179
|
+
|
|
177
180
|
/** Where deploy previews are read from; undefined when the project reads none. */
|
|
178
181
|
type PreviewsConfig = JackherdConfig['watch']['previews']
|
|
179
182
|
|
|
@@ -191,10 +194,11 @@ type Watch = {
|
|
|
191
194
|
settings: PlansSettings
|
|
192
195
|
deploy: DeployConfig | undefined
|
|
193
196
|
feedback: FeedbackConfig
|
|
197
|
+
checks: ChecksConfig
|
|
194
198
|
previews: PreviewsConfig
|
|
195
199
|
sources: SourceSettings[]
|
|
196
200
|
texts: WatchTexts
|
|
197
|
-
/** Runs inside herdr with the herdr adapter, so missing
|
|
201
|
+
/** Runs inside herdr with the herdr adapter, so missing herd panes can be brought back. */
|
|
198
202
|
herdr: boolean
|
|
199
203
|
}
|
|
200
204
|
|
|
@@ -234,6 +238,8 @@ type Watching = {
|
|
|
234
238
|
ownLogin: string | null
|
|
235
239
|
/** The last feedback read that answered; its targets and items are recorded with every state. */
|
|
236
240
|
feedbackRead: FeedbackRead | null
|
|
241
|
+
/** The logs of the red pull requests read at the last GitHub poll; recorded with every state like the feedback read. */
|
|
242
|
+
failureReadings: FailureReading[]
|
|
237
243
|
/** The last preview read that answered; recorded with every state like the feedback read. */
|
|
238
244
|
previewRead: PreviewRead | null
|
|
239
245
|
/** The runs of the sources due at the last GitHub poll; recorded with every state like the feedback read. */
|
|
@@ -242,6 +248,12 @@ type Watching = {
|
|
|
242
248
|
sourceRanAt: Record<string, number>
|
|
243
249
|
/** The lanes' fills of the last read; a lane without an entry or with null has no value. */
|
|
244
250
|
laneReadings: LaneReadings
|
|
251
|
+
/** The lanes' models against their board rows at the last read; recorded with every state like the fills. */
|
|
252
|
+
laneModelReadings: Record<string, LaneModelReading>
|
|
253
|
+
/** The phases of each lane's plan that can run together at the last read; recorded with every state like the models. */
|
|
254
|
+
laneParallelReadings: Record<string, string[]>
|
|
255
|
+
/** The approval requests of the last poll; null before the first read, so the stored ids stay. */
|
|
256
|
+
approvalRequests: ApprovalRequestFile[] | null
|
|
245
257
|
planViews: PlanViews
|
|
246
258
|
}
|
|
247
259
|
|
|
@@ -267,24 +279,30 @@ const recordedOf = (watch: Watch, watching: Watching): WatchState => {
|
|
|
267
279
|
recordWatchState(watching.state, observationOf(watching), watching.githubReadAt),
|
|
268
280
|
readingOf(watching.coordinator.read),
|
|
269
281
|
)
|
|
270
|
-
const
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
282
|
+
const withReads = recordedReadsOf(recorded, {
|
|
283
|
+
feedback: watching.feedbackRead,
|
|
284
|
+
previews: watching.previewRead,
|
|
285
|
+
pullRequests: answered(watching.github)?.pullRequests ?? null,
|
|
286
|
+
failures: watching.failureReadings,
|
|
287
|
+
})
|
|
276
288
|
const withLevels = {
|
|
277
|
-
...
|
|
278
|
-
laneLevels: recordLaneLevels(
|
|
279
|
-
|
|
289
|
+
...withReads,
|
|
290
|
+
laneLevels: recordLaneLevels(withReads.laneLevels, watching.laneReadings, watching.lanes),
|
|
291
|
+
laneModels: recordLaneModels(withReads.laneModels, watching.laneModelReadings, watching.lanes),
|
|
292
|
+
laneParallels: recordLaneParallels(withReads.laneParallels, watching.laneParallelReadings, watching.lanes),
|
|
293
|
+
approvals: watching.approvalRequests ? recordApprovals(watching.approvalRequests) : withReads.approvals,
|
|
294
|
+
sources: recordWatchedSources(withReads.sources, watching.sourceRuns, watch.sources, watching.githubReadAt),
|
|
280
295
|
}
|
|
281
296
|
return withReportBaselines(withLevels, watching.lanes, (lane) => reportMtime(reportPathOf(watch, lane)))
|
|
282
297
|
}
|
|
283
298
|
|
|
284
|
-
const sourceNamesOf = (watch: Watch): string[] => watch.sources.map(({ name }) => name)
|
|
285
|
-
|
|
286
299
|
/** The events of one GitHub read against the state it is compared with. */
|
|
287
|
-
const githubEventsOf = (
|
|
300
|
+
const githubEventsOf = (
|
|
301
|
+
state: WatchState,
|
|
302
|
+
github: GithubRead,
|
|
303
|
+
laneBranches: Record<string, string>,
|
|
304
|
+
readings: readonly FailureReading[],
|
|
305
|
+
): WatchEvent[] => {
|
|
288
306
|
if (!github.ok) {
|
|
289
307
|
const outage = ghOutageEvent(state, github.error)
|
|
290
308
|
return outage ? [outage] : []
|
|
@@ -292,8 +310,9 @@ const githubEventsOf = (state: WatchState, github: GithubRead, laneBranches: Rec
|
|
|
292
310
|
const branches = { ...state.laneBranches, ...laneBranches }
|
|
293
311
|
const merges = mergeCommitsOf(github.pullRequests, branches)
|
|
294
312
|
const ownMerges = ownMergeCommitsOf(state, github.pullRequests, branches)
|
|
313
|
+
const failures = recordFailures(state.failures, github.pullRequests, readings)
|
|
295
314
|
return [
|
|
296
|
-
...pullRequestEvents(state, github.pullRequests, branches),
|
|
315
|
+
...pullRequestEvents(state, github.pullRequests, branches, failures),
|
|
297
316
|
...deployEvents(state, github.runs, merges, ownMerges),
|
|
298
317
|
]
|
|
299
318
|
}
|
|
@@ -324,13 +343,16 @@ const coordinatorNameFor = (watch: Watch, agents: readonly HerdrAgent[]): string
|
|
|
324
343
|
coordinatorNameOf(agents, process.env.HERDR_PANE_ID) ??
|
|
325
344
|
defaultCoordinatorName(basename(watch.root))
|
|
326
345
|
|
|
327
|
-
/**
|
|
328
|
-
|
|
346
|
+
/**
|
|
347
|
+
* One read of the fills: the coordinator's, and each watched lane's (null without a value), with `models`, lane → the
|
|
348
|
+
* model of its agent's last answer (null without one), read from the same report.
|
|
349
|
+
*/
|
|
350
|
+
type FillRead = { coordinator: CoordinatorRead; lanes: LaneReadings; models: Record<string, string | null> }
|
|
329
351
|
|
|
330
352
|
/**
|
|
331
353
|
* The coordinator's fill with its own thresholds, as `jh context <name> --coordinator` computes it, or why there
|
|
332
|
-
* is none, and each lane's fill with the lanes' thresholds, as the
|
|
333
|
-
* herdr cannot be asked, the lanes have no reading, so their stored levels stay.
|
|
354
|
+
* is none, and each lane's fill with the lanes' thresholds, as the herd shows it: from one herdr listing. When
|
|
355
|
+
* herdr cannot be asked, the lanes have no reading, so their stored levels stay. Each lane's report also gives its model.
|
|
334
356
|
*/
|
|
335
357
|
const fillReadFor = (watch: Watch, name: string, agents: readonly HerdrAgent[], lanes: readonly string[]): FillRead => {
|
|
336
358
|
const target = coordinatorTargetOf(agents, name, process.env.HERDR_PANE_ID)
|
|
@@ -339,14 +361,14 @@ const fillReadFor = (watch: Watch, name: string, agents: readonly HerdrAgent[],
|
|
|
339
361
|
target.kind === 'unnamedPane' ? { pane: target.pane, name } : undefined,
|
|
340
362
|
)
|
|
341
363
|
const own = contextReport(listed, name, agentLog, coordinatorSettings(watch.settings.context))
|
|
342
|
-
const
|
|
343
|
-
contextReadingOf(contextReport(listed, lane, agentLog, watch.settings.context))
|
|
364
|
+
const reports = lanes.map((lane) => [lane, contextReport(listed, lane, agentLog, watch.settings.context)] as const)
|
|
344
365
|
return {
|
|
345
366
|
coordinator: explainedRead(coordinatorReadOf(own), target, name, watch.texts),
|
|
346
|
-
lanes: Object.fromEntries(
|
|
367
|
+
lanes: Object.fromEntries(reports.map(([lane, report]) => [lane, contextReadingOf(report)])),
|
|
368
|
+
models: Object.fromEntries(reports.map(([lane, report]) => [lane, report?.model ?? null])),
|
|
347
369
|
}
|
|
348
370
|
} catch (error) {
|
|
349
|
-
return { coordinator: { reason: errorText(error) }, lanes: {} }
|
|
371
|
+
return { coordinator: { reason: errorText(error) }, lanes: {}, models: {} }
|
|
350
372
|
}
|
|
351
373
|
}
|
|
352
374
|
|
|
@@ -515,6 +537,9 @@ const reportOf = async (
|
|
|
515
537
|
...blockedDutyLines(stopped, watching.current, watch.texts),
|
|
516
538
|
...mergeDutyLines(events, watching.lanes, watch.texts),
|
|
517
539
|
...laneFillDutyLines(events, watch.texts),
|
|
540
|
+
...laneModelDutyLines(events, watch.texts),
|
|
541
|
+
...parallelDutyLines(events, watch.texts),
|
|
542
|
+
...approvalDutyLines(events, watch.texts),
|
|
518
543
|
]
|
|
519
544
|
const status = await statusLinesOf(watch, watching, next)
|
|
520
545
|
const block = statusBlock(status.laneLines, status.coordinator, next.statusLines, status.others)
|
|
@@ -533,11 +558,33 @@ const recordGithub = (
|
|
|
533
558
|
github: GithubRead,
|
|
534
559
|
laneBranches: Record<string, string>,
|
|
535
560
|
now: number,
|
|
561
|
+
readings: FailureReading[],
|
|
536
562
|
): WatchEvent[] => {
|
|
537
563
|
watching.github = github
|
|
538
564
|
watching.laneBranches = laneBranches
|
|
539
565
|
watching.githubReadAt = now
|
|
540
|
-
|
|
566
|
+
watching.failureReadings = readings
|
|
567
|
+
return githubEventsOf(watching.state, watching.github, watching.laneBranches, readings)
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
/**
|
|
571
|
+
* The logs of the lanes' red pull requests, once per head commit (`failureTargets`): `gh run view --log-failed` of each
|
|
572
|
+
* failed run, matched with `watch.checks.failedTestPattern`, which holds with feedback off too. A read
|
|
573
|
+
* that fails is recorded as not read, so the next GitHub poll asks again, a few times at most.
|
|
574
|
+
*/
|
|
575
|
+
const readFailures = async (
|
|
576
|
+
watch: Watch,
|
|
577
|
+
watching: Watching,
|
|
578
|
+
github: GithubRead,
|
|
579
|
+
branches: Record<string, string>,
|
|
580
|
+
): Promise<FailureReading[]> => {
|
|
581
|
+
const answer = answered(github)
|
|
582
|
+
if (!answer) return []
|
|
583
|
+
const pattern = watch.checks.failedTestPattern
|
|
584
|
+
const targets = failureTargets(watching.state.failures, answer.pullRequests, branches)
|
|
585
|
+
return Promise.all(
|
|
586
|
+
targets.map(async (target) => failureReadingOf(target, await readFailedTests(watch.root, target.runIds, pattern))),
|
|
587
|
+
)
|
|
541
588
|
}
|
|
542
589
|
|
|
543
590
|
const recordCoordinator = (watching: Watching, coordinator: Coordinator): WatchEvent[] => {
|
|
@@ -551,6 +598,49 @@ const recordLaneFills = (watching: Watching, readings: LaneReadings): WatchEvent
|
|
|
551
598
|
return laneFillEvents(watching.state.laneLevels, readings)
|
|
552
599
|
}
|
|
553
600
|
|
|
601
|
+
/** Lane → the model column of its newest board row; none when the board cannot be read, so no lane reports. */
|
|
602
|
+
const laneModelColumns = (watch: Watch): Record<string, string> =>
|
|
603
|
+
Object.fromEntries(
|
|
604
|
+
(readSprintLanes(watch) ?? []).flatMap((lane) => lane.steps.slice(-1).map((step) => [lane.name, step.model])),
|
|
605
|
+
)
|
|
606
|
+
|
|
607
|
+
/** The agent states a lane can be switched in: it ended its turn and waits for no answer. */
|
|
608
|
+
const IDLE_STATUSES: ReadonlySet<string> = new Set([AGENT_STATUS.done, AGENT_STATUS.idle])
|
|
609
|
+
|
|
610
|
+
/** Each lane's model against its column, as far as both are known, with whether its agent is idle. */
|
|
611
|
+
const laneModelReadingsOf = (
|
|
612
|
+
watch: Watch,
|
|
613
|
+
watching: Watching,
|
|
614
|
+
models: Record<string, string | null>,
|
|
615
|
+
): Record<string, LaneModelReading> => {
|
|
616
|
+
const columns = laneModelColumns(watch)
|
|
617
|
+
return Object.fromEntries(
|
|
618
|
+
Object.entries(models).flatMap(([lane, model]) => {
|
|
619
|
+
const column = columns[lane]
|
|
620
|
+
const idle = IDLE_STATUSES.has(watching.current[lane]?.status ?? '')
|
|
621
|
+
return column === undefined ? [] : [[lane, { model, idle, column }]]
|
|
622
|
+
}),
|
|
623
|
+
)
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
const recordLaneModelReads = (watching: Watching, readings: Record<string, LaneModelReading>): WatchEvent[] => {
|
|
627
|
+
watching.laneModelReadings = readings
|
|
628
|
+
return laneModelEvents(watching.state.laneModels, readings)
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
/** Each open lane's phases that can run together, read from the plan of its newest row; none when the board cannot be read. */
|
|
632
|
+
const laneParallelReadingsOf = (watch: Watch, watching: Watching): Record<string, string[]> =>
|
|
633
|
+
Object.fromEntries(
|
|
634
|
+
(readSprintLanes(watch) ?? [])
|
|
635
|
+
.filter((lane) => !lane.closed && watching.lanes.includes(lane.name))
|
|
636
|
+
.map((lane) => [lane.name, watchedLaneParallel(watch.root, watch.settings, watch.sprintFile, lane)]),
|
|
637
|
+
)
|
|
638
|
+
|
|
639
|
+
const recordLaneParallelReads = (watching: Watching, readings: Record<string, string[]>): WatchEvent[] => {
|
|
640
|
+
watching.laneParallelReadings = readings
|
|
641
|
+
return laneParallelEvents(watching.state.laneParallels, readings)
|
|
642
|
+
}
|
|
643
|
+
|
|
554
644
|
/** The account `gh` runs as, read once and kept; null while `gh` cannot tell. */
|
|
555
645
|
const ensureOwnLogin = async (watch: Watch, watching: Watching): Promise<string | null> => {
|
|
556
646
|
watching.ownLogin ??= await readOwnLogin(watch.root)
|
|
@@ -573,7 +663,7 @@ const readFeedback = async (
|
|
|
573
663
|
if (own === null) return null
|
|
574
664
|
const items = await readFeedbackItems(watch.root, targets, feedbackSince(watching.state.feedback, now))
|
|
575
665
|
if (!items.ok) return null
|
|
576
|
-
const filter = feedbackFilterOf(own, watch.feedback
|
|
666
|
+
const filter = feedbackFilterOf(own, watch.feedback, watch.previews?.authors ?? [])
|
|
577
667
|
return { read: feedbackReadOf(now, targets, items.items, filter), items: items.items }
|
|
578
668
|
}
|
|
579
669
|
|
|
@@ -669,30 +759,35 @@ const pollGithubAndCoordinator = async (watch: Watch, watching: Watching, now: n
|
|
|
669
759
|
])
|
|
670
760
|
const pullRequests = answered(github)?.pullRequests ?? null
|
|
671
761
|
const branches = { ...watching.state.laneBranches, ...laneBranches }
|
|
762
|
+
const failureReadings = await readFailures(watch, watching, github, branches)
|
|
672
763
|
const targets = feedbackTargets(pullRequests, branches, watching.state.feedback)
|
|
673
764
|
const feedback = await readFeedback(watch, watching, targets, now)
|
|
674
765
|
const previews = pullRequests ? await readPreviews(watch, watching, targets, feedback?.items ?? null, now) : null
|
|
675
766
|
return [
|
|
676
|
-
...recordGithub(watching, github, laneBranches, now),
|
|
767
|
+
...recordGithub(watching, github, laneBranches, now, failureReadings),
|
|
677
768
|
...recordFeedbackRead(watching, feedback?.read ?? null),
|
|
678
769
|
...recordPreviewRead(watching, previews),
|
|
679
770
|
...recordSourceRuns(watch, watching, sourceRuns),
|
|
680
771
|
...recordCoordinator(watching, { name, read: fills.coordinator }),
|
|
681
772
|
...recordLaneFills(watching, fills.lanes),
|
|
773
|
+
...recordLaneModelReads(watching, laneModelReadingsOf(watch, watching, fills.models)),
|
|
774
|
+
...recordLaneParallelReads(watching, laneParallelReadingsOf(watch, watching)),
|
|
682
775
|
]
|
|
683
776
|
}
|
|
684
777
|
|
|
685
|
-
/** The
|
|
686
|
-
const
|
|
778
|
+
/** The board's lanes, null when the board cannot be read. */
|
|
779
|
+
const readSprintLanes = (watch: Watch): SprintLane[] | null => {
|
|
687
780
|
try {
|
|
688
|
-
return parseSprintLanes(readFileSync(watch.sprintFile, 'utf8'), watch.settings.planLanguage)
|
|
689
|
-
(lane) => lane.name,
|
|
690
|
-
)
|
|
781
|
+
return parseSprintLanes(readFileSync(watch.sprintFile, 'utf8'), watch.settings.planLanguage)
|
|
691
782
|
} catch {
|
|
692
|
-
return
|
|
783
|
+
return null
|
|
693
784
|
}
|
|
694
785
|
}
|
|
695
786
|
|
|
787
|
+
/** The sprint's lane names; the previous ones when the sprint cannot be read, so no lane drops out over it. */
|
|
788
|
+
const sprintLaneNames = (watch: Watch, previous: string[]): string[] =>
|
|
789
|
+
readSprintLanes(watch)?.map((lane) => lane.name) ?? previous
|
|
790
|
+
|
|
696
791
|
const lanesFor = (watch: Watch, agents: readonly HerdrAgent[], sprintLanes: readonly string[]): string[] =>
|
|
697
792
|
watch.options.lanes ??
|
|
698
793
|
discoverLanes({
|
|
@@ -701,7 +796,7 @@ const lanesFor = (watch: Watch, agents: readonly HerdrAgent[], sprintLanes: read
|
|
|
701
796
|
sprintLanes,
|
|
702
797
|
})
|
|
703
798
|
|
|
704
|
-
/** Brings back missing
|
|
799
|
+
/** Brings back missing herd panes beside the coordinator and the board's lanes when `trigger` calls for it; only inside herdr. */
|
|
705
800
|
const keepViews = (watch: Watch, watching: Watching, trigger: EnsureTrigger) => {
|
|
706
801
|
if (!watch.herdr) return
|
|
707
802
|
watching.planViews = keepPlanViews(trigger, watching.planViews, {
|
|
@@ -738,7 +833,7 @@ const readAgents = (watching: Watching): boolean => {
|
|
|
738
833
|
}
|
|
739
834
|
}
|
|
740
835
|
|
|
741
|
-
/** One herdr poll: the agents, the lane set, and once per poll the
|
|
836
|
+
/** One herdr poll: the agents, the lane set, and once per poll the herd panes of added lanes and of new agents. */
|
|
742
837
|
const pollLanes = (watch: Watch, watching: Watching) => {
|
|
743
838
|
const previous = watching.current
|
|
744
839
|
if (!readAgents(watching)) return
|
|
@@ -790,7 +885,11 @@ const pollOnce = async (watch: Watch, watching: Watching): Promise<boolean> => {
|
|
|
790
885
|
const { stream, heartbeatMinutes } = watch.options
|
|
791
886
|
const now = Date.now()
|
|
792
887
|
const due = now - watching.githubReadAt >= GITHUB_POLL_MS
|
|
793
|
-
const
|
|
888
|
+
const githubEvents = due ? await pollGithubAndCoordinator(watch, watching, now) : []
|
|
889
|
+
// read at every poll, not with GitHub: a lane waits for the answer only minutes; after the await, so a state saved
|
|
890
|
+
// during the GitHub read records no request as seen before it was reported
|
|
891
|
+
watching.approvalRequests = readApprovalRequests(watch.root)
|
|
892
|
+
const polledEvents = [...githubEvents, ...approvalEvents(watching.state.approvals, watching.approvalRequests, now)]
|
|
794
893
|
const stopped = [...firedLanes(watching.baseline, watching.current, watching.lanes), ...watching.departed]
|
|
795
894
|
watching.departed = []
|
|
796
895
|
const heartbeat = isHeartbeatDue(watching.quietSince, now, heartbeatMinutes * MINUTE_MS)
|
|
@@ -835,10 +934,14 @@ const initialWatching = (
|
|
|
835
934
|
fetchingBase: false,
|
|
836
935
|
ownLogin: null,
|
|
837
936
|
feedbackRead: null,
|
|
937
|
+
failureReadings: [],
|
|
838
938
|
previewRead: null,
|
|
839
939
|
sourceRuns: [],
|
|
840
940
|
sourceRanAt: {},
|
|
841
941
|
laneReadings: {},
|
|
942
|
+
laneModelReadings: {},
|
|
943
|
+
laneParallelReadings: {},
|
|
944
|
+
approvalRequests: null,
|
|
842
945
|
planViews: { failureReported: false },
|
|
843
946
|
})
|
|
844
947
|
|
|
@@ -901,6 +1004,7 @@ const watchOf = (options: WatchOptions): Watch => {
|
|
|
901
1004
|
settings,
|
|
902
1005
|
deploy: config.watch.deploy,
|
|
903
1006
|
feedback: config.watch.feedback,
|
|
1007
|
+
checks: config.watch.checks,
|
|
904
1008
|
previews: config.watch.previews,
|
|
905
1009
|
sources: config.watch.sources,
|
|
906
1010
|
texts: WATCH_TEXTS[settings.responseLanguage],
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { terminalSafe } from '../plan-format/terminal-text'
|
|
2
|
+
|
|
3
|
+
/** The most failed tests kept for one head commit. */
|
|
4
|
+
export const FAILED_TEST_LIMIT = 50
|
|
5
|
+
|
|
6
|
+
/** The longest test name kept; a log line is third-party text. */
|
|
7
|
+
const FAILED_TEST_NAME_LENGTH = 200
|
|
8
|
+
|
|
9
|
+
/** Log lines longer than this are not matched against the pattern, which bounds its work. */
|
|
10
|
+
const MAX_LINE_LENGTH = 2_000
|
|
11
|
+
|
|
12
|
+
/** `<job>\t<step>\t<ISO timestamp> ` in front of every line of `gh run view --log-failed`. */
|
|
13
|
+
const LOG_PREFIX = /^[^\t]*\t[^\t]*\t\d{4}-\d{2}-\d{2}T[\d:.]+Z /
|
|
14
|
+
|
|
15
|
+
const nameOf = (text: string): string => terminalSafe(text).trim().slice(0, FAILED_TEST_NAME_LENGTH)
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* `names` as the view and the report may show them: terminal-safe, cut to the name length, empty and repeated ones
|
|
19
|
+
* dropped, at most `FAILED_TEST_LIMIT`. For names read back from the state file, which is no more trusted than the log.
|
|
20
|
+
*/
|
|
21
|
+
export const sanitisedFailedTests = (names: readonly string[]): string[] =>
|
|
22
|
+
[...new Set(names.map(nameOf).filter((name) => name !== ''))].slice(0, FAILED_TEST_LIMIT)
|
|
23
|
+
|
|
24
|
+
const compiled = (pattern: string): RegExp | null => {
|
|
25
|
+
try {
|
|
26
|
+
return new RegExp(pattern)
|
|
27
|
+
} catch {
|
|
28
|
+
return null
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The names of the failed tests in the log `gh run view <id> --log-failed` printed, in the order first seen. Each line
|
|
34
|
+
* loses its job, step and timestamp prefix, then `pattern` (a regular expression) is matched against it: the first
|
|
35
|
+
* group is the name, else the whole match. A test the log names twice (Bun lists it in the run and in the summary) is
|
|
36
|
+
* listed once. Names are made terminal-safe and cut; at most `FAILED_TEST_LIMIT` are kept. A pattern that is no
|
|
37
|
+
* regular expression finds nothing.
|
|
38
|
+
*/
|
|
39
|
+
export const extractFailedTests = (log: string, pattern: string): string[] => {
|
|
40
|
+
const expression = compiled(pattern)
|
|
41
|
+
if (!expression) return []
|
|
42
|
+
const names = new Set<string>()
|
|
43
|
+
for (const raw of log.split('\n')) {
|
|
44
|
+
if (names.size >= FAILED_TEST_LIMIT) break
|
|
45
|
+
const line = raw.replace(/\r$/, '').replace(LOG_PREFIX, '')
|
|
46
|
+
if (line.length > MAX_LINE_LENGTH) continue
|
|
47
|
+
const match = expression.exec(line)
|
|
48
|
+
const name = nameOf(match?.[1] ?? match?.[0] ?? '')
|
|
49
|
+
if (name !== '') names.add(name)
|
|
50
|
+
}
|
|
51
|
+
return [...names]
|
|
52
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { extractFailedTests, FAILED_TEST_LIMIT } from './failed-tests'
|
|
2
|
+
import { type GhAnswer, runGh } from './gh'
|
|
3
|
+
import { isRecord } from './json-guards'
|
|
4
|
+
|
|
5
|
+
/** How long `gh run view --log-failed` may take before the call is given up; a read that timed out is tried again. */
|
|
6
|
+
export const LOG_TIMEOUT_MS = 30_000
|
|
7
|
+
|
|
8
|
+
/** Of a longer log only this many characters from the end are kept; Bun repeats its failures in the summary there. */
|
|
9
|
+
export const LOG_TAIL_CHARS = 2_000_000
|
|
10
|
+
|
|
11
|
+
/** `running` is true when a run was still going, which is no failure of the read and is not counted as an attempt. */
|
|
12
|
+
export type FailedTestsRead = { ok: true; tests: string[] } | { ok: false; running: boolean }
|
|
13
|
+
|
|
14
|
+
type RunGh = (args: string[]) => Promise<GhAnswer>
|
|
15
|
+
|
|
16
|
+
export const logArgs = (runId: number): string[] => ['run', 'view', String(runId), '--log-failed']
|
|
17
|
+
|
|
18
|
+
export const statusArgs = (runId: number): string[] => ['run', 'view', String(runId), '--json', 'status,conclusion']
|
|
19
|
+
|
|
20
|
+
const COMPLETED = 'completed'
|
|
21
|
+
|
|
22
|
+
const isRunning = (answer: GhAnswer): boolean => {
|
|
23
|
+
if (!answer.ok) return false
|
|
24
|
+
try {
|
|
25
|
+
const value: unknown = JSON.parse(answer.stdout)
|
|
26
|
+
return isRecord(value) && typeof value.status === 'string' && value.status !== COMPLETED
|
|
27
|
+
} catch {
|
|
28
|
+
return false
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The failed tests in the logs of `runIds` matched with `pattern`. Each run is first asked for its status
|
|
34
|
+
* (`gh run view --json status`, cheap): while any is not completed, `gh` cannot give its failed log, so the read
|
|
35
|
+
* fails with `running` and the log is not asked for. Then one `gh run view --log-failed` per run, in parallel; any
|
|
36
|
+
* call that fails makes the read fail. The default calls give up after `LOG_TIMEOUT_MS` and keep only the last
|
|
37
|
+
* `LOG_TAIL_CHARS` characters of a log as it streams in. The log is whatever the project's jobs print, never interpreted.
|
|
38
|
+
*/
|
|
39
|
+
export const readFailedTests = async (
|
|
40
|
+
root: string,
|
|
41
|
+
runIds: readonly number[],
|
|
42
|
+
pattern: string,
|
|
43
|
+
run: RunGh = (args) =>
|
|
44
|
+
runGh(args, root, {
|
|
45
|
+
timeoutMs: LOG_TIMEOUT_MS,
|
|
46
|
+
...(args.includes('--log-failed') && { tailChars: LOG_TAIL_CHARS }),
|
|
47
|
+
}),
|
|
48
|
+
): Promise<FailedTestsRead> => {
|
|
49
|
+
const statuses = await Promise.all(runIds.map((runId) => run(statusArgs(runId))))
|
|
50
|
+
if (statuses.some(isRunning)) return { ok: false, running: true }
|
|
51
|
+
const answers = await Promise.all(runIds.map((runId) => run(logArgs(runId))))
|
|
52
|
+
const logs = answers.flatMap((answer) => (answer.ok ? [answer.stdout] : []))
|
|
53
|
+
if (logs.length < answers.length) return { ok: false, running: false }
|
|
54
|
+
const tests = new Set(logs.flatMap((text) => extractFailedTests(text, pattern)))
|
|
55
|
+
return { ok: true, tests: [...tests].slice(0, FAILED_TEST_LIMIT) }
|
|
56
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { CHECKS } from '../plans/pull-requests'
|
|
2
|
+
import type { FailedTestsRead } from './failure-read'
|
|
3
|
+
import { isRecord } from './json-guards'
|
|
4
|
+
import { PULL_REQUEST_STATE, type WatchedPullRequest } from './pull-requests'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* What the watcher read of a red pull request's logs: the tests that failed on `headCommit`. `read` is true once the
|
|
8
|
+
* logs answered (with no test among them, too), `attempts` counts the reads tried, so a run that was still going when
|
|
9
|
+
* it was asked is asked again, a few times, and no head commit is read more than once it has answered.
|
|
10
|
+
*/
|
|
11
|
+
export type FailureRecord = { headCommit: string; tests: string[]; read: boolean; attempts: number }
|
|
12
|
+
|
|
13
|
+
/** Pull request number → what was read for it; kept for the open pull requests only. */
|
|
14
|
+
export type FailureState = Record<string, FailureRecord>
|
|
15
|
+
|
|
16
|
+
/** A red pull request of a lane whose logs are to be read: the runs of its failed checks, and the reads tried so far. */
|
|
17
|
+
export type FailureTarget = { number: number; headCommit: string; runIds: number[]; attempts: number }
|
|
18
|
+
|
|
19
|
+
/** The result of reading one target; `attempts` is the count including this read. */
|
|
20
|
+
export type FailureReading = FailureRecord & { number: number }
|
|
21
|
+
|
|
22
|
+
/** A read that failed for another reason than a run still going is tried again at the next GitHub read, this often at most. */
|
|
23
|
+
export const FAILURE_READ_ATTEMPTS = 3
|
|
24
|
+
|
|
25
|
+
/** The most runs read for one head commit. */
|
|
26
|
+
export const FAILURE_RUN_LIMIT = 3
|
|
27
|
+
|
|
28
|
+
export const emptyFailureState = (): FailureState => ({})
|
|
29
|
+
|
|
30
|
+
const isFailureRecord = (value: unknown): boolean =>
|
|
31
|
+
isRecord(value) &&
|
|
32
|
+
typeof value.headCommit === 'string' &&
|
|
33
|
+
Array.isArray(value.tests) &&
|
|
34
|
+
value.tests.every((test) => typeof test === 'string') &&
|
|
35
|
+
typeof value.read === 'boolean' &&
|
|
36
|
+
typeof value.attempts === 'number'
|
|
37
|
+
|
|
38
|
+
export const isFailureState = (value: unknown): value is FailureState =>
|
|
39
|
+
isRecord(value) && Object.values(value).every(isFailureRecord)
|
|
40
|
+
|
|
41
|
+
const runIdsOf = (pullRequest: WatchedPullRequest): number[] => [
|
|
42
|
+
...new Set(pullRequest.failedRuns.flatMap(({ runId }) => (runId === null ? [] : [runId]))),
|
|
43
|
+
]
|
|
44
|
+
|
|
45
|
+
const needsReading = (record: FailureRecord | undefined, headCommit: string): boolean =>
|
|
46
|
+
record?.headCommit !== headCommit || (!record.read && record.attempts < FAILURE_READ_ATTEMPTS)
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The pull requests whose logs to read now: open, red, on a lane's branch, with a run among their failed checks, and
|
|
50
|
+
* not read yet on this head commit. Other pull requests get the names of their checks and nothing more.
|
|
51
|
+
*/
|
|
52
|
+
export const failureTargets = (
|
|
53
|
+
previous: FailureState,
|
|
54
|
+
pullRequests: readonly WatchedPullRequest[],
|
|
55
|
+
laneBranches: Readonly<Record<string, string>>,
|
|
56
|
+
): FailureTarget[] => {
|
|
57
|
+
const branches = new Set(Object.values(laneBranches))
|
|
58
|
+
return pullRequests.flatMap((pullRequest) => {
|
|
59
|
+
const record = previous[String(pullRequest.number)]
|
|
60
|
+
const runIds = runIdsOf(pullRequest).slice(0, FAILURE_RUN_LIMIT)
|
|
61
|
+
const due =
|
|
62
|
+
pullRequest.state === PULL_REQUEST_STATE.open &&
|
|
63
|
+
pullRequest.checks === CHECKS.failed &&
|
|
64
|
+
branches.has(pullRequest.branch) &&
|
|
65
|
+
runIds.length > 0 &&
|
|
66
|
+
needsReading(record, pullRequest.headCommit)
|
|
67
|
+
const known = record?.headCommit === pullRequest.headCommit ? record.attempts : 0
|
|
68
|
+
return due ? [{ number: pullRequest.number, headCommit: pullRequest.headCommit, runIds, attempts: known }] : []
|
|
69
|
+
})
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* The reading of `target` from what `readFailedTests` answered. A failed read counts as an attempt, except one that
|
|
74
|
+
* found the run still going: that is asked again at every GitHub read until it ends, and the cap is for real errors.
|
|
75
|
+
*/
|
|
76
|
+
export const failureReadingOf = (
|
|
77
|
+
{ number, headCommit, attempts }: FailureTarget,
|
|
78
|
+
result: FailedTestsRead,
|
|
79
|
+
): FailureReading =>
|
|
80
|
+
result.ok
|
|
81
|
+
? { number, headCommit, tests: result.tests, read: true, attempts: attempts + 1 }
|
|
82
|
+
: { number, headCommit, tests: [], read: false, attempts: result.running ? attempts : attempts + 1 }
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* The state after `readings`: the readings stored, and only the open pull requests of `pullRequests` kept. Without
|
|
86
|
+
* GitHub's list (`null`) nothing is forgotten. Recording a reading twice gives the same state.
|
|
87
|
+
*/
|
|
88
|
+
export const recordFailures = (
|
|
89
|
+
previous: FailureState,
|
|
90
|
+
pullRequests: readonly WatchedPullRequest[] | null,
|
|
91
|
+
readings: readonly FailureReading[],
|
|
92
|
+
): FailureState => {
|
|
93
|
+
const open = pullRequests
|
|
94
|
+
? new Set(pullRequests.filter(({ state }) => state === PULL_REQUEST_STATE.open).map(({ number }) => String(number)))
|
|
95
|
+
: null
|
|
96
|
+
const merged: FailureState = {
|
|
97
|
+
...previous,
|
|
98
|
+
...Object.fromEntries(readings.map(({ number, ...record }) => [String(number), record])),
|
|
99
|
+
}
|
|
100
|
+
return open ? Object.fromEntries(Object.entries(merged).filter(([number]) => open.has(number))) : merged
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** The failed tests read for `headCommit` of the pull request; none for any other head commit. */
|
|
104
|
+
export const failedTestsOf = (state: FailureState, number: number, headCommit: string): string[] => {
|
|
105
|
+
const record = state[String(number)]
|
|
106
|
+
return record?.headCommit === headCommit ? record.tests : []
|
|
107
|
+
}
|