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:
@@ -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";
|
||||
|
||||
@@ -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" });
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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. */
|
||||
|
||||
Reference in New Issue
Block a user