Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
346 changes: 346 additions & 0 deletions packages/config/local/pr-trackers.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,346 @@
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import * as fs from "fs";
import * as os from "os";
import * as path from "path";
import { invalidateOdeConfigCache, ODE_CONFIG_FILE } from "./ode-store";
import type { GitHubRepo } from "@/utils/git-remote";
import {
clearPrTrackersForTests,
closePrTrackerDatabaseForTests,
DEFAULT_PR_AGENT_PROVIDER,
DEFAULT_PR_POLL_INTERVAL_SEC,
DEFAULT_PR_PROMPT_TEMPLATE,
deletePrTracker,
getPrTrackerById,
getPrTrackerSettings,
listDuePrTrackers,
listPrTrackerEvents,
listPrTrackers,
listProcessedEventIds,
markPrTrackerPolled,
recordPrTrackerEvent,
scanPrTrackers,
updatePrTracker,
updatePrTrackerSettings,
} from "./pr-trackers";

let tempDir: string;
let originalConfigEnv: string | undefined;
let originalConfigContent: string | null;
let originalConfigExisted: boolean;

function writeTestOdeConfig(channels: Array<{ id: string; name: string; cwd?: string }>): void {
const config = {
user: {},
workspaces: [
{
id: "ws-test",
name: "Test Workspace",
type: "slack",
channelDetails: channels.map((c) => ({
id: c.id,
name: c.name,
...(c.cwd ? { workingDirectory: c.cwd } : {}),
})),
},
],
};
fs.mkdirSync(path.dirname(ODE_CONFIG_FILE), { recursive: true });
fs.writeFileSync(ODE_CONFIG_FILE, JSON.stringify(config));
invalidateOdeConfigCache();
}

/** Make a deterministic probe that returns a fixed repo for a given cwd. */
function makeProbe(
mapping: Record<string, GitHubRepo | null>,
): (cwd: string) => GitHubRepo | null {
return (cwd: string) => {
if (cwd in mapping) return mapping[cwd] ?? null;
return null;
};
}

beforeEach(() => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "ode-pr-trackers-test-"));
originalConfigEnv = process.env.ODE_INBOX_DB_FILE;
process.env.ODE_INBOX_DB_FILE = path.join(tempDir, "inbox.db");

originalConfigExisted = fs.existsSync(ODE_CONFIG_FILE);
originalConfigContent = originalConfigExisted
? fs.readFileSync(ODE_CONFIG_FILE, "utf-8")
: null;

writeTestOdeConfig([
{ id: "C_DEV", name: "dev", cwd: "/tmp/repos/ode" },
{ id: "C_WEB", name: "web", cwd: "/tmp/repos/web-ui" },
{ id: "C_EMPTY", name: "empty" },
]);

closePrTrackerDatabaseForTests();
clearPrTrackersForTests();
});

afterEach(() => {
closePrTrackerDatabaseForTests();
if (originalConfigEnv === undefined) {
delete process.env.ODE_INBOX_DB_FILE;
} else {
process.env.ODE_INBOX_DB_FILE = originalConfigEnv;
}

try {
if (originalConfigExisted && originalConfigContent !== null) {
fs.writeFileSync(ODE_CONFIG_FILE, originalConfigContent);
} else {
fs.rmSync(ODE_CONFIG_FILE, { force: true });
}
} catch {
// Best effort.
}
invalidateOdeConfigCache();

try {
fs.rmSync(tempDir, { recursive: true, force: true });
} catch {
// Best effort.
}
});

describe("pr-trackers settings", () => {
test("returns defaults on first access", () => {
const settings = getPrTrackerSettings();
expect(settings.defaultPollIntervalSec).toBe(DEFAULT_PR_POLL_INTERVAL_SEC);
expect(settings.defaultAgentProvider).toBe(DEFAULT_PR_AGENT_PROVIDER);
expect(settings.defaultPromptTemplate).toBe(DEFAULT_PR_PROMPT_TEMPLATE);
expect(settings.defaultGithubToken).toBe("");
});

test("updates persist", () => {
updatePrTrackerSettings({
defaultPollIntervalSec: 600,
defaultAgentProvider: "claudecode",
defaultGithubToken: " ghp_abc ",
});
const settings = getPrTrackerSettings();
expect(settings.defaultPollIntervalSec).toBe(600);
expect(settings.defaultAgentProvider).toBe("claudecode");
expect(settings.defaultGithubToken).toBe("ghp_abc");
});

test("rejects too-short poll interval", () => {
expect(() => updatePrTrackerSettings({ defaultPollIntervalSec: 30 })).toThrow();
});

test("rejects invalid agent provider", () => {
expect(() =>
updatePrTrackerSettings({ defaultAgentProvider: "bogus" })
).toThrow(/Unsupported agent/);
});
});

describe("pr-trackers scan", () => {
test("inserts rows for channels with GitHub remotes", () => {
const probe = makeProbe({
"/tmp/repos/ode": { host: "github.com", owner: "anomalyco", repo: "ode" },
"/tmp/repos/web-ui": { host: "github.com", owner: "anomalyco", repo: "web-ui" },
});
const result = scanPrTrackers(probe);
expect(result.inserted).toBe(2);
expect(result.scanned).toBe(2); // C_EMPTY has no cwd → not scanned

const trackers = listPrTrackers();
expect(trackers).toHaveLength(2);
expect(trackers.every((t) => !t.enabled)).toBe(true);
expect(trackers.every((t) => t.missingSince === null)).toBe(true);
expect(trackers.map((t) => t.repoName).sort()).toEqual(["ode", "web-ui"]);
});

test("skips channels whose cwd has no GitHub remote", () => {
const probe = makeProbe({
"/tmp/repos/ode": { host: "github.com", owner: "anomalyco", repo: "ode" },
"/tmp/repos/web-ui": null,
});
scanPrTrackers(probe);
const trackers = listPrTrackers();
expect(trackers).toHaveLength(1);
expect(trackers[0]!.repoName).toBe("ode");
});

test("idempotent: rescanning doesn't create duplicates", () => {
const probe = makeProbe({
"/tmp/repos/ode": { host: "github.com", owner: "anomalyco", repo: "ode" },
});
scanPrTrackers(probe);
const first = listPrTrackers();
scanPrTrackers(probe);
const second = listPrTrackers();
expect(second.map((t) => t.id)).toEqual(first.map((t) => t.id));
});

test("marks trackers missing when remote disappears", () => {
const probe = makeProbe({
"/tmp/repos/ode": { host: "github.com", owner: "anomalyco", repo: "ode" },
});
scanPrTrackers(probe);
expect(listPrTrackers()[0]!.missingSince).toBeNull();

const empty = makeProbe({});
const result = scanPrTrackers(empty);
expect(result.markedMissing).toBe(1);
expect(listPrTrackers()[0]!.missingSince).not.toBeNull();
});

test("reactivates a previously-missing tracker when remote comes back", () => {
const probe = makeProbe({
"/tmp/repos/ode": { host: "github.com", owner: "anomalyco", repo: "ode" },
});
scanPrTrackers(probe);
scanPrTrackers(makeProbe({}));
expect(listPrTrackers()[0]!.missingSince).not.toBeNull();

const result = scanPrTrackers(probe);
expect(result.reactivated).toBe(1);
expect(listPrTrackers()[0]!.missingSince).toBeNull();
});
});

describe("pr-trackers enable/update", () => {
test("enabling without a target channel throws", () => {
scanPrTrackers(
makeProbe({ "/tmp/repos/ode": { host: "github.com", owner: "anomalyco", repo: "ode" } })
);
const tracker = listPrTrackers()[0]!;
expect(() => updatePrTracker(tracker.id, { enabled: true })).toThrow(
/target channel/
);
});

test("first enable snaps last_polled_at to now so we don't backfill", () => {
scanPrTrackers(
makeProbe({ "/tmp/repos/ode": { host: "github.com", owner: "anomalyco", repo: "ode" } })
);
const tracker = listPrTrackers()[0]!;
expect(tracker.lastPolledAt).toBeNull();

const before = Date.now();
const updated = updatePrTracker(tracker.id, {
enabled: true,
targetChannelId: "C_WEB",
});
const after = Date.now();

expect(updated.enabled).toBe(true);
expect(updated.targetChannelId).toBe("C_WEB");
expect(updated.lastPolledAt).not.toBeNull();
expect(updated.lastPolledAt!).toBeGreaterThanOrEqual(before);
expect(updated.lastPolledAt!).toBeLessThanOrEqual(after);
});

test("disabling then re-enabling preserves the poll cursor", () => {
scanPrTrackers(
makeProbe({ "/tmp/repos/ode": { host: "github.com", owner: "anomalyco", repo: "ode" } })
);
const id = listPrTrackers()[0]!.id;
const enabled = updatePrTracker(id, { enabled: true, targetChannelId: "C_DEV" });
const originalCursor = enabled.lastPolledAt!;

updatePrTracker(id, { enabled: false });
// Simulate time passing + a successful poll.
markPrTrackerPolled(id, { success: true, pollCompletedAt: originalCursor + 10_000 });
const afterPoll = getPrTrackerById(id)!;
expect(afterPoll.lastPolledAt).toBe(originalCursor + 10_000);

const reEnabled = updatePrTracker(id, { enabled: true, targetChannelId: "C_DEV" });
expect(reEnabled.lastPolledAt).toBe(originalCursor + 10_000);
});

test("cannot enable a tracker whose source repo is missing", () => {
scanPrTrackers(
makeProbe({ "/tmp/repos/ode": { host: "github.com", owner: "anomalyco", repo: "ode" } })
);
scanPrTrackers(makeProbe({}));
const id = listPrTrackers()[0]!.id;
expect(() =>
updatePrTracker(id, { enabled: true, targetChannelId: "C_DEV" })
).toThrow(/missing/);
});

test("deletePrTracker removes the row and its events", () => {
scanPrTrackers(
makeProbe({ "/tmp/repos/ode": { host: "github.com", owner: "anomalyco", repo: "ode" } })
);
const id = listPrTrackers()[0]!.id;
recordPrTrackerEvent({
trackerId: id,
prNumber: 1,
eventType: "comment",
githubEventId: "c1",
});
deletePrTracker(id);
expect(listPrTrackers()).toHaveLength(0);
expect(listPrTrackerEvents(id)).toHaveLength(0);
});
});

describe("pr-trackers due selection", () => {
test("enabled trackers with stale cursor are due", () => {
scanPrTrackers(
makeProbe({ "/tmp/repos/ode": { host: "github.com", owner: "anomalyco", repo: "ode" } })
);
const id = listPrTrackers()[0]!.id;
updatePrTracker(id, { enabled: true, targetChannelId: "C_DEV", pollIntervalSec: 60 });
// Simulate a very old last-poll timestamp.
markPrTrackerPolled(id, { success: true, pollCompletedAt: 0 });
const due = listDuePrTrackers(Date.now());
expect(due.map((t) => t.id)).toContain(id);
});

test("recently polled trackers are not due", () => {
scanPrTrackers(
makeProbe({ "/tmp/repos/ode": { host: "github.com", owner: "anomalyco", repo: "ode" } })
);
const id = listPrTrackers()[0]!.id;
updatePrTracker(id, { enabled: true, targetChannelId: "C_DEV", pollIntervalSec: 1800 });
markPrTrackerPolled(id, { success: true });
expect(listDuePrTrackers(Date.now())).toHaveLength(0);
});

test("disabled trackers are never due", () => {
scanPrTrackers(
makeProbe({ "/tmp/repos/ode": { host: "github.com", owner: "anomalyco", repo: "ode" } })
);
expect(listDuePrTrackers(Date.now())).toHaveLength(0);
});
});

describe("pr-trackers events", () => {
test("recordPrTrackerEvent dedupes on (tracker, type, event_id)", () => {
scanPrTrackers(
makeProbe({ "/tmp/repos/ode": { host: "github.com", owner: "anomalyco", repo: "ode" } })
);
const id = listPrTrackers()[0]!.id;

expect(
recordPrTrackerEvent({ trackerId: id, prNumber: 1, eventType: "comment", githubEventId: "c1" })
).toBe(true);
expect(
recordPrTrackerEvent({ trackerId: id, prNumber: 1, eventType: "comment", githubEventId: "c1" })
).toBe(false);
// Different type, same id → new row.
expect(
recordPrTrackerEvent({ trackerId: id, prNumber: 1, eventType: "review", githubEventId: "c1" })
).toBe(true);
});

test("listProcessedEventIds finds only previously-recorded ids", () => {
scanPrTrackers(
makeProbe({ "/tmp/repos/ode": { host: "github.com", owner: "anomalyco", repo: "ode" } })
);
const id = listPrTrackers()[0]!.id;
recordPrTrackerEvent({ trackerId: id, prNumber: 1, eventType: "comment", githubEventId: "c1" });
recordPrTrackerEvent({ trackerId: id, prNumber: 1, eventType: "comment", githubEventId: "c2" });

const seen = listProcessedEventIds(id, "comment", ["c1", "c2", "c3"]);
expect(Array.from(seen).sort()).toEqual(["c1", "c2"]);
});
});
Loading
Loading