diff --git a/package.json b/package.json index 7f76457a..1ce39057 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ode", - "version": "0.1.34", + "version": "0.1.35", "description": "Coding anywhere with your coding agents connected", "module": "packages/core/index.ts", "type": "module", diff --git a/packages/config/local/pr-trackers.test.ts b/packages/config/local/pr-trackers.test.ts new file mode 100644 index 00000000..9cbf2b98 --- /dev/null +++ b/packages/config/local/pr-trackers.test.ts @@ -0,0 +1,390 @@ +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, + setPrTrackerCursor, + 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, +): (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"]); + }); +}); + +describe("pr-trackers poll cursor semantics", () => { + test("markPrTrackerPolled(success) advances last_polled_at", () => { + scanPrTrackers( + makeProbe({ "/tmp/repos/ode": { host: "github.com", owner: "anomalyco", repo: "ode" } }) + ); + const id = listPrTrackers()[0]!.id; + const before = Date.now(); + markPrTrackerPolled(id, { success: true }); + const after = Date.now(); + const tracker = getPrTrackerById(id)!; + expect(tracker.lastPolledAt!).toBeGreaterThanOrEqual(before); + expect(tracker.lastPolledAt!).toBeLessThanOrEqual(after); + expect(tracker.lastSuccessAt!).toBeGreaterThanOrEqual(before); + expect(tracker.lastError).toBeNull(); + }); + + test("markPrTrackerPolled(failure) leaves last_polled_at untouched", () => { + scanPrTrackers( + makeProbe({ "/tmp/repos/ode": { host: "github.com", owner: "anomalyco", repo: "ode" } }) + ); + const id = listPrTrackers()[0]!.id; + // Seed an initial success so we can observe that the next failure + // doesn't move the cursor. + markPrTrackerPolled(id, { success: true, pollCompletedAt: 1_000_000 }); + const anchor = getPrTrackerById(id)!.lastPolledAt; + expect(anchor).toBe(1_000_000); + + markPrTrackerPolled(id, { success: false, errorMessage: "boom" }); + const after = getPrTrackerById(id)!; + expect(after.lastPolledAt).toBe(1_000_000); // unchanged + expect(after.lastError).toBe("boom"); + }); + + test("setPrTrackerCursor writes the cursor verbatim", () => { + scanPrTrackers( + makeProbe({ "/tmp/repos/ode": { host: "github.com", owner: "anomalyco", repo: "ode" } }) + ); + const id = listPrTrackers()[0]!.id; + setPrTrackerCursor(id, 42_000); + expect(getPrTrackerById(id)!.lastPolledAt).toBe(42_000); + }); +}); diff --git a/packages/config/local/pr-trackers.ts b/packages/config/local/pr-trackers.ts new file mode 100644 index 00000000..ded38907 --- /dev/null +++ b/packages/config/local/pr-trackers.ts @@ -0,0 +1,998 @@ +import { Database } from "bun:sqlite"; +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; +import { AGENT_PROVIDERS, isAgentProviderId } from "@/shared/agent-provider"; +import { getGitHubRepoFromCwd, type GitHubRepo } from "@/utils/git-remote"; +import { loadOdeConfig } from "./ode-store"; + +// --------------------------------------------------------------------------- +// PR Tracker storage. +// +// A "PR tracker" watches one GitHub repo (derived from a channel's +// workingDirectory) and, when enabled, lets the scheduler periodically poll +// GitHub for new PR activity, aggregate events per PR, and dispatch an +// agent run that posts back to a configured target channel. +// +// Three tables share the same SQLite DB as tasks/cron (`~/.config/ode/inbox.db`): +// +// pr_trackers - one row per (source channel, github repo) +// pr_tracker_events - per-PR-event audit + dedupe log +// pr_tracker_settings - single-row global defaults (id = 1) +// --------------------------------------------------------------------------- + +export type PrTrackerPlatform = "slack" | "discord" | "lark"; + +export type PrTrackerRecord = { + id: string; + /** Source channel: where the working directory comes from. */ + sourceWorkspaceId: string; + sourceWorkspaceName: string | null; + sourceChannelId: string; + sourceChannelName: string | null; + sourcePlatform: PrTrackerPlatform; + workingDirectory: string; + repoOwner: string; + repoName: string; + repoHost: string; + enabled: boolean; + /** Null = use global default. */ + agentProvider: string | null; + /** Null = use global default. */ + promptTemplate: string | null; + /** Null = use global default. */ + pollIntervalSec: number | null; + /** Null = use global default token / gh CLI fallback. */ + githubToken: string | null; + /** Where the agent result is posted. Required to be set before enable. */ + targetWorkspaceId: string | null; + targetChannelId: string | null; + targetChannelName: string | null; + targetPlatform: PrTrackerPlatform | null; + /** + * Cursor: last successful poll completion. The next poll asks GitHub for + * comments/reviews `since` this timestamp. Set to `now` when the tracker + * is first enabled (we don't backfill history). + */ + lastPolledAt: number | null; + lastSuccessAt: number | null; + lastError: string | null; + /** + * When non-null, the most recent rescan didn't find this repo at the + * source channel's working directory anymore (cwd missing or remote + * changed). UI greys the row out; user can delete it. + */ + missingSince: number | null; + createdAt: number; + updatedAt: number; +}; + +export type PrTrackerEventStatus = "pending" | "running" | "success" | "failed"; + +export type PrTrackerEventRecord = { + id: string; + trackerId: string; + prNumber: number; + /** "comment" | "review" | "review_comment" | "push" | "state_change" | "aggregate" */ + eventType: string; + /** GitHub object id (comment id / review id / event id). */ + githubEventId: string; + prUpdatedAt: number | null; + agentSessionId: string | null; + agentStatus: PrTrackerEventStatus; + errorMessage: string | null; + createdAt: number; +}; + +export type PrTrackerSettings = { + defaultPollIntervalSec: number; + defaultAgentProvider: string; + defaultPromptTemplate: string; + /** Optional global GitHub token. Empty string = fall back to `gh auth token`. */ + defaultGithubToken: string; + updatedAt: number; +}; + +export const DEFAULT_PR_POLL_INTERVAL_SEC = 30 * 60; // 30 minutes +export const DEFAULT_PR_AGENT_PROVIDER = "opencode"; +export const DEFAULT_PR_PROMPT_TEMPLATE = `A pull request in {{repo_full_name}} has new activity: +PR #{{pr_number}}: {{pr_title}} by {{pr_author}} +URL: {{pr_url}} + +New events since last check: +{{new_events_summary}} + +Please review these updates, decide if action is needed (reply, assign, flag), and take the next step.`; + +// --------------------------------------------------------------------------- +// Database boilerplate (mirrors tasks.ts / cron-jobs.ts patterns). +// --------------------------------------------------------------------------- + +const existsSync = fs.existsSync; +const mkdirSync = fs.mkdirSync; +const join = typeof path.join === "function" ? path.join : (...parts: string[]) => parts.join("/"); +const homedir = typeof os.homedir === "function" ? os.homedir : () => ""; + +const ODE_CONFIG_DIR = join(homedir(), ".config", "ode"); +const DEFAULT_DB_FILE = join(ODE_CONFIG_DIR, "inbox.db"); + +let cachedDatabase: { path: string; db: Database } | null = null; + +function resolveDbFile(): string { + const override = process.env.ODE_INBOX_DB_FILE?.trim(); + return override && override.length > 0 ? override : DEFAULT_DB_FILE; +} + +function ensureParentDir(filePath: string): void { + const dir = path.dirname(filePath); + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }); + } +} + +function initializeDatabase(db: Database): void { + db.exec("PRAGMA journal_mode = WAL;"); + db.exec("PRAGMA busy_timeout = 5000;"); + + db.exec(` + CREATE TABLE IF NOT EXISTS pr_trackers ( + id TEXT PRIMARY KEY, + source_workspace_id TEXT NOT NULL, + source_workspace_name TEXT, + source_channel_id TEXT NOT NULL, + source_channel_name TEXT, + source_platform TEXT NOT NULL, + working_directory TEXT NOT NULL, + repo_owner TEXT NOT NULL, + repo_name TEXT NOT NULL, + repo_host TEXT NOT NULL DEFAULT 'github.com', + enabled INTEGER NOT NULL DEFAULT 0, + agent_provider TEXT, + prompt_template TEXT, + poll_interval_sec INTEGER, + github_token TEXT, + target_workspace_id TEXT, + target_channel_id TEXT, + target_channel_name TEXT, + target_platform TEXT, + last_polled_at INTEGER, + last_success_at INTEGER, + last_error TEXT, + missing_since INTEGER, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + UNIQUE(source_workspace_id, source_channel_id, repo_host, repo_owner, repo_name) + ); + `); + db.exec( + "CREATE INDEX IF NOT EXISTS idx_pr_trackers_enabled_polled ON pr_trackers(enabled, last_polled_at);" + ); + db.exec( + "CREATE INDEX IF NOT EXISTS idx_pr_trackers_source_channel ON pr_trackers(source_workspace_id, source_channel_id);" + ); + + db.exec(` + CREATE TABLE IF NOT EXISTS pr_tracker_events ( + id TEXT PRIMARY KEY, + tracker_id TEXT NOT NULL, + pr_number INTEGER NOT NULL, + event_type TEXT NOT NULL, + github_event_id TEXT NOT NULL, + pr_updated_at INTEGER, + agent_session_id TEXT, + agent_status TEXT NOT NULL DEFAULT 'pending', + error_message TEXT, + created_at INTEGER NOT NULL, + UNIQUE(tracker_id, event_type, github_event_id) + ); + `); + db.exec( + "CREATE INDEX IF NOT EXISTS idx_pr_tracker_events_tracker ON pr_tracker_events(tracker_id, created_at DESC);" + ); + db.exec( + "CREATE INDEX IF NOT EXISTS idx_pr_tracker_events_pr ON pr_tracker_events(tracker_id, pr_number, created_at DESC);" + ); + + db.exec(` + CREATE TABLE IF NOT EXISTS pr_tracker_settings ( + id INTEGER PRIMARY KEY CHECK (id = 1), + default_poll_interval_sec INTEGER NOT NULL, + default_agent_provider TEXT NOT NULL, + default_prompt_template TEXT NOT NULL, + default_github_token TEXT NOT NULL DEFAULT '', + updated_at INTEGER NOT NULL + ); + `); +} + +function getDatabase(): Database { + const filePath = resolveDbFile(); + if (cachedDatabase?.path === filePath) { + return cachedDatabase.db; + } + + if (cachedDatabase) { + try { + cachedDatabase.db.close(); + } catch { + // Ignore close errors on path switch. + } + } + + ensureParentDir(filePath); + const db = new Database(filePath); + initializeDatabase(db); + cachedDatabase = { path: filePath, db }; + return db; +} + +// --------------------------------------------------------------------------- +// Row mappers. +// --------------------------------------------------------------------------- + +type PrTrackerRow = { + id: string; + source_workspace_id: string; + source_workspace_name: string | null; + source_channel_id: string; + source_channel_name: string | null; + source_platform: PrTrackerPlatform; + working_directory: string; + repo_owner: string; + repo_name: string; + repo_host: string; + enabled: number; + agent_provider: string | null; + prompt_template: string | null; + poll_interval_sec: number | null; + github_token: string | null; + target_workspace_id: string | null; + target_channel_id: string | null; + target_channel_name: string | null; + target_platform: PrTrackerPlatform | null; + last_polled_at: number | null; + last_success_at: number | null; + last_error: string | null; + missing_since: number | null; + created_at: number; + updated_at: number; +}; + +function mapTrackerRow(row: PrTrackerRow): PrTrackerRecord { + return { + id: row.id, + sourceWorkspaceId: row.source_workspace_id, + sourceWorkspaceName: row.source_workspace_name, + sourceChannelId: row.source_channel_id, + sourceChannelName: row.source_channel_name, + sourcePlatform: row.source_platform, + workingDirectory: row.working_directory, + repoOwner: row.repo_owner, + repoName: row.repo_name, + repoHost: row.repo_host, + enabled: row.enabled === 1, + agentProvider: row.agent_provider, + promptTemplate: row.prompt_template, + pollIntervalSec: row.poll_interval_sec, + githubToken: row.github_token, + targetWorkspaceId: row.target_workspace_id, + targetChannelId: row.target_channel_id, + targetChannelName: row.target_channel_name, + targetPlatform: row.target_platform, + lastPolledAt: row.last_polled_at, + lastSuccessAt: row.last_success_at, + lastError: row.last_error, + missingSince: row.missing_since, + createdAt: row.created_at, + updatedAt: row.updated_at, + }; +} + +type PrTrackerEventRow = { + id: string; + tracker_id: string; + pr_number: number; + event_type: string; + github_event_id: string; + pr_updated_at: number | null; + agent_session_id: string | null; + agent_status: PrTrackerEventStatus; + error_message: string | null; + created_at: number; +}; + +function mapEventRow(row: PrTrackerEventRow): PrTrackerEventRecord { + return { + id: row.id, + trackerId: row.tracker_id, + prNumber: row.pr_number, + eventType: row.event_type, + githubEventId: row.github_event_id, + prUpdatedAt: row.pr_updated_at, + agentSessionId: row.agent_session_id, + agentStatus: row.agent_status, + errorMessage: row.error_message, + createdAt: row.created_at, + }; +} + +// --------------------------------------------------------------------------- +// Settings (single row, id=1). +// --------------------------------------------------------------------------- + +function ensureSettingsRow(db: Database): void { + const existing = db.query("SELECT id FROM pr_tracker_settings WHERE id = 1").get(); + if (existing) return; + const now = Date.now(); + db.query(` + INSERT INTO pr_tracker_settings ( + id, + default_poll_interval_sec, + default_agent_provider, + default_prompt_template, + default_github_token, + updated_at + ) VALUES (1, ?, ?, ?, '', ?) + `).run( + DEFAULT_PR_POLL_INTERVAL_SEC, + DEFAULT_PR_AGENT_PROVIDER, + DEFAULT_PR_PROMPT_TEMPLATE, + now + ); +} + +export function getPrTrackerSettings(): PrTrackerSettings { + const db = getDatabase(); + ensureSettingsRow(db); + const row = db + .query(` + SELECT + default_poll_interval_sec, + default_agent_provider, + default_prompt_template, + default_github_token, + updated_at + FROM pr_tracker_settings + WHERE id = 1 + `) + .get() as { + default_poll_interval_sec: number; + default_agent_provider: string; + default_prompt_template: string; + default_github_token: string; + updated_at: number; + }; + return { + defaultPollIntervalSec: row.default_poll_interval_sec, + defaultAgentProvider: row.default_agent_provider, + defaultPromptTemplate: row.default_prompt_template, + defaultGithubToken: row.default_github_token, + updatedAt: row.updated_at, + }; +} + +export type UpdatePrTrackerSettingsParams = Partial<{ + defaultPollIntervalSec: number; + defaultAgentProvider: string; + defaultPromptTemplate: string; + defaultGithubToken: string; +}>; + +export function updatePrTrackerSettings( + params: UpdatePrTrackerSettingsParams +): PrTrackerSettings { + const db = getDatabase(); + ensureSettingsRow(db); + const current = getPrTrackerSettings(); + + const pollInterval = + params.defaultPollIntervalSec !== undefined + ? normalizePollInterval(params.defaultPollIntervalSec) + : current.defaultPollIntervalSec; + const agentProvider = + params.defaultAgentProvider !== undefined + ? normalizeRequiredAgent(params.defaultAgentProvider) + : current.defaultAgentProvider; + const promptTemplate = + params.defaultPromptTemplate !== undefined + ? normalizeRequiredText(params.defaultPromptTemplate, "promptTemplate") + : current.defaultPromptTemplate; + const githubToken = + params.defaultGithubToken !== undefined + ? params.defaultGithubToken.trim() + : current.defaultGithubToken; + + const now = Date.now(); + db.query(` + UPDATE pr_tracker_settings + SET + default_poll_interval_sec = ?, + default_agent_provider = ?, + default_prompt_template = ?, + default_github_token = ?, + updated_at = ? + WHERE id = 1 + `).run(pollInterval, agentProvider, promptTemplate, githubToken, now); + + return getPrTrackerSettings(); +} + +// --------------------------------------------------------------------------- +// Validation helpers. +// --------------------------------------------------------------------------- + +function normalizePollInterval(value: number): number { + if (!Number.isFinite(value) || value < 60) { + throw new Error("Poll interval must be at least 60 seconds"); + } + return Math.floor(value); +} + +function normalizeOptionalAgent(value: string | null | undefined): string | null { + if (value === null || value === undefined) return null; + const trimmed = value.trim().toLowerCase(); + if (trimmed.length === 0) return null; + if (!isAgentProviderId(trimmed)) { + throw new Error( + `Unsupported agent "${value}". Expected one of: ${AGENT_PROVIDERS.join(", ")}` + ); + } + return trimmed; +} + +function normalizeRequiredAgent(value: string): string { + const out = normalizeOptionalAgent(value); + if (!out) throw new Error("agentProvider is required"); + return out; +} + +function normalizeRequiredText(value: string, field: string): string { + const trimmed = value.trim(); + if (!trimmed) throw new Error(`${field} cannot be empty`); + return trimmed; +} + +function resolveConfigChannelId(channelId: string): string { + const trimmed = channelId.trim(); + if (!trimmed) return trimmed; + const delimiter = "::"; + const index = trimmed.lastIndexOf(delimiter); + if (index < 0) return trimmed; + const raw = trimmed.slice(index + delimiter.length).trim(); + return raw || trimmed; +} + +type ChannelSnapshot = { + platform: PrTrackerPlatform; + workspaceId: string; + workspaceName: string; + channelId: string; + channelName: string; + workingDirectory: string; +}; + +function getChannelSnapshot(channelId: string): ChannelSnapshot { + const resolved = resolveConfigChannelId(channelId); + const config = loadOdeConfig(); + for (const workspace of config.workspaces) { + const channel = workspace.channelDetails.find((item) => item.id === resolved); + if (!channel) continue; + return { + platform: workspace.type, + workspaceId: workspace.id, + workspaceName: workspace.name || workspace.id, + channelId: channel.id, + channelName: channel.name || channel.id, + workingDirectory: channel.workingDirectory?.trim() || "", + }; + } + throw new Error(`Channel ${channelId} not found in configured workspaces`); +} + +function getChannelSnapshotForTarget(channelId: string): { + platform: PrTrackerPlatform; + workspaceId: string; + channelId: string; + channelName: string; +} { + const snap = getChannelSnapshot(channelId); + return { + platform: snap.platform, + workspaceId: snap.workspaceId, + channelId: snap.channelId, + channelName: snap.channelName, + }; +} + +// --------------------------------------------------------------------------- +// Scan: walk every workspace channel, derive (cwd, github repo) pairs, and +// upsert pr_trackers rows so the UI can list them. Existing rows are kept +// (config preserved); rows whose source no longer points at the same repo +// are flagged with `missing_since`. +// --------------------------------------------------------------------------- + +export type PrTrackerScanResult = { + scanned: number; + inserted: number; + reactivated: number; + markedMissing: number; +}; + +export type PrTrackerScanProbe = (cwd: string) => GitHubRepo | null; + +/** + * Rescan all channels and reconcile pr_trackers with what's currently on disk. + * + * Behavior: + * - For each channel with a workingDirectory, probe its git remote. + * - If we can derive a GitHub repo: upsert a pr_trackers row. + * * New row → enabled=0, defaults inherited. + * * Existing row that was marked missing → clear `missing_since`. + * * Existing row that already matches → only refresh derived metadata + * (workingDirectory, channel/workspace names). + * - For each existing tracker whose channel/cwd no longer maps to the same + * repo (or whose cwd disappeared), mark `missing_since = now`. Rows are + * NEVER auto-deleted; users keep the row until they choose to delete. + * + * `probe` is injectable for testing; defaults to `getGitHubRepoFromCwd`. + */ +export function scanPrTrackers(probe: PrTrackerScanProbe = getGitHubRepoFromCwd): PrTrackerScanResult { + const db = getDatabase(); + const config = loadOdeConfig(); + const now = Date.now(); + + const result: PrTrackerScanResult = { + scanned: 0, + inserted: 0, + reactivated: 0, + markedMissing: 0, + }; + + // Build a map of currently-found (workspaceId|channelId|host|owner|repo) + // so we can detect which existing trackers no longer apply. + const foundKeys = new Set(); + + for (const workspace of config.workspaces) { + for (const channel of workspace.channelDetails) { + const cwd = channel.workingDirectory?.trim(); + if (!cwd) continue; + result.scanned += 1; + + let repo: GitHubRepo | null = null; + try { + repo = probe(cwd); + } catch { + repo = null; + } + if (!repo) continue; + + const key = `${workspace.id}|${channel.id}|${repo.host}|${repo.owner}|${repo.repo}`; + foundKeys.add(key); + + const existing = db + .query(` + SELECT * FROM pr_trackers + WHERE source_workspace_id = ? + AND source_channel_id = ? + AND repo_host = ? + AND repo_owner = ? + AND repo_name = ? + `) + .get(workspace.id, channel.id, repo.host, repo.owner, repo.repo) as + | PrTrackerRow + | null; + + if (!existing) { + const id = crypto.randomUUID(); + db.query(` + INSERT INTO pr_trackers ( + id, + source_workspace_id, + source_workspace_name, + source_channel_id, + source_channel_name, + source_platform, + working_directory, + repo_owner, + repo_name, + repo_host, + enabled, + created_at, + updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?) + `).run( + id, + workspace.id, + workspace.name || workspace.id, + channel.id, + channel.name || channel.id, + workspace.type, + cwd, + repo.owner, + repo.repo, + repo.host, + now, + now + ); + result.inserted += 1; + } else { + const wasMissing = existing.missing_since !== null; + db.query(` + UPDATE pr_trackers + SET + source_workspace_name = ?, + source_channel_name = ?, + source_platform = ?, + working_directory = ?, + missing_since = NULL, + updated_at = ? + WHERE id = ? + `).run( + workspace.name || workspace.id, + channel.name || channel.id, + workspace.type, + cwd, + now, + existing.id + ); + if (wasMissing) result.reactivated += 1; + } + } + } + + // Find trackers that didn't show up in this scan and flag them. + const allRows = db.query("SELECT * FROM pr_trackers").all() as PrTrackerRow[]; + for (const row of allRows) { + const key = `${row.source_workspace_id}|${row.source_channel_id}|${row.repo_host}|${row.repo_owner}|${row.repo_name}`; + if (foundKeys.has(key)) continue; + if (row.missing_since !== null) continue; + db.query("UPDATE pr_trackers SET missing_since = ?, updated_at = ? WHERE id = ?").run( + now, + now, + row.id + ); + result.markedMissing += 1; + } + + return result; +} + +// --------------------------------------------------------------------------- +// CRUD. +// --------------------------------------------------------------------------- + +export function listPrTrackers(): PrTrackerRecord[] { + const db = getDatabase(); + const rows = db + .query(` + SELECT * FROM pr_trackers + ORDER BY + missing_since IS NOT NULL, + source_workspace_name, + source_channel_name, + repo_owner, + repo_name + `) + .all() as PrTrackerRow[]; + return rows.map(mapTrackerRow); +} + +export function getPrTrackerById(id: string): PrTrackerRecord | null { + const db = getDatabase(); + const row = db + .query("SELECT * FROM pr_trackers WHERE id = ?") + .get(id) as PrTrackerRow | null; + return row ? mapTrackerRow(row) : null; +} + +/** + * Tracker rows that are due for a poll: enabled, not missing, and either + * never polled or polled longer ago than their (resolved) interval. The + * scheduler uses this to drive its tick. + */ +export function listDuePrTrackers(nowMs: number = Date.now()): PrTrackerRecord[] { + const settings = getPrTrackerSettings(); + const db = getDatabase(); + const rows = db + .query(` + SELECT * FROM pr_trackers + WHERE enabled = 1 AND missing_since IS NULL + `) + .all() as PrTrackerRow[]; + const due: PrTrackerRecord[] = []; + for (const row of rows) { + const tracker = mapTrackerRow(row); + const interval = (tracker.pollIntervalSec ?? settings.defaultPollIntervalSec) * 1000; + const last = tracker.lastPolledAt ?? 0; + if (last === 0 || nowMs - last >= interval) { + due.push(tracker); + } + } + return due; +} + +export type UpdatePrTrackerParams = Partial<{ + enabled: boolean; + agentProvider: string | null; + promptTemplate: string | null; + pollIntervalSec: number | null; + githubToken: string | null; + targetChannelId: string | null; +}>; + +export function updatePrTracker(id: string, params: UpdatePrTrackerParams): PrTrackerRecord { + const existing = getPrTrackerById(id); + if (!existing) throw new Error("PR tracker not found"); + if (existing.missingSince !== null && params.enabled === true) { + throw new Error("Cannot enable a tracker whose source repo is missing"); + } + + const db = getDatabase(); + const now = Date.now(); + + const enabled = params.enabled !== undefined ? (params.enabled ? 1 : 0) : existing.enabled ? 1 : 0; + + const agentProvider = + params.agentProvider !== undefined + ? normalizeOptionalAgent(params.agentProvider) + : existing.agentProvider; + + const promptTemplate = + params.promptTemplate !== undefined + ? params.promptTemplate === null || params.promptTemplate.trim() === "" + ? null + : params.promptTemplate.trim() + : existing.promptTemplate; + + const pollIntervalSec = + params.pollIntervalSec !== undefined + ? params.pollIntervalSec === null + ? null + : normalizePollInterval(params.pollIntervalSec) + : existing.pollIntervalSec; + + const githubToken = + params.githubToken !== undefined + ? params.githubToken === null || params.githubToken.trim() === "" + ? null + : params.githubToken.trim() + : existing.githubToken; + + let targetWorkspaceId = existing.targetWorkspaceId; + let targetChannelId = existing.targetChannelId; + let targetChannelName = existing.targetChannelName; + let targetPlatform = existing.targetPlatform; + + if (params.targetChannelId !== undefined) { + if (params.targetChannelId === null || params.targetChannelId.trim() === "") { + targetWorkspaceId = null; + targetChannelId = null; + targetChannelName = null; + targetPlatform = null; + } else { + const snap = getChannelSnapshotForTarget(params.targetChannelId); + targetWorkspaceId = snap.workspaceId; + targetChannelId = snap.channelId; + targetChannelName = snap.channelName; + targetPlatform = snap.platform; + } + } + + // Enforce: enabling requires a target channel. + if (enabled === 1 && !targetChannelId) { + throw new Error("Cannot enable a tracker without a target channel"); + } + + // First-enable bookkeeping: when transitioning from disabled→enabled and + // there's no prior poll cursor, set last_polled_at to "now" so we don't + // backfill historical events. Re-enabling later keeps the existing cursor. + let nextLastPolledAt = existing.lastPolledAt; + if (enabled === 1 && !existing.enabled && existing.lastPolledAt === null) { + nextLastPolledAt = now; + } + + db.query(` + UPDATE pr_trackers + SET + enabled = ?, + agent_provider = ?, + prompt_template = ?, + poll_interval_sec = ?, + github_token = ?, + target_workspace_id = ?, + target_channel_id = ?, + target_channel_name = ?, + target_platform = ?, + last_polled_at = ?, + updated_at = ? + WHERE id = ? + `).run( + enabled, + agentProvider, + promptTemplate, + pollIntervalSec, + githubToken, + targetWorkspaceId, + targetChannelId, + targetChannelName, + targetPlatform, + nextLastPolledAt, + now, + id + ); + + return getPrTrackerById(id)!; +} + +export function deletePrTracker(id: string): void { + const db = getDatabase(); + // Cascade: drop event audit rows for the tracker as well. + db.query("DELETE FROM pr_tracker_events WHERE tracker_id = ?").run(id); + db.query("DELETE FROM pr_trackers WHERE id = ?").run(id); +} + +// --------------------------------------------------------------------------- +// Scheduler-side bookkeeping. +// --------------------------------------------------------------------------- + +export function markPrTrackerPolled(id: string, options: { + success: boolean; + errorMessage?: string | null; + pollCompletedAt?: number; +}): void { + const db = getDatabase(); + const ts = options.pollCompletedAt ?? Date.now(); + if (options.success) { + db.query(` + UPDATE pr_trackers + SET last_polled_at = ?, + last_success_at = ?, + last_error = NULL, + updated_at = ? + WHERE id = ? + `).run(ts, ts, ts, id); + } else { + // IMPORTANT: `last_polled_at` is reused as the GitHub `since` cursor in + // the scheduler. Advancing it on failure would skip the unprocessed + // activity window forever, so we only update the error + updated_at + // timestamps here and leave the cursor untouched. The next tick will + // retry from the last successful cursor. + db.query(` + UPDATE pr_trackers + SET last_error = ?, + updated_at = ? + WHERE id = ? + `).run(options.errorMessage ?? "unknown error", ts, id); + } +} + +/** + * Advance the poll cursor explicitly. Used by the scheduler when it wants to + * move the cursor to a time earlier than "now" (e.g. when per-poll caps + * leave some PRs unhandled and we need to keep them in the next `since` + * window). Safe to call regardless of the current cursor; monotonicity is + * the caller's concern. + */ +export function setPrTrackerCursor(id: string, cursorMs: number): void { + const db = getDatabase(); + const now = Date.now(); + db.query(` + UPDATE pr_trackers + SET last_polled_at = ?, + updated_at = ? + WHERE id = ? + `).run(Math.floor(cursorMs), now, id); +} + +// --------------------------------------------------------------------------- +// Event log. +// --------------------------------------------------------------------------- + +export type RecordPrTrackerEventParams = { + trackerId: string; + prNumber: number; + eventType: string; + githubEventId: string; + prUpdatedAt?: number | null; + agentSessionId?: string | null; + agentStatus?: PrTrackerEventStatus; + errorMessage?: string | null; +}; + +/** + * Record a processed event. Returns true if a new row was inserted (the event + * was novel) and false if the (tracker_id, event_type, github_event_id) tuple + * already existed (already processed). + */ +export function recordPrTrackerEvent(params: RecordPrTrackerEventParams): boolean { + const db = getDatabase(); + const now = Date.now(); + const id = crypto.randomUUID(); + try { + db.query(` + INSERT INTO pr_tracker_events ( + id, + tracker_id, + pr_number, + event_type, + github_event_id, + pr_updated_at, + agent_session_id, + agent_status, + error_message, + created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `).run( + id, + params.trackerId, + params.prNumber, + params.eventType, + params.githubEventId, + params.prUpdatedAt ?? null, + params.agentSessionId ?? null, + params.agentStatus ?? "success", + params.errorMessage ?? null, + now + ); + return true; + } catch (error) { + const msg = String((error as Error)?.message ?? error); + if (msg.includes("UNIQUE")) return false; + throw error; + } +} + +export function listProcessedEventIds( + trackerId: string, + eventType: string, + ids: string[] +): Set { + if (ids.length === 0) return new Set(); + const db = getDatabase(); + const placeholders = ids.map(() => "?").join(", "); + const rows = db + .query( + `SELECT github_event_id FROM pr_tracker_events + WHERE tracker_id = ? AND event_type = ? AND github_event_id IN (${placeholders})` + ) + .all(trackerId, eventType, ...ids) as Array<{ github_event_id: string }>; + return new Set(rows.map((r) => r.github_event_id)); +} + +export function listPrTrackerEvents( + trackerId: string, + limit: number = 50 +): PrTrackerEventRecord[] { + const db = getDatabase(); + const rows = db + .query(` + SELECT * FROM pr_tracker_events + WHERE tracker_id = ? + ORDER BY created_at DESC + LIMIT ? + `) + .all(trackerId, Math.max(1, Math.min(limit, 500))) as PrTrackerEventRow[]; + return rows.map(mapEventRow); +} + +// --------------------------------------------------------------------------- +// Test helpers. +// --------------------------------------------------------------------------- + +export function clearPrTrackersForTests(): void { + const db = getDatabase(); + db.exec("DELETE FROM pr_tracker_events;"); + db.exec("DELETE FROM pr_trackers;"); + db.exec("DELETE FROM pr_tracker_settings;"); +} + +export function closePrTrackerDatabaseForTests(): void { + if (!cachedDatabase) return; + try { + cachedDatabase.db.close(); + } catch { + // ignore + } finally { + cachedDatabase = null; + } +} diff --git a/packages/core/cli-handlers/pr-tracker.ts b/packages/core/cli-handlers/pr-tracker.ts new file mode 100644 index 00000000..acef42a9 --- /dev/null +++ b/packages/core/cli-handlers/pr-tracker.ts @@ -0,0 +1,489 @@ +import { getWebHost, getWebPort } from "@/config"; +import type { + PrTrackerEventRecord, + PrTrackerRecord, + PrTrackerScanResult, + PrTrackerSettings, +} from "@/config/local/pr-trackers"; + +// --------------------------------------------------------------------------- +// `ode pr-tracker` CLI handler. +// +// Thin HTTP client for /api/pr-trackers/*. The real work (DB writes, +// scheduling) happens in the daemon; this CLI exists so humans and agents +// can inspect and toggle trackers without opening the Web UI. +// +// Design mirrors `packages/core/cli-handlers/task.ts`: flag parser lifted +// verbatim, pretty-printed detail views, JSON flag on list/show for scripts. +// Intentionally does NOT support `create` — tracker rows are populated by +// the scan endpoint since each row is tied to a discovered repo. +// --------------------------------------------------------------------------- + +type CliArgs = string[]; + +type FlagSpec = Record; + +function parseFlags( + args: CliArgs, + specs: FlagSpec, +): { flags: Record; positional: string[] } { + const flags: Record = {}; + const positional: string[] = []; + for (let i = 0; i < args.length; i += 1) { + const arg = args[i] ?? ""; + if (arg.startsWith("--")) { + const eqIdx = arg.indexOf("="); + let name: string; + let value: string | undefined; + if (eqIdx >= 0) { + name = arg.slice(2, eqIdx); + value = arg.slice(eqIdx + 1); + } else { + name = arg.slice(2); + } + const takesValue = specs[name]; + if (takesValue === undefined) { + throw new Error(`Unknown flag: --${name}`); + } + if (!takesValue) { + flags[name] = true; + continue; + } + if (value === undefined) { + const next = args[i + 1]; + if (next === undefined || next.startsWith("--")) { + throw new Error(`Flag --${name} requires a value`); + } + value = next; + i += 1; + } + flags[name] = value; + } else { + positional.push(arg); + } + } + return { flags, positional }; +} + +function apiBase(): string { + return `http://${getWebHost()}:${getWebPort()}`; +} + +type ApiResponse = { ok?: boolean; error?: string; result?: T }; + +async function apiFetch(path: string, init?: RequestInit): Promise { + const url = `${apiBase()}${path}`; + let response: Response; + try { + response = await fetch(url, init); + } catch (error) { + throw new Error( + `Failed to reach Ode daemon at ${url}. Is the daemon running? (Try \`ode status\` / \`ode start\`.) ${String(error)}`, + ); + } + const payload = (await response.json().catch(() => ({}))) as ApiResponse; + if (!response.ok || payload.ok === false) { + throw new Error(payload.error || `Request failed with status ${response.status}`); + } + if (payload.result === undefined) { + throw new Error("Empty response from Ode daemon"); + } + return payload.result; +} + +function formatTimestamp(value: number | null | undefined): string { + if (!value || !Number.isFinite(value)) return "n/a"; + return new Date(value).toISOString(); +} + +function printTrackerRow(tracker: PrTrackerRecord): void { + const state = tracker.missingSince + ? "missing" + : tracker.enabled + ? "enabled" + : "disabled"; + const source = `${tracker.sourceWorkspaceName || tracker.sourceWorkspaceId}/${tracker.sourceChannelName || tracker.sourceChannelId}`; + const target = tracker.targetChannelId + ? `${tracker.targetChannelName || tracker.targetChannelId}` + : "(none)"; + console.log( + [ + tracker.id, + state.padEnd(9), + `${tracker.repoOwner}/${tracker.repoName}`.padEnd(32), + `from=${source}`, + `to=${target}`, + `lastPoll=${formatTimestamp(tracker.lastPolledAt)}`, + ].join(" "), + ); +} + +function printTrackerDetail(tracker: PrTrackerRecord, settings?: PrTrackerSettings): void { + console.log(`id: ${tracker.id}`); + console.log(`repo: ${tracker.repoOwner}/${tracker.repoName} (${tracker.repoHost})`); + console.log(`enabled: ${tracker.enabled}`); + console.log(`missingSince: ${formatTimestamp(tracker.missingSince)}`); + console.log( + `sourceChannel: ${tracker.sourceWorkspaceName || tracker.sourceWorkspaceId} / ${tracker.sourceChannelName || tracker.sourceChannelId} (${tracker.sourceChannelId})`, + ); + console.log(`workingDirectory: ${tracker.workingDirectory}`); + console.log( + `targetChannel: ${tracker.targetChannelId ? `${tracker.targetChannelName || tracker.targetChannelId} (${tracker.targetChannelId})` : "(none)"}`, + ); + console.log( + `agent: ${tracker.agentProvider ?? `(default: ${settings?.defaultAgentProvider ?? "opencode"})`}`, + ); + console.log( + `pollIntervalSec: ${tracker.pollIntervalSec ?? `(default: ${settings?.defaultPollIntervalSec ?? "-"})`}`, + ); + console.log(`githubToken: ${tracker.githubToken ? "(set)" : "(default)"}`); + console.log(`lastPolledAt: ${formatTimestamp(tracker.lastPolledAt)}`); + console.log(`lastSuccessAt: ${formatTimestamp(tracker.lastSuccessAt)}`); + console.log(`lastError: ${tracker.lastError ?? "(none)"}`); + console.log(`createdAt: ${formatTimestamp(tracker.createdAt)}`); + console.log(`updatedAt: ${formatTimestamp(tracker.updatedAt)}`); + console.log("--- prompt template ---"); + console.log(tracker.promptTemplate ?? "(default)"); +} + +function printEventRow(event: PrTrackerEventRecord): void { + console.log( + [ + formatTimestamp(event.createdAt), + event.agentStatus.padEnd(8), + `pr=${event.prNumber}`, + event.eventType.padEnd(16), + event.githubEventId, + event.errorMessage ? `err=${event.errorMessage}` : "", + ] + .filter(Boolean) + .join(" "), + ); +} + +function printHelp(): void { + console.log( + [ + "ode pr-tracker - watch GitHub PR activity per channel", + "", + "Usage:", + " ode pr-tracker list [--enabled | --disabled | --missing] [--json]", + " ode pr-tracker show [--json]", + " ode pr-tracker scan [--json]", + " ode pr-tracker enable [--target-channel ]", + " ode pr-tracker disable ", + " ode pr-tracker update [--agent ] [--prompt ] [--prompt-file ]", + " [--interval ] [--token ] [--target-channel ]", + " ode pr-tracker run ", + " ode pr-tracker delete ", + " ode pr-tracker events [--limit N] [--json]", + " ode pr-tracker settings [--show | --set-interval | --set-agent ", + " --set-prompt-file | --set-token ]", + "", + "Notes:", + " Tracker rows are populated by `ode pr-tracker scan`, which walks every", + " configured channel's workingDirectory and extracts GitHub repos from", + " the origin remote. Each tracker is disabled by default.", + " Enabling a tracker requires a --target-channel (where the agent posts results).", + ].join("\n"), + ); +} + +// --------------------------------------------------------------------------- +// Subcommands. +// --------------------------------------------------------------------------- + +type ListPayload = { + trackers: PrTrackerRecord[]; + channels: Array<{ value: string; label: string }>; + settings: PrTrackerSettings; +}; + +async function handleList(args: CliArgs): Promise { + const { flags } = parseFlags(args, { + enabled: false, + disabled: false, + missing: false, + json: false, + }); + const result = await apiFetch("/api/pr-trackers"); + let trackers = result.trackers; + if (flags.enabled) trackers = trackers.filter((t) => t.enabled && !t.missingSince); + if (flags.disabled) trackers = trackers.filter((t) => !t.enabled && !t.missingSince); + if (flags.missing) trackers = trackers.filter((t) => t.missingSince !== null); + + if (flags.json) { + console.log(JSON.stringify(trackers, null, 2)); + return; + } + if (trackers.length === 0) { + console.log("No trackers. Run `ode pr-tracker scan` first."); + return; + } + console.log("id state repo source target lastPoll"); + console.log("-".repeat(80)); + for (const tracker of trackers) printTrackerRow(tracker); +} + +async function handleShow(args: CliArgs): Promise { + const { flags, positional } = parseFlags(args, { json: false }); + const id = positional[0]; + if (!id) throw new Error("Tracker id is required: ode pr-tracker show "); + const result = await apiFetch<{ + tracker: PrTrackerRecord; + events: PrTrackerEventRecord[]; + settings: PrTrackerSettings; + }>(`/api/pr-trackers/${encodeURIComponent(id)}`); + if (flags.json) { + console.log(JSON.stringify(result, null, 2)); + return; + } + printTrackerDetail(result.tracker, result.settings); +} + +async function handleScan(args: CliArgs): Promise { + const { flags } = parseFlags(args, { json: false }); + const result = await apiFetch<{ scan: PrTrackerScanResult } & ListPayload>( + "/api/pr-trackers/scan", + { method: "POST" }, + ); + if (flags.json) { + console.log(JSON.stringify(result, null, 2)); + return; + } + console.log( + `Scan: ${result.scan.scanned} channels scanned, ${result.scan.inserted} new, ${result.scan.reactivated} reactivated, ${result.scan.markedMissing} marked missing.`, + ); +} + +async function handleUpdate(args: CliArgs, idFromCmd?: string): Promise { + const { flags, positional } = parseFlags(args, { + agent: true, + prompt: true, + "prompt-file": true, + interval: true, + token: true, + "target-channel": true, + }); + const id = idFromCmd ?? positional[0]; + if (!id) throw new Error("Tracker id is required"); + + const body: Record = {}; + if (flags.agent !== undefined) { + const raw = (flags.agent as string).trim().toLowerCase(); + body.agentProvider = raw === "default" ? null : raw; + } + if (flags["prompt-file"] !== undefined) { + const file = Bun.file(flags["prompt-file"] as string); + body.promptTemplate = (await file.text()).trim(); + } else if (flags.prompt !== undefined) { + const raw = flags.prompt as string; + body.promptTemplate = raw.trim().length === 0 ? null : raw; + } + if (flags.interval !== undefined) { + const n = Number(flags.interval); + if (!Number.isFinite(n)) throw new Error("--interval must be a positive number of seconds"); + body.pollIntervalSec = n; + } + if (flags.token !== undefined) { + body.githubToken = (flags.token as string) || null; + } + if (flags["target-channel"] !== undefined) { + const raw = (flags["target-channel"] as string).trim(); + body.targetChannelId = raw.length === 0 ? null : raw; + } + + const result = await apiFetch<{ tracker: PrTrackerRecord; settings: PrTrackerSettings }>( + `/api/pr-trackers/${encodeURIComponent(id)}`, + { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }, + ); + return result.tracker; +} + +async function handleEnable(args: CliArgs): Promise { + const { flags, positional } = parseFlags(args, { "target-channel": true }); + const id = positional[0]; + if (!id) throw new Error("Tracker id is required: ode pr-tracker enable "); + const body: Record = { enabled: true }; + if (flags["target-channel"] !== undefined) { + body.targetChannelId = (flags["target-channel"] as string).trim() || null; + } + const result = await apiFetch<{ tracker: PrTrackerRecord }>( + `/api/pr-trackers/${encodeURIComponent(id)}`, + { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }, + ); + console.log(`Tracker ${id} enabled.`); + printTrackerRow(result.tracker); +} + +async function handleDisable(args: CliArgs): Promise { + const { positional } = parseFlags(args, {}); + const id = positional[0]; + if (!id) throw new Error("Tracker id is required: ode pr-tracker disable "); + const result = await apiFetch<{ tracker: PrTrackerRecord }>( + `/api/pr-trackers/${encodeURIComponent(id)}`, + { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ enabled: false }), + }, + ); + console.log(`Tracker ${id} disabled.`); + printTrackerRow(result.tracker); +} + +async function handleRun(args: CliArgs): Promise { + const { positional } = parseFlags(args, {}); + const id = positional[0]; + if (!id) throw new Error("Tracker id is required: ode pr-tracker run "); + await apiFetch(`/api/pr-trackers/${encodeURIComponent(id)}/run`, { method: "POST" }); + console.log(`Tracker ${id} triggered (poll runs asynchronously; use \`ode pr-tracker show ${id}\` to see the outcome).`); +} + +async function handleDelete(args: CliArgs): Promise { + const { positional } = parseFlags(args, {}); + const id = positional[0]; + if (!id) throw new Error("Tracker id is required: ode pr-tracker delete "); + await apiFetch(`/api/pr-trackers/${encodeURIComponent(id)}`, { method: "DELETE" }); + console.log(`Tracker ${id} deleted.`); +} + +async function handleEvents(args: CliArgs): Promise { + const { flags, positional } = parseFlags(args, { limit: true, json: false }); + const id = positional[0]; + if (!id) throw new Error("Tracker id is required: ode pr-tracker events "); + const result = await apiFetch<{ events: PrTrackerEventRecord[] }>( + `/api/pr-trackers/${encodeURIComponent(id)}`, + ); + let events = result.events; + if (flags.limit !== undefined) { + const n = Number(flags.limit); + if (Number.isFinite(n) && n > 0) events = events.slice(0, n); + } + if (flags.json) { + console.log(JSON.stringify(events, null, 2)); + return; + } + if (events.length === 0) { + console.log("No events."); + return; + } + for (const event of events) printEventRow(event); +} + +async function handleSettings(args: CliArgs): Promise { + const { flags } = parseFlags(args, { + show: false, + "set-interval": true, + "set-agent": true, + "set-prompt-file": true, + "set-token": true, + }); + + const mutates = + flags["set-interval"] !== undefined || + flags["set-agent"] !== undefined || + flags["set-prompt-file"] !== undefined || + flags["set-token"] !== undefined; + + if (mutates) { + const body: Record = {}; + if (flags["set-interval"] !== undefined) { + const n = Number(flags["set-interval"]); + if (!Number.isFinite(n)) throw new Error("--set-interval must be a number"); + body.defaultPollIntervalSec = n; + } + if (flags["set-agent"] !== undefined) { + body.defaultAgentProvider = (flags["set-agent"] as string).trim(); + } + if (flags["set-prompt-file"] !== undefined) { + const file = Bun.file(flags["set-prompt-file"] as string); + body.defaultPromptTemplate = (await file.text()).trim(); + } + if (flags["set-token"] !== undefined) { + body.defaultGithubToken = (flags["set-token"] as string) ?? ""; + } + const result = await apiFetch<{ settings: PrTrackerSettings }>( + "/api/pr-trackers/settings", + { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }, + ); + console.log("Settings updated."); + console.log(JSON.stringify(result.settings, null, 2)); + return; + } + + const result = await apiFetch<{ settings: PrTrackerSettings }>("/api/pr-trackers/settings"); + console.log(JSON.stringify(result.settings, null, 2)); +} + +// --------------------------------------------------------------------------- +// Dispatcher. +// --------------------------------------------------------------------------- + +export async function handlePrTrackerCommand(args: CliArgs): Promise { + const sub = args[0]; + if (!sub || sub === "help" || sub === "--help" || sub === "-h") { + printHelp(); + return 0; + } + try { + const rest = args.slice(1); + switch (sub) { + case "list": + case "ls": + await handleList(rest); + return 0; + case "show": + case "get": + await handleShow(rest); + return 0; + case "scan": + await handleScan(rest); + return 0; + case "enable": + await handleEnable(rest); + return 0; + case "disable": + await handleDisable(rest); + return 0; + case "update": { + const tracker = await handleUpdate(rest); + console.log(`Tracker ${tracker.id} updated.`); + printTrackerRow(tracker); + return 0; + } + case "run": + await handleRun(rest); + return 0; + case "delete": + case "rm": + await handleDelete(rest); + return 0; + case "events": + await handleEvents(rest); + return 0; + case "settings": + await handleSettings(rest); + return 0; + default: + console.error(`Unknown pr-tracker subcommand: ${sub}`); + printHelp(); + return 1; + } + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + return 1; + } +} diff --git a/packages/core/cli.ts b/packages/core/cli.ts index 98bcedd3..c07f046c 100644 --- a/packages/core/cli.ts +++ b/packages/core/cli.ts @@ -10,6 +10,7 @@ import { isProcessAlive, readDaemonState, type DaemonState } from "@/core/daemon import { runOnboarding } from "@/core/onboarding"; import { handleTaskCommand } from "@/core/cli-handlers/task"; import { handleCronCommand } from "@/core/cli-handlers/cron"; +import { handlePrTrackerCommand } from "@/core/cli-handlers/pr-tracker"; import { handleSendCommand } from "@/core/cli-handlers/send"; import { handleMessagesCommand } from "@/core/cli-handlers/messages"; import { handleReactionCommand } from "@/core/cli-handlers/reaction"; @@ -515,6 +516,11 @@ if (command === "cron") { process.exit(code); } +if (command === "pr-tracker" || command === "pr_tracker") { + const code = await handlePrTrackerCommand(args.slice(1)); + process.exit(code); +} + if (command === "send") { const code = await handleSendCommand(args.slice(1)); process.exit(code); diff --git a/packages/core/index.ts b/packages/core/index.ts index 539ae9b1..6b3ef300 100644 --- a/packages/core/index.ts +++ b/packages/core/index.ts @@ -34,6 +34,10 @@ import { checkForUpdate, isInstalledBinary, performUpgrade } from "./upgrade"; import { runOnboardingIfNeeded } from "./onboarding"; import { startCronJobScheduler, stopCronJobScheduler } from "@/core/cron/scheduler"; import { startTaskScheduler, stopTaskScheduler } from "@/core/tasks/scheduler"; +import { + startPrTrackerScheduler, + stopPrTrackerScheduler, +} from "@/core/pr-tracker/scheduler"; import { initSentry, shutdownSentry } from "@/core/observability/sentry"; import packageJson from "../../package.json" with { type: "json" }; @@ -285,6 +289,7 @@ async function main(): Promise { await startLarkRuntime("startup"); startCronJobScheduler(); startTaskScheduler(); + startPrTrackerScheduler(); if (slackApps.length > 0) { log.debug("Slack app created"); @@ -305,6 +310,7 @@ async function main(): Promise { try { stopCronJobScheduler(); stopTaskScheduler(); + stopPrTrackerScheduler(); stopOAuthServer(); await stopSlackRuntime("shutdown"); await stopDiscordRuntime("shutdown"); diff --git a/packages/core/pr-tracker/github.test.ts b/packages/core/pr-tracker/github.test.ts new file mode 100644 index 00000000..eaa9be59 --- /dev/null +++ b/packages/core/pr-tracker/github.test.ts @@ -0,0 +1,409 @@ +import { describe, expect, test } from "bun:test"; +import { + fetchPrActivity, + GitHubActivityFetchError, + renderEventsSummary, + renderPrompt, + resolveApiBaseUrl, + resolveGitHubToken, + type PrEvent, +} from "./github"; + +function buildMockFetch( + responses: Record, +): (url: string) => Promise { + return async (url: string) => { + // Strip the base URL so test keys can stay short. + const key = url.replace(/^https?:\/\/[^/]+/, ""); + if (!(key in responses)) { + return new Response(JSON.stringify({ message: `no mock for ${key}` }), { + status: 404, + }); + } + const value = responses[key]; + return new Response(JSON.stringify(value), { + status: 200, + headers: { "content-type": "application/json" }, + }); + }; +} + +describe("resolveGitHubToken", () => { + test("prefers tracker token over global", () => { + expect( + resolveGitHubToken({ trackerToken: "tracker", globalToken: "global" }), + ).toBe("tracker"); + }); + + test("falls back to global when tracker empty", () => { + expect( + resolveGitHubToken({ trackerToken: " ", globalToken: "global" }), + ).toBe("global"); + }); + + test("returns null or a string from gh CLI when neither is set", () => { + const result = resolveGitHubToken({}); + // We can't assert the value because it depends on the host machine; we + // only assert the shape. + expect(result === null || typeof result === "string").toBe(true); + }); +}); + +describe("renderEventsSummary", () => { + test("returns placeholder when no events", () => { + expect(renderEventsSummary([])).toBe("(no new events)"); + }); + + test("labels each kind", () => { + const events: PrEvent[] = [ + { kind: "comment", githubEventId: "1", timestamp: 1, author: "alice", body: "looks good" }, + { + kind: "review", + githubEventId: "2", + timestamp: 2, + author: "bob", + body: "nit: fix typo", + meta: { state: "changes_requested" }, + }, + { kind: "review_comment", githubEventId: "3", timestamp: 3, author: "carol", body: "?" }, + { kind: "pr_updated", githubEventId: "4", timestamp: 4, author: "dan", body: "Add feature" }, + ]; + const out = renderEventsSummary(events); + expect(out).toContain("[Comment] @alice"); + expect(out).toContain("[Review (changes_requested)] @bob"); + expect(out).toContain("[Review comment] @carol"); + expect(out).toContain("[PR update] @dan"); + }); + + test("caps to 20 events with overflow marker", () => { + const events: PrEvent[] = Array.from({ length: 25 }, (_, i) => ({ + kind: "comment" as const, + githubEventId: `c${i}`, + timestamp: i, + author: `user${i}`, + body: `body ${i}`, + })); + const out = renderEventsSummary(events); + // First line is overflow marker, then 20 events. + const lines = out.split("\n"); + expect(lines[0]).toMatch(/\.\.\.and 5 older event/); + expect(lines).toHaveLength(21); + // Latest events are kept. + expect(out).toContain("@user24"); + expect(out).not.toContain("@user4"); + }); + + test("truncates very long bodies", () => { + const big = "x".repeat(1000); + const out = renderEventsSummary([ + { kind: "comment", githubEventId: "1", timestamp: 1, author: "a", body: big }, + ]); + expect(out.length).toBeLessThan(500); + expect(out).toContain("..."); + }); +}); + +describe("renderPrompt", () => { + test("substitutes known variables", () => { + const out = renderPrompt( + "Repo: {{repo_full_name}} PR #{{pr_number}} by {{pr_author}}\n{{new_events_summary}}", + { + repoFullName: "acme/thing", + prNumber: 42, + prTitle: "Do the thing", + prUrl: "https://github.com/acme/thing/pull/42", + prAuthor: "alice", + prState: "open", + headRef: "feature", + baseRef: "main", + events: [ + { kind: "comment", githubEventId: "1", timestamp: 1, author: "bob", body: "hi" }, + ], + }, + ); + expect(out).toContain("Repo: acme/thing PR #42 by alice"); + expect(out).toContain("[Comment] @bob"); + }); + + test("leaves unknown tokens untouched (graceful)", () => { + const out = renderPrompt("Keep {{unknown}} as-is", { + repoFullName: "a/b", + prNumber: 1, + prTitle: "t", + prUrl: "u", + prAuthor: "x", + prState: "open", + headRef: null, + baseRef: null, + events: [], + }); + expect(out).toContain("{{unknown}}"); + }); +}); + +describe("fetchPrActivity", () => { + test("aggregates issue comments, review comments, and PR updates by PR", async () => { + const since = Date.parse("2026-04-20T00:00:00Z"); + const newer = new Date(since + 60_000).toISOString(); + const older = new Date(since - 60_000).toISOString(); + + const fetchImpl = buildMockFetch({ + // Pulls page 1 + "/repos/acme/thing/pulls?state=all&sort=updated&direction=desc&per_page=50&page=1": [ + { + number: 42, + title: "Feature A", + html_url: "https://github.com/acme/thing/pull/42", + state: "open", + updated_at: newer, + user: { login: "alice" }, + head: { ref: "feat/a" }, + base: { ref: "main" }, + }, + { + number: 7, + title: "Old PR", + html_url: "https://github.com/acme/thing/pull/7", + state: "open", + updated_at: older, // cursor stops here + user: { login: "zed" }, + head: { ref: "old" }, + base: { ref: "main" }, + }, + ], + [`/repos/acme/thing/issues/comments?since=${encodeURIComponent(new Date(since).toISOString())}&per_page=100&sort=updated&direction=asc`]: [ + { + id: 111, + html_url: "https://github.com/acme/thing/pull/42#issuecomment-111", + body: "Comment on 42", + user: { login: "bob" }, + created_at: newer, + updated_at: newer, + issue_url: "https://api.github.com/repos/acme/thing/issues/42", + pull_request_url: "https://api.github.com/repos/acme/thing/pulls/42", + }, + // An issue-only comment (no pull_request_url field). Should be dropped + // so we don't fabricate a PR bucket for a regular issue. + { + id: 112, + html_url: "https://github.com/acme/thing/issues/99#issuecomment-112", + body: "Comment on an issue", + user: { login: "bob" }, + created_at: newer, + updated_at: newer, + issue_url: "https://api.github.com/repos/acme/thing/issues/99", + }, + ], + [`/repos/acme/thing/pulls/comments?since=${encodeURIComponent(new Date(since).toISOString())}&per_page=100&sort=updated&direction=asc`]: [ + { + id: 222, + html_url: "https://github.com/acme/thing/pull/42#discussion_r222", + body: "Inline nit", + user: { login: "carol" }, + created_at: newer, + updated_at: newer, + pull_request_url: "https://api.github.com/repos/acme/thing/pulls/42", + }, + ], + "/repos/acme/thing/pulls/42/reviews?per_page=100": [ + { + id: 333, + user: { login: "dan" }, + state: "APPROVED", + body: "lgtm", + submitted_at: newer, + html_url: "https://github.com/acme/thing/pull/42#review-333", + }, + { + // Stale review from before cursor — filtered out. + id: 334, + user: { login: "old" }, + state: "COMMENTED", + body: "old", + submitted_at: older, + html_url: "https://github.com/acme/thing/pull/42#review-334", + }, + ], + "/repos/acme/thing/pulls/99/reviews?per_page=100": [], + }); + + const summaries = await fetchPrActivity( + { owner: "acme", repo: "thing", sinceMs: since, token: "fake" }, + fetchImpl, + ); + + // PR 42 should contain the pr_updated event, the issue comment, the review + // comment, and the fresh review. The issue-99 comment has no + // pull_request_url so it's correctly dropped (not a PR thread). + const byNumber = new Map(summaries.map((s) => [s.prNumber, s] as const)); + expect(Array.from(byNumber.keys()).sort()).toEqual([42]); + + const pr42 = byNumber.get(42)!; + expect(pr42.title).toBe("Feature A"); + expect(pr42.author).toBe("alice"); + const kinds42 = pr42.events.map((e) => e.kind).sort(); + expect(kinds42).toEqual(["comment", "pr_updated", "review", "review_comment"]); + // Events are sorted by timestamp ascending. + const times = pr42.events.map((e) => e.timestamp); + expect([...times].sort((a, b) => a - b)).toEqual(times); + }); + + test("returns empty when no activity", async () => { + const since = Date.parse("2026-04-20T00:00:00Z"); + const fetchImpl = buildMockFetch({ + "/repos/acme/quiet/pulls?state=all&sort=updated&direction=desc&per_page=50&page=1": [], + [`/repos/acme/quiet/issues/comments?since=${encodeURIComponent(new Date(since).toISOString())}&per_page=100&sort=updated&direction=asc`]: [], + [`/repos/acme/quiet/pulls/comments?since=${encodeURIComponent(new Date(since).toISOString())}&per_page=100&sort=updated&direction=asc`]: [], + }); + const summaries = await fetchPrActivity( + { owner: "acme", repo: "quiet", sinceMs: since, token: "fake" }, + fetchImpl, + ); + expect(summaries).toEqual([]); + }); + + test("swallows individual endpoint failures (degraded mode)", async () => { const since = Date.parse("2026-04-20T00:00:00Z"); + const newer = new Date(since + 60_000).toISOString(); + + const fetchImpl: (url: string) => Promise = async (url: string) => { + const key = url.replace(/^https?:\/\/[^/]+/, ""); + if (key.startsWith("/repos/acme/thing/pulls?")) { + return new Response(JSON.stringify({ message: "rate limited" }), { status: 403 }); + } + if (key.startsWith("/repos/acme/thing/issues/comments")) { + return new Response( + JSON.stringify([ + { + id: 1, + html_url: "https://github.com/acme/thing/pull/1", + body: "survives", + user: { login: "x" }, + created_at: newer, + updated_at: newer, + issue_url: "https://api.github.com/repos/acme/thing/issues/1", + pull_request_url: "https://api.github.com/repos/acme/thing/pulls/1", + }, + ]), + { status: 200 }, + ); + } + if (key.startsWith("/repos/acme/thing/pulls/comments")) { + return new Response(JSON.stringify([]), { status: 200 }); + } + if (key.includes("/pulls/1/reviews")) { + return new Response(JSON.stringify([]), { status: 200 }); + } + return new Response(JSON.stringify([]), { status: 200 }); + }; + + const summaries = await fetchPrActivity( + { owner: "acme", repo: "thing", sinceMs: since, token: "fake" }, + fetchImpl, + ); + expect(summaries).toHaveLength(1); + expect(summaries[0]!.prNumber).toBe(1); + expect(summaries[0]!.events.map((e) => e.kind)).toEqual(["comment"]); + }); + + test("throws GitHubActivityFetchError when all three endpoints fail", async () => { + const since = Date.parse("2026-04-20T00:00:00Z"); + const fetchImpl: (url: string) => Promise = async () => + new Response(JSON.stringify({ message: "rate limited" }), { status: 403 }); + + await expect( + fetchPrActivity( + { owner: "acme", repo: "thing", sinceMs: since, token: "fake" }, + fetchImpl, + ), + ).rejects.toThrow(GitHubActivityFetchError); + }); + + test("routes enterprise host into GHES base URL", async () => { + const since = Date.parse("2026-04-20T00:00:00Z"); + const newer = new Date(since + 60_000).toISOString(); + const calls: string[] = []; + const fetchImpl: (url: string) => Promise = async (url: string) => { + calls.push(url); + // Return a valid (empty-ish) response for the enterprise base URL. + if (url.startsWith("https://github.corp.example.com/api/v3")) { + if (url.includes("/pulls?")) { + return new Response( + JSON.stringify([ + { + number: 7, + title: "T", + html_url: "https://github.corp.example.com/acme/thing/pull/7", + state: "open", + updated_at: newer, + user: { login: "alice" }, + head: { ref: "feat" }, + base: { ref: "main" }, + }, + ]), + { status: 200 }, + ); + } + return new Response(JSON.stringify([]), { status: 200 }); + } + return new Response(JSON.stringify({ message: "wrong host" }), { status: 404 }); + }; + + const summaries = await fetchPrActivity( + { + owner: "acme", + repo: "thing", + sinceMs: since, + token: "fake", + host: "github.corp.example.com", + }, + fetchImpl, + ); + expect(summaries.map((s) => s.prNumber)).toEqual([7]); + // Every call should hit the enterprise base URL. + expect(calls.every((u) => u.startsWith("https://github.corp.example.com/api/v3"))).toBe(true); + }); + + test("drops issue comments without pull_request_url", async () => { + const since = Date.parse("2026-04-20T00:00:00Z"); + const newer = new Date(since + 60_000).toISOString(); + const fetchImpl = buildMockFetch({ + "/repos/acme/thing/pulls?state=all&sort=updated&direction=desc&per_page=50&page=1": [], + [`/repos/acme/thing/issues/comments?since=${encodeURIComponent(new Date(since).toISOString())}&per_page=100&sort=updated&direction=asc`]: [ + { + id: 1, + html_url: "https://github.com/acme/thing/issues/100#issuecomment-1", + body: "plain issue comment", + user: { login: "x" }, + created_at: newer, + updated_at: newer, + issue_url: "https://api.github.com/repos/acme/thing/issues/100", + // no pull_request_url → plain issue → must be ignored + }, + ], + [`/repos/acme/thing/pulls/comments?since=${encodeURIComponent(new Date(since).toISOString())}&per_page=100&sort=updated&direction=asc`]: [], + }); + const summaries = await fetchPrActivity( + { owner: "acme", repo: "thing", sinceMs: since, token: "fake" }, + fetchImpl, + ); + expect(summaries).toEqual([]); + }); +}); + +describe("resolveApiBaseUrl", () => { + test("github.com → api.github.com", () => { + expect(resolveApiBaseUrl("github.com")).toBe("https://api.github.com"); + expect(resolveApiBaseUrl("GITHUB.COM")).toBe("https://api.github.com"); + expect(resolveApiBaseUrl(null)).toBe("https://api.github.com"); + expect(resolveApiBaseUrl("")).toBe("https://api.github.com"); + }); + + test("GHES hostname → /api/v3", () => { + expect(resolveApiBaseUrl("github.corp.example.com")).toBe( + "https://github.corp.example.com/api/v3", + ); + expect(resolveApiBaseUrl("github.enterprise.io")).toBe( + "https://github.enterprise.io/api/v3", + ); + }); +}); diff --git a/packages/core/pr-tracker/github.ts b/packages/core/pr-tracker/github.ts new file mode 100644 index 00000000..a84c768a --- /dev/null +++ b/packages/core/pr-tracker/github.ts @@ -0,0 +1,622 @@ +import { spawnSync } from "child_process"; +import { log } from "@/utils/logger"; + +// --------------------------------------------------------------------------- +// Minimal GitHub REST client tailored for the PR Tracker scheduler. +// +// Responsibilities: +// - Resolve an auth token from (per-tracker override → global setting → `gh auth token`). +// - Fetch recent PR activity since a cursor timestamp: issue comments, +// review comments, and PRs whose `updated_at` moved, plus per-PR reviews. +// - Aggregate those pieces into a per-PR event bundle the scheduler can +// turn into a single agent run. +// +// We deliberately keep this client narrow: just enough surface to power the +// tracker. Heavier GitHub orchestration should live elsewhere. +// --------------------------------------------------------------------------- + +const USER_AGENT = "ode-pr-tracker"; +const DEFAULT_BASE = "https://api.github.com"; + +export type PrEventKind = "comment" | "review_comment" | "review" | "pr_updated"; + +export type PrEvent = { + kind: PrEventKind; + /** Stable GitHub id for dedupe (comment id / review id / pr node). */ + githubEventId: string; + /** Event timestamp (ms since epoch). */ + timestamp: number; + /** Commenting / reviewing user (falls back to "unknown"). */ + author: string; + /** Short preview body used when rendering the prompt summary. */ + body: string; + /** Extra metadata we expose to the prompt (review state, etc.). */ + meta?: Record; +}; + +export type PrSummary = { + prNumber: number; + title: string; + url: string; + author: string; + state: string; + headRef: string | null; + baseRef: string | null; + updatedAt: number; + /** All new events seen in this poll, oldest first. */ + events: PrEvent[]; +}; + +export type FetchActivityOptions = { + owner: string; + repo: string; + /** Cursor: fetch activity with updated_at strictly after this ms timestamp. */ + sinceMs: number; + /** Optional explicit token. If omitted, resolved via {@link resolveGitHubToken}. */ + token?: string | null; + /** + * Override the REST base URL (useful for tests / GHES). Takes precedence + * over `host` when both are set. + */ + baseUrl?: string; + /** + * GitHub hostname (e.g. "github.com" or "github.corp.example.com"). When + * provided and `baseUrl` is not, we resolve the REST endpoint per host: + * - github.com → https://api.github.com + * - anything else → https:///api/v3 (GHES convention) + */ + host?: string | null; +}; + +/** + * Map a GitHub hostname to its REST API base URL. + * + * github.com uses the public `api.github.com` subdomain; GitHub Enterprise + * Server (GHES) instances expose the REST API under `/api/v3` on the same + * hostname. This helper encapsulates that convention so callers don't have + * to care. + */ +export function resolveApiBaseUrl(host: string | null | undefined): string { + const trimmed = (host ?? "").trim().toLowerCase(); + if (!trimmed || trimmed === "github.com" || trimmed === "api.github.com") { + return DEFAULT_BASE; + } + return `https://${trimmed}/api/v3`; +} + +// --------------------------------------------------------------------------- +// Token resolution. +// --------------------------------------------------------------------------- + +export type TokenResolutionInput = { + /** Tracker-level override. */ + trackerToken?: string | null; + /** Global PR Tracker setting. */ + globalToken?: string | null; +}; + +/** + * Resolve a GitHub token using the documented fallback chain: + * 1. Tracker-specific token (if non-empty). + * 2. Global PR Tracker default (if non-empty). + * 3. `gh auth token` from the local GitHub CLI (if installed and logged in). + * + * Returns null if no token is available. + */ +export function resolveGitHubToken(input: TokenResolutionInput): string | null { + const tracker = input.trackerToken?.trim(); + if (tracker) return tracker; + const global = input.globalToken?.trim(); + if (global) return global; + + const result = spawnSync("gh", ["auth", "token"], { encoding: "utf-8" }); + if (result.status !== 0) return null; + const out = String(result.stdout || "").trim(); + return out || null; +} + +// --------------------------------------------------------------------------- +// Low-level fetch wrapper. +// --------------------------------------------------------------------------- + +type FetchFn = (url: string, init?: RequestInit) => Promise; + +export type RequestContext = { + baseUrl: string; + token: string | null; + fetchImpl: FetchFn; +}; + +function buildHeaders(token: string | null): Record { + const headers: Record = { + Accept: "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + "User-Agent": USER_AGENT, + }; + if (token) headers.Authorization = `Bearer ${token}`; + return headers; +} + +async function githubGet(ctx: RequestContext, pathAndQuery: string): Promise { + const url = pathAndQuery.startsWith("http") + ? pathAndQuery + : `${ctx.baseUrl}${pathAndQuery}`; + const response = await ctx.fetchImpl(url, { headers: buildHeaders(ctx.token) }); + if (!response.ok) { + const text = await response.text().catch(() => ""); + throw new Error( + `GitHub request failed: ${response.status} ${response.statusText} — ${truncate(text, 300)}`, + ); + } + return (await response.json()) as T; +} + +function truncate(value: string, max: number): string { + if (value.length <= max) return value; + return `${value.slice(0, max - 3)}...`; +} + +// --------------------------------------------------------------------------- +// GitHub response shapes (narrow subset we actually use). +// --------------------------------------------------------------------------- + +type GhUser = { login?: string | null } | null | undefined; + +type GhIssueComment = { + id: number; + html_url: string; + body: string | null; + user: GhUser; + created_at: string; + updated_at: string; + issue_url: string; + /** + * Set by GitHub when the comment is on a pull request (REST returns + * `issues/{n}` for both PRs and plain issues; `pull_request_url` is the + * disambiguator). Absent on plain issue comments. We rely on this to + * filter `/issues/comments` results down to PR-only. + */ + pull_request_url?: string | null; +}; + +type GhReviewComment = { + id: number; + html_url: string; + body: string | null; + user: GhUser; + created_at: string; + updated_at: string; + pull_request_url: string; +}; + +type GhReview = { + id: number; + user: GhUser; + state: string; + body: string | null; + submitted_at: string | null; + html_url: string; +}; + +type GhPullRequest = { + number: number; + title: string; + html_url: string; + state: string; + updated_at: string; + user: GhUser; + head: { ref?: string | null } | null; + base: { ref?: string | null } | null; +}; + +// --------------------------------------------------------------------------- +// Helpers. +// --------------------------------------------------------------------------- + +function toMs(iso: string | null | undefined): number { + if (!iso) return 0; + const t = Date.parse(iso); + return Number.isFinite(t) ? t : 0; +} + +function prNumberFromIssueUrl(issueUrl: string): number | null { + const m = issueUrl.match(/\/issues\/(\d+)$/); + if (!m) return null; + const n = Number(m[1]); + return Number.isFinite(n) ? n : null; +} + +function prNumberFromPullUrl(pullUrl: string): number | null { + const m = pullUrl.match(/\/pulls\/(\d+)$/); + if (!m) return null; + const n = Number(m[1]); + return Number.isFinite(n) ? n : null; +} + +function loginOf(user: GhUser): string { + return user?.login?.trim() || "unknown"; +} + +/** + * Unwrap a `Promise.allSettled` result: return the fulfilled array, or log + + * record the failure and fall back to an empty list. Lets callers collect + * partial results with a single trip through the list of settled results. + */ +function extractSettled( + settled: PromiseSettledResult, + label: string, + failures: Error[], +): T[] { + if (settled.status === "fulfilled") return settled.value; + const err = settled.reason instanceof Error ? settled.reason : new Error(String(settled.reason)); + failures.push(new Error(`${label}: ${err.message}`)); + log.warn(`pr-tracker: ${label} failed`, { err: String(err) }); + return []; +} + +// --------------------------------------------------------------------------- +// Fetchers — one per GitHub endpoint we care about. +// +// Each returns the full page contents, filtered to the caller's `sinceMs` +// cursor where the GitHub API's `since` param isn't exact (reviews lack a +// server-side cursor, so we fetch and filter). +// --------------------------------------------------------------------------- + +async function fetchIssueComments( + ctx: RequestContext, + owner: string, + repo: string, + sinceIso: string, +): Promise { + return githubGet( + ctx, + `/repos/${owner}/${repo}/issues/comments?since=${encodeURIComponent(sinceIso)}&per_page=100&sort=updated&direction=asc`, + ); +} + +async function fetchReviewComments( + ctx: RequestContext, + owner: string, + repo: string, + sinceIso: string, +): Promise { + return githubGet( + ctx, + `/repos/${owner}/${repo}/pulls/comments?since=${encodeURIComponent(sinceIso)}&per_page=100&sort=updated&direction=asc`, + ); +} + +async function fetchRecentlyUpdatedPulls( + ctx: RequestContext, + owner: string, + repo: string, + sinceMs: number, +): Promise { + // GitHub's pulls endpoint doesn't support `since`, but sorted by updated + // descending lets us stop at the first PR older than our cursor. We cap + // pagination to avoid runaway loops on large repos. + const pageSize = 50; + const maxPages = 4; + const collected: GhPullRequest[] = []; + for (let page = 1; page <= maxPages; page += 1) { + const batch = await githubGet( + ctx, + `/repos/${owner}/${repo}/pulls?state=all&sort=updated&direction=desc&per_page=${pageSize}&page=${page}`, + ); + if (!Array.isArray(batch) || batch.length === 0) break; + let reachedCursor = false; + for (const pr of batch) { + if (toMs(pr.updated_at) <= sinceMs) { + reachedCursor = true; + break; + } + collected.push(pr); + } + if (reachedCursor || batch.length < pageSize) break; + } + return collected; +} + +async function fetchReviewsForPr( + ctx: RequestContext, + owner: string, + repo: string, + prNumber: number, + sinceMs: number, +): Promise { + const reviews = await githubGet( + ctx, + `/repos/${owner}/${repo}/pulls/${prNumber}/reviews?per_page=100`, + ); + return reviews.filter((r) => toMs(r.submitted_at) > sinceMs); +} + +// --------------------------------------------------------------------------- +// Top-level orchestration. +// --------------------------------------------------------------------------- + +function toPrEventFromIssueComment(c: GhIssueComment): PrEvent | null { + // `/issues/comments` mixes PR-thread comments with comments on plain + // issues. GitHub populates `pull_request_url` only on PR-thread comments, + // so we use its presence as the disambiguator. If it's absent we drop + // the event entirely — otherwise the tracker would fabricate a PR bucket + // for a regular issue number and dispatch a bogus "PR update" agent run. + const prUrl = c.pull_request_url?.trim(); + if (!prUrl) return null; + const prNumber = prNumberFromPullUrl(prUrl) ?? prNumberFromIssueUrl(c.issue_url); + if (!prNumber) return null; + return { + kind: "comment", + githubEventId: String(c.id), + timestamp: toMs(c.updated_at || c.created_at), + author: loginOf(c.user), + body: (c.body ?? "").trim(), + meta: { url: c.html_url, prNumber: String(prNumber) }, + }; +} + +function toPrEventFromReviewComment(c: GhReviewComment): PrEvent { + return { + kind: "review_comment", + githubEventId: String(c.id), + timestamp: toMs(c.updated_at || c.created_at), + author: loginOf(c.user), + body: (c.body ?? "").trim(), + meta: { url: c.html_url }, + }; +} + +function toPrEventFromReview(r: GhReview): PrEvent { + return { + kind: "review", + githubEventId: String(r.id), + timestamp: toMs(r.submitted_at), + author: loginOf(r.user), + body: (r.body ?? "").trim(), + meta: { url: r.html_url, state: r.state }, + }; +} + +function toPrEventFromPr(pr: GhPullRequest): PrEvent { + return { + kind: "pr_updated", + // pr_updated_at isn't a stable "event id", so we synthesize one keyed on + // the updated_at timestamp; the scheduler's dedupe key is + // (tracker, kind, id) so this stays idempotent within a single cursor + // window without flooding the events table on every poll. + githubEventId: `pr-${pr.number}-${toMs(pr.updated_at)}`, + timestamp: toMs(pr.updated_at), + author: loginOf(pr.user), + body: pr.title ?? "", + meta: { url: pr.html_url, state: pr.state }, + }; +} + +/** + * Fetch PR activity for a repo since `sinceMs`, bucketed by PR number. + * + * Strategy: + * 1. Pull recently-updated PRs (to capture state changes / syncs and to + * discover which PRs to look up reviews for). Results are capped to a + * handful of pages so a huge active repo doesn't blow up. + * 2. Pull issue + review comments updated since the cursor. + * 3. For every PR we touched in steps 1 & 2, pull its reviews and filter + * to ones submitted after the cursor. + * 4. Group everything by PR number and build a PrSummary per PR. PRs with + * zero qualifying events are omitted. + * + * The cursor is exclusive on the server side (`since` semantics in GitHub + * are "updated_at >= since", but we additionally filter `timestamp > sinceMs` + * on our side so a stale cursor doesn't double-fire an event). + */ +/** + * Thrown by {@link fetchPrActivity} when none of the core GitHub queries + * succeed. Lets the scheduler distinguish "repo is quiet" (empty result) + * from "we have no idea what's going on" (broken auth, 5xx, rate limit), + * so the caller can choose not to advance the poll cursor. + */ +export class GitHubActivityFetchError extends Error { + readonly causes: Error[]; + constructor(message: string, causes: Error[]) { + super(message); + this.name = "GitHubActivityFetchError"; + this.causes = causes; + } +} + +export async function fetchPrActivity( + options: FetchActivityOptions, + fetchImpl: FetchFn = fetch, +): Promise { + const token = options.token !== undefined + ? options.token + : resolveGitHubToken({}); + const resolvedBase = options.baseUrl?.trim() + ? options.baseUrl.replace(/\/$/, "") + : resolveApiBaseUrl(options.host); + const ctx: RequestContext = { + baseUrl: resolvedBase, + token, + fetchImpl, + }; + + const sinceMs = Math.max(0, Math.floor(options.sinceMs)); + const sinceIso = new Date(sinceMs || 0).toISOString(); + + // Run the three high-level queries in parallel — they hit different + // endpoints and don't share pagination state. We use `allSettled` so that + // a single endpoint failure degrades to partial results, while a total + // failure (every endpoint rejected) is surfaced to the scheduler — in + // that case we MUST NOT let the caller treat the empty list as "no + // activity" and advance the poll cursor. + const [pullsResult, issueCommentsResult, reviewCommentsResult] = await Promise.allSettled([ + fetchRecentlyUpdatedPulls(ctx, options.owner, options.repo, sinceMs), + fetchIssueComments(ctx, options.owner, options.repo, sinceIso), + fetchReviewComments(ctx, options.owner, options.repo, sinceIso), + ]); + + const failures: Error[] = []; + const pulls = extractSettled(pullsResult, "pulls list", failures); + const issueComments = extractSettled(issueCommentsResult, "issue comments", failures); + const reviewComments = extractSettled(reviewCommentsResult, "review comments", failures); + + if (failures.length >= 3) { + throw new GitHubActivityFetchError( + `All GitHub endpoints failed while fetching activity for ${options.owner}/${options.repo}: ${failures[0]!.message}`, + failures, + ); + } + + // Index PRs we've already seen (mix: recent updates + PRs referenced by + // comments). A single pass keeps bucket construction cheap. + const bucketByPr = new Map(); + const ensureBucket = (prNumber: number, prMeta?: GhPullRequest): PrSummary => { + let bucket = bucketByPr.get(prNumber); + if (!bucket) { + bucket = { + prNumber, + title: prMeta?.title ?? `#${prNumber}`, + url: prMeta?.html_url ?? "", + author: loginOf(prMeta?.user), + state: prMeta?.state ?? "unknown", + headRef: prMeta?.head?.ref ?? null, + baseRef: prMeta?.base?.ref ?? null, + updatedAt: toMs(prMeta?.updated_at), + events: [], + }; + bucketByPr.set(prNumber, bucket); + } else if (prMeta) { + // Upgrade the bucket with richer metadata if we see the full PR after + // an initial stub. + bucket.title = prMeta.title || bucket.title; + bucket.url = prMeta.html_url || bucket.url; + bucket.author = loginOf(prMeta.user) || bucket.author; + bucket.state = prMeta.state || bucket.state; + bucket.headRef = prMeta.head?.ref ?? bucket.headRef; + bucket.baseRef = prMeta.base?.ref ?? bucket.baseRef; + bucket.updatedAt = Math.max(bucket.updatedAt, toMs(prMeta.updated_at)); + } + return bucket; + }; + + for (const pr of pulls) { + const bucket = ensureBucket(pr.number, pr); + bucket.events.push(toPrEventFromPr(pr)); + } + + for (const c of issueComments) { + if (toMs(c.updated_at || c.created_at) <= sinceMs) continue; + const ev = toPrEventFromIssueComment(c); + if (!ev) continue; // skip non-PR issue comments + const prNumberFromMeta = Number(ev.meta?.prNumber); + if (!Number.isFinite(prNumberFromMeta)) continue; + const bucket = ensureBucket(prNumberFromMeta); + bucket.events.push(ev); + } + + for (const c of reviewComments) { + if (toMs(c.updated_at || c.created_at) <= sinceMs) continue; + const prNumber = prNumberFromPullUrl(c.pull_request_url); + if (!prNumber) continue; + const bucket = ensureBucket(prNumber); + bucket.events.push(toPrEventFromReviewComment(c)); + } + + // Pull reviews for every bucket we now have. Done sequentially per-PR to + // keep the request fan-out bounded; most polls touch ≤ a handful of PRs. + for (const bucket of bucketByPr.values()) { + try { + const reviews = await fetchReviewsForPr( + ctx, + options.owner, + options.repo, + bucket.prNumber, + sinceMs, + ); + for (const r of reviews) bucket.events.push(toPrEventFromReview(r)); + } catch (err) { + log.warn("pr-tracker: reviews fetch failed", { + owner: options.owner, + repo: options.repo, + prNumber: bucket.prNumber, + err: String(err), + }); + } + } + + // Drop buckets with zero qualifying events (e.g. a PR that only rolled + // over its `updated_at` before our cursor due to clock skew). + const summaries: PrSummary[] = []; + for (const bucket of bucketByPr.values()) { + if (bucket.events.length === 0) continue; + bucket.events.sort((a, b) => a.timestamp - b.timestamp); + summaries.push(bucket); + } + // Stable deterministic ordering: oldest updated first so downstream + // processing preserves causal ordering in output. + summaries.sort((a, b) => a.updatedAt - b.updatedAt); + return summaries; +} + +// --------------------------------------------------------------------------- +// Prompt rendering helpers (used by scheduler, factored here to keep the +// templating next to the GitHub response shapes). +// --------------------------------------------------------------------------- + +const MAX_EVENTS_IN_SUMMARY = 20; +const MAX_EVENT_BODY_CHARS = 400; + +export function renderEventsSummary(events: PrEvent[]): string { + if (events.length === 0) return "(no new events)"; + const capped = events.slice(-MAX_EVENTS_IN_SUMMARY); + const hidden = events.length - capped.length; + const lines = capped.map((ev) => { + const kindLabel = + ev.kind === "comment" + ? "Comment" + : ev.kind === "review_comment" + ? "Review comment" + : ev.kind === "review" + ? `Review (${ev.meta?.state ?? "commented"})` + : "PR update"; + const body = ev.body ? ` — ${truncate(ev.body.replace(/\s+/g, " "), MAX_EVENT_BODY_CHARS)}` : ""; + return `- [${kindLabel}] @${ev.author}${body}`; + }); + if (hidden > 0) { + lines.unshift(`...and ${hidden} older event(s) omitted`); + } + return lines.join("\n"); +} + +export function renderPrompt( + template: string, + context: { + repoFullName: string; + prNumber: number; + prTitle: string; + prUrl: string; + prAuthor: string; + prState: string; + headRef: string | null; + baseRef: string | null; + events: PrEvent[]; + }, +): string { + const summary = renderEventsSummary(context.events); + const replacements: Record = { + "{{repo_full_name}}": context.repoFullName, + "{{pr_number}}": String(context.prNumber), + "{{pr_title}}": context.prTitle, + "{{pr_url}}": context.prUrl, + "{{pr_author}}": context.prAuthor, + "{{pr_state}}": context.prState, + "{{pr_head_ref}}": context.headRef ?? "", + "{{pr_base_ref}}": context.baseRef ?? "", + "{{new_events_summary}}": summary, + }; + let rendered = template; + for (const [token, value] of Object.entries(replacements)) { + rendered = rendered.split(token).join(value); + } + return rendered; +} diff --git a/packages/core/pr-tracker/scheduler.ts b/packages/core/pr-tracker/scheduler.ts new file mode 100644 index 00000000..747415f3 --- /dev/null +++ b/packages/core/pr-tracker/scheduler.ts @@ -0,0 +1,608 @@ +import { createAgentAdapter } from "@/agents/adapter"; +import type { OpenCodeMessageContext } from "@/agents"; +import { + getChannelAgentProvider, + getChannelBaseBranch, + getChannelModel, + getChannelSystemMessage, + getUserGeneralSettings, + resolveChannelCwd, +} from "@/config"; +import { + DEFAULT_PR_AGENT_PROVIDER, + DEFAULT_PR_PROMPT_TEMPLATE, + getPrTrackerById, + getPrTrackerSettings, + listDuePrTrackers, + listProcessedEventIds, + markPrTrackerPolled, + recordPrTrackerEvent, + setPrTrackerCursor, + type PrTrackerRecord, +} from "@/config/local/pr-trackers"; +import { saveSession, type PersistedSession } from "@/config/local/sessions"; +import { buildMessageOptions } from "@/core/runtime/message-options"; +import { + buildFinalResponseText, + categorizeRuntimeError, +} from "@/core/runtime/helpers"; +import { buildSessionEnvironment, prepareSessionWorkspace } from "@/core/session"; +import { sendChannelMessage as sendDiscordChannelMessage } from "@/ims/discord/client"; +import { sendChannelMessage as sendLarkChannelMessage } from "@/ims/lark/client"; +import { sendChannelMessage as sendSlackChannelMessage } from "@/ims/slack/client"; +import { + type AgentProviderId, + isAgentProviderId, +} from "@/shared/agent-provider"; +import { log } from "@/utils"; +import { + fetchPrActivity, + renderPrompt, + resolveGitHubToken, + type PrEvent, + type PrSummary, +} from "./github"; + +// --------------------------------------------------------------------------- +// PR Tracker scheduler. +// +// A polling loop that, every N seconds, looks at enabled pr_trackers whose +// resolved interval has elapsed, queries GitHub for new PR activity, and +// dispatches one agent run per PR with new events. The agent's final +// response is posted to the tracker's target channel. +// +// Structurally mirrors packages/core/tasks/scheduler.ts: +// - Polls SQLite for due rows every PR_POLL_TICK_MS. +// - Uses an in-memory `runningTrackerIds` guard to avoid overlapping ticks +// for the same tracker. +// - Wraps network and agent phases in per-step timeouts so a stuck poll +// can't deadlock the tick loop. +// --------------------------------------------------------------------------- + +const PR_POLL_TICK_MS = 60_000; // loop every 60s; per-tracker cadence is enforced in listDuePrTrackers + +const PR_GITHUB_FETCH_TIMEOUT_MS = parsePositiveIntEnv( + process.env.ODE_PR_TRACKER_FETCH_TIMEOUT_MS, + 60_000, +); + +const PR_AGENT_TIMEOUT_MS = parsePositiveIntEnv( + process.env.ODE_PR_TRACKER_AGENT_TIMEOUT_MS, + 30 * 60_000, +); + +/** + * Safety cap on the number of PRs handled in a single poll for a single + * tracker. If a repo has been quiet for hours and then a storm of activity + * arrives, we don't want to fan out dozens of agent runs at once. Excess + * PRs are recorded as dedupe rows (so the next poll won't re-dispatch + * them) and surfaced via `markPrTrackerPolled` as a non-fatal warning. + */ +const PR_MAX_PRS_PER_POLL = parsePositiveIntEnv( + process.env.ODE_PR_TRACKER_MAX_PRS_PER_POLL, + 5, +); + +function parsePositiveIntEnv(raw: string | undefined, fallback: number): number { + if (!raw) return fallback; + const parsed = Number.parseInt(raw, 10); + if (!Number.isFinite(parsed) || parsed <= 0) return fallback; + return parsed; +} + +class PrStepTimeoutError extends Error { + constructor(step: string, timeoutMs: number) { + super(`${step} timed out after ${timeoutMs}ms`); + this.name = "PrStepTimeoutError"; + } +} + +function withTimeout(promise: Promise, timeoutMs: number, step: string): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new PrStepTimeoutError(step, timeoutMs)), timeoutMs); + promise.then( + (value) => { + clearTimeout(timer); + resolve(value); + }, + (error) => { + clearTimeout(timer); + reject(error); + }, + ); + }); +} + +let prTrackerTimer: ReturnType | null = null; +const runningTrackerIds = new Set(); + +function resolveAgentProvider(tracker: PrTrackerRecord): AgentProviderId { + const override = tracker.agentProvider?.trim(); + if (override && isAgentProviderId(override)) return override; + const fallback = getPrTrackerSettings().defaultAgentProvider; + if (isAgentProviderId(fallback)) return fallback; + if (isAgentProviderId(DEFAULT_PR_AGENT_PROVIDER)) return DEFAULT_PR_AGENT_PROVIDER; + // Last resort: take the tracker's source channel default. + return getChannelAgentProvider(tracker.sourceChannelId); +} + +function resolvePromptTemplate(tracker: PrTrackerRecord): string { + const override = tracker.promptTemplate?.trim(); + if (override) return override; + const global = getPrTrackerSettings().defaultPromptTemplate?.trim(); + return global && global.length > 0 ? global : DEFAULT_PR_PROMPT_TEMPLATE; +} + +function resolveToken(tracker: PrTrackerRecord): string | null { + const global = getPrTrackerSettings().defaultGithubToken; + return resolveGitHubToken({ + trackerToken: tracker.githubToken, + globalToken: global, + }); +} + +function resolveTargetPlatform(tracker: PrTrackerRecord): "slack" | "discord" | "lark" { + return (tracker.targetPlatform ?? tracker.sourcePlatform) as + | "slack" + | "discord" + | "lark"; +} + +async function sendToTargetChannel( + tracker: PrTrackerRecord, + text: string, +): Promise { + if (!tracker.targetChannelId) return undefined; + const platform = resolveTargetPlatform(tracker); + if (platform === "slack") { + // Per spec: post as a top-level channel message, not threaded. + return await sendSlackChannelMessage(tracker.targetChannelId, text); + } + if (platform === "discord") { + return await sendDiscordChannelMessage(tracker.targetChannelId, text); + } + return await sendLarkChannelMessage(tracker.targetChannelId, text); +} + +function syntheticThreadIdForPr(trackerId: string, prNumber: number, ts: number): string { + return `pr-tracker:${trackerId}:${prNumber}:${ts}`; +} + +function buildAgentContext(tracker: PrTrackerRecord, threadId: string): OpenCodeMessageContext { + return { + slack: { + platform: tracker.sourcePlatform, + channelId: tracker.sourceChannelId, + threadId, + userId: `pr-tracker:${tracker.id}`, + hasGitHubToken: false, + channelSystemMessage: + getChannelSystemMessage(tracker.sourceChannelId) ?? undefined, + }, + }; +} + +async function prepareAgentSession( + tracker: PrTrackerRecord, + prNumber: number, +): Promise<{ + session: PersistedSession; + sessionId: string; + cwd: string; + threadId: string; + providerId: AgentProviderId; +}> { + const provider = resolveAgentProvider(tracker); + const agent = createAgentAdapter({ providerOverride: provider }); + const threadId = syntheticThreadIdForPr(tracker.id, prNumber, Date.now()); + const userId = `pr-tracker:${tracker.id}`; + + let cwd = resolveChannelCwd(tracker.sourceChannelId).cwd; + + const { env: sessionEnv, gitIdentity } = buildSessionEnvironment({ + threadOwnerUserId: userId, + }); + + const { sessionId } = await agent.getOrCreateSession( + tracker.sourceChannelId, + threadId, + cwd, + sessionEnv, + ); + + if (getUserGeneralSettings().gitStrategy === "worktree") { + const baseBranch = getChannelBaseBranch(tracker.sourceChannelId); + const prepared = await prepareSessionWorkspace({ + channelId: tracker.sourceChannelId, + threadId, + cwd, + worktreeId: `ode_pr_${tracker.id.replace(/[^a-zA-Z0-9_-]/g, "_")}_${prNumber}`, + baseBranch, + sessionEnv, + gitIdentity, + }); + cwd = prepared.cwd; + } + + const providerId = agent.getProviderForSession(sessionId); + const session: PersistedSession = { + sessionId, + providerId, + platform: tracker.sourcePlatform, + channelId: tracker.sourceChannelId, + threadId, + workingDirectory: cwd, + threadOwnerUserId: userId, + participantBotIds: ["pr-tracker"], + createdAt: Date.now(), + lastActivityAt: Date.now(), + lastActivityBotId: "pr-tracker", + }; + saveSession(session); + + return { session, sessionId, cwd, threadId, providerId }; +} + +/** + * Run a single PR's aggregated events through the agent and post the result + * to the tracker's target channel. Records an `aggregate` event row on + * success so the next poll won't re-dispatch. + * + * Individual per-event dedupe rows are written separately (before dispatch) + * so that even a partially-failed run leaves a breadcrumb and avoids + * re-running on the same events the moment the scheduler recovers. + */ +async function runForPr(tracker: PrTrackerRecord, summary: PrSummary): Promise { + const template = resolvePromptTemplate(tracker); + const repoFullName = `${tracker.repoOwner}/${tracker.repoName}`; + const prompt = renderPrompt(template, { + repoFullName, + prNumber: summary.prNumber, + prTitle: summary.title, + prUrl: summary.url, + prAuthor: summary.author, + prState: summary.state, + headRef: summary.headRef, + baseRef: summary.baseRef, + events: summary.events, + }); + + const aggregateEventId = `${summary.prNumber}:${summary.events + .map((e) => `${e.kind}:${e.githubEventId}`) + .sort() + .join("|")}`; + + let agentSessionId: string | null = null; + try { + const { sessionId, cwd, threadId, providerId } = await prepareAgentSession( + tracker, + summary.prNumber, + ); + agentSessionId = sessionId; + + const options = buildMessageOptions({ + text: prompt, + channelId: tracker.sourceChannelId, + providerId, + }); + const agent = createAgentAdapter({ providerOverride: providerId }); + + const responses = await withTimeout( + agent.sendMessage( + tracker.sourceChannelId, + sessionId, + prompt, + cwd, + options, + buildAgentContext(tracker, threadId), + ), + PR_AGENT_TIMEOUT_MS, + "PR tracker agent turn", + ); + const finalText = + buildFinalResponseText(responses) ?? "_(PR tracker: agent produced no output)_"; + + const header = `PR update: <${summary.url}|#${summary.prNumber} ${escapeSlackText( + summary.title, + )}> in ${repoFullName}`; + // Slack-only mrkdwn link; Discord/Lark receive the URL inline. + const bodyHeader = + tracker.targetPlatform === "slack" + ? header + : `PR update: #${summary.prNumber} ${summary.title} (${summary.url}) in ${repoFullName}`; + await sendToTargetChannel(tracker, `${bodyHeader}\n\n${finalText}`); + + // Record per-event dedupe rows + an aggregate row. + for (const event of summary.events) { + recordPrTrackerEvent({ + trackerId: tracker.id, + prNumber: summary.prNumber, + eventType: event.kind, + githubEventId: event.githubEventId, + prUpdatedAt: event.timestamp, + agentSessionId: sessionId, + agentStatus: "success", + }); + } + recordPrTrackerEvent({ + trackerId: tracker.id, + prNumber: summary.prNumber, + eventType: "aggregate", + githubEventId: aggregateEventId, + prUpdatedAt: summary.updatedAt, + agentSessionId: sessionId, + agentStatus: "success", + }); + } catch (error) { + const { message } = categorizeRuntimeError(error); + log.warn("PR tracker agent run failed", { + trackerId: tracker.id, + prNumber: summary.prNumber, + error: String(error), + }); + // Record a failed aggregate so operators can inspect the event log. + recordPrTrackerEvent({ + trackerId: tracker.id, + prNumber: summary.prNumber, + eventType: "aggregate", + githubEventId: aggregateEventId, + prUpdatedAt: summary.updatedAt, + agentSessionId, + agentStatus: "failed", + errorMessage: message, + }); + throw error; + } +} + +function escapeSlackText(value: string): string { + return value.replace(/&/g, "&").replace(//g, ">"); +} + +/** + * Filter out events we've already processed in a previous poll, keyed on + * (tracker, kind, github_event_id). + */ +function dropAlreadyProcessedEvents( + trackerId: string, + summaries: PrSummary[], +): PrSummary[] { + if (summaries.length === 0) return summaries; + const byKind = new Map(); + for (const s of summaries) { + for (const ev of s.events) { + const list = byKind.get(ev.kind) ?? []; + list.push(ev.githubEventId); + byKind.set(ev.kind, list); + } + } + const seenByKind = new Map>(); + for (const [kind, ids] of byKind.entries()) { + seenByKind.set(kind, listProcessedEventIds(trackerId, kind, ids)); + } + const filtered: PrSummary[] = []; + for (const s of summaries) { + const fresh: PrEvent[] = s.events.filter((ev) => { + const seen = seenByKind.get(ev.kind); + return !seen || !seen.has(ev.githubEventId); + }); + if (fresh.length === 0) continue; + filtered.push({ ...s, events: fresh }); + } + return filtered; +} + +export type PrPollOutcome = { + trackerId: string; + prsScanned: number; + prsHandled: number; + prsSkipped: number; + error?: string; +}; + +/** + * Run a single tick for one tracker: fetch activity, filter, dispatch. + * Exported for tests and manual triggers (ode pr-tracker run). + */ +export async function pollTracker(trackerId: string): Promise { + const tracker = getPrTrackerById(trackerId); + if (!tracker) { + return { + trackerId, + prsScanned: 0, + prsHandled: 0, + prsSkipped: 0, + error: "tracker not found", + }; + } + if (!tracker.enabled) { + return { + trackerId, + prsScanned: 0, + prsHandled: 0, + prsSkipped: 0, + error: "tracker disabled", + }; + } + if (!tracker.targetChannelId) { + markPrTrackerPolled(tracker.id, { + success: false, + errorMessage: "target channel not configured", + }); + return { + trackerId, + prsScanned: 0, + prsHandled: 0, + prsSkipped: 0, + error: "no target channel", + }; + } + + const token = resolveToken(tracker); + const since = tracker.lastPolledAt ?? Date.now() - 24 * 60 * 60_000; + + try { + const summaries = await withTimeout( + fetchPrActivity({ + owner: tracker.repoOwner, + repo: tracker.repoName, + // Route per-tracker host into the REST base URL so Enterprise + // trackers stop hitting api.github.com. + host: tracker.repoHost, + sinceMs: since, + token, + }), + PR_GITHUB_FETCH_TIMEOUT_MS, + "PR tracker GitHub fetch", + ); + + const fresh = dropAlreadyProcessedEvents(tracker.id, summaries); + + if (fresh.length === 0) { + markPrTrackerPolled(tracker.id, { success: true }); + return { trackerId, prsScanned: summaries.length, prsHandled: 0, prsSkipped: 0 }; + } + + // Process PRs oldest-updated first so that if we get capped below, the + // skipped PRs are the most-recently-updated ones (their cursor-based + // rewind is the cleanest: they'll still show up in the next poll). + const ordered = [...fresh].sort((a, b) => a.updatedAt - b.updatedAt); + const handled: PrSummary[] = ordered.slice(0, PR_MAX_PRS_PER_POLL); + const skippedPrs: PrSummary[] = ordered.slice(PR_MAX_PRS_PER_POLL); + + let hadFailure = false; + for (const summary of handled) { + try { + await runForPr(tracker, summary); + } catch { + hadFailure = true; + // Continue with the next PR — one bad PR shouldn't block the others. + } + } + + if (skippedPrs.length > 0) { + log.info("PR tracker capped PRs in a single poll", { + trackerId: tracker.id, + totalPrs: fresh.length, + handled: handled.length, + skipped: skippedPrs.length, + }); + } + + if (hadFailure) { + // One or more PR dispatches failed. Treat the whole poll as failed so + // the cursor stays pinned; the next tick will retry. + markPrTrackerPolled(tracker.id, { + success: false, + errorMessage: "one or more PR dispatches failed", + }); + } else if (skippedPrs.length > 0) { + // Success from the caller's perspective, but we still have work left. + // Move the cursor forward only to JUST BEFORE the oldest skipped PR's + // earliest new event, so the next `since` window keeps the skipped + // PRs discoverable. This is the trick that keeps capped-out polls + // lossless across ticks. + const earliestSkippedTimestamp = earliestEventTimestamp(skippedPrs); + // Subtract 1ms so `updated_at > since` in the GitHub API still matches + // the skipped events on the next poll. + const nextCursor = Math.max(since, earliestSkippedTimestamp - 1); + setPrTrackerCursor(tracker.id, nextCursor); + // Also clear any prior `last_error` and refresh last_success_at. + markPrTrackerPolled(tracker.id, { + success: true, + pollCompletedAt: nextCursor, + }); + } else { + markPrTrackerPolled(tracker.id, { success: true }); + } + + return { + trackerId, + prsScanned: summaries.length, + prsHandled: handled.length, + prsSkipped: skippedPrs.length, + error: hadFailure ? "partial failure" : undefined, + }; + } catch (error) { + const { message } = categorizeRuntimeError(error); + // Failure branch leaves last_polled_at untouched (see + // markPrTrackerPolled) so the next tick retries the same window. + markPrTrackerPolled(tracker.id, { success: false, errorMessage: message }); + log.warn("PR tracker poll failed", { + trackerId: tracker.id, + repo: `${tracker.repoOwner}/${tracker.repoName}`, + error: String(error), + }); + return { + trackerId, + prsScanned: 0, + prsHandled: 0, + prsSkipped: 0, + error: message, + }; + } +} + +function earliestEventTimestamp(summaries: PrSummary[]): number { + let earliest = Number.POSITIVE_INFINITY; + for (const s of summaries) { + for (const ev of s.events) { + if (ev.timestamp > 0 && ev.timestamp < earliest) earliest = ev.timestamp; + } + // Fall back to the PR's updated_at if for some reason no event has a + // timestamp (defensive — shouldn't happen with our current event shapes). + if (s.updatedAt > 0 && s.updatedAt < earliest) earliest = s.updatedAt; + } + return Number.isFinite(earliest) ? earliest : Date.now(); +} + +async function tick(): Promise { + const due = listDuePrTrackers(); + for (const tracker of due) { + if (runningTrackerIds.has(tracker.id)) continue; + runningTrackerIds.add(tracker.id); + void pollTracker(tracker.id).finally(() => { + runningTrackerIds.delete(tracker.id); + }); + } +} + +export function startPrTrackerScheduler(): void { + if (prTrackerTimer) return; + // Prime a tick so newly-enabled trackers run sooner than the first interval. + void tick(); + prTrackerTimer = setInterval(() => { + void tick(); + }, PR_POLL_TICK_MS); + log.debug("PR tracker scheduler started", { intervalMs: PR_POLL_TICK_MS }); +} + +export function stopPrTrackerScheduler(): void { + if (!prTrackerTimer) return; + clearInterval(prTrackerTimer); + prTrackerTimer = null; + runningTrackerIds.clear(); + log.debug("PR tracker scheduler stopped"); +} + +/** + * Manual trigger: poll a single tracker immediately. Used by + * `ode pr-tracker run` and the HTTP run endpoint. + */ +export async function triggerPrTrackerNow(trackerId: string): Promise { + if (runningTrackerIds.has(trackerId)) { + return { + trackerId, + prsScanned: 0, + prsHandled: 0, + prsSkipped: 0, + error: "already running", + }; + } + runningTrackerIds.add(trackerId); + try { + return await pollTracker(trackerId); + } finally { + runningTrackerIds.delete(trackerId); + } +} diff --git a/packages/core/web/app.ts b/packages/core/web/app.ts index b4a29a84..07b5bf0b 100644 --- a/packages/core/web/app.ts +++ b/packages/core/web/app.ts @@ -8,6 +8,7 @@ import { registerSessionRoutes } from "./routes/sessions"; import { registerInboxRoutes } from "./routes/inbox"; import { registerCronJobRoutes } from "./routes/cron-jobs"; import { registerTaskRoutes } from "./routes/tasks"; +import { registerPrTrackerRoutes } from "./routes/pr-trackers"; import { registerSendRoutes } from "./routes/send"; import { registerMessagesRoutes } from "./routes/messages"; import { registerReactionsRoutes } from "./routes/reactions"; @@ -37,6 +38,7 @@ export function createWebApp(): Elysia { registerInboxRoutes(app); registerCronJobRoutes(app); registerTaskRoutes(app); + registerPrTrackerRoutes(app); registerSendRoutes(app); registerMessagesRoutes(app); registerReactionsRoutes(app); diff --git a/packages/core/web/routes/pr-trackers.ts b/packages/core/web/routes/pr-trackers.ts new file mode 100644 index 00000000..ef020d54 --- /dev/null +++ b/packages/core/web/routes/pr-trackers.ts @@ -0,0 +1,256 @@ +import type { Elysia } from "elysia"; +import { + deletePrTracker, + getPrTrackerById, + getPrTrackerSettings, + listPrTrackerEvents, + listPrTrackers, + scanPrTrackers, + updatePrTracker, + updatePrTrackerSettings, + type UpdatePrTrackerParams, + type UpdatePrTrackerSettingsParams, +} from "@/config/local/pr-trackers"; +import { listTaskChannelOptions } from "@/config/local/tasks"; +import { triggerPrTrackerNow } from "@/core/pr-tracker/scheduler"; +import { log } from "@/utils"; +import { jsonResponse, readJsonBody, runRoute } from "../http"; + +// --------------------------------------------------------------------------- +// PR Tracker HTTP API. +// +// Follows the same pattern as /api/tasks and /api/cron-jobs: flat JSON, +// `{ ok, result }` envelopes via jsonResponse, and a companion CLI that talks +// to these routes so storage mutations stay centralized. +// +// Mutations are intentionally narrow: +// - `POST /api/pr-trackers/scan` rescans the workspace → derives rows. +// - `PUT /api/pr-trackers/:id` updates per-tracker config (enable, token, +// prompt, interval, agent, target channel). +// - `DELETE /api/pr-trackers/:id` hard-deletes a row + its event log. +// - `POST /api/pr-trackers/:id/run` manually polls one tracker. +// Settings live under /api/pr-trackers/settings. +// --------------------------------------------------------------------------- + +function getString(payload: Record, key: string): string { + const value = payload[key]; + return typeof value === "string" ? value : ""; +} + +function getOptionalString( + payload: Record, + key: string, +): string | null | undefined { + if (!(key in payload)) return undefined; + const value = payload[key]; + if (value === null) return null; + if (typeof value === "string") return value; + return undefined; +} + +function getNumber(payload: Record, key: string): number | undefined { + const value = payload[key]; + if (typeof value === "number" && Number.isFinite(value)) return value; + if (typeof value === "string") { + const parsed = Number(value); + if (Number.isFinite(parsed)) return parsed; + } + return undefined; +} + +function getOptionalNumber( + payload: Record, + key: string, +): number | null | undefined { + if (!(key in payload)) return undefined; + const value = payload[key]; + if (value === null) return null; + return getNumber(payload, key); +} + +function getBoolean(payload: Record, key: string): boolean | undefined { + const value = payload[key]; + return typeof value === "boolean" ? value : undefined; +} + +function parseTrackerUpdate(payload: Record): UpdatePrTrackerParams { + const update: UpdatePrTrackerParams = {}; + const enabled = getBoolean(payload, "enabled"); + if (enabled !== undefined) update.enabled = enabled; + if ("agentProvider" in payload) { + const raw = getOptionalString(payload, "agentProvider"); + update.agentProvider = raw === undefined ? null : raw; + } + if ("promptTemplate" in payload) { + const raw = getOptionalString(payload, "promptTemplate"); + update.promptTemplate = raw === undefined ? null : raw; + } + if ("pollIntervalSec" in payload) { + const raw = getOptionalNumber(payload, "pollIntervalSec"); + update.pollIntervalSec = raw === undefined ? null : raw; + } + if ("githubToken" in payload) { + const raw = getOptionalString(payload, "githubToken"); + update.githubToken = raw === undefined ? null : raw; + } + if ("targetChannelId" in payload) { + const raw = getOptionalString(payload, "targetChannelId"); + update.targetChannelId = raw === undefined ? null : raw; + } + return update; +} + +function parseSettingsUpdate( + payload: Record, +): UpdatePrTrackerSettingsParams { + const update: UpdatePrTrackerSettingsParams = {}; + const interval = getNumber(payload, "defaultPollIntervalSec"); + if (interval !== undefined) update.defaultPollIntervalSec = interval; + const agent = getOptionalString(payload, "defaultAgentProvider"); + if (agent !== undefined) update.defaultAgentProvider = agent ?? ""; + const prompt = getOptionalString(payload, "defaultPromptTemplate"); + if (prompt !== undefined) update.defaultPromptTemplate = prompt ?? ""; + const token = getOptionalString(payload, "defaultGithubToken"); + if (token !== undefined) update.defaultGithubToken = token ?? ""; + return update; +} + +function buildListPayload() { + return { + trackers: listPrTrackers(), + channels: listTaskChannelOptions(), + settings: getPrTrackerSettings(), + }; +} + +export function registerPrTrackerRoutes(app: Elysia): void { + app.get("/api/pr-trackers", async () => { + return runRoute( + async () => buildListPayload(), + (result) => jsonResponse(200, { ok: true, result }), + { fallbackMessage: "Failed to load PR trackers", status: 500 }, + ); + }); + + app.get("/api/pr-trackers/:id", async ({ params }: { params: { id?: string } }) => { + return runRoute( + async () => { + const id = params.id?.trim(); + if (!id) throw new Error("Missing tracker id"); + const tracker = getPrTrackerById(id); + if (!tracker) throw new Error("Tracker not found"); + return { + tracker, + events: listPrTrackerEvents(id, 100), + channels: listTaskChannelOptions(), + settings: getPrTrackerSettings(), + }; + }, + (result) => jsonResponse(200, { ok: true, result }), + { + fallbackMessage: "Failed to load tracker", + resolveStatus: (message) => { + if (message === "Missing tracker id") return 400; + if (message === "Tracker not found") return 404; + return 500; + }, + }, + ); + }); + + app.post("/api/pr-trackers/scan", async () => { + return runRoute( + async () => { + const result = scanPrTrackers(); + return { scan: result, ...buildListPayload() }; + }, + (result) => jsonResponse(200, { ok: true, result }), + { fallbackMessage: "Scan failed", status: 500 }, + ); + }); + + app.put("/api/pr-trackers/:id", async ({ params, request }: { params: { id?: string }; request: Request }) => { + return runRoute( + async () => { + const id = params.id?.trim(); + if (!id) throw new Error("Missing tracker id"); + const body = await readJsonBody(request); + const update = parseTrackerUpdate(body); + const tracker = updatePrTracker(id, update); + return { tracker, ...buildListPayload() }; + }, + (result) => jsonResponse(200, { ok: true, result }), + { + fallbackMessage: "Failed to update tracker", + resolveStatus: (message) => { + if (message === "Missing tracker id") return 400; + if (message === "PR tracker not found") return 404; + if (message.includes("target channel")) return 400; + if (message.includes("missing")) return 409; + return 400; + }, + }, + ); + }); + + app.delete("/api/pr-trackers/:id", async ({ params }: { params: { id?: string } }) => { + return runRoute( + async () => { + const id = params.id?.trim(); + if (!id) throw new Error("Missing tracker id"); + deletePrTracker(id); + return buildListPayload(); + }, + (result) => jsonResponse(200, { ok: true, result }), + { fallbackMessage: "Failed to delete tracker", status: 400 }, + ); + }); + + app.post("/api/pr-trackers/:id/run", async ({ params }: { params: { id?: string } }) => { + return runRoute( + async () => { + const id = params.id?.trim(); + if (!id) throw new Error("Missing tracker id"); + // Fire-and-forget; callers poll /api/pr-trackers/:id to see the + // updated `last_polled_at` / `last_error` state. + triggerPrTrackerNow(id) + .then((outcome) => { + log.info("Manually triggered PR tracker poll", outcome); + }) + .catch((error) => { + log.warn("Manually triggered PR tracker poll failed", { + trackerId: id, + error: String(error), + }); + }); + return buildListPayload(); + }, + (result) => jsonResponse(200, { ok: true, result }), + { + fallbackMessage: "Failed to run tracker", + resolveStatus: (message) => (message === "Missing tracker id" ? 400 : 500), + }, + ); + }); + + app.get("/api/pr-trackers/settings", async () => { + return runRoute( + async () => ({ settings: getPrTrackerSettings() }), + (result) => jsonResponse(200, { ok: true, result }), + { fallbackMessage: "Failed to load settings", status: 500 }, + ); + }); + + app.put("/api/pr-trackers/settings", async ({ request }: { request: Request }) => { + return runRoute( + async () => { + const body = await readJsonBody(request); + const update = parseSettingsUpdate(body); + const settings = updatePrTrackerSettings(update); + return { settings }; + }, + (result) => jsonResponse(200, { ok: true, result }), + { fallbackMessage: "Failed to update settings", status: 400 }, + ); + }); +} diff --git a/packages/utils/git-remote.test.ts b/packages/utils/git-remote.test.ts new file mode 100644 index 00000000..93abc7a1 --- /dev/null +++ b/packages/utils/git-remote.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, test } from "bun:test"; +import { parseGitHubRemote } from "./git-remote"; + +describe("parseGitHubRemote", () => { + test("returns null for empty/null/undefined", () => { + expect(parseGitHubRemote(null)).toBeNull(); + expect(parseGitHubRemote(undefined)).toBeNull(); + expect(parseGitHubRemote("")).toBeNull(); + expect(parseGitHubRemote(" ")).toBeNull(); + }); + + test("parses https url with .git suffix", () => { + expect(parseGitHubRemote("https://github.com/anomalyco/ode.git")).toEqual({ + host: "github.com", + owner: "anomalyco", + repo: "ode", + }); + }); + + test("parses https url without .git suffix", () => { + expect(parseGitHubRemote("https://github.com/anomalyco/ode")).toEqual({ + host: "github.com", + owner: "anomalyco", + repo: "ode", + }); + }); + + test("parses https url with trailing slash", () => { + expect(parseGitHubRemote("https://github.com/anomalyco/ode/")).toEqual({ + host: "github.com", + owner: "anomalyco", + repo: "ode", + }); + }); + + test("parses scp-like git@ url", () => { + expect(parseGitHubRemote("git@github.com:anomalyco/ode.git")).toEqual({ + host: "github.com", + owner: "anomalyco", + repo: "ode", + }); + }); + + test("parses ssh://git@ url", () => { + expect(parseGitHubRemote("ssh://git@github.com/anomalyco/ode.git")).toEqual({ + host: "github.com", + owner: "anomalyco", + repo: "ode", + }); + }); + + test("parses git:// url", () => { + expect(parseGitHubRemote("git://github.com/anomalyco/ode.git")).toEqual({ + host: "github.com", + owner: "anomalyco", + repo: "ode", + }); + }); + + test("parses https url with user info", () => { + expect(parseGitHubRemote("https://user:token@github.com/anomalyco/ode.git")).toEqual({ + host: "github.com", + owner: "anomalyco", + repo: "ode", + }); + }); + + test("parses github enterprise hostnames", () => { + expect(parseGitHubRemote("https://github.corp.example.com/team/proj.git")).toEqual({ + host: "github.corp.example.com", + owner: "team", + repo: "proj", + }); + expect(parseGitHubRemote("git@github.enterprise.io:team/proj.git")).toEqual({ + host: "github.enterprise.io", + owner: "team", + repo: "proj", + }); + }); + + test("rejects non-github hosts", () => { + expect(parseGitHubRemote("https://gitlab.com/foo/bar.git")).toBeNull(); + expect(parseGitHubRemote("git@bitbucket.org:foo/bar.git")).toBeNull(); + expect(parseGitHubRemote("https://codeberg.org/foo/bar")).toBeNull(); + }); + + test("rejects look-alike hosts containing the substring 'github'", () => { + expect(parseGitHubRemote("https://notgithub.com/foo/bar.git")).toBeNull(); + expect(parseGitHubRemote("git@evilgithub.org:foo/bar.git")).toBeNull(); + expect(parseGitHubRemote("https://github-mirror.example.com/foo/bar")).toBeNull(); + }); + + test("accepts github.com subdomains", () => { + expect(parseGitHubRemote("https://www.github.com/foo/bar.git")?.host).toBe("www.github.com"); + expect(parseGitHubRemote("git@ssh.github.com:foo/bar.git")?.host).toBe("ssh.github.com"); + }); + + test("handles repo names with dots and dashes", () => { + expect(parseGitHubRemote("https://github.com/foo/my-repo.js.git")).toEqual({ + host: "github.com", + owner: "foo", + repo: "my-repo.js", + }); + }); + + test("returns null for garbage input", () => { + expect(parseGitHubRemote("not a url")).toBeNull(); + expect(parseGitHubRemote("https://github.com")).toBeNull(); + expect(parseGitHubRemote("https://github.com/onlyowner")).toBeNull(); + }); +}); diff --git a/packages/utils/git-remote.ts b/packages/utils/git-remote.ts new file mode 100644 index 00000000..aa07eee8 --- /dev/null +++ b/packages/utils/git-remote.ts @@ -0,0 +1,91 @@ +import { spawnSync } from "child_process"; + +export type GitHubRepo = { + owner: string; + repo: string; + host: string; // e.g. "github.com" or "github.enterprise.com" +}; + +/** + * Return true iff `host` is a legitimate GitHub hostname. We accept the + * canonical public host, explicit subdomains of `github.com`, and anything + * whose hostname literally starts with `github.` (typical GHES convention + * for per-tenant subdomains like `github.corp.example.com`). We reject + * look-alike domains such as `notgithub.com` that contain the substring + * "github" but are not actually run by GitHub. + */ +function isGitHubHost(host: string): boolean { + const lowered = host.trim().toLowerCase(); + if (!lowered) return false; + if (lowered === "github.com" || lowered === "www.github.com") return true; + if (lowered.endsWith(".github.com")) return true; // ssh.github.com, gist.github.com, ... + if (lowered.startsWith("github.")) return true; // github.corp.example.com (GHES) + return false; +} + +/** + * Parse a git remote URL and return the GitHub owner / repo. + * Supports the common GitHub URL shapes: + * - https://github.com/owner/repo.git + * - https://github.com/owner/repo + * - http://github.com/owner/repo.git + * - git@github.com:owner/repo.git + * - ssh://git@github.com/owner/repo.git + * - git://github.com/owner/repo.git + * + * Returns null for non-GitHub hosts or unparseable URLs. + */ +export function parseGitHubRemote(url: string | null | undefined): GitHubRepo | null { + if (!url) return null; + const trimmed = url.trim(); + if (!trimmed) return null; + + // scp-like syntax: git@host:owner/repo(.git) + const scpMatch = trimmed.match(/^[\w.-]+@([\w.-]+):([\w.-]+)\/([\w.-]+?)(?:\.git)?\/?$/); + if (scpMatch) { + const host = scpMatch[1]!; + const owner = scpMatch[2]!; + const repo = scpMatch[3]!; + if (!isGitHubHost(host)) return null; + return { host, owner, repo }; + } + + // URL-like syntax: scheme://[user@]host[:port]/owner/repo(.git) + const urlMatch = trimmed.match( + /^(?:[a-zA-Z][a-zA-Z0-9+.-]*:\/\/)?(?:[^@/]+@)?([\w.-]+)(?::\d+)?\/([\w.-]+)\/([\w.-]+?)(?:\.git)?\/?$/, + ); + if (urlMatch) { + const host = urlMatch[1]!; + const owner = urlMatch[2]!; + const repo = urlMatch[3]!; + if (!isGitHubHost(host)) return null; + return { host, owner, repo }; + } + + return null; +} + +/** + * Read the configured origin remote URL from a working directory. + * Returns null if not a git repo, no remote, or git fails. + */ +export function readRemoteUrl(cwd: string, remoteName: string = "origin"): string | null { + const result = spawnSync("git", ["config", "--get", `remote.${remoteName}.url`], { + cwd, + encoding: "utf-8", + }); + if (result.status !== 0) return null; + const out = String(result.stdout || "").trim(); + return out || null; +} + +/** + * Resolve the GitHub owner/repo for a given working directory. + * + * Tries `origin` first. Returns null if the directory is not inside a git repo, + * has no origin remote, or the remote isn't a GitHub URL. + */ +export function getGitHubRepoFromCwd(cwd: string): GitHubRepo | null { + const url = readRemoteUrl(cwd, "origin"); + return parseGitHubRemote(url); +} diff --git a/packages/utils/index.ts b/packages/utils/index.ts index cf40be60..c50df1a2 100644 --- a/packages/utils/index.ts +++ b/packages/utils/index.ts @@ -18,6 +18,12 @@ export { } from "./status"; export { extractEventSessionId } from "./session-id"; export { ensureSessionWorktree, resolveRepoRoot } from "./worktree"; +export { + parseGitHubRemote, + readRemoteUrl, + getGitHubRepoFromCwd, + type GitHubRepo, +} from "./git-remote"; export { truncateEventPayload, truncateString, diff --git a/packages/web-ui/src/routes/(settings)/+layout.svelte b/packages/web-ui/src/routes/(settings)/+layout.svelte index fa4fc646..0183df73 100644 --- a/packages/web-ui/src/routes/(settings)/+layout.svelte +++ b/packages/web-ui/src/routes/(settings)/+layout.svelte @@ -13,7 +13,7 @@ const pathname = $derived($page.url.pathname); const normalizedPathname = $derived(pathname.endsWith("/") && pathname.length > 1 ? pathname.slice(0, -1) : pathname); - const activeSection = $derived.by<"general" | "agents" | "inbox" | "cronJobs" | "tasks" | "workspace">(() => + const activeSection = $derived.by<"general" | "agents" | "inbox" | "cronJobs" | "tasks" | "prTracker" | "workspace">(() => normalizedPathname === "/agents" ? "agents" : normalizedPathname === "/inbox" @@ -22,6 +22,8 @@ ? "cronJobs" : normalizedPathname === "/tasks" ? "tasks" + : normalizedPathname === "/pr-tracker" + ? "prTracker" : normalizedPathname.startsWith("/workspace") ? "workspace" : "general" @@ -224,6 +226,13 @@ > {t("Tasks", "一次性任务")} + diff --git a/packages/web-ui/src/routes/(settings)/pr-tracker/+page.svelte b/packages/web-ui/src/routes/(settings)/pr-tracker/+page.svelte new file mode 100644 index 00000000..e20c5289 --- /dev/null +++ b/packages/web-ui/src/routes/(settings)/pr-tracker/+page.svelte @@ -0,0 +1,581 @@ + + + +
+
+

{t("PR Tracker", "PR 追踪")}

+

+ {t( + "Watch GitHub repos discovered from each channel's working directory. When enabled, new PR activity triggers an agent run in the target channel.", + "基于每个频道的工作目录自动发现 GitHub 仓库。启用后,PR 新活动会触发目标频道里的一次 agent 调用。", + )} +

+
+
+ + +
+
+ + {#if message} +
+ {message} +
+ {/if} + + + + + {#if isSettingsOpen} +
+
+ + +
+
+ + +
+
+ + +
+
+ +