feat(FN-840): add closed/merged PR feedback contract and clean up unused code

- Add closed/merged PR feedback contract to pr-monitor for post-merge follow-up behavior
- Fix PR monitor drainComments + consume/drain behavior with comprehensive tests
- Add PR feedback follow-up tests and scheduler follow-up logic
- Remove unused activity log, agent log viewer, and multi-agent log code from dashboard
- Remove unused store methods, types, API endpoints, and route handlers
- Update README docs to document manual-merge PR follow-up behavior and fix auth wording
- Add dashboard CLI test coverage for new commands
This commit is contained in:
gsxdsm
2026-04-04 02:01:58 -07:00
parent e2720edfb1
commit 574d1793fc
7 changed files with 416 additions and 10 deletions

View File

@@ -126,4 +126,73 @@ describe("PrMonitor", () => {
expect(() => new PrMonitor({ getGitHubToken: () => "token" })).not.toThrow();
});
});
describe("drainComments", () => {
it("returns empty array when task is not tracked", () => {
const result = monitor.drainComments("FN-999");
expect(result).toEqual([]);
});
it("returns empty array for tracked PR with no buffered comments", () => {
monitor.startMonitoring("FN-001", "owner", "repo", mockPrInfo);
const result = monitor.drainComments("FN-001");
expect(result).toEqual([]);
});
it("returns buffered comments and clears buffer (single-consumption)", () => {
monitor.startMonitoring("FN-001", "owner", "repo", mockPrInfo);
// Simulate comments being buffered (this is what checkForComments does internally)
const tracked = monitor.getTrackedPrs().get("FN-001")!;
const comments: PrComment[] = [
{ id: 1, body: "Please fix this", user: { login: "reviewer" }, created_at: "2024-01-01", updated_at: "2024-01-01", html_url: "https://example.com" },
{ id: 2, body: "Change that", user: { login: "reviewer2" }, created_at: "2024-01-01", updated_at: "2024-01-01", html_url: "https://example.com" },
];
tracked.bufferedComments.push(...comments);
// First drain should return the comments
const drained = monitor.drainComments("FN-001");
expect(drained).toHaveLength(2);
expect(drained[0].id).toBe(1);
expect(drained[1].id).toBe(2);
// Second drain should return empty (buffer was cleared)
const drainedAgain = monitor.drainComments("FN-001");
expect(drainedAgain).toEqual([]);
});
it("returns empty array after PR is stopped", () => {
monitor.startMonitoring("FN-001", "owner", "repo", mockPrInfo);
const tracked = monitor.getTrackedPrs().get("FN-001")!;
tracked.bufferedComments.push(
{ id: 1, body: "Fix this", user: { login: "reviewer" }, created_at: "2024-01-01", updated_at: "2024-01-01", html_url: "" },
);
monitor.stopMonitoring("FN-001");
const result = monitor.drainComments("FN-001");
expect(result).toEqual([]);
});
it("does not affect other tracked PRs", () => {
monitor.startMonitoring("FN-001", "owner", "repo", mockPrInfo);
monitor.startMonitoring("FN-002", "owner", "repo", { ...mockPrInfo, number: 43 });
const tracked1 = monitor.getTrackedPrs().get("FN-001")!;
const tracked2 = monitor.getTrackedPrs().get("FN-002")!;
tracked1.bufferedComments.push(
{ id: 1, body: "Fix", user: { login: "r" }, created_at: "2024-01-01", updated_at: "2024-01-01", html_url: "" },
);
tracked2.bufferedComments.push(
{ id: 2, body: "Update", user: { login: "r" }, created_at: "2024-01-01", updated_at: "2024-01-01", html_url: "" },
);
// Drain FN-001 only
const drained1 = monitor.drainComments("FN-001");
expect(drained1).toHaveLength(1);
// FN-002 buffer should still be intact
const drained2 = monitor.drainComments("FN-002");
expect(drained2).toHaveLength(1);
});
});
});

View File

@@ -9,6 +9,8 @@ export interface TrackedPr {
lastCommentId?: number;
consecutiveErrors: number;
isActive: boolean; // true if we've seen recent activity
/** Buffered comments collected since last drain, used for follow-up task creation. */
bufferedComments: PrComment[];
}
export interface PrComment {
@@ -141,6 +143,7 @@ export class PrMonitor {
lastCommentId: undefined,
consecutiveErrors: 0,
isActive: true, // Start as active
bufferedComments: [],
};
this.trackedPrs.set(taskId, tracked);
@@ -164,6 +167,20 @@ export class PrMonitor {
tracked.prInfo = prInfo;
}
/**
* Drain (consume and return) buffered comments for a tracked PR.
* Returns all comments collected since the last drain and clears the buffer.
* This is used for creating follow-up tasks when a PR is closed/merged.
* Returns an empty array if the task is not tracked or has no buffered comments.
*/
drainComments(taskId: string): PrComment[] {
const tracked = this.trackedPrs.get(taskId);
if (!tracked) return [];
const comments = tracked.bufferedComments;
tracked.bufferedComments = [];
return comments;
}
/**
* Stop monitoring a PR.
*/
@@ -253,6 +270,9 @@ export class PrMonitor {
const maxId = Math.max(...newComments.map((c) => c.id));
tracked.lastCommentId = maxId;
// Buffer comments for potential follow-up task creation on PR close/merge
tracked.bufferedComments.push(...newComments);
// Mark as active since we found new comments
tracked.isActive = true;

View File

@@ -794,6 +794,213 @@ describe("Scheduler", () => {
expect(prMonitor.stopMonitoring).toHaveBeenCalledWith("FN-001");
});
it("invokes onClosedPrFeedback with drained comments for closed/merged PR", async () => {
const mockComments = [
{ id: 1, body: "Fix this", user: { login: "reviewer" }, created_at: "2024-01-01", updated_at: "2024-01-01", html_url: "https://example.com" },
{ id: 2, body: "Update that", user: { login: "reviewer2" }, created_at: "2024-01-01", updated_at: "2024-01-01", html_url: "https://example.com" },
];
const prMonitor = {
startMonitoring: vi.fn(),
stopMonitoring: vi.fn(),
updatePrInfo: vi.fn(),
getTrackedPrs: vi.fn().mockReturnValue(new Map()),
stopAll: vi.fn(),
drainComments: vi.fn().mockReturnValue(mockComments),
} as unknown as PrMonitor;
const onClosedPrFeedback = vi.fn().mockResolvedValue(undefined);
const store = createMockStore();
new Scheduler(store, { prMonitor, onClosedPrFeedback });
const onCalls = (store.on as any).mock.calls;
const movedHandler = onCalls.find((call: any) => call[0] === "task:moved")?.[1];
const task = createMockTask({
id: "FN-001",
column: "done",
prInfo: { status: "merged", number: 42 } as any,
});
movedHandler({ task, from: "in-review", to: "done" });
// Wait for the void Promise.resolve chain to complete
await new Promise((r) => setTimeout(r, 10));
expect(prMonitor.drainComments).toHaveBeenCalledWith("FN-001");
expect(onClosedPrFeedback).toHaveBeenCalledWith("FN-001", task.prInfo, mockComments);
expect(prMonitor.stopMonitoring).toHaveBeenCalledWith("FN-001");
});
it("does not invoke onClosedPrFeedback when buffer is empty", async () => {
const prMonitor = {
startMonitoring: vi.fn(),
stopMonitoring: vi.fn(),
updatePrInfo: vi.fn(),
getTrackedPrs: vi.fn().mockReturnValue(new Map()),
stopAll: vi.fn(),
drainComments: vi.fn().mockReturnValue([]),
} as unknown as PrMonitor;
const onClosedPrFeedback = vi.fn();
const store = createMockStore();
new Scheduler(store, { prMonitor, onClosedPrFeedback });
const onCalls = (store.on as any).mock.calls;
const movedHandler = onCalls.find((call: any) => call[0] === "task:moved")?.[1];
const task = createMockTask({
id: "FN-001",
column: "done",
prInfo: { status: "merged", number: 42 } as any,
});
movedHandler({ task, from: "in-review", to: "done" });
await new Promise((r) => setTimeout(r, 10));
expect(prMonitor.drainComments).toHaveBeenCalledWith("FN-001");
expect(onClosedPrFeedback).not.toHaveBeenCalled();
expect(prMonitor.stopMonitoring).toHaveBeenCalledWith("FN-001");
});
it("does not invoke onClosedPrFeedback for open PR", async () => {
const prMonitor = {
startMonitoring: vi.fn(),
stopMonitoring: vi.fn(),
updatePrInfo: vi.fn(),
getTrackedPrs: vi.fn().mockReturnValue(new Map()),
stopAll: vi.fn(),
drainComments: vi.fn(),
} as unknown as PrMonitor;
const onClosedPrFeedback = vi.fn();
const store = createMockStore();
new Scheduler(store, { prMonitor, onClosedPrFeedback });
const onCalls = (store.on as any).mock.calls;
const movedHandler = onCalls.find((call: any) => call[0] === "task:moved")?.[1];
const task = createMockTask({
id: "FN-001",
column: "done",
prInfo: { status: "open", number: 42 } as any,
});
movedHandler({ task, from: "in-review", to: "done" });
await new Promise((r) => setTimeout(r, 10));
expect(prMonitor.drainComments).not.toHaveBeenCalled();
expect(onClosedPrFeedback).not.toHaveBeenCalled();
expect(prMonitor.stopMonitoring).toHaveBeenCalledWith("FN-001");
});
it("does not invoke onClosedPrFeedback when callback is not provided", async () => {
const prMonitor = {
startMonitoring: vi.fn(),
stopMonitoring: vi.fn(),
updatePrInfo: vi.fn(),
getTrackedPrs: vi.fn().mockReturnValue(new Map()),
stopAll: vi.fn(),
drainComments: vi.fn().mockReturnValue([
{ id: 1, body: "Fix", user: { login: "r" }, created_at: "2024-01-01", updated_at: "2024-01-01", html_url: "" },
]),
} as unknown as PrMonitor;
// No onClosedPrFeedback provided
const store = createMockStore();
new Scheduler(store, { prMonitor });
const onCalls = (store.on as any).mock.calls;
const movedHandler = onCalls.find((call: any) => call[0] === "task:moved")?.[1];
const task = createMockTask({
id: "FN-001",
column: "done",
prInfo: { status: "closed", number: 42 } as any,
});
// Should not throw
movedHandler({ task, from: "in-review", to: "done" });
await new Promise((r) => setTimeout(r, 10));
expect(prMonitor.drainComments).toHaveBeenCalledWith("FN-001");
expect(prMonitor.stopMonitoring).toHaveBeenCalledWith("FN-001");
});
it("drains comments before stopping monitoring (order matters)", async () => {
const callOrder: string[] = [];
const mockComments = [
{ id: 1, body: "Fix this", user: { login: "reviewer" }, created_at: "2024-01-01", updated_at: "2024-01-01", html_url: "" },
];
const prMonitor = {
startMonitoring: vi.fn(),
stopMonitoring: vi.fn(() => { callOrder.push("stopMonitoring"); }),
updatePrInfo: vi.fn(),
getTrackedPrs: vi.fn().mockReturnValue(new Map()),
stopAll: vi.fn(),
drainComments: vi.fn(() => { callOrder.push("drainComments"); return mockComments; }),
} as unknown as PrMonitor;
const onClosedPrFeedback = vi.fn().mockResolvedValue(undefined);
const store = createMockStore();
new Scheduler(store, { prMonitor, onClosedPrFeedback });
const onCalls = (store.on as any).mock.calls;
const movedHandler = onCalls.find((call: any) => call[0] === "task:moved")?.[1];
const task = createMockTask({
id: "FN-001",
column: "done",
prInfo: { status: "merged", number: 42 } as any,
});
movedHandler({ task, from: "in-review", to: "done" });
await new Promise((r) => setTimeout(r, 10));
// drainComments should be called before stopMonitoring
expect(callOrder).toEqual(["drainComments", "stopMonitoring"]);
});
it("second move event with empty drain does not create duplicate follow-up", async () => {
const prMonitor = {
startMonitoring: vi.fn(),
stopMonitoring: vi.fn(),
updatePrInfo: vi.fn(),
getTrackedPrs: vi.fn().mockReturnValue(new Map()),
stopAll: vi.fn(),
drainComments: vi.fn().mockReturnValue([]),
} as unknown as PrMonitor;
const onClosedPrFeedback = vi.fn();
const store = createMockStore();
new Scheduler(store, { prMonitor, onClosedPrFeedback });
const onCalls = (store.on as any).mock.calls;
const movedHandler = onCalls.find((call: any) => call[0] === "task:moved")?.[1];
const task = createMockTask({
id: "FN-001",
column: "done",
prInfo: { status: "merged", number: 42 } as any,
});
// First move — comments were already drained, buffer is empty
movedHandler({ task, from: "in-review", to: "done" });
await new Promise((r) => setTimeout(r, 10));
// Second move — still empty
movedHandler({ task, from: "in-review", to: "done" });
await new Promise((r) => setTimeout(r, 10));
// onClosedPrFeedback should never be called since buffer is empty
expect(onClosedPrFeedback).not.toHaveBeenCalled();
});
});
describe("mission integration", () => {

View File

@@ -1,10 +1,10 @@
import { resolveDependencyOrder, type TaskStore, type Task, type MissionStore } from "@fusion/core";
import { resolveDependencyOrder, type TaskStore, type Task, type MissionStore, type PrInfo } from "@fusion/core";
import { existsSync } from "node:fs";
import { readFile } from "node:fs/promises";
import { join } from "node:path";
import type { AgentSemaphore } from "./concurrency.js";
import { schedulerLog } from "./logger.js";
import type { PrMonitor } from "./pr-monitor.js";
import { type PrMonitor, type PrComment } from "./pr-monitor.js";
import { getCurrentGitHubRepo } from "./github.js";
/**
@@ -62,6 +62,17 @@ export interface SchedulerOptions {
prMonitor?: PrMonitor;
/** Optional MissionStore for slice activation and auto-advance */
missionStore?: MissionStore;
/**
* Called when a task with a closed/merged PR moves out of in-review
* and the PrMonitor has buffered actionable comments.
* The callback receives the task ID, PR info, and the drained comments.
* If no comments were buffered, this callback is NOT invoked.
*/
onClosedPrFeedback?: (
taskId: string,
prInfo: PrInfo,
comments: PrComment[]
) => void | Promise<void>;
}
/**
@@ -150,13 +161,23 @@ export class Scheduler {
this.options.prMonitor.startMonitoring(task.id, repo.owner, repo.repo, task.prInfo);
}
} else if (from === "in-review" && to !== "in-review") {
// If task has a closed/merged PR, drain buffered comments before
// stopping monitoring (drainComments needs the tracked PR to still exist)
if (task.prInfo && (task.prInfo.status === "closed" || task.prInfo.status === "merged")) {
const comments = this.options.prMonitor.drainComments(task.id);
if (comments.length > 0 && this.options.onClosedPrFeedback) {
void Promise.resolve(this.options.onClosedPrFeedback(task.id, task.prInfo, comments))
.then(() => {
schedulerLog.log(`Invoked onClosedPrFeedback for ${task.id} with ${comments.length} comment(s)`);
})
.catch((err) => {
schedulerLog.error(`Error in onClosedPrFeedback for ${task.id}:`, err);
});
}
}
// 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
}
}
}