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 e95e50ba68
commit efd7c176fb
23 changed files with 2656 additions and 5 deletions

View File

@@ -0,0 +1,37 @@
import { execFileSync } from "node:child_process";
/**
* 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

@@ -9,3 +9,5 @@ export { createKbAgent, type AgentOptions, type AgentResult } from "./pi.js";
export { WorktreePool, scanIdleWorktrees, cleanupOrphanedWorktrees } from "./worktree-pool.js";
export { createLogger, type Logger } from "./logger.js";
export { isUsageLimitError, UsageLimitPauser } from "./usage-limit-detector.js";
export { PrMonitor, type PrComment, type TrackedPr, type OnNewCommentsCallback } from "./pr-monitor.js";
export { PrCommentHandler } from "./pr-comment-handler.js";

View File

@@ -61,3 +61,6 @@ export const worktreePoolLog = createLogger("worktree-pool");
/** Logger for the review subsystem. */
export const reviewerLog = createLogger("reviewer");
/** Logger for the PR monitor subsystem. */
export const prMonitorLog = createLogger("pr-monitor");

View File

@@ -0,0 +1,237 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { PrCommentHandler } from "./pr-comment-handler.js";
import type { TaskStore } from "@kb/core";
const mockStore = {
addSteeringComment: vi.fn(),
createTask: vi.fn(),
} as unknown as TaskStore;
describe("PrCommentHandler", () => {
let handler: PrCommentHandler;
beforeEach(() => {
vi.clearAllMocks();
handler = new PrCommentHandler(mockStore);
});
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,
};
describe("isNonActionable", () => {
it.each([
"LGTM",
"lgtm",
"Looks good",
"Looks good to me",
"Thanks",
"Thank you",
"Nice",
"Great work",
"👍",
"✅",
])("filters out non-actionable comment: %s", async (body) => {
await handler.handleNewComments("KB-001", mockPrInfo, [
{
id: 1,
body,
user: { login: "reviewer" },
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
html_url: "https://github.com/owner/repo/pull/42#issuecomment-1",
},
]);
expect(mockStore.addSteeringComment).not.toHaveBeenCalled();
});
});
describe("isActionable", () => {
it.each([
{ body: "Please fix the indentation", keyword: "fix" },
{ body: "Should change the variable name", keyword: "change" },
{ body: "Update the documentation", keyword: "update" },
{ body: "Remove the unused import", keyword: "remove" },
{ body: "Add error handling", keyword: "add" },
{ body: "You should refactor this", keyword: "should" },
{ body: "Needs to handle edge cases", keyword: "needs to" },
{ body: "Consider using a different approach", keyword: "consider" },
{ body: "I suggest renaming this", keyword: "suggest" },
{ body: "Recommend adding tests", keyword: "recommend" },
])("creates steering comment for actionable feedback containing '$keyword': $body", async ({ body }) => {
await handler.handleNewComments("KB-001", mockPrInfo, [
{
id: 1,
body,
user: { login: "reviewer" },
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
html_url: "https://github.com/owner/repo/pull/42#issuecomment-1",
},
]);
expect(mockStore.addSteeringComment).toHaveBeenCalled();
});
});
describe("code suggestions", () => {
it("creates steering comment for comments with code blocks", async () => {
await handler.handleNewComments("KB-001", mockPrInfo, [
{
id: 1,
body: "```typescript\nconst x = 1;\n```",
user: { login: "reviewer" },
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
html_url: "https://github.com/owner/repo/pull/42#issuecomment-1",
},
]);
expect(mockStore.addSteeringComment).toHaveBeenCalled();
});
it("creates steering comment for inline code suggestions", async () => {
await handler.handleNewComments("KB-001", mockPrInfo, [
{
id: 1,
body: "Use `const` instead of `let`",
user: { login: "reviewer" },
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
html_url: "https://github.com/owner/repo/pull/42#issuecomment-1",
},
]);
expect(mockStore.addSteeringComment).toHaveBeenCalled();
});
});
describe("steering comment content", () => {
it("includes PR info and comment details", async () => {
await handler.handleNewComments("KB-001", mockPrInfo, [
{
id: 1,
body: "Please fix the bug",
user: { login: "reviewer" },
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
html_url: "https://github.com/owner/repo/pull/42#issuecomment-1",
},
]);
const call = mockStore.addSteeringComment.mock.calls[0];
const text = call[1] as string;
expect(text).toContain("PR Review Feedback");
expect(text).toContain("@reviewer");
expect(text).toContain("#42");
expect(text).toContain("open");
expect(text).toContain("Please fix the bug");
expect(text).toContain("View on GitHub");
});
it("truncates long comments", async () => {
const longBody = "Please fix this issue: " + "a".repeat(1000);
await handler.handleNewComments("KB-001", mockPrInfo, [
{
id: 1,
body: longBody,
user: { login: "reviewer" },
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
html_url: "https://github.com/owner/repo/pull/42#issuecomment-1",
},
]);
const call = mockStore.addSteeringComment.mock.calls[0];
const text = call[1] as string;
expect(text.length).toBeLessThan(longBody.length);
expect(text).toContain("...");
});
it("marks as agent-authored", async () => {
await handler.handleNewComments("KB-001", mockPrInfo, [
{
id: 1,
body: "Please fix this",
user: { login: "reviewer" },
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
html_url: "https://github.com/owner/repo/pull/42#issuecomment-1",
},
]);
expect(mockStore.addSteeringComment).toHaveBeenCalledWith(
"KB-001",
expect.any(String),
"agent"
);
});
});
describe("createFollowUpTask", () => {
it("creates follow-up task for unaddressed feedback", async () => {
await handler.createFollowUpTask("KB-001", mockPrInfo, [
{
id: 1,
body: "This needs fixing",
user: { login: "reviewer" },
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
html_url: "https://github.com/owner/repo/pull/42#issuecomment-1",
},
]);
expect(mockStore.createTask).toHaveBeenCalledWith({
title: "Follow-up: Address PR #42 feedback",
description: expect.stringContaining("KB-001"),
column: "triage",
dependencies: ["KB-001"],
});
});
it("does nothing when no unaddressed comments", async () => {
await handler.createFollowUpTask("KB-001", mockPrInfo, []);
expect(mockStore.createTask).not.toHaveBeenCalled();
});
it("summarizes multiple comments", async () => {
await handler.createFollowUpTask("KB-001", mockPrInfo, [
{
id: 1,
body: "First issue to fix",
user: { login: "reviewer1" },
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
html_url: "https://github.com/owner/repo/pull/42#issuecomment-1",
},
{
id: 2,
body: "Second issue",
user: { login: "reviewer2" },
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
html_url: "https://github.com/owner/repo/pull/42#issuecomment-2",
},
]);
const call = mockStore.createTask.mock.calls[0];
const description = call[0].description as string;
expect(description).toContain("@reviewer1");
expect(description).toContain("@reviewer2");
expect(description).toContain("First issue");
expect(description).toContain("Second issue");
});
});
});

View File

@@ -0,0 +1,187 @@
import type { TaskStore } from "@kb/core";
import type { PrInfo } from "@kb/core";
import { prMonitorLog } from "./logger.js";
interface PrComment {
id: number;
body: string;
user: { login: string };
created_at: string;
updated_at: string;
html_url: string;
}
/**
* Analyzes PR comments for actionable feedback and creates
* steering comments or follow-up tasks.
*/
export class PrCommentHandler {
// Keywords that suggest actionable feedback
private readonly ACTION_KEYWORDS = [
"fix",
"change",
"update",
"remove",
"add",
"should",
"need to",
"needs to",
"please",
"consider",
"suggest",
"recommend",
];
// Non-actionable patterns to filter out
private readonly NON_ACTIONABLE_PATTERNS = [
/^\s*lgtm\s*$/i,
/^\s*looks? good\s*$/i,
/^\s*thanks?\s*$/i,
/^\s*thank you\s*$/i,
/^\s*nice\s*$/i,
/^\s*great\s*$/i,
/^\s*awesome\s*$/i,
/^\s*👍\s*$/,
/^\s*✅\s*$/,
];
constructor(private store: TaskStore) {}
/**
* Process new PR comments for a task.
* Called by PrMonitor when new comments are detected.
*/
async handleNewComments(
taskId: string,
prInfo: PrInfo,
comments: PrComment[]
): Promise<void> {
for (const comment of comments) {
await this.processComment(taskId, prInfo, comment);
}
}
private async processComment(
taskId: string,
prInfo: PrInfo,
comment: PrComment
): Promise<void> {
// Skip non-actionable comments
if (this.isNonActionable(comment.body)) {
prMonitorLog.log(`Skipping non-actionable comment #${comment.id}`);
return;
}
// Check if comment contains actionable feedback
const isActionable = this.isActionable(comment.body);
const hasCodeSuggestions = this.hasCodeBlock(comment.body);
if (!isActionable && !hasCodeSuggestions) {
prMonitorLog.log(`Comment #${comment.id} does not contain actionable feedback`);
return;
}
// Build steering comment text
const text = this.buildSteeringText(prInfo, comment, hasCodeSuggestions);
try {
await this.store.addSteeringComment(taskId, text, "agent");
prMonitorLog.log(`Added steering comment for PR review #${comment.id}`);
} catch (err) {
prMonitorLog.error(`Failed to add steering comment for ${taskId}:`, err);
}
}
/**
* Check if a comment is non-actionable (LGTM, thanks, etc.)
*/
private isNonActionable(body: string): boolean {
const trimmed = body.trim();
return this.NON_ACTIONABLE_PATTERNS.some((pattern) => pattern.test(trimmed));
}
/**
* Check if a comment contains actionable feedback keywords.
*/
private isActionable(body: string): boolean {
const lowerBody = body.toLowerCase();
return this.ACTION_KEYWORDS.some((keyword) => lowerBody.includes(keyword));
}
/**
* Check if a comment contains code blocks suggesting changes.
*/
private hasCodeBlock(body: string): boolean {
// Look for code blocks (``` or `code`)
return /```[\s\S]*?```/.test(body) || /`[^`]+`/.test(body);
}
/**
* Build steering comment text from PR review comment.
*/
private buildSteeringText(
prInfo: PrInfo,
comment: PrComment,
hasCodeSuggestions: boolean
): string {
const lines: string[] = [];
lines.push(`**PR Review Feedback** from @${comment.user.login}`);
lines.push(`**PR:** #${prInfo.number} (${prInfo.status})`);
lines.push("");
// Truncate comment body if too long
const maxBodyLength = 500;
let body = comment.body.trim();
if (body.length > maxBodyLength) {
body = body.slice(0, maxBodyLength) + "...";
}
lines.push(body);
lines.push("");
if (hasCodeSuggestions) {
lines.push("💡 This comment contains code suggestions. Please review and apply if appropriate.");
}
lines.push(`[View on GitHub](${comment.html_url})`);
return lines.join("\n");
}
/**
* Create a follow-up task when a PR is closed with unaddressed feedback.
* This is called when a PR is merged or closed.
*/
async createFollowUpTask(
originalTaskId: string,
prInfo: PrInfo,
unaddressedComments: PrComment[]
): Promise<void> {
if (unaddressedComments.length === 0) return;
const summary = unaddressedComments
.map((c) => `- @${c.user.login}: ${c.body.slice(0, 100).trim()}${c.body.length > 100 ? "..." : ""}`)
.join("\n");
const description = `Follow-up for ${originalTaskId}
PR #${prInfo.number} was ${prInfo.status} with unaddressed feedback:
${summary}
Please review the PR comments and address any remaining issues.`;
try {
const task = await this.store.createTask({
title: `Follow-up: Address PR #${prInfo.number} feedback`,
description,
column: "triage",
dependencies: [originalTaskId],
});
prMonitorLog.log(`Created follow-up task ${task.id} for PR #${prInfo.number}`);
} catch (err) {
prMonitorLog.error(`Failed to create follow-up task:`, err);
}
}
}

View File

@@ -0,0 +1,143 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { PrMonitor, type PrComment } from "./pr-monitor.js";
describe("PrMonitor", () => {
let monitor: PrMonitor;
const mockFetch = vi.fn();
const originalFetch = globalThis.fetch;
beforeEach(() => {
vi.useFakeTimers();
monitor = new PrMonitor({ getGitHubToken: () => "test-token" });
globalThis.fetch = mockFetch;
});
afterEach(() => {
vi.useRealTimers();
monitor.stopAll();
globalThis.fetch = originalFetch;
vi.clearAllMocks();
});
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 mockComment: PrComment = {
id: 123,
body: "Test comment",
user: { login: "reviewer" },
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
html_url: "https://github.com/owner/repo/pull/42#issuecomment-123",
};
describe("startMonitoring", () => {
it("starts monitoring a PR", () => {
monitor.startMonitoring("KB-001", "owner", "repo", mockPrInfo);
const tracked = monitor.getTrackedPrs();
expect(tracked.has("KB-001")).toBe(true);
expect(tracked.get("KB-001")?.prInfo.number).toBe(42);
});
it("replaces existing monitoring for same task", () => {
monitor.startMonitoring("KB-001", "owner", "repo", mockPrInfo);
const newPrInfo = { ...mockPrInfo, number: 43 };
monitor.startMonitoring("KB-001", "owner", "repo", newPrInfo);
const tracked = monitor.getTrackedPrs();
expect(tracked.get("KB-001")?.prInfo.number).toBe(43);
});
});
describe("stopMonitoring", () => {
it("stops monitoring a task", () => {
monitor.startMonitoring("KB-001", "owner", "repo", mockPrInfo);
monitor.stopMonitoring("KB-001");
const tracked = monitor.getTrackedPrs();
expect(tracked.has("KB-001")).toBe(false);
});
it("does nothing for untracked task", () => {
expect(() => monitor.stopMonitoring("KB-999")).not.toThrow();
});
});
describe("stopAll", () => {
it("stops all monitoring", () => {
monitor.startMonitoring("KB-001", "owner", "repo", mockPrInfo);
monitor.startMonitoring("KB-002", "owner", "repo", mockPrInfo);
monitor.stopAll();
const tracked = monitor.getTrackedPrs();
expect(tracked.size).toBe(0);
});
});
describe("polling", () => {
it("polls for comments on interval", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve([]),
});
monitor.startMonitoring("KB-001", "owner", "repo", mockPrInfo);
// Wait for initial check
await vi.advanceTimersByTimeAsync(1);
expect(mockFetch).toHaveBeenCalled();
});
it("calls onNewComments when new comments found", async () => {
const callback = vi.fn();
monitor.onNewComments(callback);
mockFetch.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve([mockComment]),
});
monitor.startMonitoring("KB-001", "owner", "repo", mockPrInfo);
await vi.advanceTimersByTimeAsync(1);
expect(callback).toHaveBeenCalledWith("KB-001", mockPrInfo, [mockComment]);
});
it("tracks lastCommentId to avoid duplicate notifications", async () => {
const callback = vi.fn();
monitor.onNewComments(callback);
mockFetch
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve([mockComment]),
})
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve([mockComment]), // Same comment again
});
monitor.startMonitoring("KB-001", "owner", "repo", mockPrInfo);
// First check
await vi.advanceTimersByTimeAsync(1);
expect(callback).toHaveBeenCalledTimes(1);
// Second scheduled check after 30s
await vi.advanceTimersByTimeAsync(30 * 1000);
// Second poll should not trigger callback for same comment
expect(callback).toHaveBeenCalledTimes(1);
});
});
});

View File

@@ -0,0 +1,261 @@
import { prMonitorLog } from "./logger.js";
import type { PrInfo } from "@kb/core";
export interface TrackedPr {
owner: string;
repo: string;
prInfo: PrInfo;
lastCheckedAt: Date;
lastCommentId?: number;
consecutiveErrors: number;
isActive: boolean; // true if we've seen recent activity
}
export interface PrComment {
id: number;
body: string;
user: { login: string };
created_at: string;
updated_at: string;
html_url: string;
}
export type OnNewCommentsCallback = (
taskId: string,
prInfo: PrInfo,
comments: PrComment[]
) => void | Promise<void>;
/**
* Monitors GitHub PRs for new comments.
* Uses adaptive polling: 30s when active, 5min when idle.
* Implements exponential backoff on errors.
*/
export class PrMonitor {
private trackedPrs = new Map<string, TrackedPr>();
private intervals = new Map<string, ReturnType<typeof setInterval>>();
private newCommentsCallback?: OnNewCommentsCallback;
private getGitHubToken: () => string | undefined;
// Polling intervals in ms
private readonly ACTIVE_INTERVAL = 30 * 1000; // 30 seconds
private readonly IDLE_INTERVAL = 5 * 60 * 1000; // 5 minutes
private readonly MIN_INTERVAL = 30 * 1000;
private readonly MAX_INTERVAL = 15 * 60 * 1000; // 15 minutes max backoff
constructor(options: { getGitHubToken?: () => string | undefined } = {}) {
this.getGitHubToken = options.getGitHubToken ?? (() => process.env.GITHUB_TOKEN);
}
/**
* Register a callback to be called when new comments are found.
*/
onNewComments(callback: OnNewCommentsCallback): void {
this.newCommentsCallback = callback;
}
/**
* Start monitoring a PR for comments.
*/
startMonitoring(
taskId: string,
owner: string,
repo: string,
prInfo: PrInfo
): void {
// Stop any existing monitoring for this task
this.stopMonitoring(taskId);
const tracked: TrackedPr = {
owner,
repo,
prInfo,
lastCheckedAt: new Date(),
lastCommentId: undefined,
consecutiveErrors: 0,
isActive: true, // Start as active
};
this.trackedPrs.set(taskId, tracked);
// Do an initial check immediately
this.checkForComments(taskId, tracked);
// Set up polling interval
this.scheduleNextCheck(taskId, tracked);
prMonitorLog.log(`Started monitoring PR #${prInfo.number} for task ${taskId}`);
}
/**
* Stop monitoring a PR.
*/
stopMonitoring(taskId: string): void {
const interval = this.intervals.get(taskId);
if (interval) {
clearTimeout(interval);
this.intervals.delete(taskId);
}
if (this.trackedPrs.has(taskId)) {
this.trackedPrs.delete(taskId);
prMonitorLog.log(`Stopped monitoring task ${taskId}`);
}
}
/**
* Stop monitoring all PRs. Called on scheduler shutdown.
*/
stopAll(): void {
for (const [taskId] of this.trackedPrs) {
this.stopMonitoring(taskId);
}
prMonitorLog.log("Stopped all PR monitoring");
}
/**
* Get currently tracked PRs (for testing/debugging).
*/
getTrackedPrs(): Map<string, TrackedPr> {
return new Map(this.trackedPrs);
}
private scheduleNextCheck(taskId: string, tracked: TrackedPr): void {
// Calculate interval based on activity and error count
let interval = tracked.isActive ? this.ACTIVE_INTERVAL : this.IDLE_INTERVAL;
// Exponential backoff on errors: 30s * 2^errors, capped at 15min
if (tracked.consecutiveErrors > 0) {
const backoffMultiplier = Math.pow(2, Math.min(tracked.consecutiveErrors, 5));
interval = Math.min(interval * backoffMultiplier, this.MAX_INTERVAL);
}
const timeoutId = setTimeout(() => {
this.checkForComments(taskId, tracked).then(() => {
// Reschedule if still tracked
if (this.trackedPrs.has(taskId)) {
this.scheduleNextCheck(taskId, tracked);
}
});
}, interval);
this.intervals.set(taskId, timeoutId);
}
private async checkForComments(
taskId: string,
tracked: TrackedPr
): Promise<boolean> {
const token = this.getGitHubToken();
if (!token) {
prMonitorLog.warn(`No GitHub token available for task ${taskId}`);
tracked.consecutiveErrors++;
return false; // Don't reschedule - wait for next scheduled check
}
try {
const since = tracked.lastCheckedAt.toISOString();
const comments = await this.fetchComments(
tracked.owner,
tracked.repo,
tracked.prInfo.number,
since,
token
);
// Filter to only new comments (by ID)
const newComments = tracked.lastCommentId
? comments.filter((c) => c.id > tracked.lastCommentId!)
: comments;
if (newComments.length > 0) {
prMonitorLog.log(
`Found ${newComments.length} new comment(s) on PR #${tracked.prInfo.number}`
);
// Update lastCommentId
const maxId = Math.max(...newComments.map((c) => c.id));
tracked.lastCommentId = maxId;
// Mark as active since we found new comments
tracked.isActive = true;
// Notify handler
if (this.newCommentsCallback) {
try {
await this.newCommentsCallback(taskId, tracked.prInfo, newComments);
} catch (err) {
prMonitorLog.error(`Error handling new comments for ${taskId}:`, err);
}
}
} else {
// No new comments - mark as idle after 5 minutes of no activity
const timeSinceLastComment = Date.now() - tracked.lastCheckedAt.getTime();
if (timeSinceLastComment > 5 * 60 * 1000) {
tracked.isActive = false;
}
}
// Reset error count on success
tracked.consecutiveErrors = 0;
tracked.lastCheckedAt = new Date();
return true;
} catch (err: any) {
tracked.consecutiveErrors++;
prMonitorLog.error(
`Error checking PR #${tracked.prInfo.number} for task ${taskId} ` +
`(attempt ${tracked.consecutiveErrors}):`,
err.message
);
// Disable monitoring after 5 consecutive failures
if (tracked.consecutiveErrors >= 5) {
prMonitorLog.warn(
`Disabling PR monitoring for task ${taskId} after 5 consecutive failures`
);
this.stopMonitoring(taskId);
return false;
}
return false;
}
}
private async fetchComments(
owner: string,
repo: string,
prNumber: number,
since: string,
token: string
): Promise<PrComment[]> {
const params = new URLSearchParams();
params.append("per_page", "100");
if (since) {
params.append("since", since);
}
const url = `https://api.github.com/repos/${encodeURIComponent(
owner
)}/${encodeURIComponent(repo)}/issues/${prNumber}/comments?${params}`;
const headers: Record<string, string> = {
Accept: "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
"User-Agent": "kb-engine/1.0",
Authorization: `Bearer ${token}`,
};
const response = await fetch(url, { headers });
if (!response.ok) {
if (response.status === 404) {
throw new Error(`PR #${prNumber} not found in ${owner}/${repo}`);
}
if (response.status === 401 || response.status === 403) {
throw new Error("Authentication failed or rate limited");
}
throw new Error(`GitHub API error: ${response.status} ${response.statusText}`);
}
return response.json() as Promise<PrComment[]>;
}
}

View File

@@ -1,6 +1,8 @@
import { resolveDependencyOrder, type TaskStore, type Task } from "@kb/core";
import type { AgentSemaphore } from "./concurrency.js";
import { schedulerLog } from "./logger.js";
import type { PrMonitor } from "./pr-monitor.js";
import { getCurrentGitHubRepo } from "./github.js";
/**
* Check whether two sets of file scope paths overlap.
@@ -53,6 +55,8 @@ export interface SchedulerOptions {
onSchedule?: (task: Task) => void;
/** Called when a task is blocked by deps */
onBlocked?: (task: Task, blockedBy: string[]) => void;
/** Optional PR monitor for tracking in-review PRs */
prMonitor?: PrMonitor;
}
/**
@@ -111,6 +115,48 @@ export class Scheduler {
this.schedule();
}
});
/**
* PR Monitoring: Start monitoring when a task moves to "in-review",
* stop monitoring when it moves out.
*/
this.store.on("task:moved", ({ task, to }) => {
if (!this.options.prMonitor) return;
if (to === "in-review" && task.prInfo) {
// Start monitoring existing PR
const repo = getCurrentGitHubRepo(this.store.getRootDir());
if (repo) {
this.options.prMonitor.startMonitoring(task.id, repo.owner, repo.repo, task.prInfo);
}
} else if (task.column === "in-review" && to !== "in-review") {
// Task moved out of in-review, stop monitoring
this.options.prMonitor.stopMonitoring(task.id);
// If task has a closed/merged PR, check for unaddressed feedback
if (task.prInfo && (task.prInfo.status === "closed" || task.prInfo.status === "merged")) {
// This would need the tracked PR data - handled by PrMonitor/PrCommentHandler
}
}
});
/**
* PR Monitoring: Start monitoring when PR is linked to an in-review task.
*/
this.store.on("task:updated", (task) => {
if (!this.options.prMonitor) return;
if (task.column !== "in-review") return;
if (!task.prInfo) return;
// Check if we're already monitoring this task
const tracked = this.options.prMonitor.getTrackedPrs();
if (!tracked.has(task.id)) {
const repo = getCurrentGitHubRepo(this.store.getRootDir());
if (repo) {
this.options.prMonitor.startMonitoring(task.id, repo.owner, repo.repo, task.prInfo);
}
}
});
}
start(): void {
@@ -131,6 +177,10 @@ export class Scheduler {
this.pollInterval = null;
this.activePollMs = null;
}
// Stop all PR monitoring when scheduler shuts down
if (this.options.prMonitor) {
this.options.prMonitor.stopAll();
}
schedulerLog.log("Stopped");
}