SLOPSHOPPER

git-tooling

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

newguardtoaststatusprocesstimer
A shopper browsing a rack in a slop shop
README

git-tooling

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.

Features

  • Default-branch commit prompt (hook) — routes 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 workflow
  • Push reminder (hook) — after every git push, nudge the agent to update the PR title/description if the pushed scope drifted from the original PR text
  • Bulk worktree force-remove guard (hook) — routes a bulk git 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.
  • Force-push guard (hook) — routes a 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 status watching (skill 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 watching (skill 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).
  • Release Ticker (mod) — after a 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).

Prerequisites

  • git (2.15+)
  • gh (GitHub CLI, authenticated) — for PR-based workflows, push reminder, and CI/release watch
  • jq — used by the push-reminder hook (and other hook scripts)

The ci-watch and release-watch scripts use only the Python 3 standard library (no pip/Docker/jq) and need python3 (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 only gh.

Usage

Default-branch commit prompt

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 under bypassPermissions, real prompt in default interactive mode. If you hit unexpected behavior in a specific mode, open an issue.

Bulk worktree force-remove guard

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

Force-push guard

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:

  1. the push uses a non-lease force — --force, -f, or a +refspec (these overwrite the remote unconditionally and can clobber another session's/teammate's commits), or
  2. the push targets the default/protected branch — the cached repo default branch (see the commit-prompt cache above) or a literal main/master. Covers both direct-to-main pushes and force-pushes to main.

It deliberately does not fire on:

  • a plain 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

How both guards decide which repo they're looking at

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.

Push reminder 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:

  • The trigger is parsed, not substring-matched. 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.
  • Identity comes from the command's own output, never the working directory. The pushed branch is read from git's ref-update block, anchored to exactly one 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.
  • Anything ambiguous is silence. No output, a --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 branch

bin/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:

  1. Verifies the branch is actually merged — a merged PR's state via 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.
  2. Moves to the main checkout (handles being invoked from inside the worktree it's about to remove), checks out the default branch if needed, and git pull --prune --rebase --autostash.
  3. Removes the worktree. A dirty worktree is refused with its 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).
  4. Deletes the local branch with 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.
  5. 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 triage

bin/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.

CI watch skill

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.

Release watch skill

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.

Release Ticker (mod)

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:

  1. workflow — the releaseWorkflow run for the merge commit;
  2. tag — a new git tag on the merge commit or a release commit after it;
  3. GitHub release — the GitHub Release for that tag;
  4. 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:

  • falls through silently (no status, no toast) when the repo has no releaseWorkflow file;
  • polls every 30 s with bounded 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);
  • takes as the release tag only a tag that did not exist at merge time and sits on the merge commit or a commit descending from it (a release commit), reading the newest tags by commit date — so an unrelated tag pushed meanwhile (a nightly, another branch) is ignored, however many tags the repo has;
  • skips the 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);
  • gives up quietly if no release run appears within 3 minutes (a path-filtered or label-gated release that didn't fire), and stops hard at 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):

KeyDefaultMeaning
releaseWorkflowrelease.ymlWorkflow file under .github/workflows/ to follow. A repo without it is skipped silently.
registryghcr.ioRegistry 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).
timeoutMin20Hard 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

Source 3 files
hooks/release-ticker/register.ts 74 lines
1/**
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}
74
hooks/release-ticker/logic.ts 598 lines
1/**
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}
598
hooks/release-ticker/ticker.ts 389 lines
1/**
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