From 4debd3c7994edb6a358412c7f8cae4aebf6aed5d Mon Sep 17 00:00:00 2001 From: "Fredrik Liljegren (Claude Code Claude Opus 5)" Date: Tue, 8 Sep 2026 14:01:54 +0200 Subject: [PATCH] feat: a bumped pull request is prepared at once, alongside whatever the daemon is already preparing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ↑ marked the row and asked for a tick, so a bump behind a running preparation waited out the whole tick — five to thirty minutes of another pull request's agent — before its own started. It now prepares that one straight away, beside the tick's own work: the fetches land in refs of their own so two preparations can share one clone, an in-flight set keeps the tick and the bumps from touching a row an agent is on, and the shutdown stops every server and agent still running rather than one of each. The queue card says `preparing` and pulses while it runs, instead of still reading as bumped. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Bwp5QefjsjMFeq6CK6cT6w --- README.md | 2 +- package-lock.json | 12 +-- packages/api/package.json | 2 +- packages/cli/package.json | 2 +- packages/cli/src/inbox/daemon.ts | 47 +++++---- packages/cli/src/inbox/page.ts | 33 +++++- packages/cli/src/inbox/runtime.ts | 32 +++--- packages/cli/src/inbox/store.ts | 2 +- packages/cli/src/inbox/tick.ts | 78 +++++++++++++- packages/cli/src/inbox/worktree.ts | 15 ++- packages/cli/tests/inbox-daemon.test.ts | 102 +++++++++++++++--- packages/cli/tests/inbox-page.test.ts | 18 ++++ packages/cli/tests/inbox-runtime.test.ts | 22 +++- packages/cli/tests/inbox-tick.test.ts | 121 +++++++++++++++++++++- packages/cli/tests/inbox-worktree.test.ts | 33 ++++++ packages/git/package.json | 2 +- packages/github/package.json | 2 +- packages/parser/package.json | 2 +- packages/ui/package.json | 2 +- 19 files changed, 458 insertions(+), 71 deletions(-) diff --git a/README.md b/README.md index d55db2a..b3235aa 100644 --- a/README.md +++ b/README.md @@ -332,7 +332,7 @@ running past the end would otherwise be counted and highlighted with nothing to ## The review inbox -`diffity inbox` watches the pull requests awaiting your review and prepares each one ahead of time, so the review is ready the moment you look. It polls GitHub (`gh search prs --review-requested=@me`), and for each pull request worth your attention it cuts a worktree at the PR head, runs a diffity session over the diff, has an agent prepare a review with a walkthrough, and saves the result as a bundle. New commits redo a stale review; a merged, closed, or no-longer-requested PR is retired. The daemon prepares up to `maxPrepared` of them from the queue on its own — the rest wait, smallest first — and a prepared review leaves the inbox once you have posted it (GitHub withdraws the request) or dismissed it from the page — a dismissal holds until the pull request gets new commits, and dismissed pull requests stay listed at the bottom so one can be brought back. A queued, skipped, failed or dismissed pull request has a ↑ button: prepare this one next, ahead of the queue and past the limit, with the title patterns and the filter set aside; the daemon picks it up at once. Each prepared review shows its findings by severity ("1 P1 · 2 P2"), and the page can notify you when one is ready — click "Turn on notifications" once to allow it, and ⟳ in the header polls GitHub now instead of at the next interval; `localhost` counts as a secure context, so this works from the pinned tab with nothing else set up. +`diffity inbox` watches the pull requests awaiting your review and prepares each one ahead of time, so the review is ready the moment you look. It polls GitHub (`gh search prs --review-requested=@me`), and for each pull request worth your attention it cuts a worktree at the PR head, runs a diffity session over the diff, has an agent prepare a review with a walkthrough, and saves the result as a bundle. New commits redo a stale review; a merged, closed, or no-longer-requested PR is retired. The daemon prepares up to `maxPrepared` of them from the queue on its own — the rest wait, smallest first — and a prepared review leaves the inbox once you have posted it (GitHub withdraws the request) or dismissed it from the page — a dismissal holds until the pull request gets new commits, and dismissed pull requests stay listed at the bottom so one can be brought back. A queued, skipped, failed or dismissed pull request has a ↑ button: prepare this one now — at once and in parallel with whatever the daemon is preparing, past the auto-prepare count, with the filter, the title patterns and the CI hold set aside. Each bump is its own agent run, and the row says `preparing` while it runs. Each prepared review shows its findings by severity ("1 P1 · 2 P2"), and the page can notify you when one is ready — click "Turn on notifications" once to allow it, and ⟳ in the header polls GitHub now instead of at the next interval; `localhost` counts as a secure context, so this works from the pinned tab with nothing else set up. The daemon never posts your prepared reviews to GitHub — they are local drafts you open and submit yourself — and it runs the review agent with your GitHub credentials stripped from its environment. Its own git calls run with hooks disabled, so a checkout's hook scripts — the author's code — never run with your credentials. That said, the agent executes the pull request's own repository code (see the warning below), so treat the "never posts" behaviour as the daemon's design, not a sandbox. diff --git a/package-lock.json b/package-lock.json index ce6d3db..ba13e7b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8457,7 +8457,7 @@ }, "packages/api": { "name": "@diffity/api", - "version": "0.10.27", + "version": "0.10.28", "dependencies": { "@diffity/parser": "*" }, @@ -8468,7 +8468,7 @@ }, "packages/cli": { "name": "@naturalcycles/diffity", - "version": "0.10.27", + "version": "0.10.28", "license": "MIT", "dependencies": { "commander": "^14.0.3", @@ -8492,7 +8492,7 @@ }, "packages/git": { "name": "@diffity/git", - "version": "0.10.27", + "version": "0.10.28", "devDependencies": { "@types/node": "^25.5.0", "typescript": "^5.9.3", @@ -8501,7 +8501,7 @@ }, "packages/github": { "name": "@diffity/github", - "version": "0.10.27", + "version": "0.10.28", "dependencies": { "@diffity/api": "*", "@diffity/parser": "*" @@ -8514,7 +8514,7 @@ }, "packages/parser": { "name": "@diffity/parser", - "version": "0.10.27", + "version": "0.10.28", "devDependencies": { "typescript": "^5.9.3", "vitest": "^4.1.0" @@ -8522,7 +8522,7 @@ }, "packages/ui": { "name": "@diffity/ui", - "version": "0.10.27", + "version": "0.10.28", "dependencies": { "@diffity/api": "*", "@diffity/parser": "*", diff --git a/packages/api/package.json b/packages/api/package.json index 1bb0e88..0f7a86a 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -1,6 +1,6 @@ { "name": "@diffity/api", - "version": "0.10.27", + "version": "0.10.28", "private": true, "type": "module", "main": "./dist/index.js", diff --git a/packages/cli/package.json b/packages/cli/package.json index 4421ceb..3155bd6 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@naturalcycles/diffity", - "version": "0.10.27", + "version": "0.10.28", "description": "Agent-agnostic, GitHub-style diff viewer and code review tool with a live agent loop", "type": "module", "bin": { diff --git a/packages/cli/src/inbox/daemon.ts b/packages/cli/src/inbox/daemon.ts index b888f91..ccbf3ed 100644 --- a/packages/cli/src/inbox/daemon.ts +++ b/packages/cli/src/inbox/daemon.ts @@ -2,18 +2,18 @@ import { createServer, type IncomingMessage, type Server, type ServerResponse } import { MAX_SETTINGS_TEXT, parseSettingsPatch } from './settings.js'; import { existsSync, readdirSync, readFileSync, rmSync } from 'node:fs'; import { basename, join } from 'node:path'; -import { getViewerLogin, searchReviewRequested, viewPr } from '@diffity/github'; +import { getViewerLogin, searchReviewRequested, viewPr, type PrSnapshot } from '@diffity/github'; import { saveInboxSettings, type InboxConfig, type InboxSettings } from './config.js'; import { inboxDir } from './paths.js'; -import { logsDir, preparePr, type PrepareDeps } from './prepare.js'; -import { realAttendantDeps, realPrepareDeps, type Inflight } from './runtime.js'; +import { logsDir, preparePr, type PrepareDeps, type PrepareResult } from './prepare.js'; +import { noneInflight, realAttendantDeps, realPrepareDeps, type Inflight } from './runtime.js'; import { Attendants, type AttendedPr } from './attendant.js'; import { removeWorktree, cloneDir } from './worktree.js'; import { localHhMm } from './runs.js'; import { findInstanceForRepo, killInstance } from '../registry.js'; import { repoHash } from './open-session.js'; import { InboxStore } from './store.js'; -import { runTick, type Forge } from './tick.js'; +import { prepareBumped, runTick, type Forge } from './tick.js'; import { buildView } from './view.js'; import { resolveBump, resolveDismiss, resolveOpen } from './open.js'; import { openPreparedSession, realOpenSessionDeps, type OpenSessionDeps } from './open-session.js'; @@ -44,6 +44,10 @@ export interface DaemonOptions { openDeps?: OpenSessionDeps; /** Who parks on an opened review; defaults to the real attendants. Tests override it. */ attendants?: AttendantHost; + /** How one pull request is prepared; defaults to the real preparation. Tests override it. */ + prepare?: (snapshot: PrSnapshot, opts: { bumped: boolean }) => Promise; + /** Where the prepares register what they have running, for the shutdown to stop; its own by default. */ + inflight?: Inflight; /** Where the page's settings are written; without it they change the running daemon only. */ configPath?: string; } @@ -51,8 +55,8 @@ export interface DaemonOptions { /** What the page may ask of the daemon beyond the store: park agents, tick, settings, and how the tick is doing. */ export interface ServerHooks { attendants?: AttendantHost | null; - /** A bump wants a tick now, or right after the one in flight. */ - onBump?: (() => void) | null; + /** A bump wants this pull request prepared now, whatever else is being prepared. */ + onBump?: ((id: string) => void) | null; /** The page's ⟳: the same tick, asked for by hand. */ onTick?: (() => void) | null; settings?: SettingsHost | null; @@ -118,18 +122,20 @@ export async function runDaemon( let ticking = false; let lastPollAt: string | null = null; - const inflight: Inflight = {}; + const inflight = options.inflight ?? noneInflight(); const prepareDeps: PrepareDeps = realPrepareDeps(nodePath, entry, inboxDataDir, config, log, inflight); // The pause outlives this process: a session limit is the reviewer's, not the daemon's, so it is // kept in the store and a restart does not spend a run rediscovering it. const pausedUntil = () => store.pausedUntil(new Date().toISOString()); const deps = { forge: options.forge ?? realForge, - prepare: (snapshot: Parameters[0], opts: { bumped: boolean }) => preparePr(snapshot, config, prepareDeps, opts), + prepare: options.prepare ?? ((snapshot: PrSnapshot, opts: { bumped: boolean }) => preparePr(snapshot, config, prepareDeps, opts)), removeWorktree: (worktree: string, repo: string) => reclaimWorktree(config, worktree, repo), log, now: () => new Date().toISOString(), shouldContinue: () => !stopping, + // One set for the tick and the bumps: each knows what the other is already preparing. + inFlight: new Set(), // Read at each tick, not copied: the page can change it while the daemon runs. get maxPrepared() { return config.maxPrepared; }, get waitForCi() { return config.waitForCi; }, @@ -144,7 +150,7 @@ export async function runDaemon( pausedUntil, }; - // A bump arriving mid-tick is served by another tick right after, not by the next poll. + // A ⟳ arriving mid-tick is served by another tick right after it, not by the next poll. let tickWanted = false; const tick = async () => { if (ticking || stopping) { @@ -192,7 +198,11 @@ export async function runDaemon( }; const settings = settingsHost(config, options.configPath, armPoll); const server = await bindInboxServer(store, config, log, openDeps, { - attendants, onBump: requestTick, onTick: requestTick, settings, status: () => ({ ticking, lastPollAt, pausedUntil: pausedUntil() }), + attendants, onTick: requestTick, settings, status: () => ({ ticking, lastPollAt, pausedUntil: pausedUntil() }), + // Not a tick: the ↑ prepares that one at once, beside whatever a tick is already preparing, and + // the page's next refresh finds it `preparing`. + onBump: id => void prepareBumped(store, deps, id) + .catch(err => log(`could not prepare ${id}: ${err instanceof Error ? err.message : err}`)), }); reclaimLeftoverServers(log); armPoll(); @@ -205,10 +215,11 @@ export async function runDaemon( if (timer) { clearInterval(timer); } - // Kill whatever a prepare has running right now — the detached diffity server and the agent - // and its group — so nothing outlives the daemon. - inflight.agentKill?.(); - inflight.serverStop?.(); + // Kill whatever the prepares have running right now — every detached diffity server and every + // agent with its group — so nothing outlives the daemon. + for (const stop of [...inflight.stops]) { + stop(); + } attendants.stopAll(); server.close(() => { store.close(); @@ -414,8 +425,8 @@ function stateChangingId(req: IncomingMessage, res: ServerResponse, prefix: stri } } -/** Puts a pull request at the front of the queue and asks for a tick, so it is prepared now. */ -function handleBump(store: InboxStore, id: string, onBump: (() => void) | null, log: (message: string) => void, res: ServerResponse): void { +/** Puts a pull request at the front of the queue and has it prepared at once. */ +function handleBump(store: InboxStore, id: string, onBump: ((id: string) => void) | null, log: (message: string) => void, res: ServerResponse): void { const resolution = resolveBump(store, id); if (!resolution.ok) { res.writeHead(resolution.status, { 'Content-Type': 'text/plain; charset=utf-8' }); @@ -423,10 +434,10 @@ function handleBump(store: InboxStore, id: string, onBump: (() => void) | null, return; } store.bump(resolution.pr.id, new Date().toISOString()); - log(`${id} bumped to the front of the queue`); + log(`${id} bumped — preparing it now`); res.writeHead(204); res.end(); - onBump?.(); + onBump?.(resolution.pr.id); } /** Marks a pull request as one the reviewer will not review, and reclaims its worktree. */ diff --git a/packages/cli/src/inbox/page.ts b/packages/cli/src/inbox/page.ts index 89af531..3778721 100644 --- a/packages/cli/src/inbox/page.ts +++ b/packages/cli/src/inbox/page.ts @@ -76,6 +76,21 @@ export function inboxPage(): string { .badge.stale { color: var(--stale); border: 1px solid var(--stale); } .badge.work { color: var(--work); border: 1px solid var(--line); } .badge.bad { color: var(--bad); border: 1px solid var(--bad); } + .badge.busy { color: var(--accent); border-color: var(--accent); } + /* The wider edge comes out of the padding, so a card being prepared is exactly as wide as the rest. */ + .row.busy { border-left: 3px solid var(--accent); padding-left: 12px; } + @media (prefers-reduced-motion: no-preference) { + .badge.busy { animation: working 1.6s infinite ease-in-out; } + .row.busy { animation: edge 1.6s infinite ease-in-out; } + } + @keyframes working { + 0%, 100% { color: var(--work); border-color: var(--line); } + 50% { color: var(--accent); border-color: var(--accent); } + } + @keyframes edge { + 0%, 100% { border-left-color: var(--line); } + 50% { border-left-color: var(--accent); } + } .open-hint { color: var(--accent); font-size: 12px; font-weight: 600; white-space: nowrap; } .empty { color: var(--muted); padding: 12px 2px; } .dot { width: 7px; height: 7px; border-radius: 50%; background: var(--ready); flex: none; } @@ -236,9 +251,9 @@ export function inboxPage(): string { return row; } - function plainRow(r, badgeClass, badgeText) { + function plainRow(r, badgeClass, badgeText, busy) { const row = document.createElement('div'); - row.className = 'row'; + row.className = busy ? 'row busy' : 'row'; row.innerHTML = '' + sizeLabel(r) + '' + ciDot(r) + @@ -249,6 +264,16 @@ export function inboxPage(): string { return row; } + // A queue row. One with an agent on it says so and pulses while it runs; the bump that asked for + // it is spent the moment it starts, so only a row still waiting its turn reads as bumped. + function workingRow(r) { + const busy = r.status === 'preparing'; + const label = busy + ? (r.bumped ? 'preparing \\u00b7 bumped' : 'preparing') + : (r.bumped && r.status === 'queued' ? 'bumped' : r.status); + return plainRow(r, busy ? 'work busy' : 'work', label, busy); + } + function withActions(row, r) { if (!r.dismissUrl && !r.prepareUrl) return row; const wrap = document.createElement('div'); @@ -258,7 +283,7 @@ export function inboxPage(): string { const up = document.createElement('button'); up.type = 'button'; up.className = 'bump'; - up.title = 'Prepare this one next: ahead of the queue, past the limit, the skips set aside'; + up.title = 'Prepare this one now: at once and beside whatever is being prepared, past the auto-prepare count, the skips and the CI hold set aside'; up.textContent = '\\u2191'; up.onclick = () => bump(r); wrap.append(up); @@ -424,7 +449,7 @@ export function inboxPage(): string { const view = await res.json(); announce(view); fill('ready-section', 'ready', view.ready, r => withActions(readyRow(r), r)); - fill('working-section', 'working', view.working, r => withActions(plainRow(r, 'work', r.bumped ? 'bumped' : r.status), r)); + fill('working-section', 'working', view.working, r => withActions(workingRow(r), r)); fill('other-section', 'other', view.other, r => { const bad = r.status === 'failed'; return withActions(plainRow(r, bad ? 'bad' : 'work', r.status), r); diff --git a/packages/cli/src/inbox/runtime.ts b/packages/cli/src/inbox/runtime.ts index 2478aef..1452fd8 100644 --- a/packages/cli/src/inbox/runtime.ts +++ b/packages/cli/src/inbox/runtime.ts @@ -13,12 +13,18 @@ import { parseThreadList, type ReviewThread } from './validate.js'; import { diffityDir } from '../registry.js'; /** - * What a prepare currently has running, so the daemon can stop it on shutdown. Set as a server or - * an agent starts and cleared as it ends; a shutdown mid-prepare calls whichever is set. + * What the prepares currently have running, so the daemon can stop them on shutdown. A server's + * stop and an agent's kill join the set as they start and leave it as they end; a shutdown calls + * whatever is still in it. A set, not one of each: a bumped pull request is prepared alongside + * whatever the daemon is already preparing, so several servers and agents are running at once. */ export interface Inflight { - serverStop?: () => void; - agentKill?: () => void; + stops: Set<() => void>; +} + +/** An `Inflight` of its own, for a caller that is not sharing the daemon's. */ +export function noneInflight(): Inflight { + return { stops: new Set() }; } /** @@ -27,12 +33,13 @@ export interface Inflight { * `dataDirFor` gives each pull request its own diffity data directory, so a prepared session never * mixes with the reviewer's own diffity or with the previous run's findings on a re-prepare. */ -export function realPrepareDeps(nodePath: string, entry: string, dataDirFor: (worktree: string) => string, config: InboxConfig, log: (message: string) => void, inflight: Inflight = {}): PrepareDeps { +export function realPrepareDeps(nodePath: string, entry: string, dataDirFor: (worktree: string) => string, config: InboxConfig, log: (message: string) => void, inflight: Inflight = noneInflight()): PrepareDeps { return { startServer: async (worktree, diffRef) => { const handle = await startDiffityServer(nodePath, entry, worktree, diffRef, dataDirFor(worktree)); - inflight.serverStop = () => { handle.stop(); inflight.serverStop = undefined; }; - return { port: handle.port, stop: () => { handle.stop(); inflight.serverStop = undefined; } }; + const stop = () => { handle.stop(); inflight.stops.delete(stop); }; + inflight.stops.add(stop); + return { port: handle.port, stop }; }, agentArgv: () => buildAgentArgv({ nodePath, entry, agent: config.agent, systemPrompt: skillBody(entry, 'diffity-review', log) }), // No review skill: this pass checks findings that are already written, and is told how in its @@ -121,7 +128,7 @@ function stopServer(pid: number | undefined): void { * not rest on the prompt alone. On a timeout the whole process group is killed, not just the direct * child, so a tool the agent spawned cannot outlive it. */ -export function runAgent(opts: RunAgentOpts, dataDir: string, mcpAllow: string[] = [], inflight: Inflight = {}): Promise<{ stdout: string; timedOut: boolean }> { +export function runAgent(opts: RunAgentOpts, dataDir: string, mcpAllow: string[] = [], inflight: Inflight = noneInflight()): Promise<{ stdout: string; timedOut: boolean }> { mkdirSync(dirname(opts.logPath), { recursive: true }); const log = createWriteStream(opts.logPath, { flags: opts.appendLog ? 'a' : 'w' }); // The log is a convenience, not the contract: a path that cannot be opened or written must not @@ -136,11 +143,12 @@ export function runAgent(opts: RunAgentOpts, dataDir: string, mcpAllow: string[] detached: true, env: agentEnv(dataDir, mcpAllow), }); - inflight.agentKill = () => killGroup(child.pid, 'SIGTERM'); + const kill = () => killGroup(child.pid, 'SIGTERM'); + inflight.stops.add(kill); let stdout = ''; let settled = false; let escalate: ReturnType | undefined; - const clearInflight = () => { inflight.agentKill = undefined; }; + const clearInflight = () => { inflight.stops.delete(kill); }; const timer = setTimeout(() => { killGroup(child.pid, 'SIGTERM'); @@ -250,8 +258,8 @@ export function realAttendantDeps( child.on('close', code => { signal.removeEventListener('abort', onAbort); resolve(parseAwaitOutcome(code, stdout, stderr)); }); }), answer: async (worktree, pr, prompt, signal) => { - const inflight: Inflight = {}; - const onAbort = () => inflight.agentKill?.(); + const inflight = noneInflight(); + const onAbort = () => inflight.stops.forEach(stop => stop()); signal.addEventListener('abort', onAbort, { once: true }); const startedAt = new Date().toISOString(); // A question is about a finding, which is the checking model's job when one is set. diff --git a/packages/cli/src/inbox/store.ts b/packages/cli/src/inbox/store.ts index fd688ac..2323d02 100644 --- a/packages/cli/src/inbox/store.ts +++ b/packages/cli/src/inbox/store.ts @@ -45,7 +45,7 @@ export interface InboxPr { statusReason: string | null; /** How many times preparation has failed at the current head, reset when the head moves. */ attempts: number; - /** When the reviewer asked for this one next — ahead of the queue, past the cap, the skips set aside. */ + /** When the reviewer asked for this one now — at once, past the cap, the skips set aside. */ bumpedAt: string | null; /** The head the prepared review is for; older than headSha means the review is stale. */ preparedHeadSha: string | null; diff --git a/packages/cli/src/inbox/tick.ts b/packages/cli/src/inbox/tick.ts index 56cef28..12d2116 100644 --- a/packages/cli/src/inbox/tick.ts +++ b/packages/cli/src/inbox/tick.ts @@ -21,6 +21,11 @@ export interface TickDeps { now(): string; /** False once the daemon is shutting down, so the drain stops starting new preparations. */ shouldContinue?(): boolean; + /** + * The pull requests a preparation is running for right now, the daemon's own set: a bump prepares + * beside the tick, so both have to see the same ones. + */ + inFlight: Set; /** How many prepared reviews may wait for the reviewer at once; the rest of the queue waits. */ maxPrepared: number; /** Whether a pull request waits for its CI to pass before an agent is spent on it. */ @@ -52,6 +57,11 @@ export async function runTick(store: InboxStore, deps: TickDeps): Promise const toPrepare: PrSnapshot[] = []; for (const ref of requested) { + // A preparation running for this one — the tick's own, or a bump's — owns the row until it + // ends: reconciling it now would re-queue a `preparing` row and hand it a second agent. + if (deps.inFlight.has(prId(ref))) { + continue; + } const snapshot = await deps.forge.viewPr(ref); if (!snapshot) { deps.log(`could not read ${prId(ref)} this tick; leaving it as it was`); @@ -71,7 +81,7 @@ export async function runTick(store: InboxStore, deps: TickDeps): Promise // Rows the search no longer returns: retired against their latest detail, and their worktrees // reclaimed. A closed pull request may not be searchable at all, so it is asked about directly. for (const pr of store.all()) { - if (requestedIds.has(pr.id) || isRetired(pr.status)) { + if (requestedIds.has(pr.id) || isRetired(pr.status) || deps.inFlight.has(pr.id)) { continue; } const snapshot = await deps.forge.viewPr(prToRef(pr)); @@ -122,10 +132,13 @@ export async function runTick(store: InboxStore, deps: TickDeps): Promise if (deps.shouldContinue && !deps.shouldContinue()) { break; } - // Read again, not taken from the listing: the reviewer may have dismissed or bumped it from - // the page while this tick was busy with another. + // Read again, not taken from the listing: the reviewer may have dismissed it, or bumped it and + // had it prepared beside this tick, while the tick was busy with another. const row = store.get(prId(snapshot)); - if (row?.status === 'dismissed') { + if (row?.status === 'dismissed' || deps.inFlight.has(prId(snapshot))) { + continue; + } + if (row?.status === 'prepared' && row.preparedHeadSha === snapshot.headSha) { continue; } const bumped = row?.bumpedAt != null; @@ -150,12 +163,67 @@ function diffSize(snapshot: PrSnapshot): number { return snapshot.additions + snapshot.deletions; } +/** + * The one pull request the reviewer asked for by name, prepared now — beside whatever the tick is + * already preparing rather than after it, because a preparation is minutes of agent and the ↑ means + * now. There is no poll behind it: the row is read from the forge on its own and put through the + * same reconcile, so a draft, or one merged since it was queued, is still not handed an agent. + */ +export async function prepareBumped(store: InboxStore, deps: TickDeps, id: string): Promise { + const existing = store.get(id); + if (!existing) { + deps.log(`cannot prepare ${id}: the inbox has no such pull request`); + return; + } + if (deps.inFlight.has(id)) { + return; + } + const snapshot = await deps.forge.viewPr(prToRef(existing)); + if (!snapshot) { + deps.log(`could not read ${id} to prepare it; leaving it as it was`); + return; + } + const viewerLogin = await deps.forge.viewerLogin(); + store.observe(snapshot, true, deps.now()); + // Whether the review is still wanted is taken from the state, not assumed: no search ran, and one + // merged or closed while it waited is retired rather than reviewed. + const transition = reconcile({ + existing, snapshot, requested: snapshot.state === 'OPEN', viewerLogin, + waitForCi: deps.waitForCi, skipTitles: deps.skipTitles, + }); + if (transition) { + store.setStatus(id, transition.status, transition.reason); + } + if (!transition?.prepare) { + return; + } + const pausedUntil = deps.pausedUntil?.() ?? null; + if (pausedUntil) { + const reason = `waiting: preparing paused until ${localHhMm(pausedUntil)}`; + store.setStatus(id, 'queued', reason); + deps.log(`${id} left queued: ${reason}`); + return; + } + await prepareOne(store, snapshot, deps, true); +} + async function prepareOne(store: InboxStore, snapshot: PrSnapshot, deps: TickDeps, bumped: boolean): Promise { const id = prId(snapshot); + // Claimed in the same breath as it is checked: a bump and a tick can reach the same pull request + // at once, and two agents must not end up in one worktree. + if (deps.inFlight.has(id)) { + return; + } + deps.inFlight.add(id); store.setStatus(id, 'preparing', null); deps.log(`preparing ${id} — ${snapshot.title}`); - const result = await deps.prepare(snapshot, { bumped }); + let result: PrepareResult; + try { + result = await deps.prepare(snapshot, { bumped }); + } finally { + deps.inFlight.delete(id); + } store.clearBump(id); recordPrepareRun(store, snapshot, deps, result); recordValidateRun(store, snapshot, deps, result); diff --git a/packages/cli/src/inbox/worktree.ts b/packages/cli/src/inbox/worktree.ts index 55852d9..58849e8 100644 --- a/packages/cli/src/inbox/worktree.ts +++ b/packages/cli/src/inbox/worktree.ts @@ -41,6 +41,11 @@ async function runGit(cwd: string, args: string[]): Promise { * * `pinHead` cuts the worktree at that commit instead of wherever the pull request has got to, * which is how a review is re-run against the head an earlier one was written at. + * + * Each fetch lands in a ref of its own under `refs/diffity/` and is read back from there rather + * than from `FETCH_HEAD`: two preparations may be fetching in the same clone at once — a bumped + * pull request is prepared alongside whatever the daemon is already on — and one clone has a + * single `FETCH_HEAD`. */ export async function prepareWorktree(clone: string, dest: string, ref: PrRef, baseRef: string, pinHead?: string): Promise<{ head: string; diffRef: string }> { if (!existsSync(clone)) { @@ -51,13 +56,15 @@ export async function prepareWorktree(clone: string, dest: string, ref: PrRef, b } await requireMatchingOrigin(clone, ref); - await runGit(clone, ['fetch', 'origin', `refs/pull/${ref.number}/head`]); + const headRef = `refs/diffity/pull/${ref.number}`; + await runGit(clone, ['fetch', 'origin', `+refs/pull/${ref.number}/head:${headRef}`]); const head = pinHead === undefined - ? await runGit(clone, ['rev-parse', 'FETCH_HEAD']) + ? await runGit(clone, ['rev-parse', headRef]) : await reachable(clone, pinHead); // `refs/heads/` so a tag sharing the branch's name cannot be fetched in its place. - await runGit(clone, ['fetch', 'origin', `refs/heads/${baseRef}`]); - const diffRef = await runGit(clone, ['rev-parse', 'FETCH_HEAD']); + const localBaseRef = `refs/diffity/base/${baseRef}`; + await runGit(clone, ['fetch', 'origin', `+refs/heads/${baseRef}:${localBaseRef}`]); + const diffRef = await runGit(clone, ['rev-parse', localBaseRef]); if (existsSync(join(dest, '.git'))) { try { diff --git a/packages/cli/tests/inbox-daemon.test.ts b/packages/cli/tests/inbox-daemon.test.ts index e2e3ea2..e73c0d4 100644 --- a/packages/cli/tests/inbox-daemon.test.ts +++ b/packages/cli/tests/inbox-daemon.test.ts @@ -4,9 +4,11 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; import { runDaemon } from '../src/inbox/daemon.js'; +import { noneInflight } from '../src/inbox/runtime.js'; import { InboxStore } from '../src/inbox/store.js'; import type { Forge } from '../src/inbox/tick.js'; import type { AgentConfig } from '../src/inbox/config.js'; +import type { PrepareResult } from '../src/inbox/prepare.js'; import type { PrSnapshot } from '@diffity/github'; /** The built-in agent settings, fresh each call so a test cannot leak into the next. */ @@ -24,6 +26,23 @@ const emptyForge: Forge = { viewPr: () => Promise.resolve(null), }; +function snapshot(number: number, additions: number): PrSnapshot { + return { + owner: 'o', repo: 'r', number, title: `T${number}`, url: `https://github.com/o/r/pull/${number}`, + author: 'alice', isBot: false, isDraft: false, state: 'OPEN', headSha: 'aaa', baseRef: 'main', + additions, deletions: 0, changedFiles: 1, createdAt: 'now', updatedAt: 'now', checks: [], files: [], + }; +} + +/** A preparation that came to something, so a row it ran for reads as prepared. */ +function preparedResult(snapshot: PrSnapshot): PrepareResult { + return { + kind: 'prepared', headSha: snapshot.headSha, bundlePath: '/b.json', worktree: '/wt', logPath: '/l.log', + at: 'now', summary: '1 P2', alert: null, validation: 'not-needed', validateRun: null, + run: { startedAt: 'now', endedAt: 'now', stats: null }, + }; +} + /** A live process whose pid can be seeded into a registry and checked for liveness. */ function spawnDummy(): number { const child = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1e9)'], { stdio: 'ignore', detached: true }); @@ -114,13 +133,9 @@ describe('runDaemon singleton and reclaim ordering', () => { } }); - it('a bump posted while a tick is in flight gets another tick as soon as that one ends', async () => { + it('a \u27f3 pressed while a tick is in flight gets another tick as soon as that one ends', async () => { const store = new InboxStore(join(root, 'inbox', 'inbox.sqlite')); - const snap: PrSnapshot = { - owner: 'o', repo: 'r', number: 1, title: 'T', url: 'https://github.com/o/r/pull/1', author: 'alice', isBot: false, - isDraft: false, state: 'OPEN', headSha: 'aaa', baseRef: 'main', additions: 1, deletions: 0, changedFiles: 1, - createdAt: 'now', updatedAt: 'now', checks: [], files: [], - }; + const snap = snapshot(1, 1); store.observe(snap, true, 'now'); let searches = 0; let release: () => void = () => {}; @@ -136,23 +151,86 @@ describe('runDaemon singleton and reclaim ordering', () => { try { await settle(); expect(searches).toBe(1); - const res = await fetch(`http://127.0.0.1:6005/prepare/${encodeURIComponent('o/r#1')}`, { method: 'POST' }); - expect(res.status).toBe(204); - expect(store.get('o/r#1')!.bumpedAt).not.toBeNull(); - // Still the first tick: the bump did not start a second one underneath it. + expect((await fetch('http://127.0.0.1:6005/api/tick', { method: 'POST' })).status).toBe(204); + // Still the first tick: the ⟳ did not start a second one underneath it. expect(searches).toBe(1); release(); await settle(); await settle(); expect(searches).toBe(2); - // The bump was served — the row was prepared (and failed for want of a clone) — and is spent. - expect(store.get('o/r#1')!.bumpedAt).toBeNull(); } finally { await handle.stop(); } }); + it('prepares a bumped pull request at once, beside the one the tick is already on', async () => { + const store = new InboxStore(join(root, 'inbox', 'inbox.sqlite')); + const first = snapshot(1, 10); + const second = snapshot(2, 20); + store.observe(first, true, 'now'); + store.observe(second, true, 'now'); + const forge: Forge = { + viewerLogin: () => Promise.resolve('me'), + searchReviewRequested: () => Promise.resolve([{ owner: 'o', repo: 'r', number: 1 }, { owner: 'o', repo: 'r', number: 2 }]), + viewPr: ref => Promise.resolve(ref.number === 1 ? first : second), + }; + // The tick's own preparation of #1 is held open; the bumped one must not wait for it. + const started: number[] = []; + let release: () => void = () => {}; + const held = new Promise(resolve => { release = resolve; }); + const prepare = async (snap: PrSnapshot): Promise => { + started.push(snap.number); + if (snap.number === 1) { + await held; + } + return preparedResult(snap); + }; + const handle = await runDaemon(store, config(6007), process.execPath, 'unused-entry', () => {}, { forge, prepare }); + try { + await settle(); + expect(started).toEqual([1]); + expect(store.get('o/r#1')!.status).toBe('preparing'); + + const res = await fetch(`http://127.0.0.1:6007/prepare/${encodeURIComponent('o/r#2')}`, { method: 'POST' }); + expect(res.status).toBe(204); + await settle(); + + // #2 was prepared while #1 was still going, and the page has it as preparing meanwhile. + expect(started).toEqual([1, 2]); + expect(store.get('o/r#2')!.status).toBe('prepared'); + expect(store.get('o/r#1')!.status).toBe('preparing'); + + release(); + await settle(); + await settle(); + // The tick finishes #1 and does not hand #2 a second agent. + expect(started).toEqual([1, 2]); + expect(store.get('o/r#1')!.status).toBe('prepared'); + } finally { + release(); + await settle(); + await handle.stop(); + } + }); + + it('stops every preparation still running when it shuts down', async () => { + const store = new InboxStore(join(root, 'inbox', 'inbox.sqlite')); + const inflight = noneInflight(); + const handle = await runDaemon(store, config(6008), process.execPath, 'unused-entry', () => {}, { forge: emptyForge, inflight }); + await settle(); + // What two prepares running at once leave behind them: a server and an agent each. + const stopped: string[] = []; + for (const name of ['first server', 'first agent', 'second server', 'second agent']) { + inflight.stops.add(() => stopped.push(name)); + } + + await handle.stop(); + + expect(stopped).toEqual(['first server', 'first agent', 'second server', 'second agent']); + expect(inflight.stops.size).toBe(4); + }); + it('polls but prepares nothing while paused, and takes the queue up once the pause has passed', async () => { const store = new InboxStore(join(root, 'inbox', 'inbox.sqlite')); const snap: PrSnapshot = { diff --git a/packages/cli/tests/inbox-page.test.ts b/packages/cli/tests/inbox-page.test.ts index db6d502..feb8992 100644 --- a/packages/cli/tests/inbox-page.test.ts +++ b/packages/cli/tests/inbox-page.test.ts @@ -65,6 +65,24 @@ describe('the inbox page', () => { expect(inboxPage()).toContain('.ci { flex: none;'); }); + it('says a queue row is preparing rather than bumped, and shows it working', () => { + const script = pageScript(); + expect(script).toContain("const busy = r.status === 'preparing'"); + expect(script).toContain("'preparing \\u00b7 bumped'"); + // The \u2191 the reviewer pressed reads as bumped only while the row is still waiting its turn. + expect(script).toContain("r.bumped && r.status === 'queued' ? 'bumped' : r.status"); + expect(script).toContain("plainRow(r, busy ? 'work busy' : 'work', label, busy)"); + expect(script).toContain("busy ? 'row busy' : 'row'"); + + const html = inboxPage(); + expect(html).toContain('@keyframes working'); + expect(html).toContain('@media (prefers-reduced-motion: no-preference)'); + // Reduced motion gets the same badge in the accent colour, standing still. + expect(html).toContain('.badge.busy { color: var(--accent); border-color: var(--accent); }'); + // The wider edge comes out of the padding, so the card is no wider than its neighbours. + expect(html).toContain('.row.busy { border-left: 3px solid var(--accent); padding-left: 12px; }'); + }); + it('has a settings field for the CI hold and the alert paths, and sends both back', () => { const html = inboxPage(); expect(html).toContain('id="waitForCi" type="checkbox"'); diff --git a/packages/cli/tests/inbox-runtime.test.ts b/packages/cli/tests/inbox-runtime.test.ts index 13ee831..441509e 100644 --- a/packages/cli/tests/inbox-runtime.test.ts +++ b/packages/cli/tests/inbox-runtime.test.ts @@ -6,7 +6,7 @@ import { fileURLToPath } from 'node:url'; import { tmpdir } from 'node:os'; const ENTRY = join(dirname(fileURLToPath(import.meta.url)), '..', 'dist', 'index.js'); -import { realAttendantDeps, realPrepareDeps, runAgent, startDiffityServer } from '../src/inbox/runtime.js'; +import { noneInflight, realAttendantDeps, realPrepareDeps, runAgent, startDiffityServer } from '../src/inbox/runtime.js'; import { generalCommentIdOf, threadsToValidate } from '../src/inbox/validate.js'; import type { AttendedPr } from '../src/inbox/attendant.js'; import type { InboxConfig } from '../src/inbox/config.js'; @@ -130,6 +130,26 @@ describe('runAgent', () => { it('rejects when the command does not exist', async () => { await expect(runAgent(opts(['definitely-not-a-real-command-xyz']), root)).rejects.toThrow(/could not run/); }); + + it('holds a kill for every agent running at once, and drops each as it ends', async () => { + // Two preparations at once — a bumped pull request beside the daemon's own — share one set, and + // a shutdown has to reach both of them. + const inflight = noneInflight(); + const argv = ['node', '-e', 'setInterval(()=>{},1000)']; + const first = runAgent(opts(argv, { logPath: join(root, 'first.log') }), root, [], inflight); + const second = runAgent(opts(argv, { logPath: join(root, 'second.log') }), root, [], inflight); + await new Promise(resolve => setTimeout(resolve, 200)); + expect(inflight.stops.size).toBe(2); + + for (const stop of [...inflight.stops]) { + stop(); + } + + // Both children are gone — each call returns only when its own has closed — and the set with them. + expect((await first).timedOut).toBe(false); + expect((await second).timedOut).toBe(false); + expect(inflight.stops.size).toBe(0); + }); }); describe('realAttendantDeps', () => { diff --git a/packages/cli/tests/inbox-tick.test.ts b/packages/cli/tests/inbox-tick.test.ts index 7fea458..f2b0517 100644 --- a/packages/cli/tests/inbox-tick.test.ts +++ b/packages/cli/tests/inbox-tick.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, beforeEach } from 'vitest'; import { InboxStore, prId } from '../src/inbox/store.js'; -import { runTick, type Forge, type TickDeps } from '../src/inbox/tick.js'; +import { prepareBumped, runTick, type Forge, type TickDeps } from '../src/inbox/tick.js'; import { buildView } from '../src/inbox/view.js'; import { localHhMm } from '../src/inbox/runs.js'; import type { PrRef, PrSnapshot } from '@diffity/github'; @@ -61,6 +61,7 @@ function deps(over: Partial = {}): TickDeps { removeWorktree: (worktree) => { removed.push(worktree); }, log: () => {}, now: () => '2026-09-02T12:00:00.000Z', + inFlight: new Set(), maxPrepared: 100, waitForCi: false, skipTitles: [], @@ -523,6 +524,41 @@ describe('runTick', () => { expect(store.get('o/r#1')!.status).toBe('prepared'); }); + it('leaves a pull request alone while a preparation for it is running', async () => { + forge.set(snapshot()); + store.observe(snapshot(), true, 'now'); + store.setStatus('o/r#1', 'preparing', 'a bump got there first'); + + await runTick(store, deps({ inFlight: new Set(['o/r#1']) })); + + expect(prepared).toEqual([]); + expect(store.get('o/r#1')!.status).toBe('preparing'); + expect(store.get('o/r#1')!.statusReason).toBe('a bump got there first'); + + // The same row with nothing running for it is a crash's leftover, and is taken up again. + await runTick(store, deps()); + expect(prepared).toEqual(['o/r#1']); + expect(store.get('o/r#1')!.status).toBe('prepared'); + }); + + it('does not prepare a pull request a bump started preparing while the tick was busy', async () => { + forge.set(snapshot({ number: 1, additions: 10, deletions: 0 })); + forge.set(snapshot({ number: 2, additions: 20, deletions: 0 })); + const inFlight = new Set(); + const prepare = (snap: PrSnapshot) => { + prepared.push(prId(snap)); + // A bump takes #2 on while this tick is on #1. + if (snap.number === 1) { + inFlight.add('o/r#2'); + } + return Promise.resolve(prepareResult(snap)); + }; + await runTick(store, deps({ prepare, inFlight })); + + expect(prepared).toEqual(['o/r#1']); + expect(store.get('o/r#2')!.status).toBe('queued'); + }); + it('offers a bump on queued, skipped and failed rows only, and lists a bumped row first', async () => { forge.set(snapshot({ number: 1, additions: 10, deletions: 0 })); forge.set(snapshot({ number: 2, additions: 20, deletions: 0 })); @@ -538,3 +574,86 @@ describe('runTick', () => { ]); }); }); + +describe('prepareBumped', () => { + /** A row the reviewer has just pressed \u2191 on, as `handleBump` leaves it. */ + function bumpedRow(over: Partial = {}): PrSnapshot { + const snap = snapshot(over); + store.observe(snap, true, '2026-09-02T11:00:00.000Z'); + store.bump(prId(snap), '2026-09-07T10:00:00Z'); + // Not listed: the bump has no poll behind it, so the search is never asked. + forge.set(snap, false); + return snap; + } + + it('prepares the bumped pull request there and then, and spends the bump', async () => { + bumpedRow(); + + await prepareBumped(store, deps(), 'o/r#1'); + + expect(prepared).toEqual(['o/r#1']); + expect(bumpedFlags).toEqual([true]); + expect(store.get('o/r#1')!.status).toBe('prepared'); + expect(store.get('o/r#1')!.bumpedAt).toBeNull(); + }); + + it('prepares past the CI hold and the reviewer\'s own title patterns', async () => { + bumpedRow({ title: 'chore: 1.2.3 Release', checks: [{ name: 'check-job', status: 'failure' }] }); + + await prepareBumped(store, deps({ waitForCi: true, skipTitles: ['Release$'] }), 'o/r#1'); + + expect(prepared).toEqual(['o/r#1']); + expect(store.get('o/r#1')!.status).toBe('prepared'); + }); + + it('leaves it queued while preparing is paused', async () => { + bumpedRow(); + const until = '2026-09-02T14:00:00.000Z'; + + await prepareBumped(store, deps({ pausedUntil: () => until }), 'o/r#1'); + + expect(prepared).toEqual([]); + const pr = store.get('o/r#1')!; + expect(pr.status).toBe('queued'); + expect(pr.statusReason).toBe(`waiting: preparing paused until ${localHhMm(until)}`); + // The bump is not spent: the pause lifts and the queue takes it first. + expect(pr.bumpedAt).not.toBeNull(); + }); + + it('does nothing when a preparation for it is already running', async () => { + bumpedRow(); + + await prepareBumped(store, deps({ inFlight: new Set(['o/r#1']) }), 'o/r#1'); + + expect(prepared).toEqual([]); + expect(store.get('o/r#1')!.status).toBe('queued'); + expect(store.get('o/r#1')!.bumpedAt).not.toBeNull(); + }); + + it('retires one merged since it was queued, and leaves a draft a draft', async () => { + bumpedRow({ state: 'MERGED' }); + + await prepareBumped(store, deps(), 'o/r#1'); + + expect(prepared).toEqual([]); + expect(store.get('o/r#1')!.status).toBe('done'); + expect(store.get('o/r#1')!.statusReason).toBe('merged'); + + bumpedRow({ number: 2, isDraft: true }); + await prepareBumped(store, deps(), 'o/r#2'); + expect(prepared).toEqual([]); + expect(store.get('o/r#2')!.status).toBe('draft'); + }); + + it('says so when there is no such row, or the forge cannot be read', async () => { + const logged: string[] = []; + + await prepareBumped(store, deps({ log: message => { logged.push(message); } }), 'o/r#9'); + expect(logged.some(line => line.includes('no such pull request'))).toBe(true); + + store.observe(snapshot(), true, 'now'); + await prepareBumped(store, deps({ log: message => { logged.push(message); } }), 'o/r#1'); + expect(logged.some(line => line.includes('could not read'))).toBe(true); + expect(prepared).toEqual([]); + }); +}); diff --git a/packages/cli/tests/inbox-worktree.test.ts b/packages/cli/tests/inbox-worktree.test.ts index 5953a53..a8d01e6 100644 --- a/packages/cli/tests/inbox-worktree.test.ts +++ b/packages/cli/tests/inbox-worktree.test.ts @@ -82,6 +82,39 @@ describe('prepareWorktree', () => { expect(git(dest, ['rev-parse', 'HEAD'])).toBe(head); }); + it('cuts two pull requests at once from one clone, each at its own head and base', async () => { + // A second pull request on a base branch of its own, whose name carries a slash. + git(upstream, ['checkout', '-q', '-b', 'release/1.x']); + writeFileSync(join(upstream, 'base.ts'), 'const base = 1;\n'); + git(upstream, ['add', '.']); + git(upstream, ['commit', '-m', 'the other base']); + const otherBase = git(upstream, ['rev-parse', 'HEAD']); + // The pull request's own commit on top of that base, off the branch, as a fork's push looks. + git(upstream, ['checkout', '-q', '--detach']); + writeFileSync(join(upstream, 'five.ts'), 'const five = 5;\n'); + git(upstream, ['add', '.']); + git(upstream, ['commit', '-m', 'the other change']); + git(upstream, ['update-ref', 'refs/pull/5/head', 'HEAD']); + const otherHead = git(upstream, ['rev-parse', 'HEAD']); + git(upstream, ['checkout', '-q', 'main']); + const main = git(upstream, ['rev-parse', 'main']); + const otherRef = { owner: 'o', repo: 'demo', number: 5 }; + const otherDest = join(root, 'worktrees', 'o-demo-5'); + + const [four, five] = await Promise.all([ + prepareWorktree(clone, dest, ref, 'main'), + prepareWorktree(clone, otherDest, otherRef, 'release/1.x'), + ]); + + // Neither fetch was read back off the other's: each cut is at its own head, against its own base. + expect(four).toEqual({ head, diffRef: main }); + expect(five).toEqual({ head: otherHead, diffRef: otherBase }); + expect(git(dest, ['rev-parse', 'HEAD'])).toBe(head); + expect(git(otherDest, ['rev-parse', 'HEAD'])).toBe(otherHead); + expect(existsSync(join(dest, 'five.ts'))).toBe(false); + expect(existsSync(join(otherDest, 'five.ts'))).toBe(true); + }); + describe('with a pinned head', () => { /** A second push on the pull request, leaving the head captured in `head` behind. */ function pushOnTop(): string { diff --git a/packages/git/package.json b/packages/git/package.json index d4a83a8..14175b5 100644 --- a/packages/git/package.json +++ b/packages/git/package.json @@ -1,6 +1,6 @@ { "name": "@diffity/git", - "version": "0.10.27", + "version": "0.10.28", "private": true, "type": "module", "main": "./dist/index.js", diff --git a/packages/github/package.json b/packages/github/package.json index ec81283..7df2da9 100644 --- a/packages/github/package.json +++ b/packages/github/package.json @@ -1,6 +1,6 @@ { "name": "@diffity/github", - "version": "0.10.27", + "version": "0.10.28", "private": true, "type": "module", "main": "./dist/index.js", diff --git a/packages/parser/package.json b/packages/parser/package.json index bf7b4d0..4f56a0f 100644 --- a/packages/parser/package.json +++ b/packages/parser/package.json @@ -1,6 +1,6 @@ { "name": "@diffity/parser", - "version": "0.10.27", + "version": "0.10.28", "private": true, "type": "module", "main": "./dist/index.js", diff --git a/packages/ui/package.json b/packages/ui/package.json index 2fdc889..b622c23 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@diffity/ui", - "version": "0.10.27", + "version": "0.10.28", "type": "module", "private": true, "scripts": {