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,121 @@
|
|
|
1
|
+
export type HerdrAgent = { name?: string; pane_id: string; agent_status: string; state_change_seq?: number }
|
|
2
|
+
|
|
3
|
+
/** The herdr agent states; herdr's JSON delivers them as plain strings. */
|
|
4
|
+
export const AGENT_STATUS = { working: 'working', blocked: 'blocked', done: 'done', idle: 'idle' } as const
|
|
5
|
+
|
|
6
|
+
/** The state a report gives a lane whose agent left the herdr listing. */
|
|
7
|
+
export const LANE_GONE = 'no longer listed'
|
|
8
|
+
|
|
9
|
+
/** The state a report gives a lane that has no agent in herdr. */
|
|
10
|
+
export const NO_AGENT = 'no agent'
|
|
11
|
+
|
|
12
|
+
export type AgentStatus = (typeof AGENT_STATUS)[keyof typeof AGENT_STATUS]
|
|
13
|
+
|
|
14
|
+
export type LaneState = { status: string; seq: number | null }
|
|
15
|
+
|
|
16
|
+
export type LaneSnapshot = Record<string, LaneState>
|
|
17
|
+
|
|
18
|
+
/** The last `state_change_seq` the watcher saw per lane, kept across runs; no entry means never seen. */
|
|
19
|
+
export type SeenSeqs = Record<string, number | null>
|
|
20
|
+
|
|
21
|
+
const STOP_STATUSES = new Set<string>([AGENT_STATUS.blocked, AGENT_STATUS.done, AGENT_STATUS.idle])
|
|
22
|
+
|
|
23
|
+
export const isAgentStatus = (value: string): value is AgentStatus =>
|
|
24
|
+
(Object.values(AGENT_STATUS) as string[]).includes(value)
|
|
25
|
+
|
|
26
|
+
/** The state of each named lane in a `herdr agent list` answer; unnamed and other agents are left out. */
|
|
27
|
+
export const toSnapshot = (agents: readonly HerdrAgent[], lanes: readonly string[]): LaneSnapshot =>
|
|
28
|
+
Object.fromEntries(
|
|
29
|
+
agents
|
|
30
|
+
.filter((agent) => agent.name && lanes.includes(agent.name))
|
|
31
|
+
.map((agent) => [agent.name, { status: agent.agent_status, seq: agent.state_change_seq ?? null }]),
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
const isHerdrAgent = (value: unknown): value is HerdrAgent => {
|
|
35
|
+
if (typeof value !== 'object' || value === null) return false
|
|
36
|
+
const candidate = value as Record<string, unknown>
|
|
37
|
+
return (
|
|
38
|
+
typeof candidate.pane_id === 'string' &&
|
|
39
|
+
typeof candidate.agent_status === 'string' &&
|
|
40
|
+
(candidate.name === undefined || typeof candidate.name === 'string') &&
|
|
41
|
+
(candidate.state_change_seq === undefined || typeof candidate.state_change_seq === 'number')
|
|
42
|
+
)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Reads the JSON of `herdr agent list`; throws on anything else, so a changed format is seen, not guessed. */
|
|
46
|
+
export const agentsOf = (json: string): HerdrAgent[] => {
|
|
47
|
+
const agents = (JSON.parse(json) as { result?: { agents?: unknown } }).result?.agents
|
|
48
|
+
if (!Array.isArray(agents) || !agents.every(isHerdrAgent)) throw new Error('herdr agent list: unexpected answer')
|
|
49
|
+
return agents
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** The sidebar titles that differ from the ones last reported, in the order of `titles`; null clears one. */
|
|
53
|
+
export const changedTitles = (
|
|
54
|
+
titles: ReadonlyMap<string, string | null>,
|
|
55
|
+
reported: ReadonlyMap<string, string | null>,
|
|
56
|
+
): [string, string | null][] => [...titles].filter(([pane, title]) => reported.get(pane) !== title)
|
|
57
|
+
|
|
58
|
+
const isWaiting = (state: LaneState): boolean => STOP_STATUSES.has(state.status)
|
|
59
|
+
|
|
60
|
+
const hasSeen = (seen: SeenSeqs, lane: string): boolean => Object.hasOwn(seen, lane)
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The baseline a new run starts from. A lane never seen before takes its current state, so a freshly
|
|
64
|
+
* started lane does not wake the coordinator. A waiting lane seen earlier with another sequence is
|
|
65
|
+
* set back to that sequence, so a stop between the last return and the re-arm fires on the first
|
|
66
|
+
* poll instead of being swallowed. Lanes that are working start from their current state. A lane
|
|
67
|
+
* seen earlier that is missing now stays in the baseline, so its exit fires.
|
|
68
|
+
*/
|
|
69
|
+
export const armBaseline = (current: LaneSnapshot, seen: SeenSeqs): LaneSnapshot => {
|
|
70
|
+
const gone = Object.keys(seen)
|
|
71
|
+
.filter((lane) => !(lane in current))
|
|
72
|
+
.map((lane) => [lane, { status: LANE_GONE, seq: seen[lane] ?? null }])
|
|
73
|
+
const listed = Object.entries(current).map(([lane, state]) => [
|
|
74
|
+
lane,
|
|
75
|
+
isWaiting(state) && hasSeen(seen, lane) && seen[lane] !== state.seq ? { ...state, seq: seen[lane] ?? null } : state,
|
|
76
|
+
])
|
|
77
|
+
return Object.fromEntries([...gone, ...listed])
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* The lanes that need the coordinator: a lane fires when it sits in `blocked`, `done` or `idle` with
|
|
82
|
+
* a `state_change_seq` other than the one in the baseline. A lane the last run already reported keeps
|
|
83
|
+
* its sequence and does not fire again, so re-arming right after handling a lane never returns at
|
|
84
|
+
* once. A lane that left the listing (its agent exited) fires as well; one never listed does not.
|
|
85
|
+
*/
|
|
86
|
+
export const firedLanes = (baseline: LaneSnapshot, current: LaneSnapshot, lanes: readonly string[]): string[] =>
|
|
87
|
+
lanes.filter((lane) => {
|
|
88
|
+
const now = current[lane]
|
|
89
|
+
if (!now) return baseline[lane] !== undefined
|
|
90
|
+
return isWaiting(now) && baseline[lane]?.seq !== now.seq
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
/** The sequence of every observed lane; saved on each return so "no entry" means "never seen". */
|
|
94
|
+
export const seenSeqsOf = (current: LaneSnapshot): SeenSeqs =>
|
|
95
|
+
Object.fromEntries(Object.entries(current).map(([lane, state]) => [lane, state.seq]))
|
|
96
|
+
|
|
97
|
+
/** Moves the baseline along for lanes that are working, so their next stop counts as a change. */
|
|
98
|
+
export const advanceBaseline = (baseline: LaneSnapshot, current: LaneSnapshot): LaneSnapshot =>
|
|
99
|
+
Object.fromEntries(
|
|
100
|
+
Object.entries(current).map(([lane, state]) => [
|
|
101
|
+
lane,
|
|
102
|
+
state.status === AGENT_STATUS.working ? state : (baseline[lane] ?? state),
|
|
103
|
+
]),
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* The baseline once agents showed up: a lane whose agent is listed but has no baseline entry takes
|
|
108
|
+
* its current state, as on a fresh start, so an agent that starts out idle does not wake the
|
|
109
|
+
* coordinator. Entries already there stay, including those of lanes whose agent left.
|
|
110
|
+
*/
|
|
111
|
+
export const baselineWithNewAgents = (baseline: LaneSnapshot, current: LaneSnapshot): LaneSnapshot => ({
|
|
112
|
+
...current,
|
|
113
|
+
...baseline,
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* The lanes that left the watched set together with their agent: they were seen, and nothing is
|
|
118
|
+
* listed for them now. They fire as gone once, although they are no longer watched.
|
|
119
|
+
*/
|
|
120
|
+
export const departedLanes = (removed: readonly string[], baseline: LaneSnapshot, current: LaneSnapshot): string[] =>
|
|
121
|
+
removed.filter((lane) => lane in baseline && !(lane in current))
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
type Environment = Readonly<Record<string, string | undefined>>
|
|
2
|
+
|
|
3
|
+
const PROVIDER_SWITCHES = ['CLAUDE_CODE_USE_BEDROCK', 'CLAUDE_CODE_USE_VERTEX', 'CLAUDE_CODE_USE_FOUNDRY']
|
|
4
|
+
|
|
5
|
+
/** Read only for being set at all: any non-empty value, `0` included, switches the behaviour on. */
|
|
6
|
+
const PRESENCE_SWITCHES = ['DISABLE_TELEMETRY', 'CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC']
|
|
7
|
+
|
|
8
|
+
const ON_VALUES = ['1', 'true', 'yes', 'on']
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Whether a Claude Code session started in this environment has the Monitor tool. Source: Claude Code docs,
|
|
12
|
+
* tools reference, "Monitor tool" (code.claude.com/docs/en/tools-reference#monitor-tool): not available on
|
|
13
|
+
* Amazon Bedrock, Google Cloud's Agent Platform (Vertex) or Microsoft Foundry, nor when `DISABLE_TELEMETRY` or
|
|
14
|
+
* `CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC` is set. The provider switches take `1`/`true`/`yes`/`on` in any
|
|
15
|
+
* casing (env-vars reference); the two others count when set to anything non-empty.
|
|
16
|
+
*/
|
|
17
|
+
export const monitorToolAvailable = (env: Environment): boolean =>
|
|
18
|
+
!PROVIDER_SWITCHES.some((name) => ON_VALUES.includes((env[name] ?? '').toLowerCase())) &&
|
|
19
|
+
!PRESENCE_SWITCHES.some((name) => (env[name] ?? '') !== '')
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { CLI_NAME } from '../version/release'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* `lanes` null finds the lanes instead of taking them from the command line. `coordinator` names the
|
|
5
|
+
* coordinator's agent; null leaves it to the agent in the watcher's own pane. `stream` keeps the
|
|
6
|
+
* watcher running after a report instead of returning.
|
|
7
|
+
*/
|
|
8
|
+
export type WatchOptions = {
|
|
9
|
+
lanes: string[] | null
|
|
10
|
+
heartbeatMinutes: number
|
|
11
|
+
boardPath: string | null
|
|
12
|
+
coordinator: string | null
|
|
13
|
+
stream: boolean
|
|
14
|
+
/** `watch source <target> --dry-run [--raw]`: run one source once instead of watching (`SourceDryRun`). */
|
|
15
|
+
sourceDryRun: SourceDryRun | null
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** `target` is a configured name, a draft file or `-` for stdin; `raw` also prints the command's output. */
|
|
19
|
+
export type SourceDryRun = { target: string; raw: boolean }
|
|
20
|
+
|
|
21
|
+
export const DEFAULT_HEARTBEAT_MINUTES = 15
|
|
22
|
+
|
|
23
|
+
const VALUE_OPTIONS = ['--heartbeat', '--board', '--coordinator']
|
|
24
|
+
const SOURCE_SUBCOMMAND = 'source'
|
|
25
|
+
const DRY_RUN_FLAG = '--dry-run'
|
|
26
|
+
const RAW_FLAG = '--raw'
|
|
27
|
+
const STREAM_FLAG = '--stream'
|
|
28
|
+
|
|
29
|
+
const USAGE = `usage: ${CLI_NAME} watch [--stream] [--heartbeat <minutes>] [--board <board.md>] [--coordinator <name>] [<lane> ...]\n ${CLI_NAME} watch source <name|draft.json|-> --dry-run [--raw]`
|
|
30
|
+
|
|
31
|
+
const optionValue = (args: readonly string[], option: string): string | null => {
|
|
32
|
+
const index = args.indexOf(option)
|
|
33
|
+
if (index === -1) return null
|
|
34
|
+
const value = args[index + 1]
|
|
35
|
+
if (value === undefined || value.startsWith('--')) throw new Error(`${option} needs a value\n${USAGE}`)
|
|
36
|
+
return value
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const heartbeatOf = (value: string | null): number => {
|
|
40
|
+
if (value === null) return DEFAULT_HEARTBEAT_MINUTES
|
|
41
|
+
const minutes = Number(value)
|
|
42
|
+
if (!Number.isFinite(minutes) || minutes <= 0)
|
|
43
|
+
throw new Error(`--heartbeat needs a positive number of minutes\n${USAGE}`)
|
|
44
|
+
return minutes
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** `source <name|draft.json|-> --dry-run [--raw]` and nothing else; a source is never watched from the command line. */
|
|
48
|
+
const sourceDryRunOf = (args: readonly string[]): SourceDryRun => {
|
|
49
|
+
const [, target, flag, ...rest] = args
|
|
50
|
+
const raw = rest.length === 1 && rest[0] === RAW_FLAG
|
|
51
|
+
if (target === undefined || target.startsWith('--') || flag !== DRY_RUN_FLAG || (rest.length > 0 && !raw))
|
|
52
|
+
throw new Error(`watch source runs one source once: source <name|draft.json|-> --dry-run [--raw]\n${USAGE}`)
|
|
53
|
+
return { target, raw }
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Reads the watcher's command line; throws with the usage on an unknown option or a missing value. */
|
|
57
|
+
export const parseWatchOptions = (args: readonly string[]): WatchOptions => {
|
|
58
|
+
if (args[0] === SOURCE_SUBCOMMAND)
|
|
59
|
+
return {
|
|
60
|
+
lanes: null,
|
|
61
|
+
heartbeatMinutes: DEFAULT_HEARTBEAT_MINUTES,
|
|
62
|
+
boardPath: null,
|
|
63
|
+
coordinator: null,
|
|
64
|
+
stream: false,
|
|
65
|
+
sourceDryRun: sourceDryRunOf(args),
|
|
66
|
+
}
|
|
67
|
+
const unknown = args.find((arg) => arg.startsWith('--') && !VALUE_OPTIONS.includes(arg) && arg !== STREAM_FLAG)
|
|
68
|
+
if (unknown) throw new Error(`unknown option ${unknown}\n${USAGE}`)
|
|
69
|
+
const valueIndexes = new Set(VALUE_OPTIONS.map((option) => args.indexOf(option) + 1).filter((index) => index > 0))
|
|
70
|
+
const lanes = args.filter((arg, index) => !arg.startsWith('--') && !valueIndexes.has(index))
|
|
71
|
+
return {
|
|
72
|
+
lanes: lanes.length > 0 ? lanes : null,
|
|
73
|
+
heartbeatMinutes: heartbeatOf(optionValue(args, '--heartbeat')),
|
|
74
|
+
boardPath: optionValue(args, '--board'),
|
|
75
|
+
coordinator: optionValue(args, '--coordinator'),
|
|
76
|
+
stream: args.includes(STREAM_FLAG),
|
|
77
|
+
sourceDryRun: null,
|
|
78
|
+
}
|
|
79
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
import { WATCH_HEALTH, type WatchReading, watchBadgesOf, watchHealthOf } from './plan-badges'
|
|
4
|
+
import { parseWatchState, type WatchState } from './watch-events'
|
|
5
|
+
import { type CommandLineOf, isWatcherRunning, WATCHER_FILES, watcherDirectoryOf } from './watcher-lock'
|
|
6
|
+
|
|
7
|
+
const readState = (file: string): WatchState | null => {
|
|
8
|
+
try {
|
|
9
|
+
return parseWatchState(readFileSync(file, 'utf8'))
|
|
10
|
+
} catch {
|
|
11
|
+
return null
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* What the watcher of the repository at `root` gives the plans view, read from its state file without a GitHub call:
|
|
17
|
+
* its badges, and its health, so the view can say why there are none. A missing or damaged file shows no badges; the
|
|
18
|
+
* view goes on without them.
|
|
19
|
+
*/
|
|
20
|
+
export const readWatchReading = (root: string, now: number, lookUp?: CommandLineOf): WatchReading => {
|
|
21
|
+
const directory = watcherDirectoryOf(root)
|
|
22
|
+
const state = readState(join(directory, WATCHER_FILES.state))
|
|
23
|
+
const health = watchHealthOf(
|
|
24
|
+
state?.savedAt ?? null,
|
|
25
|
+
isWatcherRunning(join(directory, WATCHER_FILES.lock), lookUp),
|
|
26
|
+
now,
|
|
27
|
+
)
|
|
28
|
+
const badges = state && health.kind === WATCH_HEALTH.live ? watchBadgesOf(state, now) : {}
|
|
29
|
+
return { badges, health }
|
|
30
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { PULL_REQUEST_STATE } from './pull-requests'
|
|
2
|
+
import type { WatchState } from './watch-events'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* What the plans view shows beside a pull request: the checked URL of its preview, the feedback since its last push,
|
|
6
|
+
* and whether a reviewer requests changes.
|
|
7
|
+
*/
|
|
8
|
+
export type PullRequestBadges = { preview: string | null; feedback: number; changesRequested: boolean }
|
|
9
|
+
|
|
10
|
+
/** The badges of a pull request the watcher shows nothing for. */
|
|
11
|
+
export const NO_BADGES: PullRequestBadges = { preview: null, feedback: 0, changesRequested: false }
|
|
12
|
+
|
|
13
|
+
/** Pull request number → its badges; a pull request without an entry has none. */
|
|
14
|
+
export type WatchBadges = Record<string, PullRequestBadges>
|
|
15
|
+
|
|
16
|
+
/** Whether the watcher's state can be shown: no watcher runs, its state is too old, or it is live. */
|
|
17
|
+
export const WATCH_HEALTH = { live: 'live', off: 'off', stale: 'stale' } as const
|
|
18
|
+
|
|
19
|
+
/** `minutes` is the whole minutes since the stale state was saved. */
|
|
20
|
+
export type WatchHealth =
|
|
21
|
+
| { kind: typeof WATCH_HEALTH.live }
|
|
22
|
+
| { kind: typeof WATCH_HEALTH.off }
|
|
23
|
+
| { kind: typeof WATCH_HEALTH.stale; minutes: number }
|
|
24
|
+
|
|
25
|
+
/** The badges the view draws and why there may be none. */
|
|
26
|
+
export type WatchReading = { badges: WatchBadges; health: WatchHealth }
|
|
27
|
+
|
|
28
|
+
/** Three missed GitHub reads of the watcher (one a minute): an older state is not shown (D96). */
|
|
29
|
+
export const STALE_AFTER_MS = 180_000
|
|
30
|
+
|
|
31
|
+
const MINUTE_MS = 60_000
|
|
32
|
+
|
|
33
|
+
const isFresh = (savedAt: number | null, now: number): boolean => savedAt !== null && now - savedAt <= STALE_AFTER_MS
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The health of a watcher from whether it runs and when it last saved its state. A watcher that runs but has not
|
|
37
|
+
* saved after a GitHub read yet is live: it only just started.
|
|
38
|
+
*/
|
|
39
|
+
export const watchHealthOf = (savedAt: number | null, running: boolean, now: number): WatchHealth => {
|
|
40
|
+
if (!running) return { kind: WATCH_HEALTH.off }
|
|
41
|
+
if (savedAt === null || isFresh(savedAt, now)) return { kind: WATCH_HEALTH.live }
|
|
42
|
+
return { kind: WATCH_HEALTH.stale, minutes: Math.floor((now - savedAt) / MINUTE_MS) }
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The badges of the open pull requests the watcher follows, from its saved state alone: the preview reported for the
|
|
47
|
+
* head the watcher knows, the feedback counted since that head, and the requested changes, which a push keeps. A value
|
|
48
|
+
* of an older head, a preview recorded without its URL, and a state older than `STALE_AFTER_MS` show nothing.
|
|
49
|
+
*/
|
|
50
|
+
export const watchBadgesOf = (state: WatchState, now: number): WatchBadges => {
|
|
51
|
+
if (!isFresh(state.savedAt, now)) return {}
|
|
52
|
+
return Object.fromEntries(
|
|
53
|
+
Object.entries(state.pullRequests).flatMap(([number, { state: prState, headCommit }]) => {
|
|
54
|
+
if (prState !== PULL_REQUEST_STATE.open) return []
|
|
55
|
+
const preview = state.previews.pullRequests[number]
|
|
56
|
+
const feedback = state.feedback.pullRequests[number]
|
|
57
|
+
const badges = {
|
|
58
|
+
preview: preview?.reportedHead === headCommit ? (preview.url ?? null) : null,
|
|
59
|
+
feedback: feedback?.headCommit === headCommit ? feedback.count : 0,
|
|
60
|
+
changesRequested: feedback?.changesRequested === true,
|
|
61
|
+
}
|
|
62
|
+
const shown = badges.preview !== null || badges.feedback > 0 || badges.changesRequested
|
|
63
|
+
return shown ? [[number, badges]] : []
|
|
64
|
+
}),
|
|
65
|
+
)
|
|
66
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { TERMINAL_ADAPTER } from '../config/schema'
|
|
2
|
+
import { errorText } from '../plans/error-text'
|
|
3
|
+
import type { PlansSettings } from '../plans/settings'
|
|
4
|
+
import type { LaneSnapshot } from './lane-watch'
|
|
5
|
+
|
|
6
|
+
export const ENSURE_TRIGGER = { start: 'start', poll: 'poll' } as const
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Why the watcher may bring back plans views: its own start, or a poll that found lanes added to the watched set
|
|
10
|
+
* or watched lanes whose agent appeared (a lane gets its view only once its agent runs).
|
|
11
|
+
*/
|
|
12
|
+
export type EnsureTrigger =
|
|
13
|
+
| { kind: typeof ENSURE_TRIGGER.start }
|
|
14
|
+
| { kind: typeof ENSURE_TRIGGER.poll; added: string[]; withNewAgent: string[] }
|
|
15
|
+
|
|
16
|
+
/** Whether the last failure of `plans --ensure` was already printed; a run that works clears it. */
|
|
17
|
+
export type PlanViews = { failureReported: boolean }
|
|
18
|
+
|
|
19
|
+
type Ensured = { ok: true; lines: string[] } | { ok: false; error: string }
|
|
20
|
+
|
|
21
|
+
type Effects = { ensure: () => string[]; log: (line: string) => void }
|
|
22
|
+
|
|
23
|
+
export const insideHerdr = (adapter: PlansSettings['adapter'], env: Record<string, string | undefined>): boolean =>
|
|
24
|
+
adapter === TERMINAL_ADAPTER.herdr && env.HERDR_ENV === '1'
|
|
25
|
+
|
|
26
|
+
/** The watched lanes that had no agent at the last poll and have one now. */
|
|
27
|
+
export const lanesWithNewAgent = (previous: LaneSnapshot, next: LaneSnapshot, lanes: readonly string[]): string[] =>
|
|
28
|
+
lanes.filter((lane) => !previous[lane] && next[lane])
|
|
29
|
+
|
|
30
|
+
/** At the start, and after a poll that added a lane or saw a lane's agent appear; a lane that only left needs no view. */
|
|
31
|
+
export const ensureDue = (trigger: EnsureTrigger): boolean =>
|
|
32
|
+
trigger.kind === ENSURE_TRIGGER.start || trigger.added.length > 0 || trigger.withNewAgent.length > 0
|
|
33
|
+
|
|
34
|
+
/** The lines one run leaves in the watcher's output: what it brought back, or a failure once per streak. */
|
|
35
|
+
export const ensuredLines = (ensured: Ensured, views: PlanViews): { lines: string[]; views: PlanViews } => {
|
|
36
|
+
if (ensured.ok)
|
|
37
|
+
return { lines: ensured.lines.map((line) => `plans --ensure: ${line}`), views: { failureReported: false } }
|
|
38
|
+
if (views.failureReported) return { lines: [], views }
|
|
39
|
+
return { lines: [`plans --ensure failed, still watching: ${ensured.error}`], views: { failureReported: true } }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const ensuredOf = (ensure: () => string[]): Ensured => {
|
|
43
|
+
try {
|
|
44
|
+
return { ok: true, lines: ensure() }
|
|
45
|
+
} catch (error) {
|
|
46
|
+
return { ok: false, error: errorText(error) }
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Brings back missing plans views when `trigger` calls for it, as `jh plans --ensure` does. A failure
|
|
52
|
+
* never ends the watch; it is printed once until a run works again.
|
|
53
|
+
*/
|
|
54
|
+
export const keepPlanViews = (trigger: EnsureTrigger, views: PlanViews, effects: Effects): PlanViews => {
|
|
55
|
+
if (!ensureDue(trigger)) return views
|
|
56
|
+
const { lines, views: next } = ensuredLines(ensuredOf(effects.ensure), views)
|
|
57
|
+
for (const line of lines) effects.log(line)
|
|
58
|
+
return next
|
|
59
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { errorText } from '../plans/error-text'
|
|
2
|
+
import type { FeedbackTarget } from './feedback'
|
|
3
|
+
import { runGh } from './gh'
|
|
4
|
+
import {
|
|
5
|
+
deploymentCandidate,
|
|
6
|
+
type PreviewCandidate,
|
|
7
|
+
parseDeploymentLines,
|
|
8
|
+
parseDeploymentStatus,
|
|
9
|
+
previewDeployments,
|
|
10
|
+
} from './previews'
|
|
11
|
+
|
|
12
|
+
export type PreviewCandidatesRead = { ok: true; candidates: PreviewCandidate[] } | { ok: false; error: string }
|
|
13
|
+
|
|
14
|
+
type HeadRead = { ok: true; candidate: PreviewCandidate | null } | { ok: false; error: string }
|
|
15
|
+
|
|
16
|
+
const DEPLOYMENT_LIMIT = 30
|
|
17
|
+
const DEPLOYMENT_FIELDS = '.[] | {id, environment, production: (.production_environment // false)}'
|
|
18
|
+
/** A deployment without a status yet answers `null`, read as an empty state. */
|
|
19
|
+
const STATUS_FIELDS = '.[0] | {state: (.state // ""), url: (.environment_url // "")}'
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The newest preview deployment of the target's head as a candidate once its latest status succeeded with a URL, else
|
|
23
|
+
* none: a head costs one list and at most one status call per read. An older deployment of the same head that
|
|
24
|
+
* succeeded is not asked, the newest one stands for the head.
|
|
25
|
+
*/
|
|
26
|
+
const readHeadPreview = async (
|
|
27
|
+
root: string,
|
|
28
|
+
target: FeedbackTarget,
|
|
29
|
+
deployEnvironments: readonly string[],
|
|
30
|
+
): Promise<HeadRead> => {
|
|
31
|
+
const listPath = `repos/{owner}/{repo}/deployments?sha=${target.headCommit}&per_page=${DEPLOYMENT_LIMIT}`
|
|
32
|
+
const listed = await runGh(['api', listPath, '--jq', DEPLOYMENT_FIELDS], root)
|
|
33
|
+
if (!listed.ok) return listed
|
|
34
|
+
try {
|
|
35
|
+
const [newest] = previewDeployments(parseDeploymentLines(listed.stdout), deployEnvironments)
|
|
36
|
+
if (!newest) return { ok: true, candidate: null }
|
|
37
|
+
const statusPath = `repos/{owner}/{repo}/deployments/${newest.id}/statuses?per_page=1`
|
|
38
|
+
const status = await runGh(['api', statusPath, '--jq', STATUS_FIELDS], root)
|
|
39
|
+
if (!status.ok) return status
|
|
40
|
+
return { ok: true, candidate: deploymentCandidate(target, newest.id, parseDeploymentStatus(status.stdout)) }
|
|
41
|
+
} catch (error) {
|
|
42
|
+
return { ok: false, error: errorText(error) }
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The preview deployments of the targets' head commits over REST, one list per target and the status of its newest
|
|
48
|
+
* preview deployment. The deployments of `deployEnvironments` are the project's own deploys, never a
|
|
49
|
+
* preview. No target, no call; the first failure is the reason.
|
|
50
|
+
*/
|
|
51
|
+
export const readDeploymentCandidates = async (
|
|
52
|
+
root: string,
|
|
53
|
+
targets: readonly FeedbackTarget[],
|
|
54
|
+
deployEnvironments: readonly string[],
|
|
55
|
+
): Promise<PreviewCandidatesRead> => {
|
|
56
|
+
const reads = await Promise.all(targets.map((target) => readHeadPreview(root, target, deployEnvironments)))
|
|
57
|
+
const failed = reads.find((read) => !read.ok)
|
|
58
|
+
if (failed && !failed.ok) return { ok: false, error: failed.error }
|
|
59
|
+
return { ok: true, candidates: reads.flatMap((read) => (read.ok && read.candidate ? [read.candidate] : [])) }
|
|
60
|
+
}
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
import { FEEDBACK_FORM, type FeedbackFilter, type FeedbackItem, type FeedbackTarget, isReadTime } from './feedback'
|
|
2
|
+
import { isRecord } from './json-guards'
|
|
3
|
+
|
|
4
|
+
export const PREVIEW_EVENT = 'preview'
|
|
5
|
+
|
|
6
|
+
/** Where a preview is read: a deployment of the head commit, or a comment of a configured author. */
|
|
7
|
+
export const PREVIEW_SOURCE = { deployment: 'deployment', comment: FEEDBACK_FORM.comment } as const
|
|
8
|
+
|
|
9
|
+
export type PreviewSource = (typeof PREVIEW_SOURCE)[keyof typeof PREVIEW_SOURCE]
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* A preview as one source shows it, with what changes when it is updated: `key` names the comment or deployment
|
|
13
|
+
* (`<source>:<id>`), `stamp` its version (a comment's `updated_at`, a deployment's `success`). `url` is null when the
|
|
14
|
+
* source names no usable link yet; `headCommit` is the head a deployment was read for, null for a comment, which
|
|
15
|
+
* names no commit.
|
|
16
|
+
*/
|
|
17
|
+
export type PreviewCandidate = {
|
|
18
|
+
pullRequest: number
|
|
19
|
+
source: PreviewSource
|
|
20
|
+
key: string
|
|
21
|
+
stamp: string
|
|
22
|
+
url: string | null
|
|
23
|
+
headCommit: string | null
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* `reportedHead` is the head a preview was last reported (or, at the first sighting, found) for, `url` that preview's
|
|
28
|
+
* checked URL (missing in a state written before the plans view showed it); `stamps` maps `key` to `stamp`.
|
|
29
|
+
*/
|
|
30
|
+
export type PreviewRecord = { reportedHead: string | null; stamps: Record<string, string>; url?: string | null }
|
|
31
|
+
|
|
32
|
+
/** `since` is the epoch milliseconds of the last read, null before the first. */
|
|
33
|
+
export type PreviewState = { since: number | null; pullRequests: Record<string, PreviewRecord> }
|
|
34
|
+
|
|
35
|
+
export type PreviewRead = { readAt: number; targets: FeedbackTarget[]; candidates: PreviewCandidate[] }
|
|
36
|
+
|
|
37
|
+
/** Which comments name a preview: their authors' logins, and optionally a pattern whose first group is the URL. */
|
|
38
|
+
export type PreviewSettings = { authors: readonly string[]; urlPattern?: string }
|
|
39
|
+
|
|
40
|
+
export type PreviewEvent = { kind: typeof PREVIEW_EVENT; lane: string; number: number; url: string; headCommit: string }
|
|
41
|
+
|
|
42
|
+
/** A deployment of a commit as `gh api --jq` lists it, newest first. */
|
|
43
|
+
export type Deployment = { id: number; environment: string; production: boolean }
|
|
44
|
+
|
|
45
|
+
/** A deployment's latest status; both empty when it has none. */
|
|
46
|
+
export type DeploymentStatus = { state: string; url: string }
|
|
47
|
+
|
|
48
|
+
const DEPLOYMENT_SUCCESS = 'success'
|
|
49
|
+
/** A plain https link up to the first space, control character or Markdown and table delimiter. */
|
|
50
|
+
const FIRST_HTTPS_LINK = /https:\/\/[^\s<>()[\]{}"'`|\p{Cc}]+/u
|
|
51
|
+
const TRAILING_PUNCTUATION = /[.,;:!?]+$/
|
|
52
|
+
const URL_MAX_LENGTH = 200
|
|
53
|
+
|
|
54
|
+
export const emptyPreviewState = (): PreviewState => ({ since: null, pullRequests: {} })
|
|
55
|
+
|
|
56
|
+
/** The URL a text names, as a checked https URL: the first group of `pattern` (or its match), else the first https link. */
|
|
57
|
+
export const previewUrlOf = (text: string, pattern: string | undefined): string | null => {
|
|
58
|
+
const match = pattern === undefined ? FIRST_HTTPS_LINK.exec(text) : new RegExp(pattern).exec(text)
|
|
59
|
+
const raw = (match?.[1] ?? match?.[0] ?? '').replace(TRAILING_PUNCTUATION, '')
|
|
60
|
+
const linked = FIRST_HTTPS_LINK.exec(raw)?.[0]
|
|
61
|
+
if (linked === undefined || linked !== raw || raw.length > URL_MAX_LENGTH) return null
|
|
62
|
+
try {
|
|
63
|
+
const url = new URL(raw)
|
|
64
|
+
const withCredentials = url.username !== '' || url.password !== ''
|
|
65
|
+
return url.protocol === 'https:' && !withCredentials ? url.href : null
|
|
66
|
+
} catch {
|
|
67
|
+
return null
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** GitHub logins ignore case. */
|
|
72
|
+
export const isPreviewAuthor = (author: string, authors: readonly string[]): boolean =>
|
|
73
|
+
authors.some((name) => name.toLowerCase() === author.toLowerCase())
|
|
74
|
+
|
|
75
|
+
/** Whose comments are no feedback: the account the watcher runs as, the ignored logins and the preview authors. */
|
|
76
|
+
export const feedbackFilterOf = (
|
|
77
|
+
own: string,
|
|
78
|
+
ignore: readonly string[],
|
|
79
|
+
previews: Pick<PreviewSettings, 'authors'> | undefined,
|
|
80
|
+
): FeedbackFilter => ({ own, ignore: [...ignore, ...(previews?.authors ?? [])] })
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Whether the feedback read, which reached back from its own last read, also covers everything since the previews'
|
|
84
|
+
* last read; a first preview read is a baseline and needs no history.
|
|
85
|
+
*/
|
|
86
|
+
export const feedbackReadCovers = (feedbackSince: number | null, previewsSince: number | null): boolean =>
|
|
87
|
+
previewsSince === null || (feedbackSince !== null && feedbackSince <= previewsSince)
|
|
88
|
+
|
|
89
|
+
/** The pull request comments of the configured authors as candidates; a comment's text gives nothing but its URL. */
|
|
90
|
+
export const commentCandidates = (items: readonly FeedbackItem[], settings: PreviewSettings): PreviewCandidate[] =>
|
|
91
|
+
items
|
|
92
|
+
.filter((item) => item.form === FEEDBACK_FORM.comment && isPreviewAuthor(item.author, settings.authors))
|
|
93
|
+
.map((item) => ({
|
|
94
|
+
pullRequest: item.pullRequest,
|
|
95
|
+
source: PREVIEW_SOURCE.comment,
|
|
96
|
+
key: `${PREVIEW_SOURCE.comment}:${item.id}`,
|
|
97
|
+
stamp: item.updatedAt,
|
|
98
|
+
url: previewUrlOf(item.body, settings.urlPattern),
|
|
99
|
+
headCommit: null,
|
|
100
|
+
}))
|
|
101
|
+
|
|
102
|
+
const isDeployment = (value: unknown): value is Deployment =>
|
|
103
|
+
isRecord(value) &&
|
|
104
|
+
Number.isSafeInteger(value.id) &&
|
|
105
|
+
(value.id as number) > 0 &&
|
|
106
|
+
typeof value.environment === 'string' &&
|
|
107
|
+
typeof value.production === 'boolean'
|
|
108
|
+
|
|
109
|
+
/** Reads the lines of `gh api --jq '.[] | {id, environment, production}'`; throws on any other shape. */
|
|
110
|
+
export const parseDeploymentLines = (text: string): Deployment[] =>
|
|
111
|
+
text
|
|
112
|
+
.split('\n')
|
|
113
|
+
.filter((line) => line.trim() !== '')
|
|
114
|
+
.map((line) => {
|
|
115
|
+
const value: unknown = JSON.parse(line)
|
|
116
|
+
if (!isDeployment(value)) throw new Error('gh api deployments: unexpected answer')
|
|
117
|
+
return { id: value.id, environment: value.environment, production: value.production }
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
/** Reads `{state, url}` of a deployment's latest status; throws on any other shape. */
|
|
121
|
+
export const parseDeploymentStatus = (text: string): DeploymentStatus => {
|
|
122
|
+
const value: unknown = JSON.parse(text)
|
|
123
|
+
if (!isRecord(value) || typeof value.state !== 'string' || typeof value.url !== 'string')
|
|
124
|
+
throw new Error('gh api deployment status: unexpected answer')
|
|
125
|
+
return { state: value.state, url: value.url }
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** The deployments that may hold a preview: no production one and none of the project's deploy environments. */
|
|
129
|
+
export const previewDeployments = (deployments: readonly Deployment[], deployEnvironments: readonly string[]) =>
|
|
130
|
+
deployments.filter(({ production, environment }) => !production && !deployEnvironments.includes(environment))
|
|
131
|
+
|
|
132
|
+
/** A deployment of `target`'s head as a candidate once its latest status succeeded with an https URL. */
|
|
133
|
+
export const deploymentCandidate = (
|
|
134
|
+
target: FeedbackTarget,
|
|
135
|
+
id: number,
|
|
136
|
+
status: DeploymentStatus,
|
|
137
|
+
): PreviewCandidate | null => {
|
|
138
|
+
const url = status.state === DEPLOYMENT_SUCCESS ? previewUrlOf(status.url, undefined) : null
|
|
139
|
+
return url === null
|
|
140
|
+
? null
|
|
141
|
+
: {
|
|
142
|
+
pullRequest: target.number,
|
|
143
|
+
source: PREVIEW_SOURCE.deployment,
|
|
144
|
+
key: `${PREVIEW_SOURCE.deployment}:${id}`,
|
|
145
|
+
stamp: DEPLOYMENT_SUCCESS,
|
|
146
|
+
url,
|
|
147
|
+
headCommit: target.headCommit,
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export const previewReadOf = (
|
|
152
|
+
readAt: number,
|
|
153
|
+
targets: FeedbackTarget[],
|
|
154
|
+
candidates: readonly PreviewCandidate[],
|
|
155
|
+
): PreviewRead => {
|
|
156
|
+
const numbers = new Set(targets.map(({ number }) => number))
|
|
157
|
+
return { readAt, targets, candidates: candidates.filter(({ pullRequest }) => numbers.has(pullRequest)) }
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** A candidate with a URL that may stand for the target's current head: a comment, or a deployment of that head. */
|
|
161
|
+
const isUsable = (candidate: PreviewCandidate, target: FeedbackTarget): boolean =>
|
|
162
|
+
candidate.url !== null && (candidate.headCommit === null || candidate.headCommit === target.headCommit)
|
|
163
|
+
|
|
164
|
+
/** Deployments before comments: a deployment names its commit, a comment does not. */
|
|
165
|
+
const byDeploymentFirst = (a: PreviewCandidate, b: PreviewCandidate): number =>
|
|
166
|
+
Number(a.source !== PREVIEW_SOURCE.deployment) - Number(b.source !== PREVIEW_SOURCE.deployment)
|
|
167
|
+
|
|
168
|
+
const candidatesOf = (read: PreviewRead, target: FeedbackTarget): PreviewCandidate[] =>
|
|
169
|
+
read.candidates.filter(({ pullRequest }) => pullRequest === target.number).sort(byDeploymentFirst)
|
|
170
|
+
|
|
171
|
+
/** The usable candidate that changed since `record`, when the head has had no preview reported yet. */
|
|
172
|
+
const freshPreview = (record: PreviewRecord, target: FeedbackTarget, candidates: readonly PreviewCandidate[]) =>
|
|
173
|
+
target.headCommit === record.reportedHead
|
|
174
|
+
? undefined
|
|
175
|
+
: candidates.find((candidate) => record.stamps[candidate.key] !== candidate.stamp && isUsable(candidate, target))
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* One event per pull request whose head is new since its last reported preview and whose preview source changed
|
|
179
|
+
* since. A pull request without a record is a baseline: what it shows at its first read fires nothing.
|
|
180
|
+
*/
|
|
181
|
+
export const previewEvents = (previous: PreviewState, read: PreviewRead): PreviewEvent[] =>
|
|
182
|
+
read.targets.flatMap((target) => {
|
|
183
|
+
const record = previous.pullRequests[String(target.number)]
|
|
184
|
+
const fresh = record ? freshPreview(record, target, candidatesOf(read, target)) : undefined
|
|
185
|
+
return fresh?.url
|
|
186
|
+
? [
|
|
187
|
+
{
|
|
188
|
+
kind: PREVIEW_EVENT,
|
|
189
|
+
lane: target.lane,
|
|
190
|
+
number: target.number,
|
|
191
|
+
url: fresh.url,
|
|
192
|
+
headCommit: target.headCommit,
|
|
193
|
+
},
|
|
194
|
+
]
|
|
195
|
+
: []
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
const recordOf = (record: PreviewRecord | undefined, target: FeedbackTarget, candidates: PreviewCandidate[]) => {
|
|
199
|
+
const stamps = Object.fromEntries(candidates.map(({ key, stamp }) => [key, stamp]))
|
|
200
|
+
const shown = record
|
|
201
|
+
? freshPreview(record, target, candidates)
|
|
202
|
+
: candidates.find((candidate) => isUsable(candidate, target))
|
|
203
|
+
const reportedHead = shown ? target.headCommit : (record?.reportedHead ?? null)
|
|
204
|
+
const url = shown ? shown.url : (record?.url ?? null)
|
|
205
|
+
return { reportedHead, stamps: { ...record?.stamps, ...stamps }, url }
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** The state after `read`: each target's head marked as reported when it fired (or showed a preview at its first read). */
|
|
209
|
+
export const recordPreviews = (previous: PreviewState, read: PreviewRead): PreviewState => ({
|
|
210
|
+
since: read.readAt,
|
|
211
|
+
pullRequests: Object.fromEntries(
|
|
212
|
+
read.targets.map((target) => [
|
|
213
|
+
String(target.number),
|
|
214
|
+
recordOf(previous.pullRequests[String(target.number)], target, candidatesOf(read, target)),
|
|
215
|
+
]),
|
|
216
|
+
),
|
|
217
|
+
})
|
|
218
|
+
|
|
219
|
+
/** The targets whose current head has no preview reported yet; only they need their deployments read. */
|
|
220
|
+
export const unreportedTargets = (state: PreviewState, targets: readonly FeedbackTarget[]): FeedbackTarget[] =>
|
|
221
|
+
targets.filter(({ number, headCommit }) => state.pullRequests[String(number)]?.reportedHead !== headCommit)
|
|
222
|
+
|
|
223
|
+
const isPreviewRecord = (value: unknown): boolean =>
|
|
224
|
+
isRecord(value) &&
|
|
225
|
+
(value.reportedHead === null || typeof value.reportedHead === 'string') &&
|
|
226
|
+
isRecord(value.stamps) &&
|
|
227
|
+
Object.values(value.stamps).every((stamp) => typeof stamp === 'string') &&
|
|
228
|
+
(value.url === undefined || value.url === null || typeof value.url === 'string')
|
|
229
|
+
|
|
230
|
+
export const isPreviewState = (value: unknown): value is PreviewState =>
|
|
231
|
+
isRecord(value) &&
|
|
232
|
+
isReadTime(value.since) &&
|
|
233
|
+
isRecord(value.pullRequests) &&
|
|
234
|
+
Object.values(value.pullRequests).every(isPreviewRecord)
|