|
|
|
@@ -42,6 +42,7 @@ const ROLES_DEFAULT = ["review", "blitz"];
|
|
|
|
|
type Config = {
|
|
|
|
|
pollSeconds?: number;
|
|
|
|
|
reconcileSeconds?: number;
|
|
|
|
|
hintCooldownSeconds?: number; // quiet period per PR and role between hints
|
|
|
|
|
maxSessionsPerTick?: number;
|
|
|
|
|
reviewProfile?: string;
|
|
|
|
|
reviewTool?: string;
|
|
|
|
@@ -97,6 +98,17 @@ const dirty = new Set<string>();
|
|
|
|
|
const noPulls = new Set<string>();
|
|
|
|
|
let firstRun = false;
|
|
|
|
|
|
|
|
|
|
// The snapshot advances on the tick that diffed it, so a reason not sent
|
|
|
|
|
// immediately can never be recomputed. Held per PR and role until it goes out.
|
|
|
|
|
const pending = new Map<string, Set<string>>();
|
|
|
|
|
const hintedAt = new Map<string, number>();
|
|
|
|
|
const lastHint = new Map<string, string>();
|
|
|
|
|
const HINT_COOLDOWN_MS = (config.hintCooldownSeconds ?? 300) * 1000;
|
|
|
|
|
|
|
|
|
|
// Canonical order, so a coalesced hint reads the same however it accumulated.
|
|
|
|
|
// That is what makes the duplicate check below meaningful.
|
|
|
|
|
const REASON_ORDER = ["ci", "state", "conflicts", "comments"];
|
|
|
|
|
|
|
|
|
|
const log = (...args: unknown[]) => console.log(new Date().toISOString(), ...args);
|
|
|
|
|
|
|
|
|
|
// A webhook has to cut the wait short, or its only effect would be to mark a
|
|
|
|
@@ -321,6 +333,14 @@ async function seenIds(worktree: string, n: number): Promise<Set<string> | null>
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// True when the session's own worktree already holds the PR head, which means
|
|
|
|
|
// the session pushed it and does not need waking to hear about its own commit.
|
|
|
|
|
function pushedLocally(worktree: string, sha: string): boolean {
|
|
|
|
|
if (!sha) return false;
|
|
|
|
|
const proc = Bun.spawnSync(["git", "-C", worktree, "rev-parse", "HEAD"]);
|
|
|
|
|
return proc.exitCode === 0 && proc.stdout.toString().trim() === sha;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Ids of everything commented after `since`. null means the fetch failed.
|
|
|
|
|
async function newCommentIds(pr: Pr, since: string): Promise<string[] | null> {
|
|
|
|
|
try {
|
|
|
|
@@ -408,6 +428,30 @@ const samePath = (a?: string, b?: string) =>
|
|
|
|
|
|
|
|
|
|
type Session = { id: string; title: string; path: string; profile: string; branch: string; mainRepo: string; tool: string };
|
|
|
|
|
|
|
|
|
|
function gitLine(path: string, args: string[]): string {
|
|
|
|
|
if (!path) return "";
|
|
|
|
|
const proc = Bun.spawnSync(["git", "-C", path, ...args]);
|
|
|
|
|
return proc.exitCode === 0 ? proc.stdout.toString().trim() : "";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// aoe reports worktree.branch as the worktree *name* for worktrees it created
|
|
|
|
|
// itself, and only as the git branch for ones it merely attached to. A session
|
|
|
|
|
// you started by hand in a worktree named after something other than its
|
|
|
|
|
// branch therefore never matched its own PR, and the daemon opened a second
|
|
|
|
|
// session on the same directory. Ask git instead; the field is the fallback.
|
|
|
|
|
function branchAt(path: string): string {
|
|
|
|
|
const branch = gitLine(path, ["rev-parse", "--abbrev-ref", "HEAD"]);
|
|
|
|
|
return branch === "HEAD" ? "" : branch; // detached: no branch to route on
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Same reason as branchAt: aoe only fills main_repo_path for worktrees it
|
|
|
|
|
// knows about, so a session started by hand carried no repo and matched no PR.
|
|
|
|
|
// The common dir is the main clone's .git for every worktree of it.
|
|
|
|
|
function repoAt(path: string): string {
|
|
|
|
|
const dir = gitLine(path, ["rev-parse", "--path-format=absolute", "--git-common-dir"]);
|
|
|
|
|
return dir.replace(/\/\.git\/?$/, "");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function listSessions(): Promise<Session[]> {
|
|
|
|
|
const rows = JSON.parse(await aoe(["list", "--json", "--all"]));
|
|
|
|
|
return rows.map((r: any) => ({
|
|
|
|
@@ -415,8 +459,8 @@ async function listSessions(): Promise<Session[]> {
|
|
|
|
|
title: r.title,
|
|
|
|
|
path: r.path ?? "",
|
|
|
|
|
profile: r.profile ?? "default",
|
|
|
|
|
branch: r.worktree?.branch ?? "",
|
|
|
|
|
mainRepo: r.worktree?.main_repo_path ?? "",
|
|
|
|
|
branch: branchAt(r.path ?? "") || r.worktree?.branch || "",
|
|
|
|
|
mainRepo: r.worktree?.main_repo_path || repoAt(r.path ?? ""),
|
|
|
|
|
tool: r.tool ?? "",
|
|
|
|
|
}));
|
|
|
|
|
}
|
|
|
|
@@ -814,6 +858,16 @@ async function evaluate(prs: Pr[], mentioned: Set<string>, budget: { sessions: n
|
|
|
|
|
log(`${session.title} is waiting on input (${full.key})`);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Conflicts are the author's to resolve on their own branch, so the
|
|
|
|
|
// reviewer never hears about them. Comments it does hear: a reply to a
|
|
|
|
|
// finding is addressed to the reviewer, and an addressed thread is the
|
|
|
|
|
// reviewer's to resolve (review-pr §3.1).
|
|
|
|
|
// Banked before anything can skip out of this iteration.
|
|
|
|
|
const pkey = `${full.key}:${role}`;
|
|
|
|
|
const banked = pending.get(pkey) ?? new Set<string>();
|
|
|
|
|
for (const r of role === "land" ? why : why.filter((w) => w !== "conflicts")) banked.add(r);
|
|
|
|
|
if (banked.size) pending.set(pkey, banked);
|
|
|
|
|
|
|
|
|
|
// A send into a busy pane can be swallowed. Since hints are idempotent,
|
|
|
|
|
// holding it costs one cycle and nothing else.
|
|
|
|
|
if (st !== "idle") {
|
|
|
|
@@ -825,25 +879,53 @@ async function evaluate(prs: Pr[], mentioned: Set<string>, budget: { sessions: n
|
|
|
|
|
if (known && !known.prompted) {
|
|
|
|
|
await send(known.profile, session.id, opening(full, skill));
|
|
|
|
|
known.prompted = true;
|
|
|
|
|
pending.delete(pkey); // the opening sends it to read the PR whole
|
|
|
|
|
hintedAt.set(pkey, Date.now());
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Conflicts are the author's to resolve on their own branch, so the
|
|
|
|
|
// reviewer never hears about them. Comments it does hear: a reply to a
|
|
|
|
|
// finding is addressed to the reviewer, and an addressed thread is the
|
|
|
|
|
// reviewer's to resolve (review-pr §3.1).
|
|
|
|
|
let mine = role === "land" ? why : why.filter((w) => w !== "conflicts");
|
|
|
|
|
if (mine.includes("comments") && prev) {
|
|
|
|
|
const acc = pending.get(pkey);
|
|
|
|
|
if (!acc?.size) continue; // label, assignee, edited title: nothing to act on
|
|
|
|
|
|
|
|
|
|
if (acc.has("comments") && prev) {
|
|
|
|
|
const ids = await newCommentIds(full, prev.updatedAt);
|
|
|
|
|
const seen = session.path ? await seenIds(session.path, full.number) : null;
|
|
|
|
|
if (ids?.length && seen && ids.every((id) => seen.has(id))) {
|
|
|
|
|
mine = mine.filter((w) => w !== "comments");
|
|
|
|
|
acc.delete("comments");
|
|
|
|
|
log(`comments on ${full.key} already in ${session.title}'s seen file, hint dropped`);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if (!mine.length) continue; // label, assignee, edited title: nothing to act on
|
|
|
|
|
// The land session pushed the commit CI is running on, so the run is no
|
|
|
|
|
// news to it. A reviewer still hears about it: the head moved under them.
|
|
|
|
|
if (acc.has("ci") && role === "land" && session.path && pushedLocally(session.path, full.headSha)) {
|
|
|
|
|
acc.delete("ci");
|
|
|
|
|
log(`ci on ${full.key} is ${session.title}'s own push, hint dropped`);
|
|
|
|
|
}
|
|
|
|
|
if (!acc.size) {
|
|
|
|
|
pending.delete(pkey);
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const wait = HINT_COOLDOWN_MS - (Date.now() - (hintedAt.get(pkey) ?? 0));
|
|
|
|
|
if (wait > 0) {
|
|
|
|
|
dirty.add(full.key);
|
|
|
|
|
setTimeout(wake, wait + 1000);
|
|
|
|
|
log(`cooling ${full.key} (${[...acc].join(",")}): ${Math.round(wait / 1000)}s left`);
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const mine = REASON_ORDER.filter((r) => acc.has(r));
|
|
|
|
|
const message = hint(full, mine, skill);
|
|
|
|
|
if (lastHint.get(pkey) === message) {
|
|
|
|
|
pending.delete(pkey);
|
|
|
|
|
log(`hint ${full.key} repeats the last one verbatim, dropped`);
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
try {
|
|
|
|
|
await send(session.profile, session.id, hint(full, mine, skill));
|
|
|
|
|
await send(session.profile, session.id, message);
|
|
|
|
|
pending.delete(pkey);
|
|
|
|
|
hintedAt.set(pkey, Date.now());
|
|
|
|
|
lastHint.set(pkey, message);
|
|
|
|
|
log(`hint ${full.key} reason=${mine.join(",")} -> ${session.title}`);
|
|
|
|
|
} catch (e) {
|
|
|
|
|
dirty.add(full.key);
|
|
|
|
|