feat(FN-982): add branch selection and commit viewing to Git Manager

- Add branch commit fetching API endpoint (GET /api/git/branches/:name/commits)
- Enhance BranchesPanel with selectable branches and commit diff viewer
- Add CSS styles for branch selection list and commit detail view
- Fix relativeDate utility to handle invalid/empty date strings gracefully
- Fix interview route ordering in mission-routes
- Add loading state for branch commit diff to prevent false error flash
- Add comprehensive tests for branch selection, commit viewing, and route handling
This commit is contained in:
gsxdsm
2026-04-06 13:10:28 -07:00
parent 8fa3401d82
commit dbb310ee67
7 changed files with 948 additions and 340 deletions

View File

@@ -250,6 +250,310 @@ export function createMissionRouter(
})
);
// ── Interview Endpoints ─────────────────────────────────────────────────────
// Note: These are mounted at /api/missions/interview/* via the router
/**
* Helper to resolve rootDir for the current request's project scope.
*/
async function getRootDirForRequest(req: TypedRequest): Promise<string> {
const projectId = getProjectIdFromRequest(req);
const scopedStore = projectId ? await getOrCreateProjectStore(projectId) : store;
return scopedStore.getRootDir();
}
/**
* POST /api/missions/interview/start
* Start a mission interview session with AI agent streaming.
* Body: { missionTitle: string }
* Returns: { sessionId: string }
*/
router.post(
"/interview/start",
asyncHandler(async (req, res) => {
const { missionTitle } = req.body;
if (!missionTitle || typeof missionTitle !== "string" || !missionTitle.trim()) {
res.status(400).json({ error: "missionTitle is required and must be a non-empty string" });
return;
}
if (missionTitle.length > 500) {
res.status(400).json({ error: "missionTitle must be 500 characters or less" });
return;
}
try {
const ip = req.ip || req.socket.remoteAddress || "unknown";
const rootDir = await getRootDirForRequest(req);
const {
createMissionInterviewSession,
RateLimitError,
} = await import("./mission-interview.js");
const sessionId = await createMissionInterviewSession(ip, missionTitle.trim(), rootDir);
res.status(201).json({ sessionId });
} catch (err: any) {
if (err.name === "RateLimitError") {
res.status(429).json({ error: err.message });
} else {
res.status(500).json({ error: err.message || "Failed to start interview session" });
}
}
})
);
/**
* POST /api/missions/interview/respond
* Submit response to interview question.
* Body: { sessionId: string, responses: Record<string, unknown> }
*/
router.post(
"/interview/respond",
asyncHandler(async (req, res) => {
const { sessionId, responses } = req.body;
if (!sessionId || typeof sessionId !== "string") {
res.status(400).json({ error: "sessionId is required" });
return;
}
if (!responses || typeof responses !== "object") {
res.status(400).json({ error: "responses is required and must be an object" });
return;
}
try {
const {
submitMissionInterviewResponse,
SessionNotFoundError,
InvalidSessionStateError,
} = await import("./mission-interview.js");
const result = await submitMissionInterviewResponse(sessionId, responses);
res.json(result);
} catch (err: any) {
if (err.name === "SessionNotFoundError") {
res.status(404).json({ error: err.message });
} else if (err.name === "InvalidSessionStateError") {
res.status(400).json({ error: err.message });
} else {
res.status(500).json({ error: err.message || "Failed to process response" });
}
}
})
);
/**
* POST /api/missions/interview/cancel
* Cancel and cleanup an interview session.
* Body: { sessionId: string }
*/
router.post(
"/interview/cancel",
asyncHandler(async (req, res) => {
const { sessionId } = req.body;
if (!sessionId || typeof sessionId !== "string") {
res.status(400).json({ error: "sessionId is required" });
return;
}
try {
const {
cancelMissionInterviewSession,
SessionNotFoundError,
} = await import("./mission-interview.js");
await cancelMissionInterviewSession(sessionId);
res.json({ success: true });
} catch (err: any) {
if (err.name === "SessionNotFoundError") {
res.status(404).json({ error: err.message });
} else {
res.status(500).json({ error: err.message || "Failed to cancel session" });
}
}
})
);
/**
* GET /api/missions/interview/:sessionId/stream
* SSE endpoint for real-time interview session updates.
* Streams thinking output, questions, summaries, and errors.
*/
router.get(
"/interview/:sessionId/stream",
asyncHandler(async (req, res) => {
const { sessionId } = req.params;
// Set SSE headers
res.setHeader("Content-Type", "text/event-stream");
res.setHeader("Cache-Control", "no-cache");
res.setHeader("Connection", "keep-alive");
res.setHeader("X-Accel-Buffering", "no");
res.flushHeaders();
// Send initial connection confirmation
res.write(": connected\n\n");
try {
const {
missionInterviewStreamManager,
getMissionInterviewSession,
} = await import("./mission-interview.js");
// Verify session exists
const session = getMissionInterviewSession(sessionId);
if (!session) {
res.write(`event: error\ndata: ${JSON.stringify({ message: "Session not found or expired" })}\n\n`);
res.end();
return;
}
// Subscribe to session events
const unsubscribe = missionInterviewStreamManager.subscribe(sessionId, (event) => {
try {
const data = (event as { data?: unknown }).data;
res.write(`event: ${event.type}\ndata: ${JSON.stringify(data ?? {})}\n\n`);
// End stream on complete or error
if (event.type === "complete" || event.type === "error") {
unsubscribe();
res.end();
}
} catch {
// Client disconnected
unsubscribe();
}
});
// Handle client disconnect
req.on("close", () => {
unsubscribe();
});
// Heartbeat every 30s
const heartbeat = setInterval(() => {
if (res.writableEnded) {
clearInterval(heartbeat);
return;
}
res.write(": heartbeat\n\n");
}, 30_000);
req.on("close", () => {
clearInterval(heartbeat);
});
} catch (err: any) {
res.write(`event: error\ndata: ${JSON.stringify({ message: err.message || "Stream error" })}\n\n`);
res.end();
}
})
);
/**
* POST /api/missions/interview/create-mission
* Create mission with full hierarchy from completed interview.
* Body: { sessionId: string, summary?: MissionPlanSummary }
* Returns: MissionWithHierarchy
*/
router.post(
"/interview/create-mission",
asyncHandler(async (req, res) => {
const { sessionId, summary: editedSummary } = req.body;
if (!sessionId || typeof sessionId !== "string") {
res.status(400).json({ error: "sessionId is required" });
return;
}
try {
const {
getMissionInterviewSession,
getMissionInterviewSummary,
cleanupMissionInterviewSession,
SessionNotFoundError,
} = await import("./mission-interview.js");
const session = getMissionInterviewSession(sessionId);
if (!session) {
res.status(404).json({ error: `Interview session ${sessionId} not found or expired` });
return;
}
// Use edited summary if provided, otherwise use the session's generated summary
const summary = editedSummary || getMissionInterviewSummary(sessionId);
if (!summary || !Array.isArray(summary.milestones)) {
res.status(400).json({ error: "Interview session is not complete or summary is missing" });
return;
}
// Create the full mission hierarchy
const mission = missionStore.createMission({
title: summary.missionTitle || session.missionTitle,
description: summary.missionDescription,
});
// Update interview state to completed
missionStore.updateMission(mission.id, { interviewState: "completed" as InterviewState });
// Create milestones, slices, and features
// Verification criteria are appended to descriptions since the schema
// doesn't have dedicated verification fields yet.
for (const milestoneData of summary.milestones) {
let msDesc = milestoneData.description || "";
if (milestoneData.verification) {
msDesc += msDesc ? "\n\n" : "";
msDesc += `**Verification:** ${milestoneData.verification}`;
}
const milestone = missionStore.addMilestone(mission.id, {
title: milestoneData.title,
description: msDesc || undefined,
});
if (Array.isArray(milestoneData.slices)) {
for (const sliceData of milestoneData.slices) {
let slDesc = sliceData.description || "";
if (sliceData.verification) {
slDesc += slDesc ? "\n\n" : "";
slDesc += `**Verification:** ${sliceData.verification}`;
}
const slice = missionStore.addSlice(milestone.id, {
title: sliceData.title,
description: slDesc || undefined,
});
if (Array.isArray(sliceData.features)) {
for (const featureData of sliceData.features) {
missionStore.addFeature(slice.id, {
title: featureData.title,
description: featureData.description,
acceptanceCriteria: featureData.acceptanceCriteria,
});
}
}
}
}
}
// Cleanup the interview session
cleanupMissionInterviewSession(sessionId);
// Return the full hierarchy
const result = missionStore.getMissionWithHierarchy(mission.id);
res.status(201).json(result);
} catch (err: any) {
if (err.name === "SessionNotFoundError") {
res.status(404).json({ error: err.message });
} else {
res.status(500).json({ error: err.message || "Failed to create mission" });
}
}
})
);
/**
* GET /api/missions/:missionId
* Get mission by ID with full hierarchy
@@ -1552,309 +1856,6 @@ export function createMissionRouter(
})
);
// ── Interview Endpoints ─────────────────────────────────────────────────────
// Note: These are mounted at /api/missions/interview/* via the router
/**
* Helper to resolve rootDir for the current request's project scope.
*/
async function getRootDirForRequest(req: TypedRequest): Promise<string> {
const projectId = getProjectIdFromRequest(req);
const scopedStore = projectId ? await getOrCreateProjectStore(projectId) : store;
return scopedStore.getRootDir();
}
/**
* POST /api/missions/interview/start
* Start a mission interview session with AI agent streaming.
* Body: { missionTitle: string }
* Returns: { sessionId: string }
*/
router.post(
"/interview/start",
asyncHandler(async (req, res) => {
const { missionTitle } = req.body;
if (!missionTitle || typeof missionTitle !== "string" || !missionTitle.trim()) {
res.status(400).json({ error: "missionTitle is required and must be a non-empty string" });
return;
}
if (missionTitle.length > 500) {
res.status(400).json({ error: "missionTitle must be 500 characters or less" });
return;
}
try {
const ip = req.ip || req.socket.remoteAddress || "unknown";
const rootDir = await getRootDirForRequest(req);
const {
createMissionInterviewSession,
RateLimitError,
} = await import("./mission-interview.js");
const sessionId = await createMissionInterviewSession(ip, missionTitle.trim(), rootDir);
res.status(201).json({ sessionId });
} catch (err: any) {
if (err.name === "RateLimitError") {
res.status(429).json({ error: err.message });
} else {
res.status(500).json({ error: err.message || "Failed to start interview session" });
}
}
})
);
/**
* POST /api/missions/interview/respond
* Submit response to interview question.
* Body: { sessionId: string, responses: Record<string, unknown> }
*/
router.post(
"/interview/respond",
asyncHandler(async (req, res) => {
const { sessionId, responses } = req.body;
if (!sessionId || typeof sessionId !== "string") {
res.status(400).json({ error: "sessionId is required" });
return;
}
if (!responses || typeof responses !== "object") {
res.status(400).json({ error: "responses is required and must be an object" });
return;
}
try {
const {
submitMissionInterviewResponse,
SessionNotFoundError,
InvalidSessionStateError,
} = await import("./mission-interview.js");
const result = await submitMissionInterviewResponse(sessionId, responses);
res.json(result);
} catch (err: any) {
if (err.name === "SessionNotFoundError") {
res.status(404).json({ error: err.message });
} else if (err.name === "InvalidSessionStateError") {
res.status(400).json({ error: err.message });
} else {
res.status(500).json({ error: err.message || "Failed to process response" });
}
}
})
);
/**
* POST /api/missions/interview/cancel
* Cancel and cleanup an interview session.
* Body: { sessionId: string }
*/
router.post(
"/interview/cancel",
asyncHandler(async (req, res) => {
const { sessionId } = req.body;
if (!sessionId || typeof sessionId !== "string") {
res.status(400).json({ error: "sessionId is required" });
return;
}
try {
const {
cancelMissionInterviewSession,
SessionNotFoundError,
} = await import("./mission-interview.js");
await cancelMissionInterviewSession(sessionId);
res.json({ success: true });
} catch (err: any) {
if (err.name === "SessionNotFoundError") {
res.status(404).json({ error: err.message });
} else {
res.status(500).json({ error: err.message || "Failed to cancel session" });
}
}
})
);
/**
* GET /api/missions/interview/:sessionId/stream
* SSE endpoint for real-time interview session updates.
* Streams thinking output, questions, summaries, and errors.
*/
router.get(
"/interview/:sessionId/stream",
asyncHandler(async (req, res) => {
const { sessionId } = req.params;
// Set SSE headers
res.setHeader("Content-Type", "text/event-stream");
res.setHeader("Cache-Control", "no-cache");
res.setHeader("Connection", "keep-alive");
res.setHeader("X-Accel-Buffering", "no");
res.flushHeaders();
// Send initial connection confirmation
res.write(": connected\n\n");
try {
const {
missionInterviewStreamManager,
getMissionInterviewSession,
} = await import("./mission-interview.js");
// Verify session exists
const session = getMissionInterviewSession(sessionId);
if (!session) {
res.write(`event: error\ndata: ${JSON.stringify({ message: "Session not found or expired" })}\n\n`);
res.end();
return;
}
// Subscribe to session events
const unsubscribe = missionInterviewStreamManager.subscribe(sessionId, (event) => {
try {
const data = (event as { data?: unknown }).data;
res.write(`event: ${event.type}\ndata: ${JSON.stringify(data ?? {})}\n\n`);
// End stream on complete or error
if (event.type === "complete" || event.type === "error") {
unsubscribe();
res.end();
}
} catch {
// Client disconnected
unsubscribe();
}
});
// Handle client disconnect
req.on("close", () => {
unsubscribe();
});
// Heartbeat every 30s
const heartbeat = setInterval(() => {
if (res.writableEnded) {
clearInterval(heartbeat);
return;
}
res.write(": heartbeat\n\n");
}, 30_000);
req.on("close", () => {
clearInterval(heartbeat);
});
} catch (err: any) {
res.write(`event: error\ndata: ${JSON.stringify({ message: err.message || "Stream error" })}\n\n`);
res.end();
}
})
);
/**
* POST /api/missions/interview/create-mission
* Create mission with full hierarchy from completed interview.
* Body: { sessionId: string, summary?: MissionPlanSummary }
* Returns: MissionWithHierarchy
*/
router.post(
"/interview/create-mission",
asyncHandler(async (req, res) => {
const { sessionId, summary: editedSummary } = req.body;
if (!sessionId || typeof sessionId !== "string") {
res.status(400).json({ error: "sessionId is required" });
return;
}
try {
const {
getMissionInterviewSession,
getMissionInterviewSummary,
cleanupMissionInterviewSession,
SessionNotFoundError,
} = await import("./mission-interview.js");
const session = getMissionInterviewSession(sessionId);
if (!session) {
res.status(404).json({ error: `Interview session ${sessionId} not found or expired` });
return;
}
// Use edited summary if provided, otherwise use the session's generated summary
const summary = editedSummary || getMissionInterviewSummary(sessionId);
if (!summary || !Array.isArray(summary.milestones)) {
res.status(400).json({ error: "Interview session is not complete or summary is missing" });
return;
}
// Create the full mission hierarchy
const mission = missionStore.createMission({
title: summary.missionTitle || session.missionTitle,
description: summary.missionDescription,
});
// Update interview state to completed
missionStore.updateMission(mission.id, { interviewState: "completed" as InterviewState });
// Create milestones, slices, and features
// Verification criteria are appended to descriptions since the schema
// doesn't have dedicated verification fields yet.
for (const milestoneData of summary.milestones) {
let msDesc = milestoneData.description || "";
if (milestoneData.verification) {
msDesc += msDesc ? "\n\n" : "";
msDesc += `**Verification:** ${milestoneData.verification}`;
}
const milestone = missionStore.addMilestone(mission.id, {
title: milestoneData.title,
description: msDesc || undefined,
});
if (Array.isArray(milestoneData.slices)) {
for (const sliceData of milestoneData.slices) {
let slDesc = sliceData.description || "";
if (sliceData.verification) {
slDesc += slDesc ? "\n\n" : "";
slDesc += `**Verification:** ${sliceData.verification}`;
}
const slice = missionStore.addSlice(milestone.id, {
title: sliceData.title,
description: slDesc || undefined,
});
if (Array.isArray(sliceData.features)) {
for (const featureData of sliceData.features) {
missionStore.addFeature(slice.id, {
title: featureData.title,
description: featureData.description,
acceptanceCriteria: featureData.acceptanceCriteria,
});
}
}
}
}
}
// Cleanup the interview session
cleanupMissionInterviewSession(sessionId);
// Return the full hierarchy
const result = missionStore.getMissionWithHierarchy(mission.id);
res.status(201).json(result);
} catch (err: any) {
if (err.name === "SessionNotFoundError") {
res.status(404).json({ error: err.message });
} else {
res.status(500).json({ error: err.message || "Failed to create mission" });
}
}
})
);
return router;
}

View File

@@ -4492,6 +4492,36 @@ describe("Git Management endpoints", () => {
});
});
describe("GET /git/branches/:name/commits", () => {
it("returns commits for a valid branch", async () => {
const res = await GET(buildApp(), "/api/git/branches/main/commits");
expect(res.status).toBe(200);
expect(Array.isArray(res.body)).toBe(true);
});
it("respects limit parameter", async () => {
const res = await GET(buildApp(), "/api/git/branches/main/commits?limit=5");
expect(res.status).toBe(200);
expect(Array.isArray(res.body)).toBe(true);
});
it("returns 400 for invalid branch name", async () => {
const res = await GET(buildApp(), "/api/git/branches/;rm%20-rf%20/commits");
expect(res.status).toBe(400);
expect(res.body.error).toContain("Invalid branch name");
});
it("returns empty array for non-existent branch", async () => {
const res = await GET(buildApp(), "/api/git/branches/nonexistent-branch-xyz/commits");
expect(res.status).toBe(200);
expect(res.body).toEqual([]);
});
});
describe("GET /git/worktrees", () => {
it("returns worktrees array", async () => {
(store.listTasks as ReturnType<typeof vi.fn>).mockResolvedValue([]);

View File

@@ -407,6 +407,42 @@ function isValidGitRef(ref: string): boolean {
return true;
}
/**
* Get recent commits for a specific branch.
* @param branch The branch name (validated before calling)
* @param limit Maximum number of commits to return
* @param cwd Working directory
*/
function getGitCommitsForBranch(branch: string, limit: number = 10, cwd?: string): GitCommit[] {
try {
const format = "%H|%h|%s|%an|%aI|%P";
const execOptions = { encoding: "utf-8" as const, timeout: 10000, cwd };
const output = execSync(`git log --max-count=${limit} --pretty=format:"${format}" "${branch}"`, execOptions);
const commits: GitCommit[] = [];
for (const line of output.split("\n")) {
const parts = line.split("|");
if (parts.length < 5) continue;
const [hash, shortHash, message, author, date, parentsStr] = parts;
const parents = parentsStr ? parentsStr.split(" ").filter(Boolean) : [];
commits.push({
hash,
shortHash,
message: message || "",
author: author || "",
date: date || "",
parents,
});
}
return commits;
} catch {
return [];
}
}
/**
* Get commits ahead of the upstream tracking branch (commits that would be pushed).
* Returns the list of local commits not yet present on the upstream.
@@ -2876,6 +2912,32 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
}
});
/**
* GET /api/git/branches/:name/commits
* Returns recent commits for a specific branch.
* Query params: limit (default 10, max 100)
* Response: Array of GitCommit objects
*/
router.get("/git/branches/:name/commits", (req, res) => {
try {
const rootDir = store.getRootDir();
if (!isGitRepo(rootDir)) {
res.status(400).json({ error: "Not a git repository" });
return;
}
const { name } = req.params;
if (!isValidGitRef(name)) {
res.status(400).json({ error: "Invalid branch name" });
return;
}
const limit = Math.min(Math.max(parseInt(String(req.query.limit)) || 10, 1), 100);
const commits = getGitCommitsForBranch(name, limit, rootDir);
res.json(commits);
} catch (err: any) {
res.status(500).json({ error: err.message });
}
});
/**
* GET /api/git/worktrees
* Returns all worktrees with path, branch, isMain, and associated task ID.