Git tooling for Claude Code — default-branch commit prompt, bulk worktree force-remove guard, force-push guard, wt-done merged-branch cleanup…

Git tooling for Claude Code. Branch and push guards, merged-branch cleanup, PR-aware push reminders, on-demand CI + release status watching, and a Release Ticker status line that follows each merge to its published release — bundled into one plugin so any Claude session that touches git stays well-behaved.
git commit through Claude Code's permission prompt when HEAD is on the repo's default branch (discovered dynamically — works with main, master, trunk, etc.), so the user gets a "pause and consider" moment to switch to a worktree -> branch -> PR workflowgit push, nudge the agent to update the PR title/description if the pushed scope drifted from the original PR textgit worktree remove --force (a loop/pipe/glob/multiple targets) through the permission prompt, so an unscoped force-removal can't silently wipe other sessions' worktrees and their uncommitted work. Single literal-path removals pass untouched.git push through the permission prompt when it uses a non-lease force (--force/-f/+refspec) or targets the repo's default/protected branch. Stays silent for the normal feature-branch flow, including pre-approved --force-with-lease rebase hygiene on feature branches. Catches the case where a subagent is instructed to force-push (settings don't gate subagent Bash; this hook does).wt-done (bin) — finish a single merged branch: verifies it's actually merged, then checks out + pulls the default branch, removes the worktree, deletes the local branch, and prunes. Handles the two chronic failure modes of hand-rolling this: a squash-merged branch that git branch -d refuses (falls back to -D, but only because the merge was already verified), and a dirty worktree that git worktree remove refuses (--force required, with the discarded state printed first). Single-target only — never a bulk sweep.gh-resolve-threads (bin) — list a PR's unresolved review threads, or resolve specific ones by id, without hand-writing the GraphQL each time. Paginates past the 100-thread-per-page GraphQL limit. No --resolve-all — resolution stays per-thread, on purpose.ci-watch) — invoke the Monitor tool with a bundled poller that streams pass/fail/pending/review/merge transitions for open PRs and exits when every watched PR is merged or closed. Reports a READY milestone when a PR is mergeable, then keeps watching until the actual merge. Only runs when you ask for it; no always-on background process.release-watch) — the sibling of ci-watch that begins where it ends (at MERGED). Invoke the Monitor tool with a bundled poller that follows a release through to publication: the GitHub release workflow run, the git tag + GitHub Release, and GHCR container/chart package publishes (a new image version — or a moving tag like latest repointed to a new digest — at ghcr.io/<owner>/<pkg>, incl. nested names like charts/hermes). Emits on success and failure terminals (a failed release workflow is surfaced, never silent). Public GHCR packages read anonymously; private ones need the gh token to carry read:packages (else that target fails gracefully without crashing the watch).gh pr merge that actually merged, a status line under the prompt walks the merge through its release in plain words — workflow → tag → GitHub release → image — and a toast reports the published version (or the failure / timeout / "nothing to release"). Nags when a configured floating tag (e.g. v1) wasn't moved to the new release. Visible only while a release is in flight; token-neutral (status line and toasts only, nothing reaches the model).git (2.15+)gh (GitHub CLI, authenticated) — for PR-based workflows, push reminder, and CI/release watchjq — used by the push-reminder hook (and other hook scripts)The
ci-watchandrelease-watchscripts use only the Python 3 standard library (nopip/Docker/jq) and needpython3(3.8+).The Release Ticker is a mod (a TypeScript hooks module,
hooks/release-ticker/) and needs a Claude Code version that loads mods; it needs onlygh.
Two hooks work together to catch accidental commits on the default branch:
SessionStart runs scripts/session-start-default-branch-cache.sh, which resolves the current repo's default branch (via git symbolic-ref refs/remotes/origin/HEAD, falling back to gh repo view) and caches it.PreToolUse(Bash) runs scripts/precommit-default-branch-guard.sh. If the Bash command is a real git commit ... invocation and HEAD matches the cached default branch, the hook returns permissionDecision: "ask" with an explanatory message — Claude Code then surfaces a permission prompt so the user (or the surrounding permission mode) can decide whether to proceed.Cache location: ${CLAUDE_PLUGIN_DATA}/default-branches.json (or ${CLAUDE_PLUGIN_ROOT}/.cache/ if CLAUDE_PLUGIN_DATA is unset). Entries older than 24h are re-resolved; the guard also resolves on-the-fly on cache miss.
Bypass — set GIT_TOOLING_ALLOW_DEFAULT_BRANCH_COMMIT=1 for a single invocation when you already know you want to commit on the default branch and don't want the prompt (release chores, hotfixes, etc.):
GIT_TOOLING_ALLOW_DEFAULT_BRANCH_COMMIT=1 git commit -m "..."
Note:
permissionDecision: "ask"semantics across all of Claude Code's permission modes (acceptEdits,bypassPermissions, headless, subagents) aren't fully documented at the time of writing. Expected behavior is that the prompt flows through whatever the surrounding mode would do for an unallowlisted tool call — auto-allow underbypassPermissions, real prompt in default interactive mode. If you hit unexpected behavior in a specific mode, open an issue.
PreToolUse(Bash) runs scripts/worktree-remove-guard.sh. It returns permissionDecision: "ask" only when a Bash command both (1) force-removes worktrees (--force/-f with git worktree remove) and (2) targets a bulk/dynamic set — an enumerate-then-remove pipe (git worktree list | … remove), a for/while/xargs loop, a glob target (worktrees/*), or two-plus remove invocations.
The hazard: a bulk git worktree remove --force discards uncommitted work and can wipe other sessions' worktrees, not just yours. Plain git worktree remove (no --force) already refuses a dirty/unmerged tree, so the guard nudges you to drop --force and let git's own per-target safety do the filtering.
Intentionally narrow — a single literal-path removal (the normal post-merge cleanup) is not bulk and passes silently.
Bypass — set GIT_TOOLING_ALLOW_FORCE_WORKTREE_REMOVE=1 for a deliberate bulk force-remove:
GIT_TOOLING_ALLOW_FORCE_WORKTREE_REMOVE=1 git worktree list | xargs git worktree remove --force
PreToolUse(Bash) runs scripts/force-push-guard.sh. It returns permissionDecision: "ask" when a git push crosses a gated boundary, while staying silent for the normal feature-branch flow. It fires when either:
--force, -f, or a +refspec (these overwrite the remote unconditionally and can clobber another session's/teammate's commits), ormain/master. Covers both direct-to-main pushes and force-pushes to main.It deliberately does not fire on:
git push of a feature branch (the normal PR flow), or--force-with-lease / --force-if-includes to a non-default branch — the pre-approved "rebase onto moved main" hygiene on feature branches.Why a hook rather than a settings rule: git push isn't allow-listed, so the gate is behavioral — and the realistic failure mode is a subagent being told to force-push as part of a rebase. Settings don't reliably gate subagent Bash calls; a plugin hook fires for them too. The guard reuses the same default-branches.json cache as the commit prompt and does no network calls on the hot path.
Bypass — set GIT_TOOLING_ALLOW_FORCE_PUSH=1 for a deliberate force-push:
GIT_TOOLING_ALLOW_FORCE_PUSH=1 git push --force origin main
A PreToolUse payload's cwd is the session's directory. It is not where the command runs — the hook fires before the command does, so cd other-repo && git push executes somewhere the payload never mentions. Both guards therefore resolve the directory from the command string itself (scripts/lib/git-context.sh), honouring cd, git -C, and command prefixes like sudo/env/xargs.
They fail closed. Once a real git push / git commit is recognised, a context the resolver cannot establish produces an ask, never silence. That covers an unevaluable cd target (cd "$SOMEWHERE"), a target that doesn't exist, pushd/subshell directory changes, --git-dir/--work-tree repointing git at another repo, and bodies handed to another shell (bash -c '...', eval "...", env -C). Silence is only ever an answer to "I know, and nothing is gated" — never to "I can't tell".
The reason for the emphasis: every fail-open in these guards is silence, which is indistinguishable from a correct pass unless the exit code and stderr are checked too. tests/guards.test.sh asserts all three on every case for exactly that reason, and is mutation-tested against a do-nothing hook.
Runs automatically after every Bash(git push ...) and Bash(gh pr create ...). If the pushed branch has an open PR — or a PR was just created — the hook reminds the agent to check whether the PR title/description still match what got pushed. Silent for any other Bash call.
It never emits a command for you to run. The hook reports what it observed — branch pushed, which PR appears to be open for it, that the PR body predates this push, that CI is unwatched — and leaves the decision to you. This is deliberate: PR identity is inferred, and inference can be wrong, so the design goal is that being wrong is harmless. An earlier version printed a ready-to-run gh pr edit <n> --title … --body …; when it named the wrong PR, following that command would have overwritten an unrelated PR's description.
Identification is layered so it fails silent rather than wrong:
git push / gh pr create must appear as an actual command word. rg 'git push' docs/ and gh pr comment N --body 'use gh pr create next time' are read-only commands that mention the phrase, and both used to fire.To <remote> line; a created PR's number and repo come from a PR URL occupying a whole line. A session's working directory is not necessarily where the command ran — with cd other-worktree && git push, or a session sitting in a worktree checked out to a different branch, resolving from HEAD names an unrelated PR.--dry-run / -n push, "Everything up-to-date", a rejected push, a branch deletion, a multi-ref or multi-remote push, or a gh pr create that printed no PR URL all produce nothing.Referenced commands carry -R owner/repo so they cannot be run against the right number in the wrong repo.
wt-done — finish a merged branchbin/wt-done is auto-added to PATH in Claude Code sessions — call it as a bare command.
wt-done [--force] [<branch>|<worktree-path>]
With no target, and the current directory is inside a linked worktree, that worktree is used. Otherwise: exactly one target, a literal branch name or worktree path — this is intentionally not a bulk-remove tool (multiple args or anything glob-like is rejected outright; see the bulk worktree force-remove guard above for why bulk force-removal is dangerous).
Steps, in order:
gh pr view when one exists, else a local git merge-base --is-ancestor fallback. No bypass flag here: if it can't verify the merge, it refuses and you finish manually.git pull --prune --rebase --autostash.git status --short printed; re-run with --force to discard that state (the tool prints exactly what it's about to discard before doing so).git branch -d, falling back to -D only when -d refuses with "not fully merged" — which happens for a legitimately squash-merged branch, since git's own ancestry check doesn't recognize a squash merge, even though step 1 already verified it.git worktree prune.Degrades gracefully when gh is absent or unauthenticated (falls back to the git-only merge check).
gh-resolve-threads — PR review-thread triagebin/gh-resolve-threads is auto-added to PATH — call it as a bare command.
gh-resolve-threads <pr-number> [--repo owner/repo]
gh-resolve-threads <pr-number> [--repo owner/repo] --resolve <id> [<id>...]
With no --repo, the repo is inferred from the current directory's origin remote. Default (no --resolve) lists unresolved review threads, one per line — <thread-id> | <path>:<line> | <first ~100 chars of the first comment> — followed by a count summary. --resolve <id> [<id>...] runs the resolveReviewThread mutation per id and reports each result. Paginates past GraphQL's 100-thread page limit via a cursor loop.
There is deliberately no --resolve-all — this project's review policy is "only resolve threads actually addressed" (see .claude/rules/code-review.md in home-orchestration), so resolution stays per-id.
Activates on prompts like:
> Watch CI for this PR
> Are the checks green yet?
> Tell me when CI passes
> Follow the build for PR #48
Internally invokes the Monitor tool with scripts/ci-watch.py. The script polls open-PR status every 30s (60s once all PRs are ready), emits one notification per state transition, and exits when every watched PR is merged or closed. Use TaskStop to cancel early.
Activates on prompts like:
> Watch the release for owner/repo
> Did the release publish yet?
> Wait for tag v1.4.0 to publish
> Watch for the new ghcr image
> Did the chart push to ghcr?
Internally invokes the Monitor tool with scripts/release-watch.py. Argument forms mix freely in one call:
release-watch.py owner/repo # release workflow run + newest release tag
release-watch.py owner/repo --tag v1.4.0 # wait for a specific GitHub Release
release-watch.py --ghcr owner/pkg # new GHCR image version vs baseline at start
release-watch.py --ghcr owner/charts/hermes # nested package name
release-watch.py --ghcr owner/pkg --tag v1.4.0 # wait for a specific image tag
release-watch.py owner/svc --ghcr owner/svc # both halves of one release in one watcher
--tag T binds to the immediately preceding target. The script polls every 30s (override via GIT_TOOLING_RELEASE_POLL_SECONDS), emits one notification per transition, and exits when every target is published or its release workflow concludes — exit 1 if any target hit a failure terminal (release workflow failed, or a private package the token can't read). Pass --tag for an already-published release/version and it reports it and exits at once. Use TaskStop to cancel early.
Each notification is one line per transition — good terminals (RELEASED <tag>, PUBLISHED <pkg>:<tag>, PUBLISHED <pkg>:latest repointed -> <digest>), neutral terminals (RUN success — no new release, idle — no release in flight), and failure terminals (RUN failure — release workflow failed (<url>), INACCESSIBLE — … needs read:packages). The full signature/output reference is the table in skills/release-watch/SKILL.md (its source of truth), the same way ci-watch's lives in its own SKILL.md.
A status line that follows each merge to its published release, then gets out of the way.
What it shows. While a release is in flight, one short line under the prompt: the repo (owner dropped), the PR, which step of how many, and what it is waiting for. The stages, named the same in the status line, the toasts and here:
workflow — the releaseWorkflow run for the merge commit;tag — a new git tag on the merge commit or a release commit after it;GitHub release — the GitHub Release for that tag;image — the ghcr.io package version for that tag. Only for a repo that publishes a container package named after it (decided when the watch arms); otherwise the walk has 3 steps.widget #12 · 1/4 · waiting for workflow to start
widget #12 · 1/4 · workflow queued
widget #12 · 1/4 · workflow running 1m 40s
widget #12 · 2/4 · workflow done, waiting for tag
widget #12 · 3/4 · tagged v1.2.3, waiting for GitHub release
widget #12 · 4/4 · v1.2.3 released, waiting for image
workflows #7 · 1/3 · dispatch release.yml to move v1
The running time counts from the run start GitHub reports. The line stays within 66 characters, so with Claude Code's ⚠ git-tooling: prefix it fits an 82-column terminal: a long repo name or version is cut with …, the running time is dropped, and GitHub release shortens to release before anything else is cut. With several repos in flight the segments share the line (a #3 · 1/3 · workflow queued | b #12 · 1/3 · workflow running 40s, newest first) when they fit; otherwise the newest is shown with | +N more.
The line disappears on any terminal, and a toast reports it:
owner/repo #12: v1.2.3 published (GitHub release) — the GitHub release is out and there is no image stage;owner/repo #12: v1.2.3 published (GitHub release + ghcr.io image sha256:…) — and the registry has the image for that tag;owner/repo #12: v1.2.3 published (GitHub release; ghcr.io image not checked) — the registry could not be read;owner/repo #12: v1.2.3 published (GitHub release; no ghcr.io image after 20 min) — the release is out but no matching image showed up before timeoutMin;owner/repo #12: release.yml failed (<conclusion>) — any conclusion other than success, skipped or neutral (failure, cancelled, timed_out, startup_failure, action_required, …);owner/repo #12: release.yml ran but cut no new version (nothing to release) — the run succeeded (or was skipped) and no new tag appeared within 90 s of it finishing. A release workflow tags inside its own run, so this is the usual outcome of a merge that needs no version bump (a chore: PR), and it ends within about two minutes, not at the timeout;owner/repo #12: tagged v1.2.3, but no GitHub release appeared — a new tag, but no release for it within 2 minutes;owner/repo #12: still waiting for the <stage> after 20 min — gave up — timeoutMin reached at the workflow, tag or GitHub release stage.Arming rule. It arms only on a Bash gh pr merge tool call (--repo/-R, a PR URL, GH_REPO=, a number or branch selector, or a bare merge of the current branch's PR — read before the merge runs, so --delete-branch can't switch it away), and only once the call completed and gh pr view confirms the PR is MERGED within the last 5 minutes — a failed or denied merge, gh pr merge --auto that merely enabled auto-merge, or re-running gh pr merge on a PR merged long ago arms nothing. The command may chain (git push && gh pr merge 12), redirect (2>&1, > log) or start with cd dir && (gh then runs in dir; a relative dir resolves against the session's working directory, and ~ is not expanded). A merge wrapped in bash -c, sh -c, sudo, eval or a script is not detected. It then:
releaseWorkflow file;gh calls (15 s each), never inside the tool call: the release run for the merge commit (workflow) → a new tag (tag) → that tag's GitHub Release (GitHub release) → the registry digest (image);image stage unless the repo publishes a container package named after it (looked up through the GitHub Packages API, so only ghcr.io is read; a token without read:packages or any query failure just skips the stage);timeoutMin.One watch per repo: a newer merge in the same repo replaces the older watch; merges in different repos are watched side by side. Ending the session cancels the watches and clears the line.
Floating tags. Some repos publish through a floating tag that only moves when a workflow is dispatched (merging ships nothing). For a repo listed in floatingTagRepos, the ticker keeps waiting for a (dispatched) release run until the timeout — a workflow_dispatch run created after the merge counts even when another merge moved the default branch first, so its head isn't this merge commit — shows dispatch <releaseWorkflow> to move <tag> in the status line, and — when the release resolves or the watch times out — toasts a nag such as owner/repo #12: floating tag v1 not moved — dispatch release.yml to move it if the tag doesn't point at the new release tag's commit (or at the merge commit when no release was cut).
Options (userConfig, all optional):
| Key | Default | Meaning |
|---|---|---|
releaseWorkflow | release.yml | Workflow file under .github/workflows/ to follow. A repo without it is skipped silently. |
registry | ghcr.io | Registry for the image stage. Only ghcr.io is read; any other value drops the stage (3 steps). |
floatingTagRepos | (empty) | owner/repo:tag entries (a list; several entries comma-separated). |
timeoutMin | 20 | Hard stop for a watch, in minutes after the merge (minimum 1). |
Set them with /plugin configure, or from the shell (use the plugin id claude plugin list shows):
echo '{"floatingTagRepos": "jedwards1230/release-workflows:v1", "timeoutMin": "30"}' \
| claude plugin configure git-tooling@jedwards1230-plugins --values-stdin
configure saves each value as a string, so list several floating-tag repos comma-separated ("owner/a:v1, owner/b:v2"); the ticker also accepts a real list or a JSON array string. Restart Claude Code to apply.
Token-neutral. The mod only calls $.ui.status and $.ui.toast: no context is injected into the conversation and no model calls are made. The tool.call hook hands gh pr merge's result back verbatim.
Relationship to release-watch. The ticker is the passive, always-on glance: it arms itself on a merge and needs no prompt. The [release-watch](#re
hooks/release-ticker/register.ts 74 lines1/**
2 * Release Ticker — a Claude Code mod. After a `gh pr merge` that actually
3 * merged, a status line walks the merge through its release (workflow ->
4 * tag -> GitHub release -> image) and a toast reports the
5 * outcome; a configured floating tag left behind earns a nag toast.
6 *
7 * Token-neutral: it only draws `$.ui.status` and `$.ui.toast`, never adds
8 * context for the model. The tool.call hook hands back `next(e)`'s result
9 * untouched and polls on a timer, never inside the hook.
10 *
11 * The host reads `on(...)` and `$.noun.method(...)` from source, so they are
12 * spelled literally here; the logic lives in ./logic and ./ticker.
13 */
14import type { EngineInterface, On, PluginOptions } from 'claude-code'
15
16import { configOf, isCompleted, parseMergeCommand } from './logic'
17import { createTicker } from './ticker'
18import type { Host, Ticker } from './ticker'
19
20/** The longest one `gh` call may run. */
21const GH_TIMEOUT_MS = 15_000
22
23function hostOf($: EngineInterface): Host {
24 return {
25 run: async (argv, cwd) => {
26 try {
27 return await $.process.run(argv, { ...(cwd ? { cwd } : {}), timeoutMs: GH_TIMEOUT_MS })
28 } catch {
29 return null
30 }
31 },
32 now: () => $.clock.now(),
33 every: (ms, fn) => $.clock.every(ms, fn),
34 status: text => $.ui.status(text),
35 toast: (text, timeoutMs) => $.ui.toast(text, timeoutMs ? { timeoutMs } : undefined),
36 }
37}
38
39export function register(on: On, options: PluginOptions) {
40 const config = configOf(options)
41 let ticker: Ticker | null = null
42
43 on('session.start', async ($, e, next) => {
44 ticker ??= createTicker(hostOf($), config)
45 return next(e)
46 })
47
48 on('session.end', async ($, e, next) => {
49 ticker?.stop()
50 ticker = null
51 return next(e)
52 })
53
54 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
55 const merge = e.tool === 'Bash' ? parseMergeCommand(e.command) : undefined
56 if (!merge) return next(e)
57
58 ticker ??= createTicker(hostOf($), config)
59 const t = ticker
60 // A bare `gh pr merge` merges the current branch's PR; read which one
61 // before the merge (with --delete-branch) can switch branches. Awaited
62 // here so the merge cannot race it: one bounded gh call, and awaiting `$`
63 // does not count against the hook budget.
64 const resolved = merge.pr === undefined ? await t.preResolve(merge).catch(() => undefined) : undefined
65 const pre = merge.pr === undefined ? Promise.resolve(resolved) : undefined
66
67 const result = await next(e)
68 if (isCompleted(result)) {
69 void t.arm(merge, pre).catch(() => undefined)
70 }
71 return result
72 })
73}
74hooks/release-ticker/logic.ts 598 lines1/**
2 * Release Ticker — pure logic: parsing the merge command and the plugin's
3 * options, and the stage machine that walks a merge through its release
4 * (workflow -> tag -> GitHub release -> image). No `$`, no I/O: every
5 * function here takes plain data, so the tests import it directly.
6 */
7
8/** Where a `gh pr merge` points: the repo and PR it names, if any. */
9export type MergeCommand = {
10 /** `owner/repo` from `--repo`/`-R`, a PR URL, or `GH_REPO=`. */
11 repo?: string
12 /** The PR selector: a number, or a branch name. */
13 pr?: string
14 /** A directory the command `cd`s into before running gh. */
15 cwd?: string
16 /** `--auto`: gh may only enable auto-merge, not merge. */
17 auto: boolean
18}
19
20/** A floating tag to keep in step with a repo's releases. */
21export type FloatingTag = { repo: string; tag: string }
22
23export type Config = {
24 releaseWorkflow: string
25 registry: string
26 floatingTags: FloatingTag[]
27 timeoutMs: number
28}
29
30export const DEFAULTS = {
31 releaseWorkflow: 'release.yml',
32 registry: 'ghcr.io',
33 timeoutMin: 20,
34} as const
35
36/** How often an armed watch polls. */
37export const POLL_MS = 30_000
38/** How long a merge may go without a release run before the watch gives up quietly. */
39export const NO_RUN_GRACE_MS = 3 * 60_000
40/**
41 * How long after a successful run the tag may take to appear. A release
42 * workflow tags inside its own run, so a short wait is enough; past it the
43 * run cut nothing (a chore merge that needs no version bump).
44 */
45export const TAG_GRACE_MS = 90_000
46/** How long after the tag its GitHub release may take to appear. */
47export const RELEASE_GRACE_MS = 2 * 60_000
48/**
49 * The longest status text: Claude Code prefixes the line with ` ⚠ git-tooling: `
50 * (16 characters) and the whole line must fit in 82.
51 */
52export const STATUS_MAX = 66
53/** How recently the PR must have merged for a `gh pr merge` to arm (an old, already-merged PR does not). */
54export const RECENT_MERGE_MS = 5 * 60_000
55/** Clock skew tolerated between this machine and GitHub's `mergedAt`. */
56const CLOCK_SKEW_MS = 60_000
57
58const REPO_RE = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/
59const PR_URL_RE = /^https?:\/\/[^/]+\/([A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+)\/pull\/(\d+)/
60
61/** `gh pr merge` flags that take a value (the value is not the PR selector). */
62const VALUE_FLAGS = new Set([
63 '-b',
64 '--body',
65 '-F',
66 '--body-file',
67 '-t',
68 '--subject',
69 '-A',
70 '--author-email',
71 '--match-head-commit',
72 '-R',
73 '--repo',
74])
75
76/**
77 * Normalizes a repo argument: `owner/repo`, or `HOST/owner/repo` as `-R`
78 * also takes. Anything else is undefined.
79 */
80export function repoOf(value: string | undefined): string | undefined {
81 if (!value) return undefined
82 const parts = value.replace(/\.git$/, '').split('/')
83 const tail = parts.slice(-2).join('/')
84 return parts.length >= 2 && REPO_RE.test(tail) ? tail : undefined
85}
86
87/** A redirection operator at the start of a word: `>`, `>>`, `2>`, `2>&1`, `&>`, `<`. */
88const REDIRECT_RE = /^(?:\d+|&)?(?:>>|>&|<&|>|<)/
89
90/**
91 * Splits a shell command into simple commands (on `&&`, `||`, `;`, `|`, `&`
92 * and newlines), each a list of words with quotes and backslashes resolved.
93 * The `&` of a redirection (`2>&1`, `&>file`) stays in its word.
94 * Good enough for the commands an agent writes; not a full shell parser.
95 */
96export function segmentsOf(command: string): string[][] {
97 const segments: string[][] = []
98 let words: string[] = []
99 let word = ''
100 let inWord = false
101 let quote: '"' | "'" | null = null
102
103 const endWord = () => {
104 if (inWord) words.push(word)
105 word = ''
106 inWord = false
107 }
108 const endSegment = () => {
109 endWord()
110 if (words.length > 0) segments.push(words)
111 words = []
112 }
113
114 for (let i = 0; i < command.length; i++) {
115 const c = command[i] as string
116 if (quote) {
117 if (c === quote) quote = null
118 else if (c === '\\' && quote === '"' && i + 1 < command.length) word += command[++i]
119 else word += c
120 continue
121 }
122 if (c === '"' || c === "'") {
123 quote = c
124 inWord = true
125 } else if (c === '\\' && i + 1 < command.length) {
126 const nextChar = command[++i] as string
127 if (nextChar !== '\n') {
128 word += nextChar
129 inWord = true
130 }
131 } else if (c === ' ' || c === '\t') {
132 endWord()
133 } else if (c === '&' && (/[<>]$/.test(word) || command[i + 1] === '>')) {
134 // part of a redirection (`2>&1`, `>&2`, `&>file`), not a separator
135 word += c
136 inWord = true
137 } else if (c === ';' || c === '\n' || c === '|' || c === '&') {
138 endSegment()
139 } else {
140 word += c
141 inWord = true
142 }
143 }
144 endSegment()
145 return segments
146}
147
148/**
149 * Reads a Bash command for a `gh pr merge` call: the repo and PR it names,
150 * a leading `cd`, and `--auto`. Undefined when the command runs no
151 * `gh pr merge` (or runs `gh pr merge --disable-auto`, which merges nothing).
152 */
153export function parseMergeCommand(command: string): MergeCommand | undefined {
154 let cwd: string | undefined
155 for (const words of segmentsOf(command)) {
156 if (words[0] === 'cd' && words.length === 2) {
157 cwd = words[1]
158 continue
159 }
160 const at = words.findIndex(
161 (w, i) =>
162 (w === 'gh' || w.endsWith('/gh')) && words[i + 1] === 'pr' && words[i + 2] === 'merge',
163 )
164 // gh must be the command itself (after any VAR=value prefixes), not an argument (`echo gh pr merge`).
165 if (at < 0 || !words.slice(0, at).every(w => /^[A-Za-z_][A-Za-z0-9_]*=/.test(w))) continue
166
167 const merge: MergeCommand = { auto: false }
168 if (cwd !== undefined) merge.cwd = cwd
169 for (const w of words.slice(0, at)) {
170 const env = /^GH_REPO=(.+)$/.exec(w)
171 const repo = repoOf(env?.[1])
172 if (repo) merge.repo = repo
173 }
174
175 const args = words.slice(at + 3)
176 for (let i = 0; i < args.length; i++) {
177 const arg = args[i] as string
178 const redirect = REDIRECT_RE.exec(arg)
179 if (redirect) {
180 // `> log` names its target in the next word; `>log` and `2>&1` carry it.
181 if (redirect[0].length === arg.length) i++
182 continue
183 }
184 if (arg === '--disable-auto') return undefined
185 if (arg === '--auto') {
186 merge.auto = true
187 continue
188 }
189 if (arg.startsWith('-')) {
190 const eq = arg.indexOf('=')
191 const name = eq > 0 ? arg.slice(0, eq) : arg
192 const inline = eq > 0 ? arg.slice(eq + 1) : undefined
193 if (!VALUE_FLAGS.has(name)) continue
194 const value = inline ?? args[++i]
195 if (name === '-R' || name === '--repo') {
196 const repo = repoOf(value)
197 if (repo) merge.repo = repo
198 }
199 continue
200 }
201 if (merge.pr !== undefined) continue
202 const url = PR_URL_RE.exec(arg)
203 if (url) {
204 merge.repo = url[1]
205 merge.pr = url[2]
206 } else {
207 merge.pr = arg.replace(/^#/, '')
208 }
209 }
210 return merge
211 }
212 return undefined
213}
214
215/**
216 * Parses `floatingTagRepos` entries, each `owner/repo:tag`. Takes the list
217 * the option holds, or one string of comma/whitespace-separated entries;
218 * malformed entries are dropped.
219 */
220export function parseFloatingTagRepos(value: unknown): FloatingTag[] {
221 // `claude plugin configure --values-stdin` saves a list option as one string;
222 // accept a JSON array written into it as well.
223 if (typeof value === 'string' && value.trim().startsWith('[')) {
224 try {
225 return parseFloatingTagRepos(JSON.parse(value))
226 } catch {
227 // not JSON: fall through to the comma/whitespace split
228 }
229 }
230 const entries: string[] = Array.isArray(value)
231 ? value.filter((v): v is string => typeof v === 'string')
232 : typeof value === 'string'
233 ? [value]
234 : []
235 const out: FloatingTag[] = []
236 for (const entry of entries.flatMap(e => e.split(/[\s,]+/))) {
237 const colon = entry.lastIndexOf(':')
238 if (colon <= 0) continue
239 const repo = repoOf(entry.slice(0, colon).trim())
240 const tag = entry.slice(colon + 1).trim()
241 if (repo && /^[^\s~^:?*[\\]+$/.test(tag)) out.push({ repo, tag })
242 }
243 return out
244}
245
246/** The plugin's options, defaults filled in and junk ignored. */
247export function configOf(options: Readonly<Record<string, unknown>> | undefined): Config {
248 const o = options ?? {}
249 const workflow =
250 typeof o.releaseWorkflow === 'string' && o.releaseWorkflow.trim() !== ''
251 ? o.releaseWorkflow.trim().replace(/^\.github\/workflows\//, '')
252 : DEFAULTS.releaseWorkflow
253 const registry =
254 typeof o.registry === 'string' && o.registry.trim() !== ''
255 ? o.registry.trim().replace(/\/+$/, '')
256 : DEFAULTS.registry
257 const minutes = Number(o.timeoutMin)
258 const timeoutMin = Number.isFinite(minutes) && minutes >= 1 ? minutes : DEFAULTS.timeoutMin
259 return {
260 releaseWorkflow: workflow,
261 registry,
262 floatingTags: parseFloatingTagRepos(o.floatingTagRepos),
263 timeoutMs: timeoutMin * 60_000,
264 }
265}
266
267/** The floating tag configured for a repo, if any (repo names compare case-insensitively). */
268export function floatingTagFor(config: Config, repo: string): string | undefined {
269 const lower = repo.toLowerCase()
270 return config.floatingTags.find(f => f.repo.toLowerCase() === lower)?.tag
271}
272
273// ── the stage machine ────────────────────────────────────────────────────
274
275export type Stage = 'run' | 'tag' | 'release' | 'registry'
276
277/** A GHCR-style package the repo publishes, readable through the GitHub Packages API. */
278export type Package = { scope: 'users' | 'orgs'; owner: string; name: string }
279
280export type Watch = {
281 repo: string
282 pr: number
283 mergeSha: string
284 /** When the PR merged (ms since the epoch, from GitHub's `mergedAt`). */
285 mergedAt?: number
286 armedAt: number
287 deadline: number
288 stage: Stage
289 /** The release run's status (`queued`, `in_progress`, ...) once one exists. */
290 runStatus?: string
291 /** When the release run started (GitHub's `run_started_at`, else `created_at`). */
292 runStartedAt?: number
293 /** When the run finished successfully. */
294 runDoneAt?: number
295 /** Tag names that existed when the watch armed. */
296 baselineTags: readonly string[]
297 /** The release tag this merge produced, and the commit it points at. */
298 tag?: string
299 tagSha?: string
300 /** When the tag was first seen. */
301 tagSeenAt?: number
302 pkg?: Package
303 /** The floating tag configured for this repo. */
304 floatingTag?: string
305}
306
307export type Terminal =
308 | { kind: 'published'; tag: string; digest?: string; noDigest?: true }
309 | { kind: 'tagged'; tag: string }
310 | { kind: 'failed'; conclusion: string; url?: string }
311 | { kind: 'no-release' }
312 | { kind: 'no-run' }
313 | { kind: 'timeout' }
314
315export type Step = { watch: Watch; done?: undefined } | { watch: Watch; done: Terminal }
316
317/** The newest run of the release workflow for the merge commit. */
318export type RunObs = { status: string; conclusion: string | null; url?: string; startedAt?: number }
319/**
320 * A tag and its commit. `related`: whether the commit is the merge commit or
321 * descends from it (undefined when that could not be checked).
322 */
323export type TagObs = { name: string; sha: string; related?: boolean }
324export type ReleaseObs = { draft: boolean }
325export type VersionObs = { digest: string; tags: readonly string[] }
326
327const GOOD = new Set(['success'])
328const QUIET = new Set(['skipped', 'neutral'])
329
330/**
331 * The run stage. `run` is the newest release run for the merge commit;
332 * null when none exists yet, undefined when the query failed (no change).
333 * A repo with a floating tag keeps waiting for a dispatched run until the
334 * timeout; any other repo gives up quietly after NO_RUN_GRACE_MS.
335 */
336export function stepRun(watch: Watch, run: RunObs | null | undefined, now: number): Step {
337 if (run === undefined) return { watch }
338 if (run === null) {
339 const isStale = !watch.floatingTag && now - watch.armedAt >= NO_RUN_GRACE_MS
340 return isStale ? { watch, done: { kind: 'no-run' } } : { watch }
341 }
342 if (run.status !== 'completed') {
343 return {
344 watch: { ...watch, runStatus: run.status, ...(run.startedAt !== undefined ? { runStartedAt: run.startedAt } : {}) },
345 }
346 }
347 const conclusion = run.conclusion ?? 'unknown'
348 if (GOOD.has(conclusion)) {
349 return { watch: { ...watch, runStatus: 'completed', stage: 'tag', runDoneAt: now } }
350 }
351 if (QUIET.has(conclusion)) return { watch, done: { kind: 'no-release' } }
352 return { watch, done: { kind: 'failed', conclusion, ...(run.url ? { url: run.url } : {}) } }
353}
354
355/**
356 * The tag stage: a tag that did not exist at arm time, on the merge commit
357 * or on a commit that descends from it (a release commit). A new tag off the
358 * merge's history is never taken, and joins the baseline so it is not
359 * checked again. None TAG_GRACE_MS after the run succeeded -> no release.
360 */
361export function stepTag(watch: Watch, tags: readonly TagObs[] | undefined, now: number): Step {
362 if (tags === undefined) return { watch }
363 const baseline = new Set(watch.baselineTags)
364 const fresh = tags.filter(t => !baseline.has(t.name) && t.name !== watch.floatingTag)
365 const found = fresh.find(t => t.sha === watch.mergeSha) ?? fresh.find(t => t.related === true)
366 if (found) {
367 return {
368 watch: { ...watch, stage: 'release', tag: found.name, tagSha: found.sha, tagSeenAt: now },
369 }
370 }
371 const unrelated = fresh.filter(t => t.related === false).map(t => t.name)
372 const next = unrelated.length > 0 ? { ...watch, baselineTags: [...watch.baselineTags, ...unrelated] } : watch
373 const since = watch.runDoneAt ?? now
374 return now - since >= TAG_GRACE_MS ? { watch: next, done: { kind: 'no-release' } } : { watch: next }
375}
376
377/**
378 * The release stage: the GitHub Release for the new tag (null: none yet,
379 * undefined: query failed). A tag that never gets a Release ends as
380 * `tagged` after RELEASE_GRACE_MS.
381 */
382export function stepRelease(watch: Watch, release: ReleaseObs | null | undefined, now: number): Step {
383 const tag = watch.tag as string
384 if (release && !release.draft) {
385 return watch.pkg
386 ? { watch: { ...watch, stage: 'registry' } }
387 : { watch, done: { kind: 'published', tag } }
388 }
389 if (release === undefined) return { watch }
390 const since = watch.tagSeenAt ?? now
391 return now - since >= RELEASE_GRACE_MS ? { watch, done: { kind: 'tagged', tag } } : { watch }
392}
393
394/** The tags a registry version for release tag `tag` may carry: `v1.2.3` and `1.2.3`. */
395export function registryTagsFor(tag: string): string[] {
396 return /^v\d/.test(tag) ? [tag, tag.slice(1)] : [tag]
397}
398
399/**
400 * The registry stage: a package version carrying the release tag. A failed
401 * query skips the stage (published, no digest) rather than erroring.
402 */
403export function stepRegistry(watch: Watch, versions: readonly VersionObs[] | undefined): Step {
404 const tag = watch.tag as string
405 if (versions === undefined) return { watch, done: { kind: 'published', tag } }
406 const wanted = registryTagsFor(tag)
407 const found = versions.find(v => v.tags.some(t => wanted.includes(t)))
408 return found ? { watch, done: { kind: 'published', tag, digest: found.digest } } : { watch }
409}
410
411/**
412 * The terminal for a watch that hit its deadline: one already waiting on the
413 * registry has its Release out, so it ends as published with no digest seen.
414 */
415export function timeoutOf(watch: Watch): Terminal {
416 return watch.stage === 'registry' && watch.tag
417 ? { kind: 'published', tag: watch.tag, noDigest: true }
418 : { kind: 'timeout' }
419}
420
421/** Whether a PR's `mergedAt` is recent enough that this merge command did it. */
422export function isRecentMerge(mergedAt: unknown, now: number): boolean {
423 const at = typeof mergedAt === 'string' ? Date.parse(mergedAt) : Number.NaN
424 if (!Number.isFinite(at)) return false
425 return now - at <= RECENT_MERGE_MS && at - now <= CLOCK_SKEW_MS
426}
427
428/** Whether the floating tag is checked at this terminal (not on a failed run, where nothing moved). */
429export function checksFloatingTag(watch: Watch, done: Terminal): boolean {
430 return watch.floatingTag !== undefined && done.kind !== 'failed' && done.kind !== 'no-run'
431}
432
433/**
434 * Whether the floating tag was left behind: it should point at the new
435 * release tag's commit, or at the merge commit when no release happened.
436 * An unknown floating-tag commit never nags.
437 */
438export function isFloatingTagStale(watch: Watch, floatingSha: string | undefined): boolean {
439 if (!floatingSha) return false
440 return floatingSha !== (watch.tagSha ?? watch.mergeSha)
441}
442
443// ── text ────────────────────────────────────────────────────────────────
444
445export function shortDigest(digest: string): string {
446 const m = /^(sha256:)?([0-9a-f]+)$/i.exec(digest)
447 return m ? `${m[1] ?? ''}${(m[2] as string).slice(0, 12)}` : digest
448}
449
450/** The user-facing name of each stage, used alike in the status line, toasts and README. */
451export const STAGE_NAMES: Record<Stage, string> = {
452 run: 'workflow',
453 tag: 'tag',
454 release: 'GitHub release',
455 registry: 'image',
456}
457
458const STAGE_ORDER: readonly Stage[] = ['run', 'tag', 'release', 'registry']
459
460/** The stages this watch walks: the image stage only when the repo publishes a package (decided at arm time). */
461export function stagesOf(watch: Watch): Stage[] {
462 return watch.pkg ? [...STAGE_ORDER] : STAGE_ORDER.slice(0, 3)
463}
464
465/** `2/4`: the current stage's position among the stages that apply. */
466export function stepCountOf(watch: Watch): string {
467 const stages = stagesOf(watch)
468 return `${stages.indexOf(watch.stage) + 1}/${stages.length}`
469}
470
471/** `40s`, `1m 40s`, `1h 5m`. */
472export function formatElapsed(ms: number): string {
473 const s = Math.max(0, Math.floor(ms / 1000))
474 if (s < 60) return `${s}s`
475 const m = Math.floor(s / 60)
476 if (m < 60) return s % 60 === 0 ? `${m}m` : `${m}m ${s % 60}s`
477 const h = Math.floor(m / 60)
478 return m % 60 === 0 ? `${h}h` : `${h}h ${m % 60}m`
479}
480
481/** Cuts `s` to `n` characters, ending in `…` when cut. */
482export function elide(s: string, n: number): string {
483 if (s.length <= n) return s
484 return n <= 1 ? '…' : `${s.slice(0, n - 1)}…`
485}
486
487/** The parts of one status segment that may be shortened to fit. */
488type Parts = { name: string; ver: string; wf: string; elapsed: string; compact: boolean }
489
490function renderSegment(watch: Watch, p: Parts): string {
491 const phrase = (() => {
492 switch (watch.stage) {
493 case 'run':
494 if (watch.runStatus === 'in_progress') return `workflow running${p.elapsed ? ` ${p.elapsed}` : ''}`
495 if (watch.runStatus) return `workflow ${watch.runStatus.replace(/_/g, ' ')}`
496 if (watch.floatingTag) return `dispatch ${p.wf} to move ${watch.floatingTag}`
497 return p.compact ? 'waiting for workflow' : 'waiting for workflow to start'
498 case 'tag':
499 return 'workflow done, waiting for tag'
500 case 'release':
501 return `tagged ${p.ver}, waiting for ${p.compact ? 'release' : 'GitHub release'}`
502 case 'registry':
503 return `${p.ver} released, waiting for image`
504 }
505 })()
506 return `${p.name} #${watch.pr} · ${stepCountOf(watch)} · ${phrase}`
507}
508
509/**
510 * One watch's part of the status line, at most `max` characters: the repo
511 * name (owner dropped), the step count, and what it is waiting for. Too
512 * long, it elides the repo name, drops the elapsed time, elides the
513 * version, shortens the phrase, then elides further, in that order.
514 */
515export function statusTextOf(watch: Watch, config: Config, now: number, max = STATUS_MAX): string {
516 const p: Parts = {
517 name: watch.repo.split('/')[1] ?? watch.repo,
518 ver: watch.tag ?? '',
519 wf: config.releaseWorkflow,
520 elapsed: formatElapsed(now - (watch.runStartedAt ?? watch.armedAt)),
521 compact: false,
522 }
523 const shrink = (key: 'name' | 'ver' | 'wf', min: number) => {
524 const over = renderSegment(watch, p).length - max
525 if (over > 0) p[key] = elide(p[key], Math.max(min, p[key].length - over))
526 }
527 const fits = () => renderSegment(watch, p).length <= max
528 shrink('name', 14)
529 if (!fits()) p.elapsed = ''
530 shrink('ver', 10)
531 if (!fits()) p.compact = true
532 shrink('name', 6)
533 shrink('wf', 8)
534 shrink('ver', 5)
535 return elide(renderSegment(watch, p), max)
536}
537
538/**
539 * The status line for every armed watch, or undefined when none is armed.
540 * Several watches share the line when they fit; otherwise the newest is
541 * shown with `+N more`.
542 */
543export function statusLineOf(watches: readonly Watch[], config: Config, now: number): string | undefined {
544 if (watches.length === 0) return undefined
545 const newest = [...watches].sort((a, b) => b.armedAt - a.armedAt)
546 const all = newest.map(w => statusTextOf(w, config, now, Number.POSITIVE_INFINITY)).join(' | ')
547 if (all.length <= STATUS_MAX) return all
548 if (newest.length === 1) return statusTextOf(newest[0] as Watch, config, now)
549 const more = ` | +${newest.length - 1} more`
550 return statusTextOf(newest[0] as Watch, config, now, STATUS_MAX - more.length) + more
551}
552
553/** `owner/repo #12`, how a toast names the merge. */
554function labelOf(watch: Watch): string {
555 return `${watch.repo} #${watch.pr}`
556}
557
558/** The toast a terminal shows; undefined for the quiet ones. */
559export function toastTextOf(watch: Watch, done: Terminal, config: Config): string | undefined {
560 const pr = labelOf(watch)
561 const minutes = Math.round(config.timeoutMs / 60_000)
562 switch (done.kind) {
563 case 'published':
564 if (done.digest) return `${pr}: ${done.tag} published (GitHub release + ${config.registry} image ${shortDigest(done.digest)})`
565 if (done.noDigest) return `${pr}: ${done.tag} published (GitHub release; no ${config.registry} image after ${minutes} min)`
566 return watch.pkg
567 ? `${pr}: ${done.tag} published (GitHub release; ${config.registry} image not checked)`
568 : `${pr}: ${done.tag} published (GitHub release)`
569 case 'tagged':
570 return `${pr}: tagged ${done.tag}, but no GitHub release appeared`
571 case 'failed':
572 return `${pr}: ${config.releaseWorkflow} failed (${done.conclusion})`
573 case 'no-release':
574 return `${pr}: ${config.releaseWorkflow} ran but cut no new version (nothing to release)`
575 case 'timeout':
576 return `${pr}: still waiting for the ${STAGE_NAMES[watch.stage]} after ${minutes} min — gave up`
577 case 'no-run':
578 return undefined
579 }
580}
581
582export function nagTextOf(watch: Watch, config: Config): string {
583 return `${labelOf(watch)}: floating tag ${watch.floatingTag} not moved — dispatch ${config.releaseWorkflow} to move it`
584}
585
586/** Whether a `tool.call` result means the command actually ran to completion. */
587export function isCompleted(result: unknown): boolean {
588 if (!result || typeof result !== 'object') return false
589 const r = result as { deny?: unknown; isError?: unknown; result?: unknown }
590 if (r.deny !== undefined || r.isError === true) return false
591 const inner = r.result
592 if (inner && typeof inner === 'object') {
593 const o = inner as { interrupted?: unknown; backgroundTaskId?: unknown }
594 if (o.interrupted === true || o.backgroundTaskId) return false
595 }
596 return true
597}
598hooks/release-ticker/ticker.ts 389 lines1/**
2 * Release Ticker — the watcher. Arms on a confirmed merge, polls every
3 * POLL_MS through bounded `gh` calls, keeps one status line for every armed
4 * watch, toasts each terminal outcome. Everything it touches goes through a
5 * Host, so tests drive it with a scripted one; register.ts builds the real
6 * Host over `$`.
7 */
8import {
9 checksFloatingTag,
10 floatingTagFor,
11 isFloatingTagStale,
12 isRecentMerge,
13 nagTextOf,
14 POLL_MS,
15 stepRegistry,
16 stepRelease,
17 stepRun,
18 stepTag,
19 statusLineOf,
20 timeoutOf,
21 toastTextOf,
22} from './logic'
23import type {
24 Config,
25 MergeCommand,
26 Package,
27 ReleaseObs,
28 RunObs,
29 Step,
30 TagObs,
31 Terminal,
32 VersionObs,
33 Watch,
34} from './logic'
35
36export type RunResult = { exitCode: number; stdout: string; stderr: string }
37
38export type Host = {
39 /** Runs a command, bounded; null when it could not start or timed out. */
40 run: (argv: readonly string[], cwd?: string) => Promise<RunResult | null>
41 now: () => Promise<number>
42 every: (ms: number, fn: () => void) => { cancel: () => void }
43 status: (text: string | undefined) => void
44 toast: (text: string, timeoutMs?: number) => void
45}
46
47/** A PR resolved before the merge ran (for a bare `gh pr merge` on the current branch). */
48export type PreResolved = Promise<{ number: number; repo: string } | undefined>
49
50export type Ticker = {
51 /** Starts resolving the current branch's PR, before the merge can switch branches. */
52 preResolve: (merge: MergeCommand) => PreResolved
53 /** Arms a watch for a merge that ran; does nothing unless it really merged into a releasing repo. */
54 arm: (merge: MergeCommand, pre?: PreResolved) => Promise<void>
55 /** One poll of every armed watch. */
56 poll: () => Promise<void>
57 /** Drops every watch, cancels the timer, clears the status. */
58 stop: () => void
59 /** The armed watches, for tests. */
60 watches: () => Watch[]
61}
62
63/** How long a toast that nags stays up. */
64const NAG_TOAST_MS = 10_000
65
66/**
67 * The newest tags by commit date. The REST tag list is one page sorted by
68 * name, not by age, so a busy repo's new tag can be missing from it.
69 */
70const TAGS_QUERY =
71 'query($owner:String!,$name:String!){repository(owner:$owner,name:$name){' +
72 'refs(refPrefix:"refs/tags/",first:20,orderBy:{field:TAG_COMMIT_DATE,direction:DESC}){' +
73 'nodes{name target{oid ... on Tag{target{oid}}}}}}}'
74
75type Json = { ok: true; data: unknown } | { ok: false; notFound: boolean }
76
77function recordOf(value: unknown): Record<string, unknown> {
78 return value && typeof value === 'object' ? (value as Record<string, unknown>) : {}
79}
80
81function repoFromUrl(url: unknown): string | undefined {
82 const m = typeof url === 'string' ? /\/\/[^/]+\/([^/]+\/[^/]+)\/pull\/\d+/.exec(url) : null
83 return m?.[1]
84}
85
86export function createTicker(host: Host, config: Config): Ticker {
87 const watches = new Map<string, Watch>()
88 let timer: { cancel: () => void } | null = null
89 let isPolling = false
90 let stopped = false
91 /** What the status line shows now; undefined when nothing is pinned. */
92 let shown: string | undefined
93 /** The clock at the last arm or poll, for the elapsed time in the status line. */
94 let lastNow = 0
95
96 async function gh(args: readonly string[], cwd?: string): Promise<RunResult | null> {
97 return host.run(['gh', ...args], cwd)
98 }
99
100 async function api(path: string): Promise<Json> {
101 const r = await gh(['api', path])
102 if (!r) return { ok: false, notFound: false }
103 if (r.exitCode !== 0) return { ok: false, notFound: /HTTP 404|Not Found/i.test(r.stderr + r.stdout) }
104 try {
105 return { ok: true, data: JSON.parse(r.stdout) }
106 } catch {
107 return { ok: false, notFound: false }
108 }
109 }
110
111 /** Pins the status line for the armed watches; skips a no-op redraw. */
112 function render() {
113 const text = statusLineOf([...watches.values()], config, lastNow)
114 if (text === shown) return
115 shown = text
116 host.status(text)
117 }
118
119 function ensureTimer() {
120 if (!timer && !stopped) timer = host.every(POLL_MS, () => void poll().catch(() => undefined))
121 }
122
123 function stopTimerIfIdle() {
124 if (watches.size === 0 && timer) {
125 timer.cancel()
126 timer = null
127 }
128 }
129
130 async function preResolve(merge: MergeCommand): PreResolved {
131 const r = await gh(['pr', 'view', '--json', 'number,url'], merge.cwd)
132 if (!r || r.exitCode !== 0) return undefined
133 try {
134 const pr = recordOf(JSON.parse(r.stdout))
135 const repo = repoFromUrl(pr.url)
136 return typeof pr.number === 'number' && repo ? { number: pr.number, repo } : undefined
137 } catch {
138 return undefined
139 }
140 }
141
142 async function findPackage(repo: string): Promise<Package | undefined> {
143 if (config.registry !== 'ghcr.io') return undefined
144 const [owner, name] = repo.split('/') as [string, string]
145 for (const scope of ['users', 'orgs'] as const) {
146 const r = await api(`${scope}/${owner}/packages/container/${encodeURIComponent(name)}`)
147 if (r.ok) return { scope, owner, name }
148 }
149 return undefined
150 }
151
152 async function arm(merge: MergeCommand, pre?: PreResolved): Promise<void> {
153 let selector = merge.pr
154 let repo = merge.repo
155 if (selector === undefined) {
156 const resolved = pre ? await pre : undefined
157 if (!resolved) return
158 selector = String(resolved.number)
159 repo = resolved.repo
160 }
161 const view = await gh(
162 ['pr', 'view', selector, ...(repo ? ['--repo', repo] : []), '--json', 'number,url,state,mergedAt,mergeCommit'],
163 merge.cwd,
164 )
165 if (!view || view.exitCode !== 0) return
166 let pr: Record<string, unknown>
167 try {
168 pr = recordOf(JSON.parse(view.stdout))
169 } catch {
170 return
171 }
172 const mergeSha = recordOf(pr.mergeCommit).oid
173 const prRepo = repoFromUrl(pr.url) ?? repo
174 // `--auto` with checks pending, or a merge that failed: nothing merged, nothing to watch.
175 if (pr.state !== 'MERGED' || typeof mergeSha !== 'string' || !prRepo || typeof pr.number !== 'number') {
176 return
177 }
178 // A PR that merged a while ago (re-running `gh pr merge` on it) was not merged by this call.
179 if (!isRecentMerge(pr.mergedAt, await host.now())) return
180
181 // No release workflow -> fall through quietly: no status, no toast.
182 const workflow = await api(`repos/${prRepo}/actions/workflows/${encodeURIComponent(config.releaseWorkflow)}`)
183 if (!workflow.ok) return
184
185 const tags = await newestTags(prRepo)
186 if (!tags) return
187 const baselineTags = tags.map(t => t.name)
188
189 const pkg = await findPackage(prRepo)
190 const floatingTag = floatingTagFor(config, prRepo)
191 if (stopped) return
192 const now = await host.now()
193 lastNow = now
194 const watch: Watch = {
195 repo: prRepo,
196 pr: pr.number,
197 mergeSha,
198 mergedAt: Date.parse(pr.mergedAt as string),
199 armedAt: now,
200 deadline: now + config.timeoutMs,
201 stage: 'run',
202 baselineTags,
203 ...(pkg ? { pkg } : {}),
204 ...(floatingTag ? { floatingTag } : {}),
205 }
206 // One watch per repo: a newer merge replaces the older one.
207 watches.set(prRepo.toLowerCase(), watch)
208 render()
209 ensureTimer()
210 }
211
212 function runOf(value: unknown): RunObs | null {
213 const run = recordOf(value)
214 if (typeof run.status !== 'string') return null
215 const startedAt = Date.parse(String(run.run_started_at ?? run.created_at))
216 return {
217 status: run.status,
218 conclusion: typeof run.conclusion === 'string' ? run.conclusion : null,
219 ...(typeof run.html_url === 'string' ? { url: run.html_url } : {}),
220 ...(Number.isFinite(startedAt) ? { startedAt } : {}),
221 }
222 }
223
224 /**
225 * The release run for the merge commit. A floating-tag repo releases on a
226 * dispatch, whose head is wherever the default branch is by then (another
227 * merge may have landed), so for it a run dispatched after the merge counts too.
228 */
229 async function observeRun(w: Watch): Promise<RunObs | null | undefined> {
230 const runs = `repos/${w.repo}/actions/workflows/${encodeURIComponent(config.releaseWorkflow)}/runs`
231 const r = await api(`${runs}?head_sha=${w.mergeSha}&per_page=1`)
232 if (!r.ok) return undefined
233 const list = recordOf(r.data).workflow_runs
234 const run = Array.isArray(list) ? runOf(list[0]) : null
235 if (run || !w.floatingTag) return run
236
237 const d = await api(`${runs}?event=workflow_dispatch&per_page=5`)
238 if (!d.ok) return undefined
239 const dispatched = recordOf(d.data).workflow_runs
240 const since = w.mergedAt ?? w.armedAt
241 const after = Array.isArray(dispatched)
242 ? dispatched.find(x => Date.parse(String(recordOf(x).created_at)) >= since)
243 : undefined
244 return after ? runOf(after) : null
245 }
246
247 /** The repo's newest tags by commit date, each with its commit; undefined when unreadable. */
248 async function newestTags(repo: string): Promise<TagObs[] | undefined> {
249 const [owner, name] = repo.split('/') as [string, string]
250 const r = await gh(['api', 'graphql', '-f', `query=${TAGS_QUERY}`, '-f', `owner=${owner}`, '-f', `name=${name}`])
251 if (!r || r.exitCode !== 0) return undefined
252 let nodes: unknown
253 try {
254 nodes = recordOf(recordOf(recordOf(recordOf(JSON.parse(r.stdout)).data).repository).refs).nodes
255 } catch {
256 return undefined
257 }
258 if (!Array.isArray(nodes)) return undefined
259 return nodes.flatMap(n => {
260 const o = recordOf(n)
261 const target = recordOf(o.target)
262 // An annotated tag's target is the tag object; its commit is one level down.
263 const sha = recordOf(target.target).oid ?? target.oid
264 return typeof o.name === 'string' && typeof sha === 'string' ? [{ name: o.name, sha }] : []
265 })
266 }
267
268 /** Whether `sha` is the merge commit or descends from it; undefined when the check failed. */
269 async function descendsFromMerge(w: Watch, sha: string): Promise<boolean | undefined> {
270 if (sha === w.mergeSha) return true
271 const r = await api(`repos/${w.repo}/compare/${w.mergeSha}...${sha}`)
272 if (!r.ok) return r.notFound ? false : undefined
273 const status = recordOf(r.data).status
274 return typeof status === 'string' ? status === 'ahead' || status === 'identical' : undefined
275 }
276
277 async function observeTags(w: Watch): Promise<TagObs[] | undefined> {
278 const tags = await newestTags(w.repo)
279 if (!tags) return undefined
280 const baseline = new Set(w.baselineTags)
281 const out: TagObs[] = []
282 for (const t of tags) {
283 if (baseline.has(t.name) || t.name === w.floatingTag) {
284 out.push(t)
285 continue
286 }
287 const related = await descendsFromMerge(w, t.sha)
288 out.push(related === undefined ? t : { ...t, related })
289 }
290 return out
291 }
292
293 async function observeRelease(w: Watch): Promise<ReleaseObs | null | undefined> {
294 const r = await api(`repos/${w.repo}/releases/tags/${encodeURIComponent(w.tag as string)}`)
295 if (r.ok) return { draft: recordOf(r.data).draft === true }
296 return r.notFound ? null : undefined
297 }
298
299 async function observeVersions(w: Watch): Promise<VersionObs[] | undefined> {
300 const p = w.pkg as Package
301 const r = await api(`${p.scope}/${p.owner}/packages/container/${encodeURIComponent(p.name)}/versions?per_page=30`)
302 if (!r.ok || !Array.isArray(r.data)) return undefined
303 return r.data.flatMap(v => {
304 const o = recordOf(v)
305 const tags = recordOf(recordOf(o.metadata).container).tags
306 return typeof o.name === 'string'
307 ? [{ digest: o.name, tags: Array.isArray(tags) ? tags.filter((t): t is string => typeof t === 'string') : [] }]
308 : []
309 })
310 }
311
312 /** Walks one watch as far as this poll's observations allow. */
313 async function advance(w: Watch, now: number): Promise<Step> {
314 if (now >= w.deadline) return { watch: w, done: timeoutOf(w) }
315 let step: Step = { watch: w }
316 for (let hops = 0; hops < 4; hops++) {
317 const before = step.watch.stage
318 const cur = step.watch
319 switch (cur.stage) {
320 case 'run':
321 step = stepRun(cur, await observeRun(cur), now)
322 break
323 case 'tag':
324 step = stepTag(cur, await observeTags(cur), now)
325 break
326 case 'release':
327 step = stepRelease(cur, await observeRelease(cur), now)
328 break
329 case 'registry':
330 step = stepRegistry(cur, await observeVersions(cur))
331 break
332 }
333 if (step.done || step.watch.stage === before) break
334 }
335 return step
336 }
337
338 async function floatingSha(w: Watch): Promise<string | undefined> {
339 const r = await api(`repos/${w.repo}/commits/${encodeURIComponent(w.floatingTag as string)}`)
340 const sha = r.ok ? recordOf(r.data).sha : undefined
341 return typeof sha === 'string' ? sha : undefined
342 }
343
344 async function finish(w: Watch, done: Terminal) {
345 const text = toastTextOf(w, done, config)
346 if (text) host.toast(text)
347 if (!checksFloatingTag(w, done)) return
348 const sha = await floatingSha(w)
349 // The session may have ended while the floating tag was read.
350 if (!stopped && isFloatingTagStale(w, sha)) host.toast(nagTextOf(w, config), NAG_TOAST_MS)
351 }
352
353 async function poll(): Promise<void> {
354 if (isPolling || stopped) return
355 isPolling = true
356 try {
357 for (const [key, w] of [...watches]) {
358 const now = await host.now()
359 lastNow = now
360 const step = await advance(w, now)
361 if (stopped || watches.get(key) !== w) continue // replaced or stopped meanwhile
362 if (step.done) {
363 watches.delete(key)
364 render()
365 await finish(step.watch, step.done)
366 } else {
367 watches.set(key, step.watch)
368 }
369 }
370 if (!stopped) render()
371 } finally {
372 isPolling = false
373 stopTimerIfIdle()
374 }
375 }
376
377 function stop() {
378 stopped = true
379 watches.clear()
380 if (timer) {
381 timer.cancel()
382 timer = null
383 }
384 render()
385 }
386
387 return { preResolve, arm, poll, stop, watches: () => [...watches.values()] }
388}
389