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 70190c6fe5
commit f1fb79c7c0
7 changed files with 948 additions and 340 deletions

View File

@@ -869,6 +869,12 @@ export function fetchGitBranches(): Promise<GitBranch[]> {
return api<GitBranch[]>("/git/branches");
}
/** Fetch recent commits for a specific branch */
export function fetchBranchCommits(branchName: string, limit?: number): Promise<GitCommit[]> {
const query = limit ? `?limit=${limit}` : "";
return api<GitCommit[]>(`/git/branches/${encodeURIComponent(branchName)}/commits${query}`);
}
/** Fetch all worktrees */
export function fetchGitWorktrees(): Promise<GitWorktree[]> {
return api<GitWorktree[]>("/git/worktrees");

View File

@@ -42,6 +42,7 @@ import {
updateGitRemoteUrl,
fetchAheadCommits,
fetchRemoteCommits,
fetchBranchCommits,
} from "../api";
import {
GitBranch as GitBranchIcon,
@@ -138,9 +139,11 @@ function useCopyToClipboard(addToast: (msg: string, type?: ToastType) => void) {
);
}
/** Format relative date */
function relativeDate(dateStr: string): string {
/** Format relative date. Returns "—" for invalid/empty dates. */
function relativeDate(dateStr: string | undefined | null): string {
if (!dateStr) return "—";
const date = new Date(dateStr);
if (isNaN(date.getTime())) return "—";
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMins = Math.floor(diffMs / 60000);
@@ -195,6 +198,12 @@ export function GitManagerModal({ isOpen, onClose, tasks, addToast }: GitManager
const [newBranchName, setNewBranchName] = useState("");
const [branchBase, setBranchBase] = useState("");
const [branchSearch, setBranchSearch] = useState("");
const [selectedBranch, setSelectedBranch] = useState<string | null>(null);
const [branchCommits, setBranchCommits] = useState<GitCommit[]>([]);
const [loadingBranchCommits, setLoadingBranchCommits] = useState(false);
const [expandedBranchCommit, setExpandedBranchCommit] = useState<string | null>(null);
const [branchCommitDiff, setBranchCommitDiff] = useState<{ stat: string; patch: string } | null>(null);
const [loadingBranchCommitDiff, setLoadingBranchCommitDiff] = useState(false);
// ── Worktrees state
const [worktrees, setWorktrees] = useState<GitWorktree[]>([]);
@@ -502,6 +511,61 @@ export function GitManagerModal({ isOpen, onClose, tasks, addToast }: GitManager
return branches.filter((b) => b.name.toLowerCase().includes(q));
}, [branches, branchSearch]);
// ── Branch Selection Handlers ───────────────────────────────────
/** Toggle branch selection to show commits for that branch */
const handleSelectBranch = useCallback(async (name: string) => {
if (selectedBranch === name) {
// Deselect
setSelectedBranch(null);
setBranchCommits([]);
setExpandedBranchCommit(null);
setBranchCommitDiff(null);
return;
}
setSelectedBranch(name);
setBranchCommits([]);
setExpandedBranchCommit(null);
setBranchCommitDiff(null);
setLoadingBranchCommits(true);
try {
const data = await fetchBranchCommits(name, 10);
setBranchCommits(data);
} catch {
setBranchCommits([]);
} finally {
setLoadingBranchCommits(false);
}
}, [selectedBranch]);
/** Click a commit in the branch view to expand/collapse its diff */
const handleBranchCommitClick = useCallback(async (hash: string) => {
if (expandedBranchCommit === hash) {
setExpandedBranchCommit(null);
setBranchCommitDiff(null);
return;
}
setExpandedBranchCommit(hash);
setBranchCommitDiff(null);
setLoadingBranchCommitDiff(true);
try {
const diff = await fetchCommitDiff(hash);
setBranchCommitDiff(diff);
} catch {
setBranchCommitDiff(null);
} finally {
setLoadingBranchCommitDiff(false);
}
}, [expandedBranchCommit]);
/** Close branch details panel */
const handleCloseBranchDetails = useCallback(() => {
setSelectedBranch(null);
setBranchCommits([]);
setExpandedBranchCommit(null);
setBranchCommitDiff(null);
}, []);
// ── Stash Handlers ──────────────────────────────────────────────
const handleCreateStash = useCallback(async (e: React.FormEvent) => {
@@ -731,6 +795,15 @@ export function GitManagerModal({ isOpen, onClose, tasks, addToast }: GitManager
onDeleteBranch={handleDeleteBranch}
loading={loading}
allBranches={branches}
selectedBranch={selectedBranch}
branchCommits={branchCommits}
loadingBranchCommits={loadingBranchCommits}
expandedBranchCommit={expandedBranchCommit}
branchCommitDiff={branchCommitDiff}
loadingBranchCommitDiff={loadingBranchCommitDiff}
onSelectBranch={handleSelectBranch}
onBranchCommitClick={handleBranchCommitClick}
onCloseBranchDetails={handleCloseBranchDetails}
/>
)}
@@ -1190,7 +1263,7 @@ function CommitsPanel({
);
}
/** Branches panel with creation, search, checkout, delete */
/** Branches panel with creation, search, checkout, delete, and branch commit viewing */
function BranchesPanel({
branches,
branchSearch,
@@ -1204,6 +1277,15 @@ function BranchesPanel({
onDeleteBranch,
loading,
allBranches,
selectedBranch,
branchCommits,
loadingBranchCommits,
expandedBranchCommit,
branchCommitDiff,
loadingBranchCommitDiff,
onSelectBranch,
onBranchCommitClick,
onCloseBranchDetails,
}: {
branches: GitBranch[];
branchSearch: string;
@@ -1217,6 +1299,15 @@ function BranchesPanel({
onDeleteBranch: (name: string) => void;
loading: boolean;
allBranches: GitBranch[];
selectedBranch: string | null;
branchCommits: GitCommit[];
loadingBranchCommits: boolean;
expandedBranchCommit: string | null;
branchCommitDiff: { stat: string; patch: string } | null;
loadingBranchCommitDiff: boolean;
onSelectBranch: (name: string) => void;
onBranchCommitClick: (hash: string) => void;
onCloseBranchDetails: () => void;
}) {
return (
<div className="gm-panel" data-testid="branches-panel">
@@ -1273,44 +1364,116 @@ function BranchesPanel({
</div>
) : (
branches.map((branch) => (
<div
key={branch.name}
className={`gm-branch-item${branch.isCurrent ? " current" : ""}`}
>
<div className="gm-branch-info">
<span className="gm-branch-name">
{branch.isCurrent && <Check size={14} className="gm-current-icon" />}
{branch.name}
</span>
{branch.remote && (
<span className="gm-branch-remote"> {branch.remote}</span>
)}
{branch.lastCommitDate && (
<span className="gm-branch-date">{relativeDate(branch.lastCommitDate)}</span>
)}
<div key={branch.name}>
<div
className={`gm-branch-item${branch.isCurrent ? " current" : ""}${selectedBranch === branch.name ? " selected" : ""}`}
onClick={() => onSelectBranch(branch.name)}
>
<div className="gm-branch-info">
<span className="gm-branch-name">
{branch.isCurrent && <Check size={14} className="gm-current-icon" />}
{branch.name}
</span>
{branch.remote && (
<span className="gm-branch-remote"> {branch.remote}</span>
)}
{branch.lastCommitDate && (
<span className="gm-branch-date">{relativeDate(branch.lastCommitDate)}</span>
)}
</div>
<div className="gm-branch-actions">
{!branch.isCurrent && (
<>
<button
className="btn btn-sm"
onClick={(e) => { e.stopPropagation(); onCheckoutBranch(branch.name); }}
disabled={loading}
title="Checkout"
>
<GitBranchIcon size={14} />
</button>
<button
className="btn btn-sm btn-danger"
onClick={(e) => { e.stopPropagation(); onDeleteBranch(branch.name); }}
disabled={loading}
title="Delete"
>
<Trash2 size={14} />
</button>
</>
)}
</div>
</div>
<div className="gm-branch-actions">
{!branch.isCurrent && (
<>
{/* Branch commit details — shown when this branch is selected */}
{selectedBranch === branch.name && (
<div className="gm-branch-details">
<div className="gm-branch-details-header">
<span className="gm-branch-details-title">
<GitCommitIcon size={14} />
Commits on {branch.name}
</span>
<button
className="btn btn-sm"
onClick={() => onCheckoutBranch(branch.name)}
disabled={loading}
title="Checkout"
className="gm-icon-btn"
onClick={onCloseBranchDetails}
title="Close"
data-testid="close-branch-details"
>
<GitBranchIcon size={14} />
<X size={14} />
</button>
<button
className="btn btn-sm btn-danger"
onClick={() => onDeleteBranch(branch.name)}
disabled={loading}
title="Delete"
>
<Trash2 size={14} />
</button>
</>
)}
</div>
</div>
{loadingBranchCommits ? (
<div className="gm-branch-details-loading">
<Loader2 size={16} className="spin" />
Loading commits...
</div>
) : branchCommits.length === 0 ? (
<div className="gm-empty">No commits found</div>
) : (
<div className="gm-branch-commits-list">
{branchCommits.map((commit) => (
<div key={commit.hash} className="gm-branch-commit">
<button
className="gm-branch-commit-row"
onClick={() => onBranchCommitClick(commit.hash)}
data-testid={`branch-commit-${commit.shortHash}`}
>
<span className="gm-commit-hash">{commit.shortHash}</span>
<span className="gm-commit-message" title={commit.message}>
{commit.message}
</span>
<div className="gm-commit-meta">
<span>{commit.author}</span>
<span></span>
<span>{relativeDate(commit.date)}</span>
{commit.parents.length > 1 && (
<span className="gm-merge-badge">merge</span>
)}
</div>
</button>
{expandedBranchCommit === commit.hash && (
<div className="gm-commit-diff">
{loadingBranchCommitDiff ? (
<div className="gm-diff-loading">
<Loader2 size={16} className="spin" />
Loading diff...
</div>
) : branchCommitDiff ? (
<>
{branchCommitDiff.stat && <pre className="gm-diff-stat">{branchCommitDiff.stat}</pre>}
<pre className="gm-diff-patch">{branchCommitDiff.patch}</pre>
</>
) : (
<div className="gm-diff-error">Failed to load diff</div>
)}
</div>
)}
</div>
))}
</div>
)}
</div>
)}
</div>
))
)}

View File

@@ -35,6 +35,7 @@ vi.mock("../../api", async () => {
updateGitRemoteUrl: vi.fn(),
fetchAheadCommits: vi.fn(),
fetchRemoteCommits: vi.fn(),
fetchBranchCommits: vi.fn(),
};
});
@@ -67,6 +68,7 @@ import {
updateGitRemoteUrl,
fetchAheadCommits,
fetchRemoteCommits,
fetchBranchCommits,
} from "../../api";
const mockAddToast = vi.fn();
@@ -631,6 +633,264 @@ describe("GitManagerModal", () => {
});
});
// ── Branch Selection & Commits ───────────────────────────────
it("selects a branch and fetches its commits on click", async () => {
(fetchBranchCommits as any).mockResolvedValue([
{
hash: "def456789abc",
shortHash: "def4567",
message: "Feature commit",
author: "Dev",
date: "2026-03-01T00:00:00Z",
parents: [],
},
]);
render(
<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />
);
fireEvent.click(screen.getByRole("tab", { name: /branches/i }));
await waitFor(() => {
expect(screen.getByTestId("branches-panel")).toBeInTheDocument();
});
// Find the branch items inside the branches list
const branchItems = screen.getByTestId("branches-panel").querySelectorAll(".gm-branch-item");
// "feature" is the non-current branch, should be the second one
expect(branchItems.length).toBe(2);
fireEvent.click(branchItems[1]);
await waitFor(() => {
expect(fetchBranchCommits).toHaveBeenCalledWith("feature", 10);
});
await waitFor(() => {
expect(screen.getByText("Feature commit")).toBeInTheDocument();
expect(screen.getByText("def4567")).toBeInTheDocument();
});
});
it("deselects a branch when clicking it again", async () => {
(fetchBranchCommits as any).mockResolvedValue([
{
hash: "def456789abc",
shortHash: "def4567",
message: "Feature commit",
author: "Dev",
date: "2026-03-01T00:00:00Z",
parents: [],
},
]);
render(
<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />
);
fireEvent.click(screen.getByRole("tab", { name: /branches/i }));
await waitFor(() => {
expect(screen.getByTestId("branches-panel")).toBeInTheDocument();
});
const branchItems = screen.getByTestId("branches-panel").querySelectorAll(".gm-branch-item");
fireEvent.click(branchItems[1]);
await waitFor(() => {
expect(fetchBranchCommits).toHaveBeenCalledWith("feature", 10);
});
// Click again to deselect
fireEvent.click(branchItems[1]);
await waitFor(() => {
expect(screen.queryByText("Commits on feature")).not.toBeInTheDocument();
});
});
it("shows loading state while fetching branch commits", async () => {
// Make fetchBranchCommits hang (never resolve)
(fetchBranchCommits as any).mockImplementation(() => new Promise(() => {}));
render(
<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />
);
fireEvent.click(screen.getByRole("tab", { name: /branches/i }));
await waitFor(() => {
expect(screen.getByTestId("branches-panel")).toBeInTheDocument();
});
const branchItems = screen.getByTestId("branches-panel").querySelectorAll(".gm-branch-item");
fireEvent.click(branchItems[1]);
await waitFor(() => {
expect(screen.getByText("Loading commits...")).toBeInTheDocument();
});
});
it("closes branch details via close button", async () => {
(fetchBranchCommits as any).mockResolvedValue([
{
hash: "def456789abc",
shortHash: "def4567",
message: "Feature commit",
author: "Dev",
date: "2026-03-01T00:00:00Z",
parents: [],
},
]);
render(
<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />
);
fireEvent.click(screen.getByRole("tab", { name: /branches/i }));
await waitFor(() => {
expect(screen.getByTestId("branches-panel")).toBeInTheDocument();
});
const branchItems = screen.getByTestId("branches-panel").querySelectorAll(".gm-branch-item");
fireEvent.click(branchItems[1]);
await waitFor(() => {
expect(screen.getByText("Commits on feature")).toBeInTheDocument();
});
// Click the close button
const closeBtn = screen.getByTestId("close-branch-details");
fireEvent.click(closeBtn);
await waitFor(() => {
expect(screen.queryByText("Commits on feature")).not.toBeInTheDocument();
});
});
it("expands commit diff when clicking a commit in branch view", async () => {
(fetchBranchCommits as any).mockResolvedValue([
{
hash: "def456789abc",
shortHash: "def4567",
message: "Feature commit",
author: "Dev",
date: "2026-03-01T00:00:00Z",
parents: [],
},
]);
(fetchCommitDiff as any).mockResolvedValue({
stat: " file.ts | 2 +-",
patch: "diff --git a/file.ts b/file.ts\n-old\n+new",
});
render(
<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />
);
fireEvent.click(screen.getByRole("tab", { name: /branches/i }));
await waitFor(() => {
expect(screen.getByTestId("branches-panel")).toBeInTheDocument();
});
const branchItems = screen.getByTestId("branches-panel").querySelectorAll(".gm-branch-item");
fireEvent.click(branchItems[1]);
await waitFor(() => {
expect(screen.getByText("Feature commit")).toBeInTheDocument();
});
// Click on the commit to expand diff
const commitRow = screen.getByTestId("branch-commit-def4567");
fireEvent.click(commitRow);
await waitFor(() => {
expect(fetchCommitDiff).toHaveBeenCalledWith("def456789abc");
});
});
it("handles fetchBranchCommits error gracefully", async () => {
(fetchBranchCommits as any).mockRejectedValue(new Error("Network error"));
render(
<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />
);
fireEvent.click(screen.getByRole("tab", { name: /branches/i }));
await waitFor(() => {
expect(screen.getByTestId("branches-panel")).toBeInTheDocument();
});
const branchItems = screen.getByTestId("branches-panel").querySelectorAll(".gm-branch-item");
fireEvent.click(branchItems[1]);
await waitFor(() => {
expect(fetchBranchCommits).toHaveBeenCalledWith("feature", 10);
});
// Should show empty state since fetch failed
await waitFor(() => {
expect(screen.getByText("No commits found")).toBeInTheDocument();
});
});
it("shows merge badge for merge commits in branch view", async () => {
(fetchBranchCommits as any).mockResolvedValue([
{
hash: "def456789abc",
shortHash: "def4567",
message: "Merge PR #42",
author: "Dev",
date: "2026-03-01T00:00:00Z",
parents: ["abc123", "def456"],
},
]);
render(
<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />
);
fireEvent.click(screen.getByRole("tab", { name: /branches/i }));
await waitFor(() => {
expect(screen.getByTestId("branches-panel")).toBeInTheDocument();
});
const branchItems = screen.getByTestId("branches-panel").querySelectorAll(".gm-branch-item");
fireEvent.click(branchItems[1]);
await waitFor(() => {
expect(screen.getByText("merge")).toBeInTheDocument();
});
});
// ── relativeDate function ──────────────────────────────────────
it("shows em-dash for branches with empty lastCommitDate", async () => {
(fetchGitBranches as any).mockResolvedValue([
{ name: "main", isCurrent: true, lastCommitDate: "" },
]);
render(
<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />
);
fireEvent.click(screen.getByRole("tab", { name: /branches/i }));
// With empty lastCommitDate, the date span should not render at all
// because the component conditionally renders only when lastCommitDate is truthy
await waitFor(() => {
const panel = screen.getByTestId("branches-panel");
const branchDates = panel.querySelectorAll(".gm-branch-date");
expect(branchDates.length).toBe(0);
});
});
it("shows em-dash for branches with invalid lastCommitDate", async () => {
(fetchGitBranches as any).mockResolvedValue([
{ name: "stale", isCurrent: true, lastCommitDate: "not-a-date" },
]);
render(
<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />
);
fireEvent.click(screen.getByRole("tab", { name: /branches/i }));
await waitFor(() => {
const panel = screen.getByTestId("branches-panel");
const dateSpan = panel.querySelector(".gm-branch-date");
expect(dateSpan).toBeTruthy();
expect(dateSpan!.textContent).toBe("—");
});
});
// ── Worktrees Panel ────────────────────────────────────────
it("loads worktrees and shows task associations", async () => {

View File

@@ -16220,6 +16220,80 @@ html .column.drag-over * {
flex-shrink: 0;
}
/* ── Branch Selection Details ── */
.gm-branch-item.selected {
background: rgba(88, 166, 255, 0.08);
cursor: pointer;
}
.gm-branch-item.selected:hover {
background: rgba(88, 166, 255, 0.12);
}
.gm-branch-details {
background: rgba(0, 0, 0, 0.15);
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
padding: var(--space-sm) var(--space-md);
}
.gm-branch-details-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: var(--space-sm);
}
.gm-branch-details-title {
display: inline-flex;
align-items: center;
gap: var(--space-xs);
font-size: 12px;
font-weight: 600;
color: var(--text-muted);
}
.gm-branch-details-loading {
display: flex;
align-items: center;
gap: var(--space-sm);
padding: var(--space-sm) 0;
font-size: 12px;
color: var(--text-muted);
}
.gm-branch-commits-list {
display: flex;
flex-direction: column;
}
.gm-branch-commit {
border-bottom: 1px solid rgba(255, 255, 255, 0.03);
}
.gm-branch-commit:last-child {
border-bottom: none;
}
.gm-branch-commit-row {
display: flex;
flex-direction: column;
gap: 2px;
width: 100%;
padding: var(--space-xs) var(--space-sm);
background: none;
border: none;
color: var(--text);
text-align: left;
cursor: pointer;
border-radius: var(--radius-sm);
transition: background var(--transition-fast);
}
.gm-branch-commit-row:hover {
background: rgba(255, 255, 255, 0.03);
}
/* ── Worktrees Panel ── */
.gm-worktree-stats {
@@ -16858,6 +16932,18 @@ html .column.drag-over * {
background: rgba(9, 105, 218, 0.04);
}
[data-theme="light"] .gm-branch-item.selected {
background: rgba(9, 105, 218, 0.08);
}
[data-theme="light"] .gm-branch-item.selected:hover {
background: rgba(9, 105, 218, 0.12);
}
[data-theme="light"] .gm-branch-details {
background: rgba(0, 0, 0, 0.02);
}
[data-theme="light"] .gm-hash {
background: rgba(0, 0, 0, 0.05);
}

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.