jackherd 0.0.1 → 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +65 -0
- package/LICENSE +21 -0
- package/NOTICE +38 -0
- package/README.md +1122 -3
- package/assets/branding/README.md +62 -0
- package/assets/branding/jackherd-icon-256.png +0 -0
- package/assets/branding/jackherd-icon-512.png +0 -0
- package/assets/branding/jackherd-icon-64.png +0 -0
- package/assets/branding/jackherd-icon-small-32.png +0 -0
- package/assets/branding/jackherd-icon-small-64.png +0 -0
- package/assets/branding/jackherd-icon-small.svg +1 -0
- package/assets/branding/jackherd-icon.svg +1 -0
- package/assets/branding/jackherd-logo-1024.png +0 -0
- package/assets/branding/jackherd-logo-512.png +0 -0
- 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 -0
- package/assets/branding/jackherd-logo-mark-1024.png +0 -0
- package/assets/branding/jackherd-logo-mark-512.png +0 -0
- package/assets/branding/jackherd-logo-mark.svg +1 -0
- package/assets/branding/jackherd-logo.svg +1 -0
- package/dist/chunk-R6MGHCD7.js +93 -0
- package/dist/config.d.ts +167 -0
- package/dist/config.js +401 -0
- package/dist/plan-format.d.ts +325 -0
- package/dist/plan-format.js +636 -0
- package/docs/lanes.mmd +9 -0
- package/docs/lanes.svg +1 -0
- package/docs/skills.mmd +10 -0
- package/docs/skills.svg +1 -0
- package/docs/workflow.svg +172 -0
- package/herdr-plugin/herdr-plugin.toml +22 -0
- package/herdr-plugin/open-plans.ts +43 -0
- package/herdr-plugin/restore-plans.ts +107 -0
- package/install.sh +157 -0
- package/kit/hooks/hooks/git-guard/decision.ts +438 -0
- package/kit/hooks/hooks/git-guard/index.ts +38 -0
- package/kit/hooks/hooks/git-guard/shell.ts +407 -0
- package/kit/hooks/hooks/watch-guard/background.ts +36 -0
- package/kit/hooks/hooks/watch-guard/decision.ts +53 -0
- package/kit/hooks/hooks/watch-guard/index.ts +11 -0
- package/kit/hooks/hooks/watch-guard/shell-input.ts +32 -0
- package/kit/hooks/hooks/watch-guard/starts.ts +154 -0
- package/kit/hooks/hooks/watch-guard/text-fallback.ts +40 -0
- package/kit/hooks/hooks.json +30 -0
- package/kit/hooks/statusline.sh +72 -0
- package/kit/review/agents/devops.md +11 -0
- package/kit/review/agents/public-content.md +39 -0
- package/kit/review/agents/security-audit.md +11 -0
- package/kit/review/agents/uncle-bob.md +11 -0
- package/kit/review/skills/devops/SKILL.md +50 -0
- package/kit/review/skills/diagnose/SKILL.md +140 -0
- package/kit/review/skills/diagnose/references/feedback-loops.md +59 -0
- package/kit/review/skills/diagnose/scripts/hitl-loop.template.sh +71 -0
- package/kit/review/skills/pre-commit/SKILL.md +100 -0
- package/kit/review/skills/review/SKILL.md +43 -0
- package/kit/review/skills/security-audit/SKILL.md +65 -0
- package/kit/review/skills/uncle-bob/SKILL.md +43 -0
- package/kit/rules/rules/kit/arc42.md +89 -0
- package/kit/rules/rules/kit/architecture-files.md +87 -0
- package/kit/rules/rules/kit/code-style.md +159 -0
- package/kit/rules/rules/kit/pull-requests.md +59 -0
- package/kit/rules/rules/kit/testing.md +91 -0
- package/kit/rules/rules/kit/writing-for-agents.md +110 -0
- package/kit/workflow/agents/explore.md +25 -0
- package/kit/workflow/agents/implement.md +16 -0
- package/kit/workflow/agents/plan.md +13 -0
- package/kit/workflow/agents/spec-review.md +96 -0
- package/kit/workflow/skills/follow/SKILL.md +48 -0
- package/kit/workflow/skills/grill/SKILL.md +60 -0
- package/kit/workflow/skills/implement/SKILL.md +107 -0
- package/kit/workflow/skills/orchestrate/SKILL.md +565 -0
- package/kit/workflow/skills/orchestrate/handover/decision.ts +247 -0
- package/kit/workflow/skills/orchestrate/handover/index.ts +171 -0
- package/kit/workflow/skills/plan/SKILL.md +118 -0
- package/kit/workflow/skills/plan/references/template-English.md +92 -0
- package/kit/workflow/skills/plan/references/template-German.md +92 -0
- package/kit/workflow/skills/start/SKILL.md +47 -0
- package/package.json +63 -5
- package/schemas/jackherd.schema.json +601 -0
- package/src/check/changes.ts +45 -0
- package/src/check/command-line.ts +12 -0
- package/src/check/command.ts +136 -0
- package/src/check/deferral-arrows.ts +41 -0
- package/src/check/execute.ts +29 -0
- package/src/check/glob.ts +15 -0
- package/src/check/plan-structure-check.ts +135 -0
- package/src/check/plan.ts +106 -0
- package/src/check/public-content-check.ts +32 -0
- package/src/check/public-content-facts.ts +177 -0
- package/src/check/public-content.ts +216 -0
- package/src/check/report.ts +55 -0
- package/src/check/result.ts +42 -0
- package/src/check/roots.ts +36 -0
- package/src/check/routing.ts +22 -0
- package/src/check/schema.ts +83 -0
- package/src/check/settings.ts +44 -0
- package/src/claude/paths.ts +14 -0
- package/src/cli.ts +46 -0
- package/src/config/command.ts +45 -0
- package/src/config/detect.ts +196 -0
- package/src/config/index.ts +2 -0
- package/src/config/json-schema.ts +5 -0
- package/src/config/legacy-names.ts +56 -0
- package/src/config/link-kinds.ts +25 -0
- package/src/config/location.ts +46 -0
- package/src/config/read-config.ts +42 -0
- package/src/config/repository-facts.ts +19 -0
- package/src/config/repository-values.ts +46 -0
- package/src/config/schema.ts +302 -0
- package/src/context/command.ts +39 -0
- package/src/context/coordinator-name.ts +16 -0
- package/src/context/fill.ts +69 -0
- package/src/context/index.ts +10 -0
- package/src/context/level.ts +22 -0
- package/src/context/options.ts +29 -0
- package/src/context/report.ts +42 -0
- package/src/context/session-log.ts +91 -0
- package/src/plan-format/drift.ts +69 -0
- package/src/plan-format/header.ts +27 -0
- package/src/plan-format/index.ts +9 -0
- package/src/plan-format/lanes.ts +128 -0
- package/src/plan-format/markdown.ts +98 -0
- package/src/plan-format/phase-tally.ts +55 -0
- package/src/plan-format/phases.ts +224 -0
- package/src/plan-format/plan-structure.ts +150 -0
- package/src/plan-format/progress.ts +202 -0
- package/src/plan-format/sprint-document.ts +69 -0
- package/src/plan-format/terminal-text.ts +28 -0
- package/src/plans/board-choice.ts +60 -0
- package/src/plans/board-pick.ts +72 -0
- package/src/plans/board-rows.ts +62 -0
- package/src/plans/command.ts +60 -0
- package/src/plans/ensure-panes.ts +71 -0
- package/src/plans/error-text.ts +5 -0
- package/src/plans/fetch.ts +96 -0
- package/src/plans/git.ts +145 -0
- package/src/plans/github.ts +35 -0
- package/src/plans/lane-discovery.ts +34 -0
- package/src/plans/lane-plans.ts +80 -0
- package/src/plans/layout.ts +881 -0
- package/src/plans/link-chips.ts +74 -0
- package/src/plans/live.ts +336 -0
- package/src/plans/markdown-html.ts +140 -0
- package/src/plans/options.ts +15 -0
- package/src/plans/phase-source.ts +71 -0
- package/src/plans/plan-chips.ts +75 -0
- package/src/plans/plan-panes.ts +206 -0
- package/src/plans/planned.ts +117 -0
- package/src/plans/pull-requests.ts +107 -0
- package/src/plans/restart-decision.ts +42 -0
- package/src/plans/selection.ts +352 -0
- package/src/plans/settings.ts +68 -0
- package/src/plans/single-flight.ts +25 -0
- package/src/plans/snapshot.ts +300 -0
- package/src/plans/sprint-export.ts +103 -0
- package/src/plans/sprint-html.ts +301 -0
- package/src/plans/sprint-page.ts +287 -0
- package/src/plans/supervisor.ts +124 -0
- package/src/plans/symbols.ts +54 -0
- package/src/plans/texts.ts +103 -0
- package/src/plans/unlisted-lanes.ts +45 -0
- package/src/plans/view.ts +68 -0
- package/src/plans/worked-phases.ts +53 -0
- package/src/sync/checksum.ts +13 -0
- package/src/sync/choice.ts +14 -0
- package/src/sync/command-context.ts +113 -0
- package/src/sync/command.ts +94 -0
- package/src/sync/error-message.ts +1 -0
- package/src/sync/gitignore.ts +45 -0
- package/src/sync/global-kit.ts +176 -0
- package/src/sync/herdr-plugin.ts +175 -0
- package/src/sync/if-changed.ts +109 -0
- package/src/sync/init-command.ts +239 -0
- package/src/sync/init.ts +136 -0
- package/src/sync/kit-hooks.ts +18 -0
- package/src/sync/kit-path.ts +77 -0
- package/src/sync/kit-source.ts +169 -0
- package/src/sync/kit.ts +9 -0
- package/src/sync/legacy-files.ts +116 -0
- package/src/sync/manifest.ts +44 -0
- package/src/sync/next-steps.ts +42 -0
- package/src/sync/plan-file.ts +154 -0
- package/src/sync/plan-hooks.ts +165 -0
- package/src/sync/project-fs.ts +132 -0
- package/src/sync/render.ts +60 -0
- package/src/sync/report.ts +96 -0
- package/src/sync/settings-hooks.ts +90 -0
- package/src/sync/settings-migration.ts +94 -0
- package/src/sync/settings-offer.ts +168 -0
- package/src/sync/setup-questions.ts +70 -0
- package/src/sync/setup.ts +52 -0
- package/src/sync/sync-plan.ts +120 -0
- package/src/sync/sync-result.ts +9 -0
- package/src/sync/sync.ts +307 -0
- package/src/sync/tools.ts +52 -0
- package/src/terminal/herdr.ts +91 -0
- package/src/terminal/index.ts +13 -0
- package/src/terminal/none.ts +7 -0
- package/src/terminal/open.ts +39 -0
- package/src/terminal/text.ts +159 -0
- package/src/terminal/types.ts +30 -0
- package/src/terminal/width.ts +90 -0
- package/src/update/command.ts +65 -0
- package/src/update/package-manager.ts +56 -0
- package/src/update/update.ts +105 -0
- package/src/version/installed-version.ts +32 -0
- package/src/version/release.ts +22 -0
- package/src/version/skill-diff.ts +63 -0
- package/src/version/started-kit.ts +14 -0
- package/src/vocabulary/index.ts +132 -0
- package/src/watch/base-tip.ts +18 -0
- package/src/watch/command.ts +919 -0
- package/src/watch/coordinator-fill.ts +76 -0
- package/src/watch/feedback-read.ts +73 -0
- package/src/watch/feedback.ts +308 -0
- package/src/watch/gh.ts +22 -0
- package/src/watch/github-read.ts +175 -0
- package/src/watch/herdr.ts +11 -0
- package/src/watch/json-guards.ts +9 -0
- package/src/watch/kit-update.ts +18 -0
- package/src/watch/lane-fill.ts +36 -0
- package/src/watch/lane-message.ts +99 -0
- package/src/watch/lane-progress.ts +44 -0
- package/src/watch/lane-report-file.ts +53 -0
- package/src/watch/lane-report.ts +270 -0
- package/src/watch/lane-search.ts +41 -0
- package/src/watch/lane-sources.ts +46 -0
- package/src/watch/lane-watch.ts +121 -0
- package/src/watch/monitor-tool.ts +19 -0
- package/src/watch/options.ts +79 -0
- package/src/watch/plan-badges-read.ts +30 -0
- package/src/watch/plan-badges.ts +66 -0
- package/src/watch/plan-views.ts +59 -0
- package/src/watch/preview-read.ts +60 -0
- package/src/watch/previews.ts +234 -0
- package/src/watch/pull-requests.ts +117 -0
- package/src/watch/single-report.ts +15 -0
- package/src/watch/skill-change.ts +61 -0
- package/src/watch/source-dry-run-command.ts +35 -0
- package/src/watch/source-dry-run.ts +124 -0
- package/src/watch/source-fields.ts +9 -0
- package/src/watch/source-path.ts +40 -0
- package/src/watch/source-run.ts +90 -0
- package/src/watch/sources.ts +344 -0
- package/src/watch/stream-output.ts +14 -0
- package/src/watch/texts.ts +76 -0
- package/src/watch/watch-events.ts +399 -0
- package/src/watch/watcher-lock.ts +145 -0
- package/src/watch/workflow-runs.ts +96 -0
|
@@ -0,0 +1,399 @@
|
|
|
1
|
+
import { CHECKS, type ChecksState } from '../plans/pull-requests'
|
|
2
|
+
import { type CoordinatorLevel, type CoordinatorReading, isContextLevel, levelStep } from './coordinator-fill'
|
|
3
|
+
import { emptyFeedbackState, FEEDBACK_EVENT, type FeedbackEvent, type FeedbackState, isFeedbackState } from './feedback'
|
|
4
|
+
import { isRecord } from './json-guards'
|
|
5
|
+
import { isLaneLevels, LANE_FILL_EVENT, type LaneFillEvent, type LaneLevels } from './lane-fill'
|
|
6
|
+
import { type LaneSnapshot, type SeenSeqs, seenSeqsOf } from './lane-watch'
|
|
7
|
+
import { emptyPreviewState, isPreviewState, PREVIEW_EVENT, type PreviewEvent, type PreviewState } from './previews'
|
|
8
|
+
import { PULL_REQUEST_STATE, type WatchedPullRequest } from './pull-requests'
|
|
9
|
+
import { emptySourceState, isSourceState, SOURCE_EVENT, type SourceEvent, type SourceState } from './sources'
|
|
10
|
+
import { RUN_EVENT, RUN_STATUS, type WorkflowRun } from './workflow-runs'
|
|
11
|
+
|
|
12
|
+
/** What the coordinator is told besides a lane that stopped. The values are the words of the report. */
|
|
13
|
+
export const WATCH_EVENT = {
|
|
14
|
+
merged: 'merged',
|
|
15
|
+
checksFailed: 'checks failed',
|
|
16
|
+
checksPassed: 'checks passed',
|
|
17
|
+
deploy: 'deploy',
|
|
18
|
+
heartbeat: 'heartbeat',
|
|
19
|
+
ghUnavailable: 'gh unavailable',
|
|
20
|
+
coordinator: 'coordinator',
|
|
21
|
+
feedback: FEEDBACK_EVENT,
|
|
22
|
+
preview: PREVIEW_EVENT,
|
|
23
|
+
laneFill: LANE_FILL_EVENT,
|
|
24
|
+
source: SOURCE_EVENT,
|
|
25
|
+
} as const
|
|
26
|
+
|
|
27
|
+
export type PullRequestObservation = Omit<WatchedPullRequest, 'number' | 'branch'>
|
|
28
|
+
|
|
29
|
+
/** The settled verdict of a pull request's checks on one head commit, the unit that fires once. */
|
|
30
|
+
export type CheckVerdict = { headCommit: string; checks: ChecksState }
|
|
31
|
+
|
|
32
|
+
export type PullRequestRecord = PullRequestObservation & {
|
|
33
|
+
/** The last failed or passed verdict reported; pending and none never replace it. */
|
|
34
|
+
reportedChecks: CheckVerdict | null
|
|
35
|
+
/** Seen by the search for the user's own pull requests on a branch without a lane; kept until the record drops out. */
|
|
36
|
+
own?: true
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export type PullRequestSnapshot = WatchedPullRequest
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Everything the watcher remembers between returns. A lane, pull request number or run ID without an
|
|
43
|
+
* entry has never been seen, and its first sighting is a baseline, not an event.
|
|
44
|
+
*/
|
|
45
|
+
export type WatchState = {
|
|
46
|
+
seenSeqs: SeenSeqs
|
|
47
|
+
pullRequests: Record<string, PullRequestRecord>
|
|
48
|
+
/** Per run ID: whether the run had completed when it was last seen. */
|
|
49
|
+
runs: Record<string, boolean>
|
|
50
|
+
/** Epoch milliseconds of the first failed `gh` call of the current streak; null while `gh` answers. */
|
|
51
|
+
ghDownSince: number | null
|
|
52
|
+
/** Epoch milliseconds of the last state recorded from an answering `gh`; null before the first one. */
|
|
53
|
+
savedAt: number | null
|
|
54
|
+
/**
|
|
55
|
+
* Lane → branch, kept while the lane is watched or a pull request of the branch is still listed:
|
|
56
|
+
* a deploy run that ends after its lane was closed still finds the lane's merge commit.
|
|
57
|
+
*/
|
|
58
|
+
laneBranches: Record<string, string>
|
|
59
|
+
/** The coordinator's level when it was last read with a value; null before the first one (counts as `ok`). */
|
|
60
|
+
coordinatorLevel: CoordinatorLevel | null
|
|
61
|
+
/** The feedback seen on the lanes' open pull requests; kept apart from `pullRequests`, it is read over REST. */
|
|
62
|
+
feedback: FeedbackState
|
|
63
|
+
/** The previews seen on the lanes' open pull requests: per pull request the head last reported and the source versions. */
|
|
64
|
+
previews: PreviewState
|
|
65
|
+
/** The context level last read for each watched lane's agent; a rise above it fires `lane <name> <level>`. */
|
|
66
|
+
laneLevels: LaneLevels
|
|
67
|
+
/** Per configured source of `watch.sources`: the entries seen (`[id, fingerprint]`) and the start of a failure streak. */
|
|
68
|
+
sources: SourceState
|
|
69
|
+
/** Lane → change time of the report file last shown for it, null when it had none; a lane without an entry is unseen. */
|
|
70
|
+
reportMtimes: Record<string, number | null>
|
|
71
|
+
/** The lane lines of the status block last printed; null before the first report. */
|
|
72
|
+
statusLines: string[] | null
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** The kinds of the events a pull request fires. */
|
|
76
|
+
export const PULL_REQUEST_KINDS = [WATCH_EVENT.merged, WATCH_EVENT.checksFailed, WATCH_EVENT.checksPassed] as const
|
|
77
|
+
|
|
78
|
+
/** A lane's pull request, or an own one without a lane, which names its branch instead. */
|
|
79
|
+
export type PullRequestEvent = {
|
|
80
|
+
kind: (typeof PULL_REQUEST_KINDS)[number]
|
|
81
|
+
number: number
|
|
82
|
+
headCommit: string
|
|
83
|
+
mergeCommit: string | null
|
|
84
|
+
} & ({ lane: string } | { lane: null; branch: string })
|
|
85
|
+
|
|
86
|
+
/** A run of a lane's merge, or without a lane: of an own pull request's merge (`pullRequest`), else started by hand. */
|
|
87
|
+
export type DeployEvent = {
|
|
88
|
+
kind: typeof WATCH_EVENT.deploy
|
|
89
|
+
runId: number
|
|
90
|
+
trigger: string
|
|
91
|
+
conclusion: string | null
|
|
92
|
+
} & ({ lane: string } | { lane: null; pullRequest?: number })
|
|
93
|
+
|
|
94
|
+
export type SignalEvent =
|
|
95
|
+
| { kind: typeof WATCH_EVENT.heartbeat }
|
|
96
|
+
| { kind: typeof WATCH_EVENT.ghUnavailable; reason: string }
|
|
97
|
+
|
|
98
|
+
export type CoordinatorEvent = { kind: typeof WATCH_EVENT.coordinator } & CoordinatorReading
|
|
99
|
+
|
|
100
|
+
export type WatchEvent =
|
|
101
|
+
| PullRequestEvent
|
|
102
|
+
| DeployEvent
|
|
103
|
+
| SignalEvent
|
|
104
|
+
| CoordinatorEvent
|
|
105
|
+
| FeedbackEvent
|
|
106
|
+
| PreviewEvent
|
|
107
|
+
| LaneFillEvent
|
|
108
|
+
| SourceEvent
|
|
109
|
+
|
|
110
|
+
export const isPullRequestEvent = (event: WatchEvent): event is PullRequestEvent =>
|
|
111
|
+
(PULL_REQUEST_KINDS as readonly string[]).includes(event.kind)
|
|
112
|
+
|
|
113
|
+
/** `github` is null when `gh` failed; the pull requests and runs of the last good answer then stay as they were. */
|
|
114
|
+
export type Observation = {
|
|
115
|
+
lanes: LaneSnapshot
|
|
116
|
+
/** The branch checked out in each watched lane's worktree. */
|
|
117
|
+
laneBranches: Record<string, string>
|
|
118
|
+
github: {
|
|
119
|
+
pullRequests: readonly PullRequestSnapshot[]
|
|
120
|
+
runs: readonly WorkflowRun[]
|
|
121
|
+
/** The numbers of the user's own open pull requests without a lane; none when left out. */
|
|
122
|
+
own?: readonly number[]
|
|
123
|
+
} | null
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export const emptyWatchState = (): WatchState => ({
|
|
127
|
+
seenSeqs: {},
|
|
128
|
+
pullRequests: {},
|
|
129
|
+
runs: {},
|
|
130
|
+
ghDownSince: null,
|
|
131
|
+
savedAt: null,
|
|
132
|
+
laneBranches: {},
|
|
133
|
+
coordinatorLevel: null,
|
|
134
|
+
feedback: emptyFeedbackState(),
|
|
135
|
+
previews: emptyPreviewState(),
|
|
136
|
+
laneLevels: {},
|
|
137
|
+
sources: emptySourceState(),
|
|
138
|
+
reportMtimes: {},
|
|
139
|
+
statusLines: null,
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
const isOneOf = (values: Record<string, string>, value: unknown): boolean =>
|
|
143
|
+
typeof value === 'string' && Object.values(values).includes(value)
|
|
144
|
+
|
|
145
|
+
/** A sequence or an epoch time: a number, or null when there is none yet. */
|
|
146
|
+
const isSeq = (value: unknown): value is number | null => value === null || typeof value === 'number'
|
|
147
|
+
|
|
148
|
+
const everyValue = (value: unknown, check: (entry: unknown) => boolean): boolean =>
|
|
149
|
+
isRecord(value) && Object.values(value).every(check)
|
|
150
|
+
|
|
151
|
+
const isCheckVerdict = (value: unknown): boolean =>
|
|
152
|
+
isRecord(value) && typeof value.headCommit === 'string' && isOneOf(CHECKS, value.checks)
|
|
153
|
+
|
|
154
|
+
const isPullRequestRecord = (value: unknown): boolean =>
|
|
155
|
+
isRecord(value) &&
|
|
156
|
+
isOneOf(PULL_REQUEST_STATE, value.state) &&
|
|
157
|
+
isOneOf(CHECKS, value.checks) &&
|
|
158
|
+
typeof value.headCommit === 'string' &&
|
|
159
|
+
(value.mergeCommit === null || typeof value.mergeCommit === 'string') &&
|
|
160
|
+
(value.reportedChecks === null || isCheckVerdict(value.reportedChecks)) &&
|
|
161
|
+
(value.own === undefined || value.own === true)
|
|
162
|
+
|
|
163
|
+
const isWatchState = (value: Record<string, unknown>): value is WatchState =>
|
|
164
|
+
everyValue(value.seenSeqs, isSeq) &&
|
|
165
|
+
everyValue(value.pullRequests, isPullRequestRecord) &&
|
|
166
|
+
everyValue(value.runs, (entry) => typeof entry === 'boolean') &&
|
|
167
|
+
isSeq(value.ghDownSince) &&
|
|
168
|
+
isSeq(value.savedAt) &&
|
|
169
|
+
everyValue(value.laneBranches, (entry) => typeof entry === 'string') &&
|
|
170
|
+
(value.coordinatorLevel === null || isContextLevel(value.coordinatorLevel)) &&
|
|
171
|
+
isFeedbackState(value.feedback) &&
|
|
172
|
+
isPreviewState(value.previews) &&
|
|
173
|
+
isLaneLevels(value.laneLevels) &&
|
|
174
|
+
isSourceState(value.sources) &&
|
|
175
|
+
everyValue(value.reportMtimes, isSeq) &&
|
|
176
|
+
(value.statusLines === null ||
|
|
177
|
+
(Array.isArray(value.statusLines) && value.statusLines.every((line) => typeof line === 'string')))
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Reads the state file; throws on any other shape, so a damaged file is seen, not read as empty. The
|
|
181
|
+
* flat `Record<lane, seq>` of the earlier watcher is taken over as the seen sequences.
|
|
182
|
+
*/
|
|
183
|
+
export const parseWatchState = (json: string): WatchState => {
|
|
184
|
+
const value: unknown = JSON.parse(json)
|
|
185
|
+
if (!isRecord(value)) throw new Error('watch state: unexpected shape')
|
|
186
|
+
if (isRecord(value.seenSeqs)) {
|
|
187
|
+
const complete = {
|
|
188
|
+
savedAt: null,
|
|
189
|
+
laneBranches: {},
|
|
190
|
+
coordinatorLevel: null,
|
|
191
|
+
feedback: emptyFeedbackState(),
|
|
192
|
+
previews: emptyPreviewState(),
|
|
193
|
+
laneLevels: {},
|
|
194
|
+
sources: emptySourceState(),
|
|
195
|
+
reportMtimes: {},
|
|
196
|
+
statusLines: null,
|
|
197
|
+
...value,
|
|
198
|
+
}
|
|
199
|
+
if (!isWatchState(complete)) throw new Error('watch state: unexpected shape')
|
|
200
|
+
return complete
|
|
201
|
+
}
|
|
202
|
+
if (!Object.values(value).every(isSeq)) throw new Error('watch state: unexpected shape')
|
|
203
|
+
return { ...emptyWatchState(), seenSeqs: value as SeenSeqs }
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const isSettled = (checks: ChecksState): boolean => checks === CHECKS.failed || checks === CHECKS.passed
|
|
207
|
+
|
|
208
|
+
const reportedChecksOf = (known: PullRequestRecord | undefined, observed: PullRequestSnapshot): CheckVerdict | null =>
|
|
209
|
+
isSettled(observed.checks)
|
|
210
|
+
? { headCommit: observed.headCommit, checks: observed.checks }
|
|
211
|
+
: (known?.reportedChecks ?? null)
|
|
212
|
+
|
|
213
|
+
const recordsOf = (
|
|
214
|
+
previous: WatchState,
|
|
215
|
+
pullRequests: readonly PullRequestSnapshot[],
|
|
216
|
+
own: readonly number[],
|
|
217
|
+
): Record<string, PullRequestRecord> =>
|
|
218
|
+
Object.fromEntries(
|
|
219
|
+
pullRequests.map((observed) => {
|
|
220
|
+
const { number, state, checks, headCommit, mergeCommit } = observed
|
|
221
|
+
const known = previous.pullRequests[String(number)]
|
|
222
|
+
const reportedChecks = reportedChecksOf(known, observed)
|
|
223
|
+
const isOwn = known?.own === true || own.includes(number)
|
|
224
|
+
return [String(number), { state, checks, headCommit, mergeCommit, reportedChecks, ...(isOwn && { own: true }) }]
|
|
225
|
+
}),
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
/** The remembered lane branches plus the observed ones, minus those of lanes no longer watched whose branch has no listed pull request. */
|
|
229
|
+
const keptLaneBranches = (
|
|
230
|
+
previous: WatchState,
|
|
231
|
+
observation: Observation,
|
|
232
|
+
pullRequests: readonly PullRequestSnapshot[],
|
|
233
|
+
): Record<string, string> => {
|
|
234
|
+
const listed = new Set(pullRequests.map((pullRequest) => pullRequest.branch))
|
|
235
|
+
const all = { ...previous.laneBranches, ...observation.laneBranches }
|
|
236
|
+
return Object.fromEntries(
|
|
237
|
+
Object.entries(all).filter(([lane, branch]) => lane in observation.laneBranches || listed.has(branch)),
|
|
238
|
+
)
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const runsOf = (runs: readonly WorkflowRun[]): Record<string, boolean> =>
|
|
242
|
+
Object.fromEntries(runs.map((run) => [String(run.id), run.status === RUN_STATUS.completed]))
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* The state to save after an observation: every observed lane, pull request and run, whether or not
|
|
246
|
+
* it fired. Entries that dropped out of the listings are forgotten.
|
|
247
|
+
*/
|
|
248
|
+
export const recordWatchState = (previous: WatchState, observation: Observation, now: number): WatchState => {
|
|
249
|
+
const { lanes, github } = observation
|
|
250
|
+
if (!github) {
|
|
251
|
+
const laneBranches = { ...previous.laneBranches, ...observation.laneBranches }
|
|
252
|
+
return { ...previous, seenSeqs: seenSeqsOf(lanes), ghDownSince: previous.ghDownSince ?? now, laneBranches }
|
|
253
|
+
}
|
|
254
|
+
return {
|
|
255
|
+
seenSeqs: seenSeqsOf(lanes),
|
|
256
|
+
pullRequests: recordsOf(previous, github.pullRequests, github.own ?? []),
|
|
257
|
+
runs: runsOf(github.runs),
|
|
258
|
+
ghDownSince: null,
|
|
259
|
+
savedAt: now,
|
|
260
|
+
laneBranches: keptLaneBranches(previous, observation, github.pullRequests),
|
|
261
|
+
coordinatorLevel: previous.coordinatorLevel,
|
|
262
|
+
feedback: previous.feedback,
|
|
263
|
+
previews: previous.previews,
|
|
264
|
+
laneLevels: previous.laneLevels,
|
|
265
|
+
sources: previous.sources,
|
|
266
|
+
reportMtimes: previous.reportMtimes,
|
|
267
|
+
statusLines: previous.statusLines,
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* The report times of the watched lanes only: a known lane keeps its time, a lane seen for the first time takes the
|
|
273
|
+
* report already there as its baseline (`mtimeOf`), so an old file never reads as new; a lane no longer watched is
|
|
274
|
+
* forgotten and starts afresh when it comes back.
|
|
275
|
+
*/
|
|
276
|
+
export const withReportBaselines = (
|
|
277
|
+
state: WatchState,
|
|
278
|
+
lanes: readonly string[],
|
|
279
|
+
mtimeOf: (lane: string) => number | null,
|
|
280
|
+
): WatchState => ({
|
|
281
|
+
...state,
|
|
282
|
+
reportMtimes: Object.fromEntries(
|
|
283
|
+
lanes.map((lane) => [lane, lane in state.reportMtimes ? (state.reportMtimes[lane] ?? null) : mtimeOf(lane)]),
|
|
284
|
+
),
|
|
285
|
+
})
|
|
286
|
+
|
|
287
|
+
/** The state with the coordinator's level of `reading` stored; no reading keeps the stored level. */
|
|
288
|
+
export const recordCoordinatorLevel = (state: WatchState, reading: CoordinatorReading | null): WatchState => ({
|
|
289
|
+
...state,
|
|
290
|
+
coordinatorLevel: levelStep(state.coordinatorLevel, reading?.level ?? null).level,
|
|
291
|
+
})
|
|
292
|
+
|
|
293
|
+
/** Fires when the coordinator's level rose to `warn` or `alert` since `previous` (see `levelStep`). */
|
|
294
|
+
export const coordinatorWatchEvent = (
|
|
295
|
+
previous: WatchState,
|
|
296
|
+
reading: CoordinatorReading | null,
|
|
297
|
+
): CoordinatorEvent | null =>
|
|
298
|
+
reading && levelStep(previous.coordinatorLevel, reading.level).fire
|
|
299
|
+
? { kind: WATCH_EVENT.coordinator, ...reading }
|
|
300
|
+
: null
|
|
301
|
+
|
|
302
|
+
const settledChecksKind = (checks: ChecksState): PullRequestEvent['kind'] | null => {
|
|
303
|
+
if (checks === CHECKS.failed) return WATCH_EVENT.checksFailed
|
|
304
|
+
return checks === CHECKS.passed ? WATCH_EVENT.checksPassed : null
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
const isReported = (known: PullRequestRecord, observed: PullRequestSnapshot): boolean =>
|
|
308
|
+
known.reportedChecks?.headCommit === observed.headCommit && known.reportedChecks.checks === observed.checks
|
|
309
|
+
|
|
310
|
+
const eventKindOf = (known: PullRequestRecord, observed: PullRequestSnapshot): PullRequestEvent['kind'] | null => {
|
|
311
|
+
if (observed.state === PULL_REQUEST_STATE.merged)
|
|
312
|
+
return known.state === PULL_REQUEST_STATE.merged ? null : WATCH_EVENT.merged
|
|
313
|
+
if (observed.state !== PULL_REQUEST_STATE.open || isReported(known, observed)) return null
|
|
314
|
+
return settledChecksKind(observed.checks)
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
const laneOfBranch = (laneBranches: Readonly<Record<string, string>>, branch: string): string | undefined =>
|
|
318
|
+
Object.keys(laneBranches).find((lane) => laneBranches[lane] === branch)
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* The merges and settled checks of the lanes' pull requests since `previous`, and of the user's own pull requests on
|
|
322
|
+
* a branch without a lane, which name their branch. A pull request number never seen is a baseline, and so is an own
|
|
323
|
+
* one the state does not mark as own yet. Checks fire once per head commit and settled verdict: a new head commit
|
|
324
|
+
* fires when its checks settle, not while they are pending, and pending never clears what was reported.
|
|
325
|
+
*/
|
|
326
|
+
export const pullRequestEvents = (
|
|
327
|
+
previous: WatchState,
|
|
328
|
+
current: readonly PullRequestSnapshot[],
|
|
329
|
+
laneBranches: Readonly<Record<string, string>>,
|
|
330
|
+
): PullRequestEvent[] =>
|
|
331
|
+
current.flatMap((pullRequest) => {
|
|
332
|
+
const lane = laneOfBranch(laneBranches, pullRequest.branch)
|
|
333
|
+
const known = previous.pullRequests[String(pullRequest.number)]
|
|
334
|
+
const followed = lane !== undefined || known?.own === true
|
|
335
|
+
const kind = followed && known ? eventKindOf(known, pullRequest) : null
|
|
336
|
+
if (!kind) return []
|
|
337
|
+
const { number, headCommit, mergeCommit, branch } = pullRequest
|
|
338
|
+
const owner = lane === undefined ? { lane: null, branch } : { lane }
|
|
339
|
+
return [{ kind, ...owner, number, headCommit, mergeCommit }]
|
|
340
|
+
})
|
|
341
|
+
|
|
342
|
+
/** The lane each merge commit belongs to, for matching deploy runs to a lane's merge. */
|
|
343
|
+
export const mergeCommitsOf = (
|
|
344
|
+
pullRequests: readonly PullRequestSnapshot[],
|
|
345
|
+
laneBranches: Readonly<Record<string, string>>,
|
|
346
|
+
): Record<string, string> =>
|
|
347
|
+
Object.fromEntries(
|
|
348
|
+
pullRequests.flatMap(({ branch, mergeCommit }) => {
|
|
349
|
+
const lane = laneOfBranch(laneBranches, branch)
|
|
350
|
+
return lane && mergeCommit ? [[mergeCommit, lane]] : []
|
|
351
|
+
}),
|
|
352
|
+
)
|
|
353
|
+
|
|
354
|
+
/** The number of the own pull request without a lane each merge commit belongs to, for matching deploy runs. */
|
|
355
|
+
export const ownMergeCommitsOf = (
|
|
356
|
+
previous: WatchState,
|
|
357
|
+
pullRequests: readonly PullRequestSnapshot[],
|
|
358
|
+
laneBranches: Readonly<Record<string, string>>,
|
|
359
|
+
): Record<string, number> =>
|
|
360
|
+
Object.fromEntries(
|
|
361
|
+
pullRequests.flatMap(({ number, branch, mergeCommit }) => {
|
|
362
|
+
const own = previous.pullRequests[String(number)]?.own === true
|
|
363
|
+
return own && mergeCommit && !laneOfBranch(laneBranches, branch) ? [[mergeCommit, number]] : []
|
|
364
|
+
}),
|
|
365
|
+
)
|
|
366
|
+
|
|
367
|
+
const wasOpen = (previous: WatchState, run: WorkflowRun): boolean => {
|
|
368
|
+
const completedBefore = previous.runs[String(run.id)]
|
|
369
|
+
if (completedBefore !== undefined) return !completedBefore
|
|
370
|
+
return previous.savedAt !== null && Date.parse(run.createdAt) >= previous.savedAt
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* The deploy runs that completed since `previous`, any conclusion included (a cancelled run is news).
|
|
375
|
+
* A run counts when its head commit is the merge commit of a lane or of an own pull request without a lane
|
|
376
|
+
* (`ownMergeCommits`, merge commit → number), or when someone started it by hand.
|
|
377
|
+
* A run that had already completed does not fire again. A run ID never seen is a baseline,
|
|
378
|
+
* unless it was created after the last saved state: it started and ended between two returns and counts as open.
|
|
379
|
+
*/
|
|
380
|
+
export const deployEvents = (
|
|
381
|
+
previous: WatchState,
|
|
382
|
+
runs: readonly WorkflowRun[],
|
|
383
|
+
mergeCommits: Readonly<Record<string, string>>,
|
|
384
|
+
ownMergeCommits: Readonly<Record<string, number>>,
|
|
385
|
+
): DeployEvent[] =>
|
|
386
|
+
runs.flatMap((run) => {
|
|
387
|
+
const lane = mergeCommits[run.headSha] ?? null
|
|
388
|
+
const pullRequest = lane === null ? ownMergeCommits[run.headSha] : undefined
|
|
389
|
+
const relevant = lane !== null || pullRequest !== undefined || run.event === RUN_EVENT.workflowDispatch
|
|
390
|
+
if (!wasOpen(previous, run) || run.status !== RUN_STATUS.completed || !relevant) return []
|
|
391
|
+
const deploy = { kind: WATCH_EVENT.deploy, runId: run.id, trigger: run.event, conclusion: run.conclusion, lane }
|
|
392
|
+
return [pullRequest === undefined ? deploy : { ...deploy, pullRequest }]
|
|
393
|
+
})
|
|
394
|
+
|
|
395
|
+
/** Fires once, at the first failed `gh` read of a streak (`error` is its reason); a streak that has begun is already known. */
|
|
396
|
+
export const ghOutageEvent = (previous: WatchState, error: string | null): SignalEvent | null =>
|
|
397
|
+
error !== null && previous.ghDownSince === null ? { kind: WATCH_EVENT.ghUnavailable, reason: error } : null
|
|
398
|
+
|
|
399
|
+
export const isHeartbeatDue = (armedAt: number, now: number, intervalMs: number): boolean => now - armedAt >= intervalMs
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto'
|
|
2
|
+
import { mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
|
3
|
+
import { tmpdir } from 'node:os'
|
|
4
|
+
import { basename, join } from 'node:path'
|
|
5
|
+
import { CLI_NAME, PACKAGE_NAME } from '../version/release'
|
|
6
|
+
|
|
7
|
+
/** Looks up the command line of a process; null when there is no such process. */
|
|
8
|
+
export type CommandLineOf = (pid: number) => string | null
|
|
9
|
+
|
|
10
|
+
/** What taking the lock did to a watcher that held it: stopped it, or found it still running after the wait. */
|
|
11
|
+
export type TakeOver = { replaced: number | null; stillRunning: number | null }
|
|
12
|
+
|
|
13
|
+
/** The CLI's entry by source path or bin link (`jh`, and `jackherd` for `bunx jackherd`); `agent-kit` until 2.0.0, so a watcher of the old CLI is replaced. */
|
|
14
|
+
const CLI_ENTRIES = ['cli.ts', CLI_NAME, PACKAGE_NAME, 'agent-kit']
|
|
15
|
+
const WATCH_COMMAND = 'watch'
|
|
16
|
+
const RUNTIME = 'bun'
|
|
17
|
+
const DIRECTORY_PREFIX = 'jackherd-watch'
|
|
18
|
+
const KEY_HASH_LENGTH = 12
|
|
19
|
+
const NOT_A_SEGMENT = /[^A-Za-z0-9._-]+/g
|
|
20
|
+
const PRIVATE_DIRECTORY = 0o700
|
|
21
|
+
const PRIVATE_FILE = 0o600
|
|
22
|
+
const STOP_WAIT_MS = 5_000
|
|
23
|
+
const STOP_POLL_MS = 50
|
|
24
|
+
|
|
25
|
+
export const WATCHER_FILES = { state: 'watch-state.json', lock: 'watch.lock' } as const
|
|
26
|
+
|
|
27
|
+
/** The process ID a lock file holds, or null for a missing, empty or damaged file. */
|
|
28
|
+
export const lockedPid = (content: string | null): number | null => {
|
|
29
|
+
const pid = Number.parseInt(content?.trim() ?? '', 10)
|
|
30
|
+
return Number.isInteger(pid) && pid > 0 ? pid : null
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* True for `bun <…/cli.ts|…/jh> watch …`: the runtime first, the kit's CLI as an argument of its own and
|
|
35
|
+
* `watch` right after it; not an editor, a pager or another command of the CLI.
|
|
36
|
+
*/
|
|
37
|
+
export const isWatcherCommand = (commandLine: string): boolean => {
|
|
38
|
+
const [program, ...args] = commandLine.trim().split(/\s+/)
|
|
39
|
+
if (basename(program ?? '') !== RUNTIME) return false
|
|
40
|
+
const entry = args.findIndex((arg) => CLI_ENTRIES.includes(basename(arg)))
|
|
41
|
+
return entry !== -1 && args[entry + 1] === WATCH_COMMAND
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The watcher to stop before this one takes the lock, or null. Only a process whose command line
|
|
46
|
+
* runs `jh watch` is ever named: a process ID is reused after its process ends, and a signal
|
|
47
|
+
* must never reach whatever holds the number now.
|
|
48
|
+
*/
|
|
49
|
+
export const supersedeTarget = (
|
|
50
|
+
content: string | null,
|
|
51
|
+
ownPid: number,
|
|
52
|
+
commandLineOf: CommandLineOf,
|
|
53
|
+
): number | null => {
|
|
54
|
+
const pid = lockedPid(content)
|
|
55
|
+
if (pid === null || pid === ownPid) return null
|
|
56
|
+
const commandLine = commandLineOf(pid)
|
|
57
|
+
return commandLine !== null && isWatcherCommand(commandLine) ? pid : null
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** What a watcher that received SIGTERM prints: who replaced it, or that it was stopped from outside. */
|
|
61
|
+
export const stopNotice = (content: string | null, ownPid: number): string => {
|
|
62
|
+
const pid = lockedPid(content)
|
|
63
|
+
return pid !== null && pid !== ownPid ? `superseded by ${pid}` : 'stopped by SIGTERM'
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* The name of a repository's watcher directory: the checkout's directory name, reduced to a plain path segment,
|
|
68
|
+
* and the first hex digits of a SHA-256 of its path, so two checkouts of the same name stay apart.
|
|
69
|
+
*/
|
|
70
|
+
export const repositoryKey = (root: string): string => {
|
|
71
|
+
const name = basename(root).replace(NOT_A_SEGMENT, '-')
|
|
72
|
+
const hash = createHash('sha256').update(root).digest('hex').slice(0, KEY_HASH_LENGTH)
|
|
73
|
+
return `${name}-${hash}`
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const readText = (file: string): string | null => {
|
|
77
|
+
try {
|
|
78
|
+
return readFileSync(file, 'utf8')
|
|
79
|
+
} catch {
|
|
80
|
+
return null
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const commandLineOf: CommandLineOf = (pid) => {
|
|
85
|
+
const result = Bun.spawnSync(['ps', '-o', 'command=', '-p', String(pid)], { stdout: 'pipe', stderr: 'ignore' })
|
|
86
|
+
return result.exitCode === 0 ? result.stdout.toString().trim() || null : null
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const isAlive = (pid: number): boolean => {
|
|
90
|
+
try {
|
|
91
|
+
process.kill(pid, 0)
|
|
92
|
+
return true
|
|
93
|
+
} catch {
|
|
94
|
+
return false
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Where `ensureWatcherDirectory` puts the watcher's files, without creating anything: for readers such as the plans view. */
|
|
99
|
+
export const watcherDirectoryOf = (root: string): string =>
|
|
100
|
+
join(tmpdir(), `${DIRECTORY_PREFIX}-${process.getuid?.() ?? 'user'}`, repositoryKey(root))
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* The directory for the lock and state of the watcher of the repository at `root`, one per user and repository,
|
|
104
|
+
* readable by that user only: with `TMPDIR` unset, `tmpdir()` is the shared `/tmp`, where another account could
|
|
105
|
+
* plant the files. A watcher in another repository has its own lock and does not supersede this one.
|
|
106
|
+
*/
|
|
107
|
+
export const ensureWatcherDirectory = (root: string): string => {
|
|
108
|
+
const directory = watcherDirectoryOf(root)
|
|
109
|
+
mkdirSync(directory, { recursive: true, mode: PRIVATE_DIRECTORY })
|
|
110
|
+
return directory
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Whether the lock names a live process that is a watcher, so its saved state is being kept up to date. */
|
|
114
|
+
export const isWatcherRunning = (lockFile: string, lookUp: CommandLineOf = commandLineOf): boolean => {
|
|
115
|
+
const pid = lockedPid(readText(lockFile))
|
|
116
|
+
return pid !== null && isAlive(pid) && isWatcherCommand(lookUp(pid) ?? '')
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Writes a file only the user can read. */
|
|
120
|
+
export const writePrivate = (file: string, content: string) => writeFileSync(file, content, { mode: PRIVATE_FILE })
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Takes the lock for this process. A watcher that held it gets SIGTERM and is waited for, so the state
|
|
124
|
+
* file it writes on the way out is complete before this one reads it.
|
|
125
|
+
*/
|
|
126
|
+
export const takeOverLock = async (lockFile: string): Promise<TakeOver> => {
|
|
127
|
+
const previous = supersedeTarget(readText(lockFile), process.pid, commandLineOf)
|
|
128
|
+
writePrivate(lockFile, String(process.pid))
|
|
129
|
+
if (previous === null || !isAlive(previous)) return { replaced: null, stillRunning: null }
|
|
130
|
+
try {
|
|
131
|
+
process.kill(previous, 'SIGTERM')
|
|
132
|
+
} catch {
|
|
133
|
+
return { replaced: null, stillRunning: null }
|
|
134
|
+
}
|
|
135
|
+
const deadline = Date.now() + STOP_WAIT_MS
|
|
136
|
+
while (isAlive(previous) && Date.now() < deadline) await Bun.sleep(STOP_POLL_MS)
|
|
137
|
+
return isAlive(previous) ? { replaced: null, stillRunning: previous } : { replaced: previous, stillRunning: null }
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export const stopNoticeOf = (lockFile: string): string => stopNotice(readText(lockFile), process.pid)
|
|
141
|
+
|
|
142
|
+
/** Removes the lock only while it still names this process; a successor's lock stays. */
|
|
143
|
+
export const releaseLock = (lockFile: string) => {
|
|
144
|
+
if (lockedPid(readText(lockFile)) === process.pid) rmSync(lockFile, { force: true })
|
|
145
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import type { JackherdConfig } from '../config'
|
|
2
|
+
import { isRecord, parseArray } from './json-guards'
|
|
3
|
+
|
|
4
|
+
/** Which workflow's runs are deploys, and which environments count as their target (`watch.deploy`). */
|
|
5
|
+
export type DeployConfig = NonNullable<JackherdConfig['watch']['deploy']>
|
|
6
|
+
|
|
7
|
+
/** The statuses of a run in `gh run list --json status`. */
|
|
8
|
+
export const RUN_STATUS = { queued: 'queued', inProgress: 'in_progress', completed: 'completed' } as const
|
|
9
|
+
|
|
10
|
+
/** The triggers of a run in `gh run list --json event` a deploy workflow answers to. */
|
|
11
|
+
export const RUN_EVENT = { push: 'push', workflowRun: 'workflow_run', workflowDispatch: 'workflow_dispatch' } as const
|
|
12
|
+
|
|
13
|
+
/** `conclusion` is null while a run is open; `createdAt` is ISO 8601. */
|
|
14
|
+
export type WorkflowRun = {
|
|
15
|
+
id: number
|
|
16
|
+
event: string
|
|
17
|
+
headSha: string
|
|
18
|
+
status: string
|
|
19
|
+
conclusion: string | null
|
|
20
|
+
createdAt: string
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** A GitHub deployment of a commit; its statuses point at the run that made it. */
|
|
24
|
+
type Deployment = { id: number; environment: string }
|
|
25
|
+
|
|
26
|
+
type GhWorkflowRun = {
|
|
27
|
+
databaseId: number
|
|
28
|
+
event: string
|
|
29
|
+
headSha: string
|
|
30
|
+
status: string
|
|
31
|
+
conclusion: string
|
|
32
|
+
createdAt: string
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** The fields `parseWorkflowRuns` reads; pass them to `gh run list --json`. */
|
|
36
|
+
export const WORKFLOW_RUN_FIELDS = 'databaseId,event,headSha,status,conclusion,createdAt'
|
|
37
|
+
|
|
38
|
+
const RUN_LIMIT = '20'
|
|
39
|
+
|
|
40
|
+
const RUN_LOG_PATH = /\/actions\/runs\/(\d+)\//
|
|
41
|
+
|
|
42
|
+
const isGhWorkflowRun = (value: unknown): value is GhWorkflowRun =>
|
|
43
|
+
isRecord(value) &&
|
|
44
|
+
typeof value.databaseId === 'number' &&
|
|
45
|
+
typeof value.event === 'string' &&
|
|
46
|
+
typeof value.headSha === 'string' &&
|
|
47
|
+
typeof value.status === 'string' &&
|
|
48
|
+
typeof value.conclusion === 'string' &&
|
|
49
|
+
typeof value.createdAt === 'string'
|
|
50
|
+
|
|
51
|
+
const isDeployment = (value: unknown): value is Deployment =>
|
|
52
|
+
isRecord(value) && typeof value.id === 'number' && typeof value.environment === 'string'
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Reads the JSON of `gh run list --json <WORKFLOW_RUN_FIELDS>`; throws on anything else. `gh` sends an
|
|
56
|
+
* open run's conclusion as an empty string, which becomes null here.
|
|
57
|
+
*/
|
|
58
|
+
export const parseWorkflowRuns = (json: string): WorkflowRun[] =>
|
|
59
|
+
parseArray(json, isGhWorkflowRun, 'gh run list').map((run) => ({
|
|
60
|
+
id: run.databaseId,
|
|
61
|
+
event: run.event,
|
|
62
|
+
headSha: run.headSha,
|
|
63
|
+
status: run.status,
|
|
64
|
+
conclusion: run.conclusion || null,
|
|
65
|
+
createdAt: run.createdAt,
|
|
66
|
+
}))
|
|
67
|
+
|
|
68
|
+
/** Reads `[{id, environment}]` as `gh api …/deployments --jq '[.[] | {id, environment}]'` prints it. */
|
|
69
|
+
export const parseDeployments = (json: string): Deployment[] => parseArray(json, isDeployment, 'gh api deployments')
|
|
70
|
+
|
|
71
|
+
/** True for an environment the config names as a target; any other name in a deployment is not taken as one. */
|
|
72
|
+
export const isDeployTarget = (environment: string, environments: readonly string[]): boolean =>
|
|
73
|
+
environments.includes(environment)
|
|
74
|
+
|
|
75
|
+
/** The `gh` arguments that list the deploy workflow's runs on the base branch; null when the project reports no deploys. */
|
|
76
|
+
export const runListArgs = (deploy: DeployConfig | undefined, baseBranch: string): string[] | null =>
|
|
77
|
+
deploy
|
|
78
|
+
? [
|
|
79
|
+
'run',
|
|
80
|
+
'list',
|
|
81
|
+
'--workflow',
|
|
82
|
+
deploy.workflow,
|
|
83
|
+
'--branch',
|
|
84
|
+
baseBranch,
|
|
85
|
+
'--limit',
|
|
86
|
+
RUN_LIMIT,
|
|
87
|
+
'--json',
|
|
88
|
+
WORKFLOW_RUN_FIELDS,
|
|
89
|
+
]
|
|
90
|
+
: null
|
|
91
|
+
|
|
92
|
+
/** The run ID a deployment status's `log_url` (`…/actions/runs/<id>/job/<job>`) points at, or null. */
|
|
93
|
+
export const runIdOfLogUrl = (logUrl: string): number | null => {
|
|
94
|
+
const match = logUrl.match(RUN_LOG_PATH)
|
|
95
|
+
return match ? Number(match[1]) : null
|
|
96
|
+
}
|