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

@@ -1,4 +1,4 @@
export { COLUMNS, COLUMN_LABELS, COLUMN_DESCRIPTIONS, VALID_TRANSITIONS, DEFAULT_SETTINGS, THINKING_LEVELS } from "./types.js";
export type { Column, Task, TaskAttachment, TaskCreateInput, TaskDetail, AgentLogEntry, AgentLogType, AgentRole, BoardConfig, MergeResult, Settings, TaskStep, StepStatus, TaskLogEntry, ThinkingLevel, SteeringComment } from "./types.js";
export type { Column, PrInfo, PrStatus, Task, TaskAttachment, TaskCreateInput, TaskDetail, AgentLogEntry, AgentLogType, AgentRole, BoardConfig, MergeResult, Settings, TaskStep, StepStatus, TaskLogEntry, ThinkingLevel, SteeringComment } from "./types.js";
export { TaskStore } from "./store.js";
export { canTransition, getValidTransitions, resolveDependencyOrder } from "./board.js";

View File

@@ -810,6 +810,189 @@ describe("TaskStore", () => {
});
});
describe("updatePrInfo", () => {
it("adds PR info to a task without existing PR", async () => {
const task = await createTestTask();
const prInfo = {
url: "https://github.com/owner/repo/pull/42",
number: 42,
status: "open" as const,
title: "Fix the bug",
headBranch: "kb-001-fix-bug",
baseBranch: "main",
commentCount: 0,
};
const updated = await store.updatePrInfo(task.id, prInfo);
expect(updated.prInfo).toEqual(prInfo);
expect(updated.log.some((l) => l.action === "PR linked" && l.outcome?.includes("#42"))).toBe(true);
});
it("updates existing PR info with new values", async () => {
const task = await createTestTask();
const prInfo1 = {
url: "https://github.com/owner/repo/pull/1",
number: 1,
status: "open" as const,
title: "Initial PR",
headBranch: "branch-1",
baseBranch: "main",
commentCount: 0,
};
await store.updatePrInfo(task.id, prInfo1);
const prInfo2 = {
url: "https://github.com/owner/repo/pull/1",
number: 1,
status: "merged" as const,
title: "Initial PR (updated)",
headBranch: "branch-1",
baseBranch: "main",
commentCount: 3,
lastCommentAt: "2026-01-01T00:00:00.000Z",
};
const updated = await store.updatePrInfo(task.id, prInfo2);
expect(updated.prInfo?.status).toBe("merged");
expect(updated.prInfo?.commentCount).toBe(3);
expect(updated.prInfo?.lastCommentAt).toBe("2026-01-01T00:00:00.000Z");
});
it("clears PR info when passed null", async () => {
const task = await createTestTask();
const prInfo = {
url: "https://github.com/owner/repo/pull/42",
number: 42,
status: "open" as const,
title: "Fix the bug",
headBranch: "kb-001-fix-bug",
baseBranch: "main",
commentCount: 0,
};
await store.updatePrInfo(task.id, prInfo);
const updated = await store.updatePrInfo(task.id, null);
expect(updated.prInfo).toBeUndefined();
expect(updated.log.some((l) => l.action === "PR unlinked")).toBe(true);
});
it("emits task:updated event when PR info changes", async () => {
const task = await createTestTask();
const events: any[] = [];
store.on("task:updated", (t) => events.push(t));
const prInfo = {
url: "https://github.com/owner/repo/pull/42",
number: 42,
status: "open" as const,
title: "Fix the bug",
headBranch: "kb-001-fix-bug",
baseBranch: "main",
commentCount: 0,
};
await store.updatePrInfo(task.id, prInfo);
expect(events).toHaveLength(1);
expect(events[0].prInfo?.number).toBe(42);
});
it("does NOT emit task:updated when PR info is unchanged", async () => {
const task = await createTestTask();
const prInfo = {
url: "https://github.com/owner/repo/pull/42",
number: 42,
status: "open" as const,
title: "Fix the bug",
headBranch: "kb-001-fix-bug",
baseBranch: "main",
commentCount: 0,
};
await store.updatePrInfo(task.id, prInfo);
const events: any[] = [];
store.on("task:updated", (t) => events.push(t));
// Update with same values (status and number unchanged)
await store.updatePrInfo(task.id, { ...prInfo });
// Should not emit because number and status are the same
expect(events).toHaveLength(0);
});
it("persists to disk and round-trips correctly", async () => {
const task = await createTestTask();
const prInfo = {
url: "https://github.com/owner/repo/pull/42",
number: 42,
status: "open" as const,
title: "Fix the bug",
headBranch: "kb-001-fix-bug",
baseBranch: "main",
commentCount: 5,
lastCommentAt: "2026-03-30T12:00:00.000Z",
};
await store.updatePrInfo(task.id, prInfo);
const fetched = await store.getTask(task.id);
expect(fetched.prInfo).toEqual(prInfo);
});
it("updates updatedAt timestamp", async () => {
const task = await createTestTask();
const before = task.updatedAt;
await new Promise((r) => setTimeout(r, 10)); // Ensure time passes
const prInfo = {
url: "https://github.com/owner/repo/pull/42",
number: 42,
status: "open" as const,
title: "Fix the bug",
headBranch: "kb-001-fix-bug",
baseBranch: "main",
commentCount: 0,
};
const updated = await store.updatePrInfo(task.id, prInfo);
expect(updated.updatedAt).not.toBe(before);
});
it("serializes concurrent updates correctly", async () => {
const task = await createTestTask();
// Fire 5 concurrent updates
const promises = Array.from({ length: 5 }, (_, i) =>
store.updatePrInfo(task.id, {
url: `https://github.com/owner/repo/pull/${i + 1}`,
number: i + 1,
status: "open" as const,
title: `PR ${i + 1}`,
headBranch: `branch-${i + 1}`,
baseBranch: "main",
commentCount: i,
}),
);
await Promise.all(promises);
// Read back and verify valid JSON
const taskJsonPath = join(rootDir, ".kb", "tasks", task.id, "task.json");
const raw = await readFile(taskJsonPath, "utf-8");
const result = JSON.parse(raw) as Task;
// Should have exactly one of the PRs set (last one wins)
expect(result.prInfo).toBeDefined();
expect(result.prInfo!.number).toBeGreaterThanOrEqual(1);
expect(result.prInfo!.number).toBeLessThanOrEqual(5);
// Should have all the PR linked log entries
const prLogs = result.log.filter((l) => l.action === "PR linked");
expect(prLogs).toHaveLength(5);
});
});
describe("parseDependenciesFromPrompt", () => {
it("returns single dependency from PROMPT.md", async () => {
const task = await store.createTask({ description: "Task with dep" });

View File

@@ -1023,6 +1023,57 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
});
}
/**
* Update or clear PR information for a task.
* Updates task.json atomically and emits `task:updated` event.
*
* @param id - The task ID
* @param prInfo - The PR info to set, or null to clear
* @returns The updated task
*/
async updatePrInfo(
id: string,
prInfo: import("./types.js").PrInfo | null,
): Promise<Task> {
return this.withTaskLock(id, async () => {
const dir = this.taskDir(id);
const task = await this.readTaskJson(dir);
const prevPrNumber = task.prInfo?.number;
const prevPrStatus = task.prInfo?.status;
if (prInfo) {
task.prInfo = prInfo;
task.log.push({
timestamp: new Date().toISOString(),
action: "PR linked",
outcome: `PR #${prInfo.number}: ${prInfo.url}`,
});
} else {
task.prInfo = undefined;
if (prevPrNumber) {
task.log.push({
timestamp: new Date().toISOString(),
action: "PR unlinked",
outcome: `PR #${prevPrNumber} removed`,
});
}
}
task.updatedAt = new Date().toISOString();
await this.atomicWriteTaskJson(dir, task);
if (this.watcher) this.taskCache.set(id, { ...task });
// Only emit if PR info actually changed
if (prevPrNumber !== prInfo?.number || prevPrStatus !== prInfo?.status) {
this.emit("task:updated", task);
}
return task;
});
}
/**
* Read all historical agent log entries for a task from its agent log file.
* Returns entries in chronological order (oldest first).

View File

@@ -5,6 +5,20 @@ export type ThinkingLevel = (typeof THINKING_LEVELS)[number];
export const COLUMNS = ["triage", "todo", "in-progress", "in-review", "done"] as const;
export type Column = (typeof COLUMNS)[number];
export type PrStatus = "open" | "closed" | "merged";
export interface PrInfo {
url: string;
number: number;
status: PrStatus;
title: string;
headBranch: string;
baseBranch: string;
commentCount: number;
lastCommentAt?: string;
lastCheckedAt?: string;
}
export type StepStatus = "pending" | "in-progress" | "done" | "skipped";
export interface TaskStep {
@@ -81,6 +95,8 @@ export interface Task {
baseBranch?: string;
attachments?: TaskAttachment[];
steeringComments?: SteeringComment[];
/** PR information for tasks linked to GitHub pull requests */
prInfo?: PrInfo;
log: TaskLogEntry[];
size?: "S" | "M" | "L";
reviewLevel?: number;
@@ -141,6 +157,9 @@ export interface Settings {
* Defaults to `"KB"`. Only affects new tasks — existing tasks retain
* their original IDs. */
taskPrefix?: string;
/** Whether GitHub token is configured for PR operations (read-only, set by server).
* When false, PR creation features are disabled in the UI. */
githubTokenConfigured?: boolean;
/** When true, merge commit messages include the task ID as the conventional
* commit scope (e.g. `feat(KB-001): ...`). When false, the scope is
* omitted (e.g. `feat: ...`). Default: true. */