jackherd 1.4.0 → 1.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +15 -0
- package/README.md +109 -38
- package/dist/{chunk-UI3AZNZF.js → chunk-QJSXHHQ3.js} +15 -0
- package/dist/config.d.ts +7 -0
- package/dist/config.js +27 -2
- package/dist/plan-format.d.ts +14 -1
- package/dist/plan-format.js +5 -1
- package/herdr-plugin/herdr-plugin.toml +1 -1
- package/install.sh +1 -1
- package/kit/hooks/hooks/merge-guard/check-free.ts +70 -0
- package/kit/hooks/hooks/merge-guard/decision.ts +2 -1
- package/kit/hooks/hooks/merge-guard/gh.ts +12 -0
- package/kit/hooks/hooks/merge-guard/pull-request.ts +27 -10
- package/kit/hooks/hooks/merge-guard/run.ts +22 -8
- package/kit/hooks/hooks/merge-guard/settings.ts +20 -4
- package/kit/rules/rules/kit/code-style.md +1 -0
- package/kit/workflow/skills/follow/SKILL.md +10 -3
- package/kit/workflow/skills/orchestrate/SKILL.md +26 -9
- package/package.json +1 -1
- package/schemas/jackherd.schema.json +23 -0
- package/src/cli.ts +15 -2
- package/src/config/detect.ts +15 -0
- package/src/config/schema.ts +31 -1
- package/src/plans/lane-standing.ts +59 -0
- package/src/plans/layout.ts +32 -12
- package/src/plans/texts.ts +5 -1
- package/src/sync/emptied-directories.ts +19 -0
- package/src/sync/project-fs.ts +28 -2
- package/src/sync/render.ts +14 -5
- package/src/sync/sync.ts +28 -10
- package/src/vocabulary/index.ts +24 -0
- package/src/watch/command.ts +20 -1
- package/src/watch/heartbeat-progress.ts +47 -0
- package/src/watch/source-command.ts +16 -0
- package/src/watch/source-run.ts +8 -1
- package/src/watch/stream-output.ts +20 -3
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,21 @@
|
|
|
2
2
|
|
|
3
3
|
What changed for people who use jackherd, newest first.
|
|
4
4
|
|
|
5
|
+
## 1.6.0
|
|
6
|
+
|
|
7
|
+
- **Replies address you as you like.** The new setting `responseAddress` (`informal` by default, or `formal`) says whether replies use the familiar or the polite form where the language has both: in German `du` or `Sie`. Set it in `.jackherd/config.json`; `jh sync` writes it into the kit rule `rules/kit/code-style.md`, which every session loads. It follows `responseLanguage`. English has one form, so there the setting only nudges the tone (direct or businesslike). The watcher's and the plans view's own German words use no pronoun and are unchanged.
|
|
8
|
+
- **`jh --version` (and `jh -v`) prints the installed version** and exits 0. The usage text lists it.
|
|
9
|
+
- **Closing a lane marks its rows.** Step 5 of "Close a lane" in the `orchestrate` skill now appends the cleaned-up word (`, abgeräumt <date>` on a German board, `, cleaned up <date>` on an English one) to the name cell of every row of the lane, in the same documentation PR as the history entry. The word follows `planLanguage`. The plans view no longer lists a closed lane without a worktree as `waiting`.
|
|
10
|
+
- **The worktree's dependencies are checked with one command.** When the herdr plugin `zerodice0.worktree-bootstrap` has set up the worktree, the `orchestrate` skill no longer polls for a dependencies directory (some package managers keep a workspace's out of the root, so the poll could keep waiting on an install that was long done). It runs your `commands.install` with the lock file frozen: `bun install --frozen-lockfile`, `pnpm install --frozen-lockfile`, `yarn install --immutable` or `npm ci` (which always reinstalls). Any other install command you set is run exactly as written.
|
|
11
|
+
- **`jh sync` clears the empty directories a prefix change leaves.** When `skillPrefix` changes, the unedited kit files under the old names are removed, and now the directories those removals leave empty go too; an empty directory the run did not touch stays. A directory that still holds a file the kit did not write stays, and `sync` says so (`Kept .claude/skills/<name>: it holds files the kit did not write.`). A symbolic link is not followed, and `sync --check` removes nothing.
|
|
12
|
+
|
|
13
|
+
## 1.5.0
|
|
14
|
+
|
|
15
|
+
- **The plans view lists only the lanes that still have something to do.** Before, every lane without an agent was listed (56 of 61 on one board, nearly all of them finished). Now a lane marked cleaned up, and a lane without a worktree whose named phases are all ticked, are not shown. A lane without a worktree that still has an open phase, or no plan and a "starts after" other than "now", is listed under `· waiting` with what it waits for. A worktree without an agent stays under `· no agent`. The board needs no change. The watcher is unchanged.
|
|
16
|
+
- **Documents merge without waiting for checks.** The new setting `merge.withoutChecks` (default empty) lists path patterns such as `docs/**/*.md`. When every file of a pull request matches, `/orchestrate` merges at once instead of `gh pr checks --watch`, and the merge guard does not ask for a plain `gh pr merge <number>`: it checks the file list on GitHub itself and asks as before on any doubt. Pattern rules are in the README.
|
|
17
|
+
- **The heartbeat shows progress.** In a stream, a `working` lane whose phase count moved since the last heartbeat becomes `<lane> working: P12 7/12`; a lane that did not move adds nothing. The heartbeat line stays short.
|
|
18
|
+
- **Source scripts are TypeScript.** The `follow` skill now writes a source's script as `scripts/sources/<name>.ts`, started with `["bun", "scripts/sources/<name>.ts"]` (Bun must be installed). The token goes from `process.env` into the request header, and a failure is an exit code, not an empty list. The README recipes for Jira with `acli`, Linear and Sentry are replaced, and a test compiles each one. The watcher reads `bun` as a source's program to mean the Bun that runs it, so Bun need not be on the `PATH`. Existing `watch.sources` entries keep working; recipes without a script stay direct commands.
|
|
19
|
+
|
|
5
20
|
## 1.4.0
|
|
6
21
|
|
|
7
22
|
- **A question in running text gets a hint.** The new `Stop` hook `ask-hint` tells you when the last message of a turn ends in `?` and no `AskUserQuestion` dialog was used. It only hints; the turn ends as before. A lane session stays silent, because a lane asks its coordinator as a text block. Switch it off with `"hints": { "askUserQuestion": false }` in `.jackherd/config.json`. `jh sync` brings the hook to a project install; a global install ships none.
|
package/README.md
CHANGED
|
@@ -185,7 +185,7 @@ bunx jackherd sync # later: updates the kit files
|
|
|
185
185
|
|
|
186
186
|
`init` writes what it [detected](#user-content-detected-per-repository) into the new config, so the project sees it. It asks whether to enter the [status line](#user-content-status-line) in `.claude/settings.json`; `--yes` or `--no` answer every question without asking.
|
|
187
187
|
|
|
188
|
-
**Name clashes.** A project that already has a skill or agent of a kit name (`implement`, `plan`, …): `init` names the clash and offers the prefix `jh-`, so the kit's copies sit beside yours and call each other, not yours. Any project can set `skillPrefix` in `.jackherd/config.json`; `sync` then renames, and removes the unprefixed kit files you never edited.
|
|
188
|
+
**Name clashes.** A project that already has a skill or agent of a kit name (`implement`, `plan`, …): `init` names the clash and offers the prefix `jh-`, so the kit's copies sit beside yours and call each other, not yours. Any project can set `skillPrefix` in `.jackherd/config.json`; `sync` then renames, and removes the unprefixed kit files you never edited, with the directories they leave empty. A directory that holds a file of yours stays, and `sync` names it.
|
|
189
189
|
|
|
190
190
|
**Your edits survive.** `sync` leaves files you have edited alone. When the kit changed one of them too, its version lands beside yours as `<file>.kit-new`: merge it, or take the kit version with `sync --accept <path>`. A kit file you deleted stays deleted. Project-specific additions go into `.claude/project/<skill>.md` (read first by the skill of that name) or a rule beside `.claude/rules/kit/`, so the kit files stay updatable.
|
|
191
191
|
|
|
@@ -531,6 +531,16 @@ A merge or a release is the step nobody takes back. In a project install the `me
|
|
|
531
531
|
- `guard.selfMerge.offer` (default `true`) lets `/orchestrate` offer "approve, and merge it yourself after green checks" in its PR question. With `false` the question has two options and the hook honours no such answer.
|
|
532
532
|
- `guard.selfMerge.approvalMinutes` (default 180) is how long that answer lets `gh pr merge <number>` pass without a second dialog.
|
|
533
533
|
|
|
534
|
+
**Merging without checks.** A pull request that only changes documents rarely needs its checks. List the paths in `merge.withoutChecks` (default empty, so every merge waits for the checks as before):
|
|
535
|
+
|
|
536
|
+
```json
|
|
537
|
+
{
|
|
538
|
+
"merge": { "withoutChecks": ["docs/**/*.md"] }
|
|
539
|
+
}
|
|
540
|
+
```
|
|
541
|
+
|
|
542
|
+
A pattern is made of path words with `*` and `**`: no `..`, not absolute, no braces or negation, and at least one whole path segment without a wildcard, so `docs/**/*.md` is fine and `**`, `**/*.md` and `**/*.*` are refused. Keep the folder in the pattern: a `.md` file elsewhere can be an agent instruction (`CLAUDE.md`, a skill) that ships with a package and deserves its checks. With the list set, `/orchestrate` lists a pull request's files with `gh pr diff <number> --name-only` before it waits for checks and merges at once when every file matches. The merge guard checks the same itself: for a plain `gh pr merge <number>` it asks GitHub for the pull request and then for its files in that repository (`gh api …/pulls/<number>/files`, which also names the old path of a renamed file) and does not ask you when the pull request is open, from this repository, GitHub lists as many files as the pull request changes (fewer than 3000) and every one matches a pattern. This works without a self-merge answer, also when `guard.selfMerge.offer` is `false`. No file, one file outside, a count that differs, a failed `gh` call or a pattern list it cannot read means it asks as before. Limit: a push between its look and the merge is not seen.
|
|
543
|
+
|
|
534
544
|
**What self-merge does.** You pick the second option of the PR question. The coordinator commits, pushes and opens the PR, waits for the checks in the background, merges with `gh pr merge <number> --squash --delete-branch` when they are green and tells you in one line; on a red check it does not merge and reports. The project supplement can block the option per approval tier.
|
|
535
545
|
|
|
536
546
|
**What the hook checks.** It does not take the agent's word for your answer. It reads the session's transcript, which Claude Code writes, and looks for a dialog result where you picked that exact option for a question naming one branch. Then it asks GitHub which branch pull request `<number>` carries and lets the merge pass if the two match, the PR is open and not from a fork, and the command is exactly the plain form: `gh pr merge <number>` with `--squash`, `--merge`, `--rebase` and `--delete-branch` as the only flags, run on its own in the directory you work in (no `-R`, no `--admin`, no chain, no wrapper). `gh workflow run` and every other guarded command ask every time.
|
|
@@ -580,7 +590,7 @@ coordinator coordinator no value (no agent named coordinator)
|
|
|
580
590
|
gh unavailable since 11:24:31
|
|
581
591
|
```
|
|
582
592
|
|
|
583
|
-
As a stream (`--stream`) it keeps running, and the heartbeat is one line:
|
|
593
|
+
As a stream (`--stream`) it keeps running, and the heartbeat is one line. A working lane shows its plan progress, `<lane> working: P12 7/12` (the lane's current phase and its ticked checkboxes, counted as the plans view counts them), but only when the count moved since the last heartbeat; a lane that stays on one number says nothing, and you look for yourself. The plans are read only when a lane works:
|
|
584
594
|
|
|
585
595
|
```text
|
|
586
596
|
armed: search=no agent board docs/plans/board-search/board.md
|
|
@@ -710,36 +720,70 @@ Three limits stand: the watcher cannot make a command read only, so name only co
|
|
|
710
720
|
|
|
711
721
|
##### Recipes
|
|
712
722
|
|
|
713
|
-
Each recipe is one `watch.sources` entry; put in your key, project or query, then try it with `--dry-run` (and `--raw` where the shape differs from what is shown). A token never goes into an argument: an argument shows in the process list. Where a tool keeps its own login, it is used; where a token sits in an environment variable, a small script
|
|
714
|
-
|
|
715
|
-
**Jira with the Atlassian CLI.** Login: `acli jira auth login`, kept by `acli`. `acli jira workitem search` cannot return `updated` or `comment`, so a script searches for the keys and reads each one with `view`, a few at a time to stay well under the 30 seconds a source may run.
|
|
723
|
+
Each recipe is one `watch.sources` entry; put in your key, project or query, then try it with `--dry-run` (and `--raw` where the shape differs from what is shown). A token never goes into an argument: an argument shows in the process list. Where a tool keeps its own login, it is used; where a token sits in an environment variable, a small TypeScript script run by Bun (which runs wherever `jh` does) puts it from `process.env` straight into the request header. A script fails with an exit code, never with an empty answer the watcher would take for a source with nothing new, and it prints neither the token nor the body of a failed answer. The closing `export {}` makes the file a module, so the type check accepts top-level `await` and the names do not clash between scripts. A recipe that needs no script (`gh …`, `curl` on a public page, `curl --netrc`) stays a direct command. The watcher starts a source without a shell and reads `bun` as the first word to mean the Bun that runs the watcher itself, so the entry needs no absolute path, which would differ from machine to machine, even when Bun is not on the `PATH` (installed in `~/.bun/bin` only, say).
|
|
724
|
+
|
|
725
|
+
**Jira with the Atlassian CLI.** Login: `acli jira auth login`, kept by `acli`. `acli jira workitem search` cannot return `updated` or `comment`, so a script searches for the keys and reads each one with `view`, a few at a time to stay well under the 30 seconds a source may run. `plainText` joins the text and mention nodes of the comment's document tree into plain text; it is a pure function, so you can test it on its own. The script, for example `scripts/sources/jira.ts`, with your project in the query and `<host>` your `<site>.atlassian.net`:
|
|
726
|
+
|
|
727
|
+
```ts
|
|
728
|
+
// scripts/sources/jira.ts
|
|
729
|
+
const jql = 'project = PROJ AND assignee = currentUser() AND statusCategory != Done'
|
|
730
|
+
const host = '<host>'
|
|
731
|
+
const parallel = 6
|
|
732
|
+
|
|
733
|
+
type DocumentNode = { type?: string; text?: string; attrs?: { text?: string }; content?: DocumentNode[] }
|
|
734
|
+
type IssueComment = { author?: { displayName?: string }; body?: DocumentNode }
|
|
735
|
+
type Issue = { key: string; fields: { updated: string; summary: string; comment?: { comments: IssueComment[] } } }
|
|
736
|
+
|
|
737
|
+
const plainText = (node: DocumentNode): string =>
|
|
738
|
+
node.type === 'text' || node.type === 'mention'
|
|
739
|
+
? (node.text ?? node.attrs?.text ?? '')
|
|
740
|
+
: (node.content ?? []).map(plainText).join('')
|
|
741
|
+
|
|
742
|
+
const acli = async (args: string[]): Promise<unknown> => {
|
|
743
|
+
const child = Bun.spawn(['acli', ...args], { stdout: 'pipe', stderr: 'ignore', signal: AbortSignal.timeout(20_000) })
|
|
744
|
+
const output = await new Response(child.stdout).text()
|
|
745
|
+
if ((await child.exited) !== 0) process.exit(1)
|
|
746
|
+
try {
|
|
747
|
+
return JSON.parse(output)
|
|
748
|
+
} catch {
|
|
749
|
+
return process.exit(1)
|
|
750
|
+
}
|
|
751
|
+
}
|
|
716
752
|
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
jql='project = PROJ AND assignee = currentUser() AND statusCategory != Done'
|
|
721
|
-
acli jira workitem search --jql "$jql" --limit 100 --json |
|
|
722
|
-
jq -r '.[].key' |
|
|
723
|
-
xargs -r -P 6 -I{} acli jira workitem view {} --fields summary,updated,comment --json |
|
|
724
|
-
jq -s 'map({
|
|
753
|
+
const entryOf = ({ key, fields }: Issue) => {
|
|
754
|
+
const latest = fields.comment?.comments.at(-1)
|
|
755
|
+
return {
|
|
725
756
|
key,
|
|
726
|
-
url:
|
|
727
|
-
updated:
|
|
728
|
-
summary:
|
|
729
|
-
author:
|
|
730
|
-
body: (.
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
757
|
+
url: `https://${host}/browse/${key}`,
|
|
758
|
+
updated: fields.updated,
|
|
759
|
+
summary: fields.summary,
|
|
760
|
+
author: latest?.author?.displayName ?? null,
|
|
761
|
+
body: latest?.body ? plainText(latest.body) : null,
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
const keys = ((await acli(['jira', 'workitem', 'search', '--jql', jql, '--limit', '100', '--json'])) as { key: string }[]).map(
|
|
766
|
+
({ key }) => key,
|
|
767
|
+
)
|
|
768
|
+
const entries = []
|
|
769
|
+
for (let start = 0; start < keys.length; start += parallel) {
|
|
770
|
+
const batch = keys.slice(start, start + parallel)
|
|
771
|
+
const issues = await Promise.all(
|
|
772
|
+
batch.map((key) => acli(['jira', 'workitem', 'view', key, '--fields', 'summary,updated,comment', '--json'])),
|
|
773
|
+
)
|
|
774
|
+
entries.push(...(issues as Issue[]).map(entryOf))
|
|
775
|
+
}
|
|
776
|
+
console.log(JSON.stringify(entries))
|
|
777
|
+
|
|
778
|
+
export {}
|
|
735
779
|
```
|
|
736
780
|
|
|
737
|
-
|
|
781
|
+
A failed `acli` call ends the script with exit code 1, which the watcher reports as a failing source; nothing is printed that it could take for an empty list.
|
|
738
782
|
|
|
739
783
|
```json
|
|
740
784
|
{
|
|
741
785
|
"name": "tickets-decisions",
|
|
742
|
-
"command": ["
|
|
786
|
+
"command": ["bun", "scripts/sources/jira.ts"],
|
|
743
787
|
"map": {
|
|
744
788
|
"id": ".key",
|
|
745
789
|
"updatedAt": ".updated",
|
|
@@ -768,19 +812,34 @@ acli jira workitem search --jql "$jql" --limit 100 --json |
|
|
|
768
812
|
}
|
|
769
813
|
```
|
|
770
814
|
|
|
771
|
-
**Linear over GraphQL.** Token: a personal API key in `LINEAR_API_KEY`, sent as `Authorization: <key>` without `Bearer`. The script, for example `scripts/sources/linear.
|
|
815
|
+
**Linear over GraphQL.** Token: a personal API key in `LINEAR_API_KEY`, sent as `Authorization: <key>` without `Bearer`. The script, for example `scripts/sources/linear.ts`, with `<host>` the API host `api.linear.app`. Linear answers a bad key or query with HTTP 200 and an `errors` list, so the script checks for it as well:
|
|
772
816
|
|
|
773
|
-
```
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
817
|
+
```ts
|
|
818
|
+
// scripts/sources/linear.ts
|
|
819
|
+
const token = process.env.LINEAR_API_KEY
|
|
820
|
+
if (!token) process.exit(1)
|
|
821
|
+
|
|
822
|
+
const query = `{ issues(filter: { team: { key: { eq: "ENG" } }, number: { eq: 123 } }) {
|
|
823
|
+
nodes { identifier title url updatedAt comments { nodes { body user { name } } } } } }`
|
|
824
|
+
|
|
825
|
+
const response = await fetch('https://<host>/graphql', {
|
|
826
|
+
method: 'POST',
|
|
827
|
+
headers: { Authorization: token, 'Content-Type': 'application/json' },
|
|
828
|
+
body: JSON.stringify({ query }),
|
|
829
|
+
signal: AbortSignal.timeout(20_000),
|
|
830
|
+
})
|
|
831
|
+
if (!response.ok) process.exit(1)
|
|
832
|
+
const answer = (await response.json()) as { errors?: unknown }
|
|
833
|
+
if (answer.errors) process.exit(1)
|
|
834
|
+
console.log(JSON.stringify(answer))
|
|
835
|
+
|
|
836
|
+
export {}
|
|
778
837
|
```
|
|
779
838
|
|
|
780
839
|
```json
|
|
781
840
|
{
|
|
782
841
|
"name": "linear-eng-123",
|
|
783
|
-
"command": ["
|
|
842
|
+
"command": ["bun", "scripts/sources/linear.ts"],
|
|
784
843
|
"items": ".data.issues.nodes",
|
|
785
844
|
"map": {
|
|
786
845
|
"id": ".identifier",
|
|
@@ -813,19 +872,28 @@ Check with `--raw` which end of `comments.nodes` holds the newest comment; `[0]`
|
|
|
813
872
|
}
|
|
814
873
|
```
|
|
815
874
|
|
|
816
|
-
**Sentry.** Token: an auth token with `event:read` in `SENTRY_AUTH_TOKEN`; `<host>` is `sentry.io` or your own. The script, for example `scripts/sources/sentry.
|
|
875
|
+
**Sentry.** Token: an auth token with `event:read` in `SENTRY_AUTH_TOKEN`; `<host>` is `sentry.io` or your own. The script, for example `scripts/sources/sentry.ts`:
|
|
876
|
+
|
|
877
|
+
```ts
|
|
878
|
+
// scripts/sources/sentry.ts
|
|
879
|
+
const token = process.env.SENTRY_AUTH_TOKEN
|
|
880
|
+
if (!token) process.exit(1)
|
|
881
|
+
|
|
882
|
+
const response = await fetch(
|
|
883
|
+
'https://<host>/api/0/projects/<org>/<project>/issues/?query=is:unresolved&statsPeriod=24h',
|
|
884
|
+
{ headers: { Authorization: `Bearer ${token}` }, signal: AbortSignal.timeout(20_000) },
|
|
885
|
+
)
|
|
886
|
+
if (!response.ok) process.exit(1)
|
|
887
|
+
console.log(JSON.stringify(await response.json()))
|
|
817
888
|
|
|
818
|
-
|
|
819
|
-
#!/bin/sh
|
|
820
|
-
printf 'Authorization: Bearer %s\n' "$SENTRY_AUTH_TOKEN" |
|
|
821
|
-
curl -fsS --max-time 20 -H @- "https://<host>/api/0/projects/<org>/<project>/issues/?query=is:unresolved&statsPeriod=24h"
|
|
889
|
+
export {}
|
|
822
890
|
```
|
|
823
891
|
|
|
824
892
|
```json
|
|
825
893
|
{
|
|
826
894
|
"name": "errors",
|
|
827
895
|
"every": 10,
|
|
828
|
-
"command": ["
|
|
896
|
+
"command": ["bun", "scripts/sources/sentry.ts"],
|
|
829
897
|
"map": { "id": ".id", "updatedAt": ".lastSeen", "title": ".title", "url": ".permalink", "body": ".culprit" }
|
|
830
898
|
}
|
|
831
899
|
```
|
|
@@ -865,7 +933,7 @@ Decisions never live only in a hand-over note: they are in the plan or the board
|
|
|
865
933
|
|
|
866
934
|
In a repository that ignores `docs/`, plan and board exist only in the main checkout and the plan view reads them there; list the plan in `lanes.copy` so the lane has it to work from and to tick off its own progress.
|
|
867
935
|
|
|
868
|
-
The herdr plugin `zerodice0.worktree-bootstrap`, when enabled and configured in the main checkout, can copy files and run setup commands for new worktrees; the coordinator checks its result and does the step itself when it is missing.
|
|
936
|
+
The herdr plugin `zerodice0.worktree-bootstrap`, when enabled and configured in the main checkout, can copy files and run setup commands for new worktrees; the coordinator checks its result with the install command and a frozen lock file (`bun install --frozen-lockfile`, `pnpm install --frozen-lockfile`, `yarn install --immutable`, `npm ci`, derived from `commands.install`; an install command of your own runs as written) and does the step itself when it is missing.
|
|
869
937
|
|
|
870
938
|
</details>
|
|
871
939
|
|
|
@@ -887,6 +955,7 @@ The herdr plugin `zerodice0.worktree-bootstrap`, when enabled and configured in
|
|
|
887
955
|
One block per lane of the chosen board, lanes that wait on you first:
|
|
888
956
|
|
|
889
957
|
- the agent's state: `❓ blocked` (waits for you), `📨 done` (the agent finished its answer, not the lane), `⏸ idle`, `⚡ working`, or `·` no agent
|
|
958
|
+
- which lanes are listed at all. A lane with an agent or an open pull request is drawn in full. A lane with a worktree but no agent is listed under `· no agent`. A lane without a worktree is listed under `· waiting`, with what it starts after, when a phase it names is still open in its plan or, if it names no plan, when its "starts after" cell says something other than "now" (`sofort`); otherwise it is finished and not shown. A lane whose name cell carries the cleaned-up mark (`abgeräumt`) is finished whatever its plan says. The board needs no extra column for this
|
|
890
959
|
- how full the agent's context is, and a bar with the lane's ticked tasks
|
|
891
960
|
- the branch, commits ahead and uncommitted files
|
|
892
961
|
- the lane's open pull request with its checks
|
|
@@ -1008,6 +1077,7 @@ At `warn` a lane offers a fresh chat at its next phase boundary, and the watcher
|
|
|
1008
1077
|
|
|
1009
1078
|
| Command | What it does | Options |
|
|
1010
1079
|
| ------- | ------------ | ------- |
|
|
1080
|
+
| `jh --version` | The installed version, e.g. `1.6.0`; `-v` is the same | none |
|
|
1011
1081
|
| `jh init` | Creates `.jackherd/config.json` and installs the kit | `--global`: into `~/.claude` and `~/.jackherd`; `--yes`/`--no`: answer all its questions (globally the hook and the status line, in a project the prefix and the status line) and take the default [setup](#user-content-setup-questions); a status line of your own is replaced only by a yes in the dialog |
|
|
1012
1082
|
| `jh sync` | Updates the kit files; edited files get a `.kit-new` beside them | `--global`; `--accept <path>` takes the kit's version; `--global --if-changed` syncs only after a package update and names a newer npm release once a day |
|
|
1013
1083
|
| `jh update` | Installs the newest release with the package manager that holds the kit globally, then syncs `~/.claude` and the project with the new code | none; does nothing when the installed version is the latest |
|
|
@@ -1026,6 +1096,7 @@ Without a global install, run any of them as `bunx jackherd <command>`.
|
|
|
1026
1096
|
| Key | Default | What it decides |
|
|
1027
1097
|
| --- | --- | --- |
|
|
1028
1098
|
| `responseLanguage` | `en` | the language the skills speak with you, and the watcher's status words |
|
|
1099
|
+
| `responseAddress` | `informal` | `informal` or `formal`: how replies address you where the language has both (German: `du` or `Sie`). `jh sync` writes it once into the kit rule `rules/kit/code-style.md`, which every session loads; it follows `responseLanguage`; the status words of the watcher and the plans view use no pronoun and stay as they are |
|
|
1029
1100
|
| `planLanguage` | `en` | the language of plans and boards, including their headings |
|
|
1030
1101
|
| `planDirectory` | `docs/plans` | where plans and boards live |
|
|
1031
1102
|
| `board.pattern`, `board.file`, `board.term` | `board-*`, `board.md`, `board` | [Boards](#user-content-boards) |
|
|
@@ -4,6 +4,17 @@ var LANGUAGE_NAME = {
|
|
|
4
4
|
[PLAN_LANGUAGE.en]: "English",
|
|
5
5
|
[PLAN_LANGUAGE.de]: "German"
|
|
6
6
|
};
|
|
7
|
+
var RESPONSE_ADDRESS = { informal: "informal", formal: "formal" };
|
|
8
|
+
var ADDRESS_FORM = {
|
|
9
|
+
[PLAN_LANGUAGE.en]: {
|
|
10
|
+
[RESPONSE_ADDRESS.informal]: "the plain, direct \u201Cyou\u201D, without titles",
|
|
11
|
+
[RESPONSE_ADDRESS.formal]: "a polite, businesslike \u201Cyou\u201D"
|
|
12
|
+
},
|
|
13
|
+
[PLAN_LANGUAGE.de]: {
|
|
14
|
+
[RESPONSE_ADDRESS.informal]: "the familiar \u201Cdu\u201D (du, dir, dein), never \u201CSie\u201D",
|
|
15
|
+
[RESPONSE_ADDRESS.formal]: "the polite \u201CSie\u201D (Sie, Ihnen, Ihr), never \u201Cdu\u201D"
|
|
16
|
+
}
|
|
17
|
+
};
|
|
7
18
|
var SELF_MERGE_LABEL = {
|
|
8
19
|
[PLAN_LANGUAGE.en]: "Approve, and merge it yourself after green checks",
|
|
9
20
|
[PLAN_LANGUAGE.de]: "Freigeben, und nach gr\xFCnen Checks selbst mergen"
|
|
@@ -35,6 +46,7 @@ var PLAN_VOCABULARY = {
|
|
|
35
46
|
phase: "Phase",
|
|
36
47
|
noReviewGate: "Kein Review-Gate",
|
|
37
48
|
laneClosed: "abger\xE4umt",
|
|
49
|
+
startsNow: ["sofort"],
|
|
38
50
|
phaseConnectors: ["danach", "und", "dann", "sowie"],
|
|
39
51
|
statusWords: {
|
|
40
52
|
done: { label: "Done", leading: ["done"], anywhere: ["abgeschlossen"] },
|
|
@@ -62,6 +74,7 @@ var PLAN_VOCABULARY = {
|
|
|
62
74
|
phase: "Phase",
|
|
63
75
|
noReviewGate: "No review gate",
|
|
64
76
|
laneClosed: "cleaned up",
|
|
77
|
+
startsNow: ["now", "immediately"],
|
|
65
78
|
phaseConnectors: ["then", "and", "after"],
|
|
66
79
|
statusWords: {
|
|
67
80
|
done: { label: "Done", leading: ["done"], anywhere: ["completed"] },
|
|
@@ -88,6 +101,8 @@ var sectionHeading = (language, section) => `## ${PLAN_VOCABULARY[language].sect
|
|
|
88
101
|
export {
|
|
89
102
|
PLAN_LANGUAGE,
|
|
90
103
|
LANGUAGE_NAME,
|
|
104
|
+
RESPONSE_ADDRESS,
|
|
105
|
+
ADDRESS_FORM,
|
|
91
106
|
SELF_MERGE_LABEL,
|
|
92
107
|
isPlanLanguage,
|
|
93
108
|
PLAN_STATUS,
|
package/dist/config.d.ts
CHANGED
|
@@ -43,6 +43,10 @@ declare const jackherdConfigSchema: z.ZodObject<{
|
|
|
43
43
|
de: "de";
|
|
44
44
|
en: "en";
|
|
45
45
|
}>>;
|
|
46
|
+
responseAddress: z.ZodDefault<z.ZodEnum<{
|
|
47
|
+
informal: "informal";
|
|
48
|
+
formal: "formal";
|
|
49
|
+
}>>;
|
|
46
50
|
planLanguage: z.ZodDefault<z.ZodEnum<{
|
|
47
51
|
de: "de";
|
|
48
52
|
en: "en";
|
|
@@ -139,6 +143,9 @@ declare const jackherdConfigSchema: z.ZodObject<{
|
|
|
139
143
|
approvalMinutes: z.ZodDefault<z.ZodNumber>;
|
|
140
144
|
}, z.core.$strict>>;
|
|
141
145
|
}, z.core.$strict>>;
|
|
146
|
+
merge: z.ZodPrefault<z.ZodObject<{
|
|
147
|
+
withoutChecks: z.ZodDefault<z.ZodArray<z.ZodString>>;
|
|
148
|
+
}, z.core.$strict>>;
|
|
142
149
|
hints: z.ZodPrefault<z.ZodObject<{
|
|
143
150
|
askUserQuestion: z.ZodDefault<z.ZodBoolean>;
|
|
144
151
|
}, z.core.$strict>>;
|
package/dist/config.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import {
|
|
2
|
-
PLAN_LANGUAGE
|
|
3
|
-
|
|
2
|
+
PLAN_LANGUAGE,
|
|
3
|
+
RESPONSE_ADDRESS
|
|
4
|
+
} from "./chunk-QJSXHHQ3.js";
|
|
4
5
|
|
|
5
6
|
// src/config/json-schema.ts
|
|
6
7
|
import { z as z3 } from "zod";
|
|
@@ -69,6 +70,12 @@ var DERIVED_COMMANDS = {
|
|
|
69
70
|
lintFiles: "npx @biomejs/biome check --no-errors-on-unmatched"
|
|
70
71
|
}
|
|
71
72
|
};
|
|
73
|
+
var FROZEN_INSTALL = {
|
|
74
|
+
[DERIVED_COMMANDS.bun.install]: "bun install --frozen-lockfile",
|
|
75
|
+
[DERIVED_COMMANDS.yarn.install]: "yarn install --immutable",
|
|
76
|
+
[DERIVED_COMMANDS.pnpm.install]: "pnpm install --frozen-lockfile",
|
|
77
|
+
[DERIVED_COMMANDS.npm.install]: "npm ci"
|
|
78
|
+
};
|
|
72
79
|
var BUN_COMMANDS = DERIVED_COMMANDS.bun;
|
|
73
80
|
|
|
74
81
|
// src/check/command-line.ts
|
|
@@ -299,6 +306,20 @@ var coordinatorSchema = z2.strictObject({
|
|
|
299
306
|
alertAt: share.default(0.7)
|
|
300
307
|
}).refine(warnBelowAlert, WARN_BELOW_ALERT);
|
|
301
308
|
var guardedCommand = z2.string().regex(/^[a-z][a-z0-9-]*( [a-z][a-z0-9-]*){1,3}$/, "a program and one to three plain words, such as gh pr merge");
|
|
309
|
+
var checkFreePattern = z2.string().regex(/^[A-Za-z0-9_.*/-]+$/, "path words with * and ** only, such as docs/**/*.md").refine(
|
|
310
|
+
(pattern) => !pattern.startsWith("/") && !pattern.split("/").includes(".."),
|
|
311
|
+
"a relative path that stays inside the repository"
|
|
312
|
+
).refine(
|
|
313
|
+
(pattern) => pattern.split("/").some((segment) => /^[A-Za-z0-9][A-Za-z0-9_.-]*$/.test(segment)),
|
|
314
|
+
"a pattern with a whole path segment that is no wildcard, such as docs/**/*.md"
|
|
315
|
+
);
|
|
316
|
+
var mergeSchema = z2.strictObject({
|
|
317
|
+
/**
|
|
318
|
+
* Paths of pull requests that are merged without waiting for checks: when every file of the pull request matches one
|
|
319
|
+
* of them, `/orchestrate` merges at once and the merge guard does not ask. Empty means every merge waits for checks.
|
|
320
|
+
*/
|
|
321
|
+
withoutChecks: z2.array(checkFreePattern).default([])
|
|
322
|
+
});
|
|
302
323
|
var guardSchema = z2.strictObject({
|
|
303
324
|
/**
|
|
304
325
|
* The commands the merge guard hook asks the user about before they run, also behind options before the verb, a chain,
|
|
@@ -331,6 +352,8 @@ var contextSchema = z2.strictObject({
|
|
|
331
352
|
var jackherdConfigSchema = z2.strictObject({
|
|
332
353
|
$schema: z2.string().optional(),
|
|
333
354
|
responseLanguage: language.default(PLAN_LANGUAGE.en),
|
|
355
|
+
/** Whether replies address the user with the familiar or the polite form where the language has both (German: du or Sie). */
|
|
356
|
+
responseAddress: z2.enum(Object.values(RESPONSE_ADDRESS)).default(RESPONSE_ADDRESS.informal),
|
|
334
357
|
planLanguage: language.default(PLAN_LANGUAGE.en),
|
|
335
358
|
planDirectory: z2.string().min(1).default("docs/plans"),
|
|
336
359
|
/** Where the boards live and what the project calls them. */
|
|
@@ -391,6 +414,8 @@ var jackherdConfigSchema = z2.strictObject({
|
|
|
391
414
|
}).prefault({}),
|
|
392
415
|
/** Read by the merge guard hook, which asks before the listed commands run, and by `/orchestrate`. */
|
|
393
416
|
guard: guardSchema.prefault({}),
|
|
417
|
+
/** Read by `/orchestrate` and by the merge guard hook. */
|
|
418
|
+
merge: mergeSchema.prefault({}),
|
|
394
419
|
/** Read by the `ask-hint` hook. */
|
|
395
420
|
hints: hintsSchema.prefault({}),
|
|
396
421
|
/** Read by `jh check`; validated here too, so a typo fails every command alike. */
|
package/dist/plan-format.d.ts
CHANGED
|
@@ -5,6 +5,17 @@ declare const PLAN_LANGUAGE: {
|
|
|
5
5
|
type PlanLanguage = (typeof PLAN_LANGUAGE)[keyof typeof PLAN_LANGUAGE];
|
|
6
6
|
/** The language's name as a reply or a question says it. */
|
|
7
7
|
declare const LANGUAGE_NAME: Record<PlanLanguage, string>;
|
|
8
|
+
declare const RESPONSE_ADDRESS: {
|
|
9
|
+
readonly informal: "informal";
|
|
10
|
+
readonly formal: "formal";
|
|
11
|
+
};
|
|
12
|
+
type ResponseAddress = (typeof RESPONSE_ADDRESS)[keyof typeof RESPONSE_ADDRESS];
|
|
13
|
+
/**
|
|
14
|
+
* How a skill's reply addresses the user, as the words a kit rule gives the agent. German and the other languages
|
|
15
|
+
* that tell a familiar from a polite "you" name the pronoun and the possessive; English has one form and only
|
|
16
|
+
* differs in tone.
|
|
17
|
+
*/
|
|
18
|
+
declare const ADDRESS_FORM: Record<PlanLanguage, Record<ResponseAddress, string>>;
|
|
8
19
|
/**
|
|
9
20
|
* The option of the pull request question that approves the PR and a merge by the coordinator once the checks are
|
|
10
21
|
* green. The merge guard hook matches the user's answer against this exact text, so it is fixed per language and not
|
|
@@ -55,6 +66,8 @@ type PlanVocabulary = {
|
|
|
55
66
|
noReviewGate: string;
|
|
56
67
|
/** Marks a lane as closed when its name cell contains it. */
|
|
57
68
|
laneClosed: string;
|
|
69
|
+
/** Words a starts-after cell begins with when the lane may start at once. */
|
|
70
|
+
startsNow: readonly string[];
|
|
58
71
|
/** Words that may stand between two phases of one entry (`P1 Rule und P2 Monitor`). */
|
|
59
72
|
phaseConnectors: readonly string[];
|
|
60
73
|
statusWords: Record<PlanStatus, StatusWord>;
|
|
@@ -328,4 +341,4 @@ declare const readSprintDocument: (markdown: string, language: PlanLanguage) =>
|
|
|
328
341
|
*/
|
|
329
342
|
declare const readPhaseDependencies: (planMarkdown: string, language: PlanLanguage) => Record<string, string>;
|
|
330
343
|
|
|
331
|
-
export { type ChecklistItem, type DeferredItem, type DriftFinding, LANGUAGE_NAME, type LaneProgress, type MergedPhaseWithOpenBoxes, type MisplacedDeferral, PHASE_ID_PREFIX, PLAN_LANGUAGE, PLAN_STATUS, PLAN_VOCABULARY, type PhaseProgress, type PhaseTally, type PhaseWithoutGate, type PlanHeader, type PlanLanguage, type PlanPhase, type PlanStatus, type PlanStep, type PlanVocabulary, type PlansByHref, type RowWithoutPhase, SELF_MERGE_LABEL, type SprintDocument, type SprintLane, type SprintPlanRow, type SprintPlanTable, type StatusWord, type StepProgress, activeItem, boardRowsWithoutPhase, classifyStatus, countDecisions, currentPhase, currentPhaseOf, deferralTarget, endsPhase, findDrift, hasLanesTable, isCheckboxLine, isOpenCheckboxLine, isPlanLanguage, isReviewGateLine, isTickedUnmerged, keptPhaseIds, laneNameOf, laneProgress, mergedPhasesWithOpenBoxes, misplacedDeferrals, newPhasesWithoutReviewGate, parsePlanPhases, parseSprintLanes, phaseHeading, phaseHeadingPattern, phaseIdsOfCell, phaseIdsOfPlan, readPhaseDependencies, readPlanHeader, readSprintDocument, resolvePlanPath, sectionHeading, shortTitle, sprintTitleOf, sumPhases, tallyPhases, withoutRepeats };
|
|
344
|
+
export { ADDRESS_FORM, type ChecklistItem, type DeferredItem, type DriftFinding, LANGUAGE_NAME, type LaneProgress, type MergedPhaseWithOpenBoxes, type MisplacedDeferral, PHASE_ID_PREFIX, PLAN_LANGUAGE, PLAN_STATUS, PLAN_VOCABULARY, type PhaseProgress, type PhaseTally, type PhaseWithoutGate, type PlanHeader, type PlanLanguage, type PlanPhase, type PlanStatus, type PlanStep, type PlanVocabulary, type PlansByHref, RESPONSE_ADDRESS, type ResponseAddress, type RowWithoutPhase, SELF_MERGE_LABEL, type SprintDocument, type SprintLane, type SprintPlanRow, type SprintPlanTable, type StatusWord, type StepProgress, activeItem, boardRowsWithoutPhase, classifyStatus, countDecisions, currentPhase, currentPhaseOf, deferralTarget, endsPhase, findDrift, hasLanesTable, isCheckboxLine, isOpenCheckboxLine, isPlanLanguage, isReviewGateLine, isTickedUnmerged, keptPhaseIds, laneNameOf, laneProgress, mergedPhasesWithOpenBoxes, misplacedDeferrals, newPhasesWithoutReviewGate, parsePlanPhases, parseSprintLanes, phaseHeading, phaseHeadingPattern, phaseIdsOfCell, phaseIdsOfPlan, readPhaseDependencies, readPlanHeader, readSprintDocument, resolvePlanPath, sectionHeading, shortTitle, sprintTitleOf, sumPhases, tallyPhases, withoutRepeats };
|
package/dist/plan-format.js
CHANGED
|
@@ -1,14 +1,16 @@
|
|
|
1
1
|
import {
|
|
2
|
+
ADDRESS_FORM,
|
|
2
3
|
LANGUAGE_NAME,
|
|
3
4
|
PLAN_LANGUAGE,
|
|
4
5
|
PLAN_STATUS,
|
|
5
6
|
PLAN_VOCABULARY,
|
|
7
|
+
RESPONSE_ADDRESS,
|
|
6
8
|
SELF_MERGE_LABEL,
|
|
7
9
|
classifyStatus,
|
|
8
10
|
isPlanLanguage,
|
|
9
11
|
phaseHeading,
|
|
10
12
|
sectionHeading
|
|
11
|
-
} from "./chunk-
|
|
13
|
+
} from "./chunk-QJSXHHQ3.js";
|
|
12
14
|
|
|
13
15
|
// src/plan-format/terminal-text.ts
|
|
14
16
|
var UNSAFE_RANGES = [
|
|
@@ -592,11 +594,13 @@ var readPhaseDependencies = (planMarkdown, language) => {
|
|
|
592
594
|
return Object.fromEntries(entries);
|
|
593
595
|
};
|
|
594
596
|
export {
|
|
597
|
+
ADDRESS_FORM,
|
|
595
598
|
LANGUAGE_NAME,
|
|
596
599
|
PHASE_ID_PREFIX,
|
|
597
600
|
PLAN_LANGUAGE,
|
|
598
601
|
PLAN_STATUS,
|
|
599
602
|
PLAN_VOCABULARY,
|
|
603
|
+
RESPONSE_ADDRESS,
|
|
600
604
|
SELF_MERGE_LABEL,
|
|
601
605
|
activeItem,
|
|
602
606
|
boardRowsWithoutPhase,
|
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.
|
|
12
|
+
JACKHERD_VERSION="${JACKHERD_VERSION:-1.6.0}"
|
|
13
13
|
|
|
14
14
|
JACKHERD_PACKAGE='jackherd'
|
|
15
15
|
# Test seam: the terminal the wizard asks its questions on.
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whether a pull request changes nothing but files the project merges without waiting for checks
|
|
3
|
+
* (`merge.withoutChecks`). The file list comes from GitHub, never from an agent's text. Every doubt answers
|
|
4
|
+
* "no", so the guard asks as before.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { runGh } from './gh'
|
|
8
|
+
import type { RepositoryFacts } from './pull-request'
|
|
9
|
+
|
|
10
|
+
/** What GitHub lists per file: the name, and the old name of a renamed file. */
|
|
11
|
+
const FILE_ENTRY_QUERY = '.[] | [.filename, .previous_filename] | @json'
|
|
12
|
+
|
|
13
|
+
/** GitHub lists at most this many files of a pull request and says nothing when it stops there. */
|
|
14
|
+
export const MAX_LISTED_FILES = 3000
|
|
15
|
+
|
|
16
|
+
const isControlCharacter = (character: string): boolean => {
|
|
17
|
+
const code = character.charCodeAt(0)
|
|
18
|
+
return code < 0x20 || code === 0x7f
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** A repository path as GitHub lists it: relative, no empty, `.` or `..` segment, no backslash, no control character. */
|
|
22
|
+
const isPlainPath = (file: string): boolean =>
|
|
23
|
+
file !== '' &&
|
|
24
|
+
!file.startsWith('/') &&
|
|
25
|
+
!file.includes('\\') &&
|
|
26
|
+
![...file].some(isControlCharacter) &&
|
|
27
|
+
!file.split('/').some((segment) => segment === '' || segment === '.' || segment === '..')
|
|
28
|
+
|
|
29
|
+
const isNameOrNull = (value: unknown): value is string | null => value === null || typeof value === 'string'
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The names a listing carries, the old name of a rename too, from one `[filename, previous]` JSON pair per line.
|
|
33
|
+
* The pairs must be exactly as many as the pull request changes files, and fewer than GitHub's cap: a list that
|
|
34
|
+
* was cut short would hide the files that are not in it. Anything else answers nothing.
|
|
35
|
+
*/
|
|
36
|
+
export const parseFileEntries = (stdout: string, changedFiles: number): string[] | undefined => {
|
|
37
|
+
if (changedFiles >= MAX_LISTED_FILES) return undefined
|
|
38
|
+
try {
|
|
39
|
+
const entries = stdout
|
|
40
|
+
.split('\n')
|
|
41
|
+
.filter((line) => line !== '')
|
|
42
|
+
.map((line): unknown => JSON.parse(line))
|
|
43
|
+
const pairs = entries.filter(
|
|
44
|
+
(entry): entry is [string, string | null] =>
|
|
45
|
+
Array.isArray(entry) && entry.length === 2 && typeof entry[0] === 'string' && isNameOrNull(entry[1]),
|
|
46
|
+
)
|
|
47
|
+
if (pairs.length !== entries.length || pairs.length !== changedFiles) return undefined
|
|
48
|
+
return pairs.flatMap(([filename, previous]) => (previous === null ? [filename] : [filename, previous]))
|
|
49
|
+
} catch {
|
|
50
|
+
return undefined
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** True only for at least one file, at least one pattern, and every file a plain path that matches a pattern. */
|
|
55
|
+
export const allFilesMatch = (files: readonly string[], patterns: readonly string[]): boolean => {
|
|
56
|
+
if (files.length === 0 || patterns.length === 0) return false
|
|
57
|
+
const globs = patterns.map((pattern) => new Bun.Glob(pattern))
|
|
58
|
+
return files.every((file) => isPlainPath(file) && globs.some((glob) => glob.match(file)))
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Asks `gh api` for the files of pull request <number> of `facts.repository`, named in full; any failure answers nothing.
|
|
63
|
+
*/
|
|
64
|
+
export const changedFilesOf = (pullRequest: number, directory: string, facts: RepositoryFacts): string[] | undefined => {
|
|
65
|
+
const listing = runGh(
|
|
66
|
+
['api', `repos/${facts.repository}/pulls/${pullRequest}/files?per_page=100`, '--paginate', '--jq', FILE_ENTRY_QUERY],
|
|
67
|
+
directory,
|
|
68
|
+
)
|
|
69
|
+
return listing === undefined ? undefined : parseFileEntries(listing, facts.changedFiles)
|
|
70
|
+
}
|
|
@@ -13,7 +13,8 @@ export type GuardPolicy = {
|
|
|
13
13
|
/** The guarded commands as words: a program and the plain words after it, such as `gh pr merge`. */
|
|
14
14
|
commands: readonly string[]
|
|
15
15
|
/**
|
|
16
|
-
* Whether the
|
|
16
|
+
* Whether the merge of this pull request may pass without a question: the user approved it, or every file of it
|
|
17
|
+
* matches `merge.withoutChecks`. Asked only for a plain `gh pr merge <number>`.
|
|
17
18
|
* A throw counts as not approved.
|
|
18
19
|
*/
|
|
19
20
|
isMergeApproved: (pullRequest: number) => boolean
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
const LOOKUP_TIMEOUT_MS = 4000
|
|
2
|
+
|
|
3
|
+
/** Runs `gh` with these arguments in `directory`, without a shell; stdout on success, nothing on any failure or timeout. */
|
|
4
|
+
export const runGh = (args: readonly string[], directory: string): string | undefined => {
|
|
5
|
+
const result = Bun.spawnSync(['gh', ...args], {
|
|
6
|
+
cwd: directory,
|
|
7
|
+
timeout: LOOKUP_TIMEOUT_MS,
|
|
8
|
+
stdout: 'pipe',
|
|
9
|
+
stderr: 'ignore',
|
|
10
|
+
})
|
|
11
|
+
return result.exitCode === 0 ? result.stdout.toString() : undefined
|
|
12
|
+
}
|
|
@@ -4,10 +4,17 @@
|
|
|
4
4
|
* says which branch pull request <number> carries.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
import { runGh } from './gh'
|
|
8
8
|
|
|
9
9
|
type PullRequestView = { headRefName?: unknown; isCrossRepository?: unknown; state?: unknown }
|
|
10
10
|
|
|
11
|
+
/** Where a pull request lives and how many files it changes, as GitHub's `pr view` says. */
|
|
12
|
+
export type RepositoryFacts = { repository: string; changedFiles: number }
|
|
13
|
+
|
|
14
|
+
const VIEW_FIELDS = 'headRefName,isCrossRepository,state,url,changedFiles'
|
|
15
|
+
const PULL_REQUEST_URL = /^https:\/\/github\.com\/([A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+)\/pull\/[1-9]\d*$/
|
|
16
|
+
|
|
17
|
+
|
|
11
18
|
/** The head branch of an open pull request from this repository; a fork's branch name proves nothing. */
|
|
12
19
|
export const parseHeadBranch = (json: string): string | undefined => {
|
|
13
20
|
try {
|
|
@@ -21,13 +28,23 @@ export const parseHeadBranch = (json: string): string | undefined => {
|
|
|
21
28
|
}
|
|
22
29
|
}
|
|
23
30
|
|
|
24
|
-
/**
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
31
|
+
/**
|
|
32
|
+
* The repository from the pull request's own url (host github.com, plain owner and name) and its file count. The
|
|
33
|
+
* file list is then asked of that repository by name, so it cannot come from another one than `gh pr merge` merges in.
|
|
34
|
+
*/
|
|
35
|
+
export const parseRepositoryFacts = (json: string): RepositoryFacts | undefined => {
|
|
36
|
+
try {
|
|
37
|
+
const view: unknown = JSON.parse(json)
|
|
38
|
+
if (typeof view !== 'object' || view === null) return undefined
|
|
39
|
+
const { url, changedFiles } = view as { url?: unknown; changedFiles?: unknown }
|
|
40
|
+
const repository = typeof url === 'string' ? url.match(PULL_REQUEST_URL)?.[1] : undefined
|
|
41
|
+
const counted = typeof changedFiles === 'number' && Number.isInteger(changedFiles) && changedFiles >= 0
|
|
42
|
+
return repository !== undefined && counted ? { repository, changedFiles } : undefined
|
|
43
|
+
} catch {
|
|
44
|
+
return undefined
|
|
45
|
+
}
|
|
33
46
|
}
|
|
47
|
+
|
|
48
|
+
/** Runs `gh pr view <number>` in `directory`, the directory the command itself runs in; any failure answers nothing. */
|
|
49
|
+
export const viewPullRequest = (pullRequest: number, directory: string): string | undefined =>
|
|
50
|
+
runGh(['pr', 'view', String(pullRequest), '--json', VIEW_FIELDS], directory)
|