jackherd 1.18.0 → 1.18.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 +6 -0
- package/README.md +17 -17
- package/herdr-plugin/herdr-plugin.toml +1 -1
- package/install.sh +1 -1
- package/kit/hooks/hooks/merge-guard/approval.ts +1 -1
- package/kit/hooks/hooks/merge-guard/lane-plan.ts +2 -3
- package/kit/hooks/hooks/plain-question/arguments.ts +8 -0
- package/kit/hooks/hooks/plain-question/index.ts +17 -5
- package/kit/hooks/hooks/plain-question/self-merge-files.ts +35 -0
- package/kit/hooks/hooks/plain-question/staged-files.ts +24 -0
- package/kit/hooks/hooks/shared/worktree-list.ts +8 -0
- package/kit/hooks/hooks/watch-guard/decision.ts +6 -4
- package/kit/hooks/hooks/watch-guard/index.ts +2 -0
- package/kit/workflow/skills/orchestrate/SKILL.md +3 -2
- package/package.json +1 -1
- package/src/watch/command.ts +7 -3
- package/src/watch/feedback.ts +30 -12
- package/src/watch/lane-report.ts +4 -4
- package/src/watch/preview-read.ts +3 -3
- package/src/watch/previews.ts +9 -9
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
What changed for people who use jackherd, newest first.
|
|
4
4
|
|
|
5
|
+
## 1.18.1
|
|
6
|
+
|
|
7
|
+
- **Starting the watcher under Monitor works again.** The watch guard refused `jh watch --stream` under the Monitor tool, the very place its message points to, so the coordinator fell back to single background runs. The stream refusal now applies only to a Bash call with `run_in_background`, where a never-ending stream would stay silent; Monitor and a foreground call pass.
|
|
8
|
+
- **No self-merge option where it cannot take effect.** A pull request that changes a guard file (`.claude/hooks/**`, `.jackherd/lock.json` and the like) is never covered by a self-merge approval, yet the coordinator still offered the option and you had to confirm the merge anyway. The question is now sent back when it offers the self-merge label for such a pull request; when the files cannot be determined, it passes and the merge guard still asks at merge time.
|
|
9
|
+
- **Review feedback on the coordinator's own pull requests reaches Jack.** The watcher read reviews and comments only for pull requests of lane branches, so an inline finding on a pull request without a lane reached nobody. It now reads feedback for your open pull requests without a lane too, the same set it already read checks for; the event names them `(branch <name>)`. Previews stay lane-only.
|
|
10
|
+
|
|
5
11
|
## 1.18.0
|
|
6
12
|
|
|
7
13
|
- **`jh check` measures every file your budget names.** A path in `check.skillBudgets` (no glob, no sum) is now measured even when `jh check` would not read it on its own, such as `CLAUDE.md` or a rule with `paths:`; a named file that is missing warns, it never fails. The number of measured files in the `Skill budgets` line can rise after the update.
|
package/README.md
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
<p>
|
|
2
|
-
<img alt="jackherd: a black and white Shih Tzu in a turquoise bandana, three lanes running from him to three agent nodes" src="https://unpkg.com/jackherd@1.18.
|
|
2
|
+
<img alt="jackherd: a black and white Shih Tzu in a turquoise bandana, three lanes running from him to three agent nodes" src="https://unpkg.com/jackherd@1.18.1/assets/branding/jackherd-logo-outline.svg" width="340">
|
|
3
3
|
<br>
|
|
4
4
|
</p>
|
|
5
5
|
|
|
6
|
-
[](https://www.npmjs.com/package/jackherd) [](https://www.npmjs.com/package/jackherd) [](https://www.npmjs.com/package/jackherd?activeTab=dependencies) [](https://unpkg.com/jackherd/INSTALL.md) [](https://unpkg.com/jackherd/INSTALL.md)<br><img width="1" height="8" alt="" src="https://unpkg.com/jackherd@1.18.
|
|
6
|
+
[](https://www.npmjs.com/package/jackherd) [](https://www.npmjs.com/package/jackherd) [](https://www.npmjs.com/package/jackherd?activeTab=dependencies) [](https://unpkg.com/jackherd/INSTALL.md) [](https://unpkg.com/jackherd/INSTALL.md)<br><img width="1" height="8" alt="" src="https://unpkg.com/jackherd@1.18.1/assets/icons/space.svg">
|
|
7
7
|
|
|
8
8
|
# Jack herds your coding agents.
|
|
9
9
|
|
|
@@ -15,27 +15,27 @@ For developers who'd rather decide than supervise.
|
|
|
15
15
|
curl -fsSL https://unpkg.com/jackherd/install.sh | sh
|
|
16
16
|
```
|
|
17
17
|
|
|
18
|
-
Then `/jh-start` in a repository, and Jack offers your first plan. → [Quickstart](#quickstart)<br><img width="1" height="8" alt="" src="https://unpkg.com/jackherd@1.18.
|
|
18
|
+
Then `/jh-start` in a repository, and Jack offers your first plan. → [Quickstart](#quickstart)<br><img width="1" height="8" alt="" src="https://unpkg.com/jackherd@1.18.1/assets/icons/space.svg">
|
|
19
19
|
|
|
20
20
|
<p>
|
|
21
|
-
<img src="https://unpkg.com/jackherd@1.18.
|
|
21
|
+
<img src="https://unpkg.com/jackherd@1.18.1/docs/workflow-dark.svg" width="720" alt="jackherd in herdr: a lane hits a question, Jack asks you as a choice, you pick, and the lane works on while the herd view counts its progress.">
|
|
22
22
|
<br>
|
|
23
|
-
<sub>Jack and his lanes in <a href="https://herdr.dev/">herdr</a>, a terminal for coding agents (recommended). Without it, one Claude Code session builds with subagents.</sub><br><img width="1" height="8" alt="" src="https://unpkg.com/jackherd@1.18.
|
|
23
|
+
<sub>Jack and his lanes in <a href="https://herdr.dev/">herdr</a>, a terminal for coding agents (recommended). Without it, one Claude Code session builds with subagents.</sub><br><img width="1" height="8" alt="" src="https://unpkg.com/jackherd@1.18.1/assets/icons/space.svg">
|
|
24
24
|
</p>
|
|
25
25
|
|
|
26
|
-
<p><img src="https://unpkg.com/jackherd@1.18.
|
|
26
|
+
<p><img src="https://unpkg.com/jackherd@1.18.1/assets/icons/lanes.svg" width="24" height="34" align="middle" alt=""> <b>Parallel where it pays.</b><br>Phases that don't collide run side by side, each lane on its own branch and worktree. Jack keeps the merge order and has the others rebase.</p>
|
|
27
27
|
|
|
28
|
-
<p><img src="https://unpkg.com/jackherd@1.18.
|
|
28
|
+
<p><img src="https://unpkg.com/jackherd@1.18.1/assets/icons/decide.svg" width="24" height="34" align="middle" alt=""> <b>You make the calls.</b><br>Commits, pushes and merges wait for your approval. Jack merges on green checks only where you said so: one pull request, or a plan's for up to 24 hours. The guards catch mistakes, not intent; a hard boundary takes branch protection.</p>
|
|
29
29
|
|
|
30
|
-
<p><img src="https://unpkg.com/jackherd@1.18.
|
|
30
|
+
<p><img src="https://unpkg.com/jackherd@1.18.1/assets/icons/bark.svg" width="24" height="34" align="middle" alt=""> <b>Jack barks.</b><br>A lane gets stuck, a check goes red, a decision needs you: Jack steps in and brings you only what's yours to decide. The herd view shows where every lane stands.</p>
|
|
31
31
|
|
|
32
|
-
<p><img src="https://unpkg.com/jackherd@1.18.
|
|
32
|
+
<p><img src="https://unpkg.com/jackherd@1.18.1/assets/icons/skills.svg" width="24" height="34" align="middle" alt=""> <b>Works with your skills.</b><br>Keep your own skills: a lane builds with whichever one you pick. Plans written elsewhere run too, once Jack fills in what they're missing.</p>
|
|
33
33
|
|
|
34
|
-
<p><img src="https://unpkg.com/jackherd@1.18.
|
|
34
|
+
<p><img src="https://unpkg.com/jackherd@1.18.1/assets/icons/ear.svg" width="24" height="34" align="middle" alt=""> <b>Jack listens.</b><br>Review comments on a lane's pull request go back to that lane, which fixes them with a test. <code>/jh-follow</code> brings in anything a CLI or an API can read as JSON: tickets, error logs, status pages.</p>
|
|
35
35
|
|
|
36
|
-
<p><img src="https://unpkg.com/jackherd@1.18.
|
|
36
|
+
<p><img src="https://unpkg.com/jackherd@1.18.1/assets/icons/bowl.svg" width="24" height="34" align="middle" alt=""> <b>Picks up where you left off.</b><br>Plan and board hold the state, not the chat.</p>
|
|
37
37
|
|
|
38
|
-
<p><img src="https://unpkg.com/jackherd@1.18.
|
|
38
|
+
<p><img src="https://unpkg.com/jackherd@1.18.1/assets/icons/plan.svg" width="24" height="34" align="middle" alt=""> <b>Lives in your repository.</b><br>Plans, board and every decision are Markdown, reviewed like code. <code>jh init</code> gives your team the same setup.<br><img width="1" height="8" alt="" src="https://unpkg.com/jackherd@1.18.1/assets/icons/space.svg"></p>
|
|
39
39
|
|
|
40
40
|
## Quickstart
|
|
41
41
|
|
|
@@ -66,22 +66,22 @@ Then `/jh-start` in a repository, and Jack offers your first plan. → [Quicksta
|
|
|
66
66
|
/jh-orchestrate docs/plans/board-<name>/board.md
|
|
67
67
|
```
|
|
68
68
|
|
|
69
|
-
Linux and macOS are supported (Windows through WSL); the requirements are in [INSTALL.md](https://unpkg.com/jackherd/INSTALL.md). By hand (Bun 1.3+): `bun add -g jackherd` or `npm install -g jackherd`, then `jh init --global` for yourself, or `jh init` in a repository to give your team the same skills and guard hooks.<br><img width="1" height="8" alt="" src="https://unpkg.com/jackherd@1.18.
|
|
69
|
+
Linux and macOS are supported (Windows through WSL); the requirements are in [INSTALL.md](https://unpkg.com/jackherd/INSTALL.md). By hand (Bun 1.3+): `bun add -g jackherd` or `npm install -g jackherd`, then `jh init --global` for yourself, or `jh init` in a repository to give your team the same skills and guard hooks.<br><img width="1" height="8" alt="" src="https://unpkg.com/jackherd@1.18.1/assets/icons/space.svg">
|
|
70
70
|
|
|
71
71
|
## Follow anything you can query
|
|
72
72
|
|
|
73
73
|
Tickets, error logs, status pages: if a command or an API call can read it as JSON with your login, Jack can watch it. Recipes cover Jira, Linear, GitHub issues and Projects, Sentry, Google Calendar and status pages; for anything else, `/jh-follow` writes the command with you. One yes, and every change shows up like this:
|
|
74
74
|
|
|
75
|
-
<img src="https://unpkg.com/jackherd@1.18.
|
|
75
|
+
<img src="https://unpkg.com/jackherd@1.18.1/docs/follow-dark.svg" width="720" alt="A source (ticket, error log or status page) has a new or changed entry; jh watch reports it to Jack; Jack puts a row into Your turn, prefixed with the source's label; its [ask] button makes Jack ask you, and then a lane works it.">
|
|
76
76
|
|
|
77
|
-
The herd sums up your sources in one `Sources` line: new entries per source, or its last run, red while it fails; opened, a click opens an entry. One that needs you lands in Your turn under the source's label, [ask] has Jack look at it, and a ticket shows on the lane that works it.<br><img width="1" height="8" alt="" src="https://unpkg.com/jackherd@1.18.
|
|
77
|
+
The herd sums up your sources in one `Sources` line: new entries per source, or its last run, red while it fails; opened, a click opens an entry. One that needs you lands in Your turn under the source's label, [ask] has Jack look at it, and a ticket shows on the lane that works it.<br><img width="1" height="8" alt="" src="https://unpkg.com/jackherd@1.18.1/assets/icons/space.svg">
|
|
78
78
|
|
|
79
79
|
## In detail
|
|
80
80
|
|
|
81
81
|
<details>
|
|
82
82
|
<summary><b>What Jack does on his own, and what waits for you</b></summary>
|
|
83
83
|
|
|
84
|
-
<img src="https://unpkg.com/jackherd@1.18.
|
|
84
|
+
<img src="https://unpkg.com/jackherd@1.18.1/docs/lanes-dark.svg" alt="Jack prompts two lanes and asks you for decisions and approvals; the lanes send questions back as text; jh watch reads lane state, pull requests and checks and wakes Jack.">
|
|
85
85
|
|
|
86
86
|
| On his own | After your approval | Never |
|
|
87
87
|
| --- | --- | --- |
|
|
@@ -116,7 +116,7 @@ The guards catch mistakes, not intent; for that, use branch protection. The hook
|
|
|
116
116
|
|
|
117
117
|
A **plan** is Markdown with phases, checkbox tasks, a model and a review gate per phase. A **board** lists the lanes of one or more plans: which phases each lane builds, on which model, after what. A **lane** is one agent session in its own git worktree (a second checkout of your repository), and **Jack** is the session in the main checkout that runs `/jh-orchestrate` on a board.
|
|
118
118
|
|
|
119
|
-
<img src="https://unpkg.com/jackherd@1.18.
|
|
119
|
+
<img src="https://unpkg.com/jackherd@1.18.1/docs/skills-dark.svg" alt="Your own plan or an issue is adopted (Jack adds what is missing, you see the diff) and goes to /jh-orchestrate; /jh-grill hands decisions to /jh-plan; /jh-plan feeds /jh-orchestrate or a single /jh-implement, which your own skill can replace; /jh-implement ends in /jh-pre-commit, which runs the reviewers; /jh-diagnose hands a red command to /jh-implement.">
|
|
120
120
|
|
|
121
121
|
| Skill | Does |
|
|
122
122
|
| --- | --- |
|
package/install.sh
CHANGED
|
@@ -9,7 +9,7 @@ set -eu
|
|
|
9
9
|
|
|
10
10
|
# The default is the version this file ships with. The release bump changes it together with
|
|
11
11
|
# package.json; install.test.ts fails while the two differ. Overridable, but only by a plain release.
|
|
12
|
-
JACKHERD_VERSION="${JACKHERD_VERSION:-1.18.
|
|
12
|
+
JACKHERD_VERSION="${JACKHERD_VERSION:-1.18.1}"
|
|
13
13
|
|
|
14
14
|
JACKHERD_PACKAGE='jackherd'
|
|
15
15
|
# Test seam: the terminal the wizard asks its questions on.
|
|
@@ -29,7 +29,7 @@ const onlyOf = (values: string[]): string | undefined => {
|
|
|
29
29
|
* The one branch a question names as ``Branch `name` ``, with the one change it names after it; a question that names
|
|
30
30
|
* none or several of either approves none.
|
|
31
31
|
*/
|
|
32
|
-
const namedChange = (question: string): ApprovedChange | undefined => {
|
|
32
|
+
export const namedChange = (question: string): ApprovedChange | undefined => {
|
|
33
33
|
const branch = onlyOf([...question.matchAll(BRANCH_MENTION)].map(([, name]) => name))
|
|
34
34
|
const change = onlyOf([...question.matchAll(CHANGE_MENTION)].map(([, id]) => id))
|
|
35
35
|
return branch !== undefined && change !== undefined && BRANCH_NAME.test(branch) ? { branch, change } : undefined
|
|
@@ -7,6 +7,7 @@ import { relative, resolve } from 'node:path'
|
|
|
7
7
|
import { type BoardLanguage, lanePlansOf } from '../shared/board-lanes'
|
|
8
8
|
import { type Board, boardsIn } from '../shared/boards'
|
|
9
9
|
import { laneOf } from '../shared/lane'
|
|
10
|
+
import { worktreePathOf } from '../shared/worktree-list'
|
|
10
11
|
import type { BoardSettings } from './board-settings'
|
|
11
12
|
|
|
12
13
|
const GIT_TIMEOUT_MS = 3000
|
|
@@ -18,9 +19,7 @@ export const laneInWorktreeList = (
|
|
|
18
19
|
worktreeDirectory: string,
|
|
19
20
|
branch: string,
|
|
20
21
|
): string | undefined => {
|
|
21
|
-
const
|
|
22
|
-
const block = blocks.find((lines) => lines.includes(`branch refs/heads/${branch}`))
|
|
23
|
-
const path = block?.find((line) => line.startsWith('worktree '))?.slice('worktree '.length)
|
|
22
|
+
const path = worktreePathOf(worktreeList, branch)
|
|
24
23
|
if (path === undefined) return undefined
|
|
25
24
|
const lane = laneOf({ cwd: path, main, worktreeDirectory })
|
|
26
25
|
return lane !== null && relative(resolve(main, worktreeDirectory), resolve(path)) === lane ? lane : undefined
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The positions of the values `sync` renders into the hook entry's command (`kit/hooks/hooks.json`), after the script
|
|
3
|
+
* path. `arguments.test.ts` checks the entry passes them in this order.
|
|
4
|
+
*/
|
|
5
|
+
export const ARGUMENT = { recommended: 2, approvalAllow: 3, approvalDeny: 4, selfMergeLabel: 5, standingApprove: 6 } as const
|
|
6
|
+
|
|
7
|
+
/** The first position of a label a guard reads word for word. */
|
|
8
|
+
export const FIRST_GUARDED_LABEL = ARGUMENT.approvalAllow
|
|
@@ -5,7 +5,9 @@
|
|
|
5
5
|
// pattern; a cache keyed by each file's time and size spares reading the unchanged ones. A dialog whose question
|
|
6
6
|
// lacks its recommendation as the first option, marked with argv[2], is denied as well, and so is a coordinator's
|
|
7
7
|
// question that breaks the form of `form.ts` (🐾 header, short question and labels); argv[3…] are the labels a
|
|
8
|
-
// guard reads word for word, which carry no mark.
|
|
8
|
+
// guard reads word for word, which carry no mark. A PR question that offers the self-merge label (argv[5]) for a branch
|
|
9
|
+
// whose staged change carries a guard file is denied too, since the merge guard never lets that approval cover it
|
|
10
|
+
// (`self-merge-files.ts`; the positions are named in `arguments.ts`). Anything else, and any failure, answers nothing, so the dialog opens.
|
|
9
11
|
import { existsSync, statSync } from 'node:fs'
|
|
10
12
|
import { join } from 'node:path'
|
|
11
13
|
import { boardLocationOf, boardsIn } from '../shared/boards'
|
|
@@ -17,13 +19,19 @@ import { isCoordinatorAgent } from '../shared/lane'
|
|
|
17
19
|
import { ownPaneAgent } from '../shared/own-pane'
|
|
18
20
|
import { planCodesOf } from '../shared/plan-codes'
|
|
19
21
|
import { ensurePrivateDirectory, writePrivateFile } from '../shared/private-directory'
|
|
22
|
+
import { ARGUMENT, FIRST_GUARDED_LABEL } from './arguments'
|
|
20
23
|
import { type CodeCache, lookUpCodes, type PlanFileStat, parseCodeCache, serializeCodeCache } from './code-cache'
|
|
21
24
|
import { formReasonOf } from './form'
|
|
22
25
|
import { type RecommendationWords, recommendationReasonOf } from './recommendation'
|
|
23
26
|
import { denyReasonOf, dialogTextsOf, planPathsOfBoards, planRootsOfBoards } from './run'
|
|
27
|
+
import { selfMergeGuardFileReason } from './self-merge-files'
|
|
28
|
+
import { stagedFilesOfBranch } from './staged-files'
|
|
24
29
|
|
|
25
30
|
const HOOK_EVENT = 'PreToolUse'
|
|
26
31
|
|
|
32
|
+
/** argv[5] is the self-merge label, in the order the hook entry passes its arguments; an older install passes none. */
|
|
33
|
+
const selfMergeLabelOf = (argv: readonly string[]): string => argv[ARGUMENT.selfMergeLabel] ?? ''
|
|
34
|
+
|
|
27
35
|
/** Where the cache lies: in the main checkout beside `.jackherd/approvals`, ignored by git. */
|
|
28
36
|
const cacheDirectory = (main: string): string => join(main, '.jackherd', 'plan-codes')
|
|
29
37
|
const CACHE_FILE = 'codes.json'
|
|
@@ -35,8 +43,8 @@ const statOf = (path: string): PlanFileStat | null => {
|
|
|
35
43
|
|
|
36
44
|
/** argv[2] is the recommended mark, argv[3…] the labels a guard reads word for word; an older install passes none. */
|
|
37
45
|
const recommendationWordsOf = (argv: readonly string[]): RecommendationWords => ({
|
|
38
|
-
mark: argv[
|
|
39
|
-
guardedLabels: argv.slice(
|
|
46
|
+
mark: argv[ARGUMENT.recommended] ?? '',
|
|
47
|
+
guardedLabels: argv.slice(FIRST_GUARDED_LABEL),
|
|
40
48
|
})
|
|
41
49
|
|
|
42
50
|
/** Keeps the cache for the next dialog; a cache that cannot be written only costs that dialog a full read. */
|
|
@@ -96,13 +104,17 @@ const decide = async (): Promise<string | null> => {
|
|
|
96
104
|
try {
|
|
97
105
|
const input: unknown = JSON.parse(await Bun.stdin.text())
|
|
98
106
|
if (!isRecord(input) || typeof input.cwd !== 'string') return null
|
|
107
|
+
const { cwd } = input
|
|
99
108
|
const texts = dialogTextsOf(input.tool_input)
|
|
100
109
|
if (texts.length === 0) return null
|
|
101
110
|
const words = recommendationWordsOf(process.argv)
|
|
102
111
|
const reasons = [
|
|
103
|
-
denyReasonOf(texts, planCodesFor(
|
|
112
|
+
denyReasonOf(texts, planCodesFor(cwd)),
|
|
104
113
|
recommendationReasonOf(input.tool_input, words),
|
|
105
|
-
formReasonFor(
|
|
114
|
+
formReasonFor(cwd, input.session_id, input.tool_input, words.guardedLabels),
|
|
115
|
+
selfMergeGuardFileReason(input.tool_input, selfMergeLabelOf(process.argv), (branch) =>
|
|
116
|
+
stagedFilesOfBranch(cwd, branch),
|
|
117
|
+
),
|
|
106
118
|
].filter((reason): reason is string => reason !== null)
|
|
107
119
|
return reasons.length === 0 ? null : reasons.join(' ')
|
|
108
120
|
} catch {
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { alwaysAskingFiles, STANDING_ALWAYS_ASK } from '../merge-guard/always-ask'
|
|
2
|
+
import { namedChange } from '../merge-guard/approval'
|
|
3
|
+
import { questionsOf, recordsOf } from './run'
|
|
4
|
+
|
|
5
|
+
/** The staged files of a branch's change; undefined when they cannot be determined. */
|
|
6
|
+
export type StagedFilesOf = (branch: string) => readonly string[] | undefined
|
|
7
|
+
|
|
8
|
+
const offersLabel = (question: Record<string, unknown>, label: string): boolean =>
|
|
9
|
+
recordsOf(question.options).some((option) => option.label === label)
|
|
10
|
+
|
|
11
|
+
const refusal = (files: readonly string[]): string =>
|
|
12
|
+
`The change carries a guard file (${files.map((file) => `\`${file}\``).join(', ')}), which a self-merge approval never covers, so the user confirms this merge in the merge dialog. Ask with the plain approval and the change option only.`
|
|
13
|
+
|
|
14
|
+
const branchOffering = (question: Record<string, unknown>, label: string): string | undefined =>
|
|
15
|
+
typeof question.question === 'string' && offersLabel(question, label) ? namedChange(question.question)?.branch : undefined
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The reason to send a PR question back: it offers the self-merge label for a branch whose staged change carries a
|
|
19
|
+
* guard file, the same files (`STANDING_ALWAYS_ASK`) the merge guard refuses a self-merge for. Null for a question
|
|
20
|
+
* without the label or without a branch mark, and when the files cannot be determined: the merge guard still asks at
|
|
21
|
+
* merge time, so a doubt here only costs the user a merge dialog.
|
|
22
|
+
*/
|
|
23
|
+
export const selfMergeGuardFileReason = (
|
|
24
|
+
toolInput: unknown,
|
|
25
|
+
selfMergeLabel: string,
|
|
26
|
+
stagedFilesOf: StagedFilesOf,
|
|
27
|
+
): string | null => {
|
|
28
|
+
if (selfMergeLabel === '') return null
|
|
29
|
+
for (const question of questionsOf(toolInput)) {
|
|
30
|
+
const branch = branchOffering(question, selfMergeLabel)
|
|
31
|
+
const guarded = branch === undefined ? [] : alwaysAskingFiles(stagedFilesOf(branch) ?? [], STANDING_ALWAYS_ASK)
|
|
32
|
+
if (guarded.length > 0) return refusal(guarded)
|
|
33
|
+
}
|
|
34
|
+
return null
|
|
35
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { baseBranchOf } from '../shared/base-branch'
|
|
2
|
+
import { mainCheckoutOf } from '../shared/checkout'
|
|
3
|
+
import { commandOutput } from '../shared/command'
|
|
4
|
+
import { readConfig } from '../shared/config'
|
|
5
|
+
import { worktreePathOf } from '../shared/worktree-list'
|
|
6
|
+
|
|
7
|
+
const gitOutput = (cwd: string, ...args: string[]): string => commandOutput(['git', ...args], cwd)
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The files staged in the worktree of `branch` against the merge base with the base branch, the base the branch mark's
|
|
11
|
+
* change id is taken from; undefined when the branch has no worktree or git fails.
|
|
12
|
+
*/
|
|
13
|
+
export const stagedFilesOfBranch = (cwd: string, branch: string): readonly string[] | undefined => {
|
|
14
|
+
try {
|
|
15
|
+
const main = mainCheckoutOf(cwd)
|
|
16
|
+
const base = baseBranchOf(readConfig([main]))
|
|
17
|
+
const worktree = worktreePathOf(gitOutput(main, 'worktree', 'list', '--porcelain'), branch)
|
|
18
|
+
if (base === undefined || worktree === undefined) return undefined
|
|
19
|
+
const mergeBase = gitOutput(worktree, 'merge-base', `origin/${base}`, 'HEAD')
|
|
20
|
+
return gitOutput(worktree, 'diff', '--cached', '--name-only', mergeBase).split('\n').filter(Boolean)
|
|
21
|
+
} catch {
|
|
22
|
+
return undefined
|
|
23
|
+
}
|
|
24
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/** The path of the worktree that has `branch` checked out, from `git worktree list --porcelain`; undefined for none. */
|
|
2
|
+
export const worktreePathOf = (porcelain: string, branch: string): string | undefined => {
|
|
3
|
+
const block = porcelain
|
|
4
|
+
.split('\n\n')
|
|
5
|
+
.map((entry) => entry.split('\n'))
|
|
6
|
+
.find((lines) => lines.includes(`branch refs/heads/${branch}`))
|
|
7
|
+
return block?.find((line) => line.startsWith('worktree '))?.slice('worktree '.length)
|
|
8
|
+
}
|
|
@@ -6,7 +6,7 @@ export type BashToolInput = { command?: string; run_in_background?: boolean }
|
|
|
6
6
|
const LINE_CONTINUATION = /\\\n\s*/g
|
|
7
7
|
|
|
8
8
|
const STREAM_REFUSAL = [
|
|
9
|
-
'
|
|
9
|
+
'A background Bash call never reports a jh watch --stream watcher: the stream never ends.',
|
|
10
10
|
'Start the same command with the Monitor tool (timeout_ms 1800000), where every line it prints reaches you.',
|
|
11
11
|
].join('\n')
|
|
12
12
|
|
|
@@ -40,14 +40,16 @@ export const startsOf = (command: string, parse: typeof startsIn = startsIn): Wa
|
|
|
40
40
|
* the package, or `bun` with the CLI's script) with the subcommand `watch`, also behind wrappers, inside
|
|
41
41
|
* `bash -c`, `eval`, a substitution, or a here-document or here-string fed to a shell. Text that only
|
|
42
42
|
* names the command (an argument, a quoted string, a data here-document) is no start. A `--stream` start
|
|
43
|
-
* never finishes, so
|
|
44
|
-
*
|
|
43
|
+
* never finishes, so a background call (`run_in_background: true`) never reports it and is refused; without
|
|
44
|
+
* that key it passes, as Monitor sends it (Monitor calls reach this hook as Bash calls without the key) or
|
|
45
|
+
* as a foreground call that visibly runs into its timeout. A single run reports only as its own background
|
|
46
|
+
* task, in a call that sends nothing off with `&`.
|
|
45
47
|
*/
|
|
46
48
|
export const watcherStartViolation = (input: BashToolInput): string | null => {
|
|
47
49
|
if (!input.command) return null
|
|
48
50
|
const starts = startsOf(input.command)
|
|
49
51
|
if (starts.length === 0) return null
|
|
50
|
-
if (starts.some((start) => start.stream)) return STREAM_REFUSAL
|
|
52
|
+
if (starts.some((start) => start.stream)) return input.run_in_background === true ? STREAM_REFUSAL : null
|
|
51
53
|
if (input.run_in_background !== true) return BACKGROUND_REFUSAL
|
|
52
54
|
return starts.some((start) => start.callHasBackgroundJob) ? BACKGROUND_JOB_REFUSAL : null
|
|
53
55
|
}
|
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
import { watcherStartViolation } from './decision'
|
|
5
5
|
|
|
6
6
|
const payload = await Bun.stdin.json()
|
|
7
|
+
// Claude Code passes Monitor calls to Bash-matcher hooks as tool_name "Bash" without run_in_background,
|
|
8
|
+
// which is how the decision tells them apart.
|
|
7
9
|
const violation = watcherStartViolation(payload.tool_input ?? {})
|
|
8
10
|
if (violation) {
|
|
9
11
|
console.error(violation)
|
|
@@ -106,7 +106,7 @@ also reports your own open PRs on a branch no lane has (`(branch <name>)`; act o
|
|
|
106
106
|
A restart is safe, the watcher keeps what it saw; a stop during a report re-delivers that report's events, so vet
|
|
107
107
|
a repeated event against `gh pr view` or the run before acting twice.
|
|
108
108
|
|
|
109
|
-
- **Check the start.** A start under the Monitor tool is right (the `watch-guard` hook refuses a Bash call);
|
|
109
|
+
- **Check the start.** A start under the Monitor tool is right (the `watch-guard` hook refuses a `--stream` start as a background Bash call);
|
|
110
110
|
its first line, `armed: …`, comes only when lanes, states or board changed. A start as a Bash call went
|
|
111
111
|
wrong: stop that task (`TaskStop`) and start it under the Monitor tool.
|
|
112
112
|
- **A wake-up is done when every watched lane has a started next action or a named reason to wait**
|
|
@@ -294,7 +294,8 @@ always goes to the user, whatever the plan settles.
|
|
|
294
294
|
The self-merge option is **{{kit.guard.selfMerge}}** (`guard.selfMerge.offer`). When it reads
|
|
295
295
|
`not offered`, or the project supplement blocks self-merge for the PR's tier, ask with the other two. The
|
|
296
296
|
merge guard reads the label and the branch mark back word for word and merges only that diff: never reword
|
|
297
|
-
either, and write the branch only into a question offering the label.
|
|
297
|
+
either, and write the branch only into a question offering the label. A hook sends the question back when
|
|
298
|
+
the staged change carries a guard file: ask with the plain approval and the change only.
|
|
298
299
|
3. **Acceptance in the browser**, for every lane with a visible change:
|
|
299
300
|
[`references/acceptance.md`](references/acceptance.md).
|
|
300
301
|
4. On approval the lane commits, pushes and opens the PR per `pull-requests.md`. When the lane's
|
package/package.json
CHANGED
package/src/watch/command.ts
CHANGED
|
@@ -95,6 +95,8 @@ import {
|
|
|
95
95
|
feedbackReadOf,
|
|
96
96
|
feedbackSince,
|
|
97
97
|
feedbackTargets,
|
|
98
|
+
isLaneTarget,
|
|
99
|
+
type LaneFeedbackTarget,
|
|
98
100
|
} from './feedback'
|
|
99
101
|
import { type FeedbackItem } from './feedback-item'
|
|
100
102
|
import { type FeedbackItemsRead, readCommentItems, readFeedbackItems, readOwnLogin } from './feedback-read'
|
|
@@ -937,7 +939,7 @@ const previewComments = async (
|
|
|
937
939
|
const readPreviews = async (
|
|
938
940
|
watch: Watch,
|
|
939
941
|
watching: Watching,
|
|
940
|
-
targets:
|
|
942
|
+
targets: LaneFeedbackTarget[],
|
|
941
943
|
feedbackItems: FeedbackItem[] | null,
|
|
942
944
|
now: number,
|
|
943
945
|
): Promise<PreviewRead | null> => {
|
|
@@ -1037,9 +1039,11 @@ const pollGithubAndCoordinator = async (watch: Watch, watching: Watching, now: n
|
|
|
1037
1039
|
syncPlanHeads(watch, watching, pullRequests ?? [])
|
|
1038
1040
|
const branches = { ...watching.state.laneBranches, ...laneBranches }
|
|
1039
1041
|
const failureReadings = await readFailures(watch, watching, github, branches)
|
|
1040
|
-
const targets = feedbackTargets(pullRequests, branches, watching.state.feedback)
|
|
1042
|
+
const targets = feedbackTargets(pullRequests, branches, watching.state.feedback, answered(github)?.own ?? [])
|
|
1041
1043
|
const feedback = await readFeedback(watch, watching, targets, now)
|
|
1042
|
-
const previews = pullRequests
|
|
1044
|
+
const previews = pullRequests
|
|
1045
|
+
? await readPreviews(watch, watching, targets.filter(isLaneTarget), feedback?.items ?? null, now)
|
|
1046
|
+
: null
|
|
1043
1047
|
const { progress: modelProgress } = await boardReadOrNote(watch, Object.keys(fills.models), pullRequests ?? [])
|
|
1044
1048
|
return [
|
|
1045
1049
|
...recordGithub(watching, github, laneBranches, now, failureReadings),
|
package/src/watch/feedback.ts
CHANGED
|
@@ -5,8 +5,14 @@ import { PULL_REQUEST_STATE, type WatchedPullRequest } from './pull-requests'
|
|
|
5
5
|
|
|
6
6
|
export const FEEDBACK_EVENT = 'feedback'
|
|
7
7
|
|
|
8
|
-
/**
|
|
9
|
-
export type
|
|
8
|
+
/** Whose pull request it is: a lane's, or the user's own on a `branch` no lane has. */
|
|
9
|
+
export type FeedbackOwner = { lane: string } | { lane: null; branch: string }
|
|
10
|
+
|
|
11
|
+
/** An open pull request whose feedback is read: a lane's or an own one without a lane. */
|
|
12
|
+
export type FeedbackTarget = { number: number; headCommit: string } & FeedbackOwner
|
|
13
|
+
|
|
14
|
+
/** A target of a lane, the only kind a preview is read for. */
|
|
15
|
+
export type LaneFeedbackTarget = FeedbackTarget & { lane: string }
|
|
10
16
|
|
|
11
17
|
/**
|
|
12
18
|
* What the watcher remembers of one pull request's feedback: `seen` maps `<form>:<id>` to the `updatedAt` last seen,
|
|
@@ -46,13 +52,12 @@ export type FeedbackEntry = { place: string | null; text: string }
|
|
|
46
52
|
|
|
47
53
|
export type FeedbackEvent = {
|
|
48
54
|
kind: typeof FEEDBACK_EVENT
|
|
49
|
-
lane: string
|
|
50
55
|
number: number
|
|
51
56
|
author: string
|
|
52
57
|
/** `review <state>`, `inline`, `comment`, or either of the last two with ` edited`. */
|
|
53
58
|
label: string
|
|
54
59
|
entries: FeedbackEntry[]
|
|
55
|
-
}
|
|
60
|
+
} & FeedbackOwner
|
|
56
61
|
|
|
57
62
|
/**
|
|
58
63
|
* How far a read reaches back before the last one: room for a clock that differs from GitHub's, and the round of a
|
|
@@ -77,6 +82,11 @@ const DECISIVE_REVIEW_STATES: readonly string[] = [
|
|
|
77
82
|
const EDITED = 'edited'
|
|
78
83
|
const MARKDOWN_HEADING = /^#{1,6}\s/
|
|
79
84
|
|
|
85
|
+
export const isLaneTarget = (target: FeedbackTarget): target is LaneFeedbackTarget => target.lane !== null
|
|
86
|
+
|
|
87
|
+
const ownerOf = (target: FeedbackTarget): FeedbackOwner =>
|
|
88
|
+
target.lane === null ? { lane: null, branch: target.branch } : { lane: target.lane }
|
|
89
|
+
|
|
80
90
|
export const emptyFeedbackState = (): FeedbackState => ({ since: null, pullRequests: {} })
|
|
81
91
|
|
|
82
92
|
const isNullableString = (value: unknown): boolean => value === null || value === undefined || typeof value === 'string'
|
|
@@ -217,7 +227,7 @@ const eventsOfRecord = (record: FeedbackRecord, items: readonly FeedbackItem[]):
|
|
|
217
227
|
const key = `${item.author}\n${label}`
|
|
218
228
|
const event = groups.get(key) ?? {
|
|
219
229
|
kind: FEEDBACK_EVENT,
|
|
220
|
-
|
|
230
|
+
...ownerOf(record),
|
|
221
231
|
number: record.number,
|
|
222
232
|
author: item.author,
|
|
223
233
|
label,
|
|
@@ -236,7 +246,7 @@ const eventsOfRecord = (record: FeedbackRecord, items: readonly FeedbackItem[]):
|
|
|
236
246
|
export const feedbackEvents = (previous: FeedbackState, read: FeedbackRead): FeedbackEvent[] =>
|
|
237
247
|
read.targets.flatMap((target) => {
|
|
238
248
|
const record = previous.pullRequests[String(target.number)]
|
|
239
|
-
return record ? eventsOfRecord({ ...record,
|
|
249
|
+
return record ? eventsOfRecord({ ...record, ...ownerOf(target) }, itemsOf(read, target.number)) : []
|
|
240
250
|
})
|
|
241
251
|
|
|
242
252
|
const recordOf = (
|
|
@@ -277,19 +287,27 @@ export const feedbackSince = (state: Pick<FeedbackState, 'since'>, now: number):
|
|
|
277
287
|
new Date((state.since ?? now) - SINCE_MARGIN_MS).toISOString()
|
|
278
288
|
|
|
279
289
|
/**
|
|
280
|
-
* The pull requests whose feedback is read: the open ones of lane branches
|
|
281
|
-
*
|
|
290
|
+
* The pull requests whose feedback is read: the open ones of lane branches and the user's own open ones without a
|
|
291
|
+
* lane (`own`, as the checks read them) when GitHub's list answered, else the ones the state follows, so a failing
|
|
292
|
+
* list does not stop the feedback.
|
|
282
293
|
*/
|
|
283
294
|
export const feedbackTargets = (
|
|
284
295
|
pullRequests: readonly WatchedPullRequest[] | null,
|
|
285
296
|
laneBranches: Readonly<Record<string, string>>,
|
|
286
297
|
state: FeedbackState,
|
|
298
|
+
own: readonly number[],
|
|
287
299
|
): FeedbackTarget[] => {
|
|
288
300
|
if (!pullRequests)
|
|
289
|
-
return Object.values(state.pullRequests).map((
|
|
290
|
-
|
|
301
|
+
return Object.values(state.pullRequests).map((record) => ({
|
|
302
|
+
number: record.number,
|
|
303
|
+
headCommit: record.headCommit,
|
|
304
|
+
...ownerOf(record),
|
|
305
|
+
}))
|
|
306
|
+
return pullRequests.flatMap<FeedbackTarget>(({ number, branch, state: lifecycle, headCommit }) => {
|
|
307
|
+
if (lifecycle !== PULL_REQUEST_STATE.open) return []
|
|
291
308
|
const lane = Object.keys(laneBranches).find((name) => laneBranches[name] === branch)
|
|
292
|
-
|
|
309
|
+
if (lane) return [{ number, lane, headCommit }]
|
|
310
|
+
return own.includes(number) ? [{ number, lane: null, branch, headCommit }] : []
|
|
293
311
|
})
|
|
294
312
|
}
|
|
295
313
|
|
|
@@ -303,7 +321,7 @@ export const isReadTime = (value: unknown): boolean =>
|
|
|
303
321
|
const isFeedbackRecord = (value: unknown): boolean =>
|
|
304
322
|
isRecord(value) &&
|
|
305
323
|
isPullRequestNumber(value.number) &&
|
|
306
|
-
typeof value.lane === 'string' &&
|
|
324
|
+
(typeof value.lane === 'string' || (value.lane === null && typeof value.branch === 'string')) &&
|
|
307
325
|
typeof value.headCommit === 'string' &&
|
|
308
326
|
typeof value.count === 'number' &&
|
|
309
327
|
(value.changesRequested === undefined || typeof value.changesRequested === 'boolean') &&
|
package/src/watch/lane-report.ts
CHANGED
|
@@ -127,7 +127,7 @@ export const laneStopHeader = (lane: string, status: string, handoverHint: strin
|
|
|
127
127
|
}
|
|
128
128
|
|
|
129
129
|
/** The lane, or `branch <name>` for an own pull request without a lane, its name cut like other third-party text. */
|
|
130
|
-
const pullRequestOwner = (event:
|
|
130
|
+
const pullRequestOwner = (event: { lane: string } | { lane: null; branch: string }): string =>
|
|
131
131
|
event.lane === null ? `branch ${terminalSafe(event.branch).slice(0, REPORT_TEXT_LENGTH)}` : terminalSafe(event.lane)
|
|
132
132
|
|
|
133
133
|
/** The lane, `#<number>` for an own pull request without a lane, or `manual`. */
|
|
@@ -206,7 +206,7 @@ export const watchEventLine = (event: WatchEvent, target: string | null): string
|
|
|
206
206
|
if (event.kind === WATCH_EVENT.coordinator) return `=== ${event.kind} ${event.level} ${contextLabel(event.share)}`
|
|
207
207
|
if (isPullRequestEvent(event))
|
|
208
208
|
return `=== ${event.kind} #${event.number} (${pullRequestOwner(event)})${failedChecksSuffix(event)}`
|
|
209
|
-
if ('number' in event) return `=== ${event.kind} #${event.number} (${
|
|
209
|
+
if ('number' in event) return `=== ${event.kind} #${event.number} (${pullRequestOwner(event)})`
|
|
210
210
|
return `=== ${event.kind}`
|
|
211
211
|
}
|
|
212
212
|
|
|
@@ -218,11 +218,11 @@ export const appointmentEventLine = (event: AppointmentEvent): string =>
|
|
|
218
218
|
`=== ${event.kind} ${terminalSafe(event.when)} · ${terminalSafe(event.action).slice(0, REPORT_TEXT_LENGTH)}`
|
|
219
219
|
|
|
220
220
|
/**
|
|
221
|
-
* The lines of a feedback event: `=== feedback #<number> (lane) <author> <label> · <k> new`, then one line per entry,
|
|
221
|
+
* The lines of a feedback event: `=== feedback #<number> (lane|branch <name>) <author> <label> · <k> new`, then one line per entry,
|
|
222
222
|
* `<path>:<line> <first line>` or `<first line>`, cut to a length and to `FEEDBACK_ENTRY_LIMIT` entries.
|
|
223
223
|
*/
|
|
224
224
|
export const feedbackEventLines = (event: FeedbackEvent): string[] => {
|
|
225
|
-
const header = `=== ${event.kind} #${event.number} (${
|
|
225
|
+
const header = `=== ${event.kind} #${event.number} (${pullRequestOwner(event)}) ${terminalSafe(event.author)} ${terminalSafe(event.label)} · ${event.entries.length} new`
|
|
226
226
|
const { shown: shownEntries, more } = shownWithMore(event.entries, FEEDBACK_ENTRY_LIMIT)
|
|
227
227
|
const entries = shownEntries.map(({ place, text }) => {
|
|
228
228
|
const line = terminalSafe(place === null ? text : `${place} ${text}`).slice(0, REPORT_TEXT_LENGTH)
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { errorText } from '../plans/error-text'
|
|
2
|
-
import type {
|
|
2
|
+
import type { LaneFeedbackTarget } from './feedback'
|
|
3
3
|
import { runGh } from './gh'
|
|
4
4
|
import {
|
|
5
5
|
deploymentCandidate,
|
|
@@ -25,7 +25,7 @@ const STATUS_FIELDS = '.[0] | {state: (.state // ""), url: (.environment_url //
|
|
|
25
25
|
*/
|
|
26
26
|
const readHeadPreview = async (
|
|
27
27
|
root: string,
|
|
28
|
-
target:
|
|
28
|
+
target: LaneFeedbackTarget,
|
|
29
29
|
deployEnvironments: readonly string[],
|
|
30
30
|
): Promise<HeadRead> => {
|
|
31
31
|
const listPath = `repos/{owner}/{repo}/deployments?sha=${target.headCommit}&per_page=${DEPLOYMENT_LIMIT}`
|
|
@@ -50,7 +50,7 @@ const readHeadPreview = async (
|
|
|
50
50
|
*/
|
|
51
51
|
export const readDeploymentCandidates = async (
|
|
52
52
|
root: string,
|
|
53
|
-
targets: readonly
|
|
53
|
+
targets: readonly LaneFeedbackTarget[],
|
|
54
54
|
deployEnvironments: readonly string[],
|
|
55
55
|
): Promise<PreviewCandidatesRead> => {
|
|
56
56
|
const reads = await Promise.all(targets.map((target) => readHeadPreview(root, target, deployEnvironments)))
|
package/src/watch/previews.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { isRecord } from '../shared/is-record'
|
|
2
|
-
import { type
|
|
2
|
+
import { isReadTime, type LaneFeedbackTarget } from './feedback'
|
|
3
3
|
import { FEEDBACK_FORM, type FeedbackItem } from './feedback-item'
|
|
4
4
|
|
|
5
5
|
export const PREVIEW_EVENT = 'preview'
|
|
@@ -33,7 +33,7 @@ export type PreviewRecord = { reportedHead: string | null; stamps: Record<string
|
|
|
33
33
|
/** `since` is the epoch milliseconds of the last read, null before the first. */
|
|
34
34
|
export type PreviewState = { since: number | null; pullRequests: Record<string, PreviewRecord> }
|
|
35
35
|
|
|
36
|
-
export type PreviewRead = { readAt: number; targets:
|
|
36
|
+
export type PreviewRead = { readAt: number; targets: LaneFeedbackTarget[]; candidates: PreviewCandidate[] }
|
|
37
37
|
|
|
38
38
|
/** Which comments name a preview: their authors' logins, and optionally a pattern whose first group is the URL. */
|
|
39
39
|
export type PreviewSettings = { authors: readonly string[]; urlPattern?: string }
|
|
@@ -125,7 +125,7 @@ export const previewDeployments = (deployments: readonly Deployment[], deployEnv
|
|
|
125
125
|
|
|
126
126
|
/** A deployment of `target`'s head as a candidate once its latest status succeeded with an https URL. */
|
|
127
127
|
export const deploymentCandidate = (
|
|
128
|
-
target:
|
|
128
|
+
target: LaneFeedbackTarget,
|
|
129
129
|
id: number,
|
|
130
130
|
status: DeploymentStatus,
|
|
131
131
|
): PreviewCandidate | null => {
|
|
@@ -144,7 +144,7 @@ export const deploymentCandidate = (
|
|
|
144
144
|
|
|
145
145
|
export const previewReadOf = (
|
|
146
146
|
readAt: number,
|
|
147
|
-
targets:
|
|
147
|
+
targets: LaneFeedbackTarget[],
|
|
148
148
|
candidates: readonly PreviewCandidate[],
|
|
149
149
|
): PreviewRead => {
|
|
150
150
|
const numbers = new Set(targets.map(({ number }) => number))
|
|
@@ -152,18 +152,18 @@ export const previewReadOf = (
|
|
|
152
152
|
}
|
|
153
153
|
|
|
154
154
|
/** A candidate with a URL that may stand for the target's current head: a comment, or a deployment of that head. */
|
|
155
|
-
const isUsable = (candidate: PreviewCandidate, target:
|
|
155
|
+
const isUsable = (candidate: PreviewCandidate, target: LaneFeedbackTarget): boolean =>
|
|
156
156
|
candidate.url !== null && (candidate.headCommit === null || candidate.headCommit === target.headCommit)
|
|
157
157
|
|
|
158
158
|
/** Deployments before comments: a deployment names its commit, a comment does not. */
|
|
159
159
|
const byDeploymentFirst = (a: PreviewCandidate, b: PreviewCandidate): number =>
|
|
160
160
|
Number(a.source !== PREVIEW_SOURCE.deployment) - Number(b.source !== PREVIEW_SOURCE.deployment)
|
|
161
161
|
|
|
162
|
-
const candidatesOf = (read: PreviewRead, target:
|
|
162
|
+
const candidatesOf = (read: PreviewRead, target: LaneFeedbackTarget): PreviewCandidate[] =>
|
|
163
163
|
read.candidates.filter(({ pullRequest }) => pullRequest === target.number).sort(byDeploymentFirst)
|
|
164
164
|
|
|
165
165
|
/** The usable candidate that changed since `record`, when the head has had no preview reported yet. */
|
|
166
|
-
const freshPreview = (record: PreviewRecord, target:
|
|
166
|
+
const freshPreview = (record: PreviewRecord, target: LaneFeedbackTarget, candidates: readonly PreviewCandidate[]) =>
|
|
167
167
|
target.headCommit === record.reportedHead
|
|
168
168
|
? undefined
|
|
169
169
|
: candidates.find((candidate) => record.stamps[candidate.key] !== candidate.stamp && isUsable(candidate, target))
|
|
@@ -189,7 +189,7 @@ export const previewEvents = (previous: PreviewState, read: PreviewRead): Previe
|
|
|
189
189
|
: []
|
|
190
190
|
})
|
|
191
191
|
|
|
192
|
-
const recordOf = (record: PreviewRecord | undefined, target:
|
|
192
|
+
const recordOf = (record: PreviewRecord | undefined, target: LaneFeedbackTarget, candidates: PreviewCandidate[]) => {
|
|
193
193
|
const stamps = Object.fromEntries(candidates.map(({ key, stamp }) => [key, stamp]))
|
|
194
194
|
const shown = record
|
|
195
195
|
? freshPreview(record, target, candidates)
|
|
@@ -211,7 +211,7 @@ export const recordPreviews = (previous: PreviewState, read: PreviewRead): Previ
|
|
|
211
211
|
})
|
|
212
212
|
|
|
213
213
|
/** The targets whose current head has no preview reported yet; only they need their deployments read. */
|
|
214
|
-
export const unreportedTargets = (state: PreviewState, targets: readonly
|
|
214
|
+
export const unreportedTargets = (state: PreviewState, targets: readonly LaneFeedbackTarget[]): LaneFeedbackTarget[] =>
|
|
215
215
|
targets.filter(({ number, headCommit }) => state.pullRequests[String(number)]?.reportedHead !== headCommit)
|
|
216
216
|
|
|
217
217
|
const isPreviewRecord = (value: unknown): boolean =>
|