feat(KB-004): add GitHub PR creation and comment monitoring

- Add PR fields (prInfo, prNumber, prUrl) to Task type and TaskStore with updatePrInfo method
- Add PR Management API endpoints: create, status, refresh with per-repo rate limiting
- Add GitHubClient for PR creation and status fetching with comment support
- Add PrSection component for PR status display and actions in dashboard
- Add PR comment monitoring engine with PrMonitor and PrCommentHandler
- Integrate PR monitoring into scheduler for automatic PR tracking
- Add comprehensive tests for PR features in all packages
This commit is contained in:
gsxdsm
2026-03-29 18:10:35 -07:00
parent 88ca088eda
commit 53e09ce581
23 changed files with 2656 additions and 5 deletions

View File

@@ -0,0 +1,259 @@
import { execFileSync } from "node:child_process";
import type { PrInfo } from "@kb/core";
export interface CreatePrParams {
owner: string;
repo: string;
title: string;
body?: string;
head: string;
base?: string;
}
export interface PrComment {
id: number;
body: string;
user: { login: string };
created_at: string;
updated_at: string;
html_url: string;
}
export class GitHubClient {
private token: string | undefined;
private baseUrl = "https://api.github.com";
constructor(token?: string) {
this.token = token;
}
/**
* Try to create a PR using the `gh` CLI if available, otherwise fall back
* to the REST API. Returns the created PR info.
*/
async createPr(params: CreatePrParams): Promise<PrInfo> {
// Try gh CLI first (preferred for auth handling)
try {
return this.createPrWithGh(params);
} catch {
// Fall back to REST API
return this.createPrWithApi(params);
}
}
private createPrWithGh(params: CreatePrParams): PrInfo {
const { owner, repo, title, body, head, base } = params;
// Build gh pr create command arguments (as array for safety)
const args = [
"pr", "create",
"--repo", `${owner}/${repo}`,
"--title", title,
"--head", head,
];
if (body) {
args.push("--body", body);
}
if (base) {
args.push("--base", base);
}
// Execute gh command using execFileSync for proper argument handling
const result = execFileSync("gh", args, {
encoding: "utf-8",
stdio: ["pipe", "pipe", "ignore"],
});
// Extract PR URL from output (gh outputs the PR URL on success)
const prUrl = result.trim();
const match = prUrl.match(/\/pull\/(\d+)$/);
if (!match) {
throw new Error(`Failed to parse PR URL from gh output: ${prUrl}`);
}
const number = parseInt(match[1], 10);
return {
url: prUrl,
number,
status: "open",
title,
headBranch: head,
baseBranch: base || "main",
commentCount: 0,
};
}
private async createPrWithApi(params: CreatePrParams): Promise<PrInfo> {
const { owner, repo, title, body, head, base = "main" } = params;
const url = `${this.baseUrl}/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/pulls`;
const headers = this.buildHeaders();
const response = await fetch(url, {
method: "POST",
headers,
body: JSON.stringify({
title,
body: body || "",
head,
base,
}),
});
if (!response.ok) {
const error = await response.json().catch(() => ({ message: response.statusText }));
throw new Error(`GitHub API error: ${response.status} ${error.message || response.statusText}`);
}
const data = await response.json() as {
number: number;
html_url: string;
title: string;
state: string;
head: { ref: string };
base: { ref: string };
comments: number;
};
return {
url: data.html_url,
number: data.number,
status: this.mapPrState(data.state),
title: data.title,
headBranch: data.head.ref,
baseBranch: data.base.ref,
commentCount: data.comments,
};
}
/**
* Fetch current PR status from GitHub API.
*/
async getPrStatus(owner: string, repo: string, number: number): Promise<PrInfo> {
const url = `${this.baseUrl}/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/pulls/${number}`;
const headers = this.buildHeaders();
const response = await fetch(url, { headers });
if (!response.ok) {
if (response.status === 404) {
throw new Error(`PR #${number} not found in ${owner}/${repo}`);
}
const error = await response.json().catch(() => ({ message: response.statusText }));
throw new Error(`GitHub API error: ${response.status} ${error.message || response.statusText}`);
}
const data = await response.json() as {
number: number;
html_url: string;
title: string;
state: string;
merged: boolean;
head: { ref: string };
base: { ref: string };
comments: number;
updated_at: string;
};
return {
url: data.html_url,
number: data.number,
status: data.merged ? "merged" : this.mapPrState(data.state),
title: data.title,
headBranch: data.head.ref,
baseBranch: data.base.ref,
commentCount: data.comments,
lastCommentAt: data.updated_at,
};
}
/**
* List PR comments since a specific timestamp.
*/
async listPrComments(
owner: string,
repo: string,
number: number,
since?: string,
): Promise<PrComment[]> {
const params = new URLSearchParams();
params.append("per_page", "100");
if (since) {
params.append("since", since);
}
const url = `${this.baseUrl}/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/issues/${number}/comments?${params}`;
const headers = this.buildHeaders();
const response = await fetch(url, { headers });
if (!response.ok) {
if (response.status === 404) {
return []; // PR might not exist or have no comments
}
const error = await response.json().catch(() => ({ message: response.statusText }));
throw new Error(`GitHub API error: ${response.status} ${error.message || response.statusText}`);
}
return response.json() as Promise<PrComment[]>;
}
private buildHeaders(): Record<string, string> {
const headers: Record<string, string> = {
Accept: "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
"User-Agent": "kb-dashboard/1.0",
};
if (this.token) {
headers.Authorization = `Bearer ${this.token}`;
}
return headers;
}
private mapPrState(state: string): "open" | "closed" {
return state === "open" ? "open" : "closed";
}
}
/**
* Extract owner/repo from a GitHub remote URL or return null if not a GitHub remote.
*/
export function parseGitHubRemote(remoteUrl: string): { owner: string; repo: string } | null {
// Handle HTTPS: https://github.com/owner/repo.git or https://github.com/owner/repo
const httpsMatch = remoteUrl.match(/github\.com\/([^\/]+)\/([^\/\.]+)(?:\.git)?$/);
if (httpsMatch) {
return { owner: httpsMatch[1], repo: httpsMatch[2] };
}
// Handle SSH: git@github.com:owner/repo.git or git@github.com:owner/repo
const sshMatch = remoteUrl.match(/github\.com:([^\/]+)\/([^\/\.]+)(?:\.git)?$/);
if (sshMatch) {
return { owner: sshMatch[1], repo: sshMatch[2] };
}
return null;
}
/**
* Get the current GitHub remote owner/repo from the git config.
*/
export function getCurrentGitHubRepo(cwd: string): { owner: string; repo: string } | null {
try {
const remoteUrl = execFileSync("git", ["remote", "get-url", "origin"], {
cwd,
encoding: "utf-8",
stdio: ["pipe", "pipe", "ignore"],
}).trim();
return parseGitHubRemote(remoteUrl);
} catch {
return null;
}
}

View File

@@ -763,6 +763,343 @@ describe("Pause/Unpause endpoints", () => {
expect(res.body.error).toBe("Database error");
});
});
// --- PR Management route tests ---
describe("POST /tasks/:id/pr/create", () => {
let store: TaskStore;
beforeEach(() => {
store = createMockStore({
getTask: vi.fn(),
updatePrInfo: vi.fn(),
logEntry: vi.fn().mockResolvedValue(undefined),
getRootDir: vi.fn().mockReturnValue("/fake/root"),
});
});
function buildApp() {
const app = express();
app.use(express.json());
app.use("/api", createApiRoutes(store));
return app;
}
const mockPrInfo = {
url: "https://github.com/owner/repo/pull/42",
number: 42,
status: "open" as const,
title: "Test PR",
headBranch: "kb/kb-001",
baseBranch: "main",
commentCount: 0,
};
const mockInReviewTask = {
...FAKE_TASK_DETAIL,
column: "in-review" as const,
prInfo: undefined,
};
it("returns 400 if task is not in in-review column", async () => {
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue({
...FAKE_TASK_DETAIL,
column: "in-progress",
});
const res = await REQUEST(
buildApp(),
"POST",
"/api/tasks/KB-001/pr/create",
JSON.stringify({ title: "Test PR" }),
{ "Content-Type": "application/json" }
);
expect(res.status).toBe(400);
expect(res.body.error).toContain("in-review");
});
it("returns 409 if task already has a PR", async () => {
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue({
...FAKE_TASK_DETAIL,
column: "in-review",
prInfo: mockPrInfo,
});
const res = await REQUEST(
buildApp(),
"POST",
"/api/tasks/KB-001/pr/create",
JSON.stringify({ title: "Test PR" }),
{ "Content-Type": "application/json" }
);
expect(res.status).toBe(409);
expect(res.body.error).toContain("already has PR");
});
it("returns 400 if title is missing", async () => {
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue(mockInReviewTask);
const res = await REQUEST(
buildApp(),
"POST",
"/api/tasks/KB-001/pr/create",
JSON.stringify({}),
{ "Content-Type": "application/json" }
);
expect(res.status).toBe(400);
expect(res.body.error).toContain("title is required");
});
it("returns 429 when rate limit exceeded", { timeout: 15000 }, async () => {
// Set up GITHUB_REPOSITORY env to bypass git lookup
const originalEnv = process.env.GITHUB_REPOSITORY;
process.env.GITHUB_REPOSITORY = "owner/rate-test";
// Create a fresh store mock for this test to isolate rate limit state
const freshStore = createMockStore({
getTask: vi.fn(),
updatePrInfo: vi.fn(),
logEntry: vi.fn().mockResolvedValue(undefined),
getRootDir: vi.fn().mockReturnValue("/fake/root"),
});
function buildFreshApp() {
const app = express();
app.use(express.json());
app.use("/api", createApiRoutes(freshStore));
return app;
}
// Make 60 requests to hit the rate limit
const app = buildFreshApp();
for (let i = 0; i < 60; i++) {
(freshStore.getTask as ReturnType<typeof vi.fn>).mockResolvedValue({
...mockInReviewTask,
id: `KB-RATE-${i}`,
});
await REQUEST(
app,
"POST",
`/api/tasks/KB-RATE-${i}/pr/create`,
JSON.stringify({ title: `Test PR ${i}` }),
{ "Content-Type": "application/json" }
);
}
// 61st request should be rate limited
(freshStore.getTask as ReturnType<typeof vi.fn>).mockResolvedValue({
...mockInReviewTask,
id: "KB-RATE-61",
});
const res = await REQUEST(
app,
"POST",
"/api/tasks/KB-RATE-61/pr/create",
JSON.stringify({ title: "Test PR 61" }),
{ "Content-Type": "application/json" }
);
expect(res.status).toBe(429);
expect(res.body.error).toContain("rate limit exceeded");
expect(res.body.resetAt).toBeDefined();
// Restore env
if (originalEnv) {
process.env.GITHUB_REPOSITORY = originalEnv;
} else {
delete process.env.GITHUB_REPOSITORY;
}
});
it("returns 404 for non-existent task", async () => {
// Create error with proper ENOENT code
const error = new Error("ENOENT: task not found") as NodeJS.ErrnoException;
error.code = "ENOENT";
error.errno = -2;
(store.getTask as ReturnType<typeof vi.fn>).mockRejectedValue(error);
const res = await REQUEST(
buildApp(),
"POST",
"/api/tasks/KB-999/pr/create",
JSON.stringify({ title: "Test PR" }),
{ "Content-Type": "application/json" }
);
expect(res.status).toBe(404);
expect(res.body.error).toContain("not found");
});
});
describe("GET /tasks/:id/pr/status", () => {
let store: TaskStore;
beforeEach(() => {
store = createMockStore({
getTask: vi.fn(),
getRootDir: vi.fn().mockReturnValue("/fake/root"),
});
});
function buildApp() {
const app = express();
app.use(express.json());
app.use("/api", createApiRoutes(store));
return app;
}
const mockPrInfo = {
url: "https://github.com/owner/repo/pull/42",
number: 42,
status: "open" as const,
title: "Test PR",
headBranch: "kb/kb-001",
baseBranch: "main",
commentCount: 3,
};
it("returns cached PR info when available", async () => {
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue({
...FAKE_TASK_DETAIL,
prInfo: mockPrInfo,
updatedAt: new Date().toISOString(),
});
const res = await GET(buildApp(), "/api/tasks/KB-001/pr/status");
expect(res.status).toBe(200);
expect(res.body.prInfo).toEqual(mockPrInfo);
expect(res.body.stale).toBe(false);
});
it("returns 404 when task has no PR", async () => {
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue(FAKE_TASK_DETAIL);
const res = await GET(buildApp(), "/api/tasks/KB-001/pr/status");
expect(res.status).toBe(404);
expect(res.body.error).toContain("no associated PR");
});
it("returns 404 for non-existent task", async () => {
const error = new Error("Task not found") as Error & { code?: string };
error.code = "ENOENT";
(store.getTask as ReturnType<typeof vi.fn>).mockRejectedValue(error);
const res = await GET(buildApp(), "/api/tasks/KB-999/pr/status");
expect(res.status).toBe(404);
});
it("marks data as stale when older than 5 minutes", async () => {
const oldDate = new Date(Date.now() - 6 * 60 * 1000).toISOString(); // 6 minutes ago
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue({
...FAKE_TASK_DETAIL,
prInfo: mockPrInfo,
updatedAt: oldDate,
});
const res = await GET(buildApp(), "/api/tasks/KB-001/pr/status");
expect(res.status).toBe(200);
expect(res.body.stale).toBe(true);
});
it("uses lastCheckedAt for staleness check when available", async () => {
const recentUpdate = new Date().toISOString();
const oldCheck = new Date(Date.now() - 6 * 60 * 1000).toISOString(); // 6 minutes ago
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue({
...FAKE_TASK_DETAIL,
prInfo: { ...mockPrInfo, lastCheckedAt: oldCheck },
updatedAt: recentUpdate,
});
const res = await GET(buildApp(), "/api/tasks/KB-001/pr/status");
expect(res.status).toBe(200);
// Should be stale because lastCheckedAt is old, even though updatedAt is recent
expect(res.body.stale).toBe(true);
});
it("marks data as fresh when lastCheckedAt is recent", async () => {
const recentCheck = new Date(Date.now() - 2 * 60 * 1000).toISOString(); // 2 minutes ago
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue({
...FAKE_TASK_DETAIL,
prInfo: { ...mockPrInfo, lastCheckedAt: recentCheck },
updatedAt: new Date(Date.now() - 10 * 60 * 1000).toISOString(), // 10 minutes ago
});
const res = await GET(buildApp(), "/api/tasks/KB-001/pr/status");
expect(res.status).toBe(200);
// Should be fresh because lastCheckedAt is recent, even though updatedAt is old
expect(res.body.stale).toBe(false);
});
});
describe("POST /tasks/:id/pr/refresh", () => {
let store: TaskStore;
beforeEach(() => {
store = createMockStore({
getTask: vi.fn(),
updatePrInfo: vi.fn(),
getRootDir: vi.fn().mockReturnValue("/fake/root"),
});
});
function buildApp() {
const app = express();
app.use(express.json());
app.use("/api", createApiRoutes(store));
return app;
}
const mockPrInfo = {
url: "https://github.com/owner/repo/pull/42",
number: 42,
status: "open" as const,
title: "Test PR",
headBranch: "kb/kb-001",
baseBranch: "main",
commentCount: 3,
};
it("returns 404 when task has no PR", async () => {
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue(FAKE_TASK_DETAIL);
const res = await REQUEST(
buildApp(),
"POST",
"/api/tasks/KB-001/pr/refresh",
JSON.stringify({}),
{ "Content-Type": "application/json" }
);
expect(res.status).toBe(404);
expect(res.body.error).toContain("no associated PR");
});
it("returns 404 for non-existent task", async () => {
const error = new Error("Task not found") as Error & { code?: string };
error.code = "ENOENT";
(store.getTask as ReturnType<typeof vi.fn>).mockRejectedValue(error);
const res = await REQUEST(
buildApp(),
"POST",
"/api/tasks/KB-999/pr/refresh",
JSON.stringify({}),
{ "Content-Type": "application/json" }
);
expect(res.status).toBe(404);
});
});
});
// --- GitHub Import route tests ---

View File

@@ -3,8 +3,9 @@ import multer from "multer";
import { createReadStream } from "node:fs";
import { execSync } from "node:child_process";
import type { TaskStore, Column, MergeResult } from "@kb/core";
import { COLUMNS } from "@kb/core";
import { COLUMNS, type PrInfo } from "@kb/core";
import type { ServerOptions } from "./server.js";
import { GitHubClient, getCurrentGitHubRepo } from "./github.js";
/**
* Minimal interface matching pi-coding-agent's ModelRegistry API surface
@@ -115,8 +116,46 @@ function getGitHubRemotes(): GitRemote[] {
}
}
/**
* Per-repo GitHub API rate limiter.
* Tracks requests per repo and enforces 60 requests per hour per repo.
*/
class GitHubRateLimiter {
private requests = new Map<string, number[]>();
private readonly maxRequests = 60;
private readonly windowMs = 60 * 60 * 1000; // 1 hour
canMakeRequest(repo: string): boolean {
const now = Date.now();
const timestamps = this.requests.get(repo) || [];
// Remove timestamps outside the window
const validTimestamps = timestamps.filter((ts) => now - ts < this.windowMs);
if (validTimestamps.length >= this.maxRequests) {
return false;
}
validTimestamps.push(now);
this.requests.set(repo, validTimestamps);
return true;
}
getResetTime(repo: string): Date | null {
const timestamps = this.requests.get(repo);
if (!timestamps || timestamps.length === 0) return null;
const oldest = Math.min(...timestamps);
return new Date(oldest + this.windowMs);
}
}
export function createApiRoutes(store: TaskStore, options?: ServerOptions): Router {
const router = Router();
const ghRateLimiter = new GitHubRateLimiter();
// Get GitHub token from options or env
const githubToken = options?.githubToken ?? process.env.GITHUB_TOKEN;
// Scheduler config (includes persisted settings)
router.get("/config", async (_req, res) => {
@@ -135,7 +174,11 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
router.get("/settings", async (_req, res) => {
try {
const settings = await store.getSettings();
res.json(settings);
// Inject server-side configuration flags
res.json({
...settings,
githubTokenConfigured: Boolean(githubToken),
});
} catch (err: any) {
res.status(500).json({ error: err.message });
}
@@ -592,9 +635,238 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
// ---------- Auth routes ----------
registerAuthRoutes(router, options?.authStorage);
// ── PR Management Routes ─────────────────────────────────────────
/**
* POST /api/tasks/:id/pr/create
* Create a GitHub PR for an in-review task.
* Body: { title: string, body?: string, base?: string }
* Returns: Created PrInfo
*/
router.post("/tasks/:id/pr/create", async (req, res) => {
try {
const { title, body, base } = req.body;
if (!title || typeof title !== "string") {
res.status(400).json({ error: "title is required and must be a string" });
return;
}
// Get task and validate
const task = await store.getTask(req.params.id);
if (task.column !== "in-review") {
res.status(400).json({ error: "Task must be in 'in-review' column to create a PR" });
return;
}
if (task.prInfo) {
res.status(409).json({ error: `Task already has PR #${task.prInfo.number}: ${task.prInfo.url}` });
return;
}
// Determine branch name from task
const branchName = `kb/${task.id.toLowerCase()}`;
// Get owner/repo from git remote or GITHUB_REPOSITORY env
let owner: string;
let repo: string;
const envRepo = process.env.GITHUB_REPOSITORY;
if (envRepo) {
const [o, r] = envRepo.split("/");
owner = o;
repo = r;
} else {
const gitRepo = getCurrentGitHubRepo(store.getRootDir());
if (!gitRepo) {
res.status(400).json({ error: "Could not determine GitHub repository. Set GITHUB_REPOSITORY env var or configure git remote." });
return;
}
owner = gitRepo.owner;
repo = gitRepo.repo;
}
// Check rate limit
const repoKey = `${owner}/${repo}`;
if (!ghRateLimiter.canMakeRequest(repoKey)) {
const resetTime = ghRateLimiter.getResetTime(repoKey);
res.status(429).json({
error: "GitHub API rate limit exceeded for this repository",
resetAt: resetTime?.toISOString(),
});
return;
}
// Create the PR
const client = new GitHubClient(githubToken);
const prInfo = await client.createPr({
owner,
repo,
title,
body,
head: branchName,
base,
});
// Store PR info
await store.updatePrInfo(task.id, prInfo);
await store.logEntry(task.id, "Created PR", `PR #${prInfo.number}: ${prInfo.url}`);
res.status(201).json(prInfo);
} catch (err: any) {
if (err.code === "ENOENT") {
res.status(404).json({ error: `Task ${req.params.id} not found` });
} else if (err.message?.includes("already exists")) {
res.status(409).json({ error: err.message });
} else if (err.message?.includes("No commits between")) {
res.status(400).json({ error: "Branch has no commits. Push changes before creating PR." });
} else {
res.status(500).json({ error: err.message || "Failed to create PR" });
}
}
});
/**
* GET /api/tasks/:id/pr/status
* Get cached PR status for a task. Triggers background refresh if stale (>5 min).
*/
router.get("/tasks/:id/pr/status", async (req, res) => {
try {
const task = await store.getTask(req.params.id);
if (!task.prInfo) {
res.status(404).json({ error: "Task has no associated PR" });
return;
}
// Check if data is stale (>5 minutes since last check)
const fiveMinutesMs = 5 * 60 * 1000;
const lastChecked = task.prInfo.lastCheckedAt || task.updatedAt;
const lastCheckedTime = new Date(lastChecked).getTime();
const isStale = Date.now() - lastCheckedTime > fiveMinutesMs;
// Return cached data immediately
res.json({
prInfo: task.prInfo,
stale: isStale,
});
// Trigger background refresh if stale (don't await, let it run)
if (isStale) {
refreshPrInBackground(store, task.id, task.prInfo, githubToken);
}
} catch (err: any) {
if (err.code === "ENOENT") {
res.status(404).json({ error: `Task ${req.params.id} not found` });
} else {
res.status(500).json({ error: err.message });
}
}
});
/**
* POST /api/tasks/:id/pr/refresh
* Force refresh PR status from GitHub API.
* Returns: Updated PrInfo
*/
router.post("/tasks/:id/pr/refresh", async (req, res) => {
try {
const task = await store.getTask(req.params.id);
if (!task.prInfo) {
res.status(404).json({ error: "Task has no associated PR" });
return;
}
// Get owner/repo from git remote or GITHUB_REPOSITORY env
let owner: string;
let repo: string;
const envRepo = process.env.GITHUB_REPOSITORY;
if (envRepo) {
const [o, r] = envRepo.split("/");
owner = o;
repo = r;
} else {
const gitRepo = getCurrentGitHubRepo(store.getRootDir());
if (!gitRepo) {
res.status(400).json({ error: "Could not determine GitHub repository" });
return;
}
owner = gitRepo.owner;
repo = gitRepo.repo;
}
// Check rate limit
const repoKey = `${owner}/${repo}`;
if (!ghRateLimiter.canMakeRequest(repoKey)) {
const resetTime = ghRateLimiter.getResetTime(repoKey);
res.status(429).json({
error: "GitHub API rate limit exceeded for this repository",
resetAt: resetTime?.toISOString(),
});
return;
}
// Fetch fresh PR status
const client = new GitHubClient(githubToken);
const prInfo = await client.getPrStatus(owner, repo, task.prInfo.number);
// Add lastCheckedAt timestamp
prInfo.lastCheckedAt = new Date().toISOString();
// Update stored PR info
await store.updatePrInfo(task.id, prInfo);
res.json(prInfo);
} catch (err: any) {
if (err.code === "ENOENT") {
res.status(404).json({ error: `Task ${req.params.id} not found` });
} else if (err.message?.includes("not found")) {
res.status(404).json({ error: err.message });
} else {
res.status(500).json({ error: err.message });
}
}
});
return router;
}
/**
* Background PR refresh - updates PR status without blocking the response.
* Silently logs errors without affecting the user experience.
*/
async function refreshPrInBackground(store: TaskStore, taskId: string, currentPrInfo: PrInfo, token?: string): Promise<void> {
try {
// Get owner/repo from git remote or GITHUB_REPOSITORY env
let owner: string;
let repo: string;
const envRepo = process.env.GITHUB_REPOSITORY;
if (envRepo) {
const [o, r] = envRepo.split("/");
owner = o;
repo = r;
} else {
const gitRepo = getCurrentGitHubRepo(store.getRootDir());
if (!gitRepo) return; // Silent fail - can't determine repo
owner = gitRepo.owner;
repo = gitRepo.repo;
}
const client = new GitHubClient(token);
const prInfo = await client.getPrStatus(owner, repo, currentPrInfo.number);
prInfo.lastCheckedAt = new Date().toISOString();
await store.updatePrInfo(taskId, prInfo);
} catch {
// Silent fail - background refresh is best-effort
}
}
/**
* Register the GET /api/models route.
* Returns available AI models from the ModelRegistry for the UI model selector.

View File

@@ -15,6 +15,8 @@ export interface ServerOptions {
onMerge?: (taskId: string) => Promise<MergeResult>;
/** Maximum concurrent worktrees / execution slots (default 2) */
maxConcurrent?: number;
/** Optional GitHub token for PR operations — falls back to GITHUB_TOKEN env var */
githubToken?: string;
/** Optional AuthStorage instance for auth routes — if not provided, one is created internally */
authStorage?: AuthStorageLike;
/** Optional ModelRegistry instance for the models API — if not provided, the endpoint returns an empty list */