feat(kanban): scope boards to a project

Boards gain an optional project_id. When set, the board's default_workdir
mirrors the project's primary repo and every new task inherits the project —
a deterministic worktree + branch per task — unless it names its own. New
GET /projects; board create/patch/list carry project_id + resolved name; the
create dialog defaults its workspace to the board's and allows a per-task
path override. Desktop: "Board settings…" gains a project picker.
This commit is contained in:
Brooklyn Nicholson 2026-07-18 23:23:25 -04:00
parent 9be67b7762
commit 027ef381a4
8 changed files with 518 additions and 20 deletions

View file

@ -12,9 +12,11 @@
import { atom, type PluginRestOptions, type PluginStorage, queryClient } from '@hermes/plugin-sdk'
import type {
BoardMeta,
BoardsResponse,
KanbanBoard,
KanbanProfile,
KanbanProject,
KanbanTask,
KanbanTaskDetail,
OrchestrationSettings,
@ -113,6 +115,7 @@ export const taskKey = (slug: string, id: string) => ['kanban', 'task', slug, id
export const logKey = (slug: string, id: string) => ['kanban', 'log', slug, id] as const
export const BOARDS_KEY = ['kanban', 'boards'] as const
export const PROFILES_KEY = ['kanban', 'profiles'] as const
export const PROJECTS_KEY = ['kanban', 'projects'] as const
export const ORCHESTRATION_KEY = ['kanban', 'orchestration'] as const
// ── reads ─────────────────────────────────────────────────────────────────────
@ -129,6 +132,9 @@ export const fetchBoards = () => call<BoardsResponse>('/boards')
export const fetchProfiles = () => call<{ profiles: KanbanProfile[] }>('/profiles')
/** First-class Hermes projects, for scoping a board's default workspace. */
export const fetchProjects = () => call<{ projects: KanbanProject[] }>('/projects')
export const fetchOrchestration = () => call<OrchestrationSettings>('/orchestration')
// ── writes ────────────────────────────────────────────────────────────────────
@ -191,8 +197,16 @@ export const reclaimTask = (id: string) => nudged(call(withBoard(`/tasks/${id}/r
export const uploadAttachment = (id: string, upload: { filename: string; contentType?: string; bytes: ArrayBuffer }) =>
call(withBoard(`/tasks/${id}/attachments`), { method: 'POST', upload })
export const createBoard = (slug: string, name: string) =>
call<{ board: { slug: string } }>('/boards', { method: 'POST', body: { slug, name } })
export const createBoard = (slug: string, name: string, projectId?: string) =>
call<{ board: { slug: string } }>('/boards', {
method: 'POST',
body: { slug, name, ...(projectId ? { project_id: projectId } : {}) }
})
/** Edit a board's display metadata + default project directory. Pass
* `default_workdir: ''` to clear it. Slug is immutable. */
export const updateBoard = (slug: string, patch: Record<string, unknown>) =>
call<{ board: BoardMeta }>(`/boards/${encodeURIComponent(slug)}`, { method: 'PATCH', body: patch })
export const nudgeDispatcher = () => call<{ spawned?: unknown[] }>(withBoard('/dispatch'), { method: 'POST', body: {} })

View file

@ -20,6 +20,11 @@ import {
DropdownMenuTrigger,
host,
Input,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
useMutation,
useQuery,
useQueryClient,
@ -27,12 +32,47 @@ import {
} from '@hermes/plugin-sdk'
import { useEffect, useState } from 'react'
import { $boardSlug, BOARDS_KEY, createBoard, fetchBoards } from './api'
import { errText } from './ui'
import { $boardSlug, BOARDS_KEY, createBoard, fetchBoards, fetchProjects, PROJECTS_KEY, updateBoard } from './api'
import type { BoardMeta } from './types'
import { errText, FIELD_LABEL } from './ui'
const NO_PROJECT = '__none__'
/** Board scope = a first-class Hermes project. Its primary repo becomes the
* board's default workspace root; new tasks inherit it as a worktree with a
* deterministic branch. "No project" falls back to scratch sandboxes. */
function ProjectPicker({ onChange, value }: { onChange: (id: string) => void; value: string }) {
const { data } = useQuery({ queryKey: PROJECTS_KEY, queryFn: fetchProjects, staleTime: 30_000 })
const projects = data?.projects ?? []
return (
<label className="flex flex-col gap-1">
<span className={FIELD_LABEL}>Project</span>
<Select onValueChange={id => onChange(id === NO_PROJECT ? '' : id)} value={value || NO_PROJECT}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={NO_PROJECT}>No project (scratch sandboxes)</SelectItem>
{projects.map(project => (
<SelectItem key={project.id} value={project.id}>
{project.name}
</SelectItem>
))}
</SelectContent>
</Select>
<span className="text-[0.6875rem] leading-relaxed text-(--ui-text-quaternary)">
New tasks run in the projects repo (a worktree per task); each task can still override its workspace at
creation. Manage projects with <span className="font-mono">hermes project</span>.
</span>
</label>
)
}
function NewBoardDialog({ onClose, open }: { onClose: () => void; open: boolean }) {
const qc = useQueryClient()
const [name, setName] = useState('')
const [project, setProject] = useState('')
const slug = name
.trim()
@ -43,11 +83,12 @@ function NewBoardDialog({ onClose, open }: { onClose: () => void; open: boolean
useEffect(() => {
if (open) {
setName('')
setProject('')
}
}, [open])
const create = useMutation({
mutationFn: () => createBoard(slug, name.trim()),
mutationFn: () => createBoard(slug, name.trim(), project || undefined),
onError: err => host.notify({ kind: 'error', message: errText(err) }),
onSuccess: result => {
$boardSlug.set(result.board.slug)
@ -58,19 +99,23 @@ function NewBoardDialog({ onClose, open }: { onClose: () => void; open: boolean
return (
<Dialog onOpenChange={o => !o && onClose()} open={open}>
<DialogContent className="max-w-sm">
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>New board</DialogTitle>
</DialogHeader>
<div className="flex flex-col gap-1.5">
<Input
autoFocus
onChange={event => setName(event.target.value)}
onKeyDown={event => event.key === 'Enter' && slug && create.mutate()}
placeholder="Board name"
value={name}
/>
{slug && <span className="text-[0.6875rem] text-(--ui-text-quaternary)">slug: {slug}</span>}
<div className="flex flex-col gap-3">
<label className="flex flex-col gap-1">
<span className={FIELD_LABEL}>Name</span>
<Input
autoFocus
onChange={event => setName(event.target.value)}
onKeyDown={event => event.key === 'Enter' && slug && !project && create.mutate()}
placeholder="Board name"
value={name}
/>
{slug && <span className="text-[0.6875rem] text-(--ui-text-quaternary)">slug: {slug}</span>}
</label>
<ProjectPicker onChange={setProject} value={project} />
</div>
<DialogFooter>
<Button onClick={onClose} variant="text">
@ -85,10 +130,61 @@ function NewBoardDialog({ onClose, open }: { onClose: () => void; open: boolean
)
}
function BoardSettingsDialog({ board, onClose }: { board: BoardMeta | null; onClose: () => void }) {
const qc = useQueryClient()
const [name, setName] = useState('')
const [project, setProject] = useState('')
useEffect(() => {
if (board) {
setName(board.name || '')
setProject(board.project_id || '')
}
}, [board])
const save = useMutation({
// Slug is immutable; send name + project_id ('' clears the scope, which
// also drops the mirrored default_workdir on the backend).
mutationFn: () => updateBoard(board!.slug, { name: name.trim(), project_id: project }),
onError: err => host.notify({ kind: 'error', message: errText(err) }),
onSuccess: () => {
void qc.invalidateQueries({ queryKey: BOARDS_KEY })
onClose()
}
})
return (
<Dialog onOpenChange={o => !o && onClose()} open={Boolean(board)}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Board settings{board ? `${board.name || board.slug}` : ''}</DialogTitle>
</DialogHeader>
<div className="flex flex-col gap-3">
<label className="flex flex-col gap-1">
<span className={FIELD_LABEL}>Name</span>
<Input onChange={event => setName(event.target.value)} placeholder="Board name" value={name} />
{board && <span className="text-[0.6875rem] text-(--ui-text-quaternary)">slug: {board.slug}</span>}
</label>
<ProjectPicker onChange={setProject} value={project} />
</div>
<DialogFooter>
<Button onClick={onClose} variant="text">
Cancel
</Button>
<Button disabled={save.isPending} onClick={() => save.mutate()}>
Save
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
export function BoardSwitcher() {
const slug = useValue($boardSlug)
const { data: boards } = useQuery({ queryFn: fetchBoards, queryKey: BOARDS_KEY, staleTime: 30_000 })
const [adding, setAdding] = useState(false)
const [settingsFor, setSettingsFor] = useState<BoardMeta | null>(null)
if (!boards) {
return null
@ -124,6 +220,12 @@ export function BoardSwitcher() {
</DropdownMenuItem>
))}
<DropdownMenuSeparator />
{current && (
<DropdownMenuItem onSelect={() => setSettingsFor(current)}>
<Codicon name="settings-gear" size="0.8rem" />
Board settings
</DropdownMenuItem>
)}
<DropdownMenuItem onSelect={() => setAdding(true)}>
<Codicon name="add" size="0.8rem" />
New board
@ -131,6 +233,7 @@ export function BoardSwitcher() {
</DropdownMenuContent>
</DropdownMenu>
<NewBoardDialog onClose={() => setAdding(false)} open={adding} />
<BoardSettingsDialog board={settingsFor} onClose={() => setSettingsFor(null)} />
</>
)
}

View file

@ -64,10 +64,12 @@ import {
$introDismissed,
$lanesByProfile,
boardKey,
BOARDS_KEY,
bulkTasks,
createTask,
deleteTask,
fetchBoard,
fetchBoards,
fetchProfiles,
patchTask,
PROFILES_KEY
@ -536,20 +538,34 @@ function NewTaskDialog({
// (ultimately the active profile), applied at create time. Never silently
// unassigned — parking a card is the explicit choice, not the default.
const resolvedDefault = useOrchestration()?.resolved_default_assignee || 'default'
// Board-level workspace default: a task inherits the current board's project
// workspace (scratch when unscoped, worktree in a git repo, else dir) unless
// overridden below. Set the board's project in the switcher's "Board settings…".
const selectedSlug = useValue($boardSlug)
const { data: boards } = useQuery({ queryKey: BOARDS_KEY, queryFn: fetchBoards, staleTime: 30_000 })
const currentBoard = boards?.boards.find(b => b.slug === (selectedSlug || boards.current))
const boardDefaultKind = currentBoard?.default_workspace_kind || 'scratch'
const boardDefaultDir = currentBoard?.default_workdir || ''
const isTriage = target === 'triage'
const [title, setTitle] = useState('')
const [bodyText, setBodyText] = useState('')
const [assignee, setAssignee] = useState('')
const [priority, setPriority] = useState('0')
const [skills, setSkills] = useState('')
const [workspaceKind, setWorkspaceKind] = useState<string>('scratch')
const [workspaceKind, setWorkspaceKind] = useState<string>(boardDefaultKind)
// Empty = inherit the board's project dir (backend resolves it); a path here
// overrides just this task. Only meaningful for dir/worktree.
const [workspacePath, setWorkspacePath] = useState('')
const [parent, setParent] = useState('')
const [goalMode, setGoalMode] = useState(false)
const [busy, setBusy] = useState(false)
const [error, setError] = useState<null | string>(null)
// Reset per open — the dialog is externally controlled (open = target set),
// so onOpenChange(true) never fires; key the reset off `target` instead.
// so onOpenChange(true) never fires; key the reset off `target` (and the
// resolved board default, which may arrive after the first open).
useEffect(() => {
if (target) {
setTitle('')
@ -557,13 +573,14 @@ function NewTaskDialog({
setAssignee('')
setPriority('0')
setSkills('')
setWorkspaceKind('scratch')
setWorkspaceKind(boardDefaultKind)
setWorkspacePath('')
setParent('')
setGoalMode(false)
setError(null)
setBusy(false)
}
}, [target])
}, [target, boardDefaultKind])
const submit = async () => {
const trimmed = title.trim()
@ -592,7 +609,9 @@ function NewTaskDialog({
skills: skillList.length ? skillList : undefined,
title: trimmed,
triage: isTriage,
workspace_kind: workspaceKind
workspace_kind: workspaceKind,
// Empty → backend inherits the board's project dir.
workspace_path: workspaceKind !== 'scratch' && workspacePath.trim() ? workspacePath.trim() : undefined
})
if (task && task.status !== target) {
@ -652,6 +671,7 @@ function NewTaskDialog({
{WORKSPACE_KINDS.map(kind => (
<SelectItem key={kind} value={kind}>
{kind}
{kind === boardDefaultKind ? ' · board default' : ''}
</SelectItem>
))}
</SelectContent>
@ -659,6 +679,21 @@ function NewTaskDialog({
</Field>
</div>
{workspaceKind !== 'scratch' && (
<Field label="Workspace path (optional override)">
<Input
onChange={event => setWorkspacePath(event.target.value)}
placeholder={boardDefaultDir || 'Inherits the boards project directory'}
value={workspacePath}
/>
<span className="text-[0.625rem] text-(--ui-text-quaternary)">
{boardDefaultDir
? `Leave empty to inherit ${boardDefaultDir}`
: 'Leave empty to inherit the boards project directory.'}
</span>
</Field>
)}
<Field label="Assignee">
<Select onValueChange={v => setAssignee(v === NO_PARENT ? '' : v)} value={assignee || NO_PARENT}>
<SelectTrigger>

View file

@ -121,10 +121,30 @@ export interface KanbanTaskDetail {
export interface BoardMeta {
slug: string
name?: null | string
description?: null | string
is_current?: boolean
total?: number
/** Board-level project directory new tasks inherit (empty = none). */
default_workdir?: null | string
/** Recommended workspace kind derived from default_workdir by the backend
* (`scratch` when unset, `worktree` in a git repo, else `dir`). */
default_workspace_kind?: null | string
/** First-class Project the board is scoped to (id) + resolved name. */
project_id?: null | string
project_name?: null | string
}
/** GET /projects — first-class Hermes projects available to scope a board. */
export interface KanbanProject {
id: string
slug: string
name: string
primary_path?: null | string
icon?: null | string
color?: null | string
}
export interface BoardsResponse {
boards: BoardMeta[]
current: string

View file

@ -673,6 +673,11 @@ def read_board_metadata(board: Optional[str] = None) -> dict:
"icon": "",
"color": "",
"default_workdir": None,
# Optional first-class Project this board is scoped to. When set, new
# tasks inherit it (deterministic worktree + branch under the project's
# primary repo) and ``default_workdir`` mirrors the project's primary
# path so the persistent-workspace inheritance path keeps working.
"project_id": None,
"created_at": None,
"archived": False,
}
@ -700,11 +705,16 @@ def write_board_metadata(
color: Optional[str] = None,
archived: Optional[bool] = None,
default_workdir: Optional[str] = None,
project_id: Optional[str] = None,
) -> dict:
"""Create / update ``board.json`` for ``board``.
Preserves any existing fields not mentioned in the call. Sets
``created_at`` on first write. Returns the resulting metadata dict.
``project_id``: ``None`` leaves it unchanged; empty string clears the
project scope; a value sets it (not validated here the caller resolves
it against ``projects_db``).
"""
_assert_not_delegated_child_mutation()
slug = _normalize_board_slug(board) or DEFAULT_BOARD
@ -724,6 +734,8 @@ def write_board_metadata(
meta["archived"] = bool(archived)
if default_workdir is not None:
meta["default_workdir"] = str(default_workdir) if default_workdir else None
if project_id is not None:
meta["project_id"] = str(project_id) if project_id else None
if not meta.get("created_at"):
meta["created_at"] = int(time.time())
path = board_metadata_path(slug)
@ -744,6 +756,7 @@ def create_board(
icon: Optional[str] = None,
color: Optional[str] = None,
default_workdir: Optional[str] = None,
project_id: Optional[str] = None,
) -> dict:
"""Create a new board directory + DB + metadata. Idempotent.
@ -761,6 +774,7 @@ def create_board(
icon=icon,
color=color,
default_workdir=default_workdir,
project_id=project_id,
)
# Touch the DB so list_boards() sees it immediately.
init_db(board=normed)
@ -2900,6 +2914,18 @@ def create_task(
if branch_name and workspace_kind != "worktree":
raise ValueError("branch_name is only valid for worktree workspaces")
# Inherit the board's scoped project when the caller didn't name one, so a
# project-scoped board anchors every new task to that project's repo
# (deterministic worktree + branch) without each surface repeating it.
if project_id is None:
try:
_bmeta = read_board_metadata(board if board else get_current_board())
_board_project = (_bmeta.get("project_id") or "").strip()
if _board_project:
project_id = _board_project
except Exception:
pass
# Resolve an optional first-class Project link. A project-linked task is
# anchored to the project's primary repo as a git worktree, so its branch
# can be named deterministically (project slug + task id) instead of the

View file

@ -610,6 +610,9 @@ class CreateTaskBody(BaseModel):
goal_max_turns: Optional[int] = None
model_override: Optional[str] = None
provider_override: Optional[str] = None
# Explicit project link; when omitted, create_task inherits the board's
# scoped project (if any) so a project-scoped board anchors every task.
project_id: Optional[str] = None
@router.post("/tasks")
@ -636,6 +639,8 @@ def create_task(payload: CreateTaskBody, board: Optional[str] = Query(None)):
goal_max_turns=payload.goal_max_turns,
model_override=payload.model_override,
provider_override=payload.provider_override,
project_id=payload.project_id,
board=board,
)
task = kanban_db.get_task(conn, task_id)
body: dict[str, Any] = {"task": _task_dict(task) if task else None}
@ -2078,6 +2083,10 @@ class CreateBoardBody(BaseModel):
icon: Optional[str] = None
color: Optional[str] = None
default_workdir: Optional[str] = None
# First-class Project (id or slug) to scope the board to. When set, the
# board's default_workdir mirrors the project's primary repo and new tasks
# inherit the project (deterministic worktree + branch).
project_id: Optional[str] = None
switch: bool = False
@ -2089,6 +2098,38 @@ class RenameBoardBody(BaseModel):
# Board-level default project directory for new tasks. ``None`` =
# leave unchanged; empty string = clear; a path = validate + set.
default_workdir: Optional[str] = None
# Project scope (id or slug). ``None`` = leave unchanged; empty = clear;
# a value = resolve + set (and mirror default_workdir to its primary repo).
project_id: Optional[str] = None
def _resolve_project(ref: Optional[str]) -> tuple[Optional[str], Optional[str], Optional[str]]:
"""Resolve a project id/slug to ``(id, name, primary_path)``.
Returns ``(None, None, None)`` for a falsy ref. Raises 400 when a
non-empty ref doesn't resolve to an existing project.
"""
if not ref or not ref.strip():
return None, None, None
try:
from hermes_cli import projects_db as pdb
with pdb.connect_closing() as pconn:
proj = pdb.get_project(pconn, ref.strip())
except Exception as exc:
raise HTTPException(status_code=500, detail=f"projects unavailable: {exc}")
if proj is None:
raise HTTPException(status_code=400, detail=f"project {ref!r} does not exist")
return proj.id, proj.name, (proj.primary_path or None)
def _projects_by_id() -> dict[str, Any]:
"""Map every project id -> Project (archived included) for annotation."""
try:
from hermes_cli import projects_db as pdb
with pdb.connect_closing() as pconn:
return {p.id: p for p in pdb.list_projects(pconn, include_archived=True)}
except Exception:
return {}
def _board_counts(slug: str) -> dict[str, int]:
@ -2120,11 +2161,40 @@ def _default_workspace_kind(board: dict[str, Any]) -> str:
return "dir"
@router.get("/projects")
def list_kanban_projects():
"""List first-class Hermes projects for board scoping.
Returns ``{projects: [{id, slug, name, primary_path, icon, color}]}``.
Archived projects are excluded a board can only be scoped to a live one.
"""
try:
from hermes_cli import projects_db as pdb
with pdb.connect_closing() as pconn:
projects = pdb.list_projects(pconn, include_archived=False)
except Exception as exc:
raise HTTPException(status_code=500, detail=f"failed to list projects: {exc}")
return {
"projects": [
{
"id": p.id,
"slug": p.slug,
"name": p.name,
"primary_path": p.primary_path or "",
"icon": p.icon or "",
"color": p.color or "",
}
for p in projects
]
}
@router.get("/boards")
def list_boards(include_archived: bool = Query(False)):
"""Return every board on disk with task counts and the active slug."""
boards = kanban_db.list_boards(include_archived=include_archived)
current = kanban_db.get_current_board()
proj_map = _projects_by_id()
for b in boards:
b["is_current"] = (b["slug"] == current)
b["counts"] = _board_counts(b["slug"])
@ -2135,6 +2205,10 @@ def list_boards(include_archived: bool = Query(False)):
n for status, n in b["counts"].items() if status != "archived"
)
b["default_workspace_kind"] = _default_workspace_kind(b)
pid = b.get("project_id") or None
b["project_id"] = pid
proj = proj_map.get(pid) if pid else None
b["project_name"] = proj.name if proj else None
return {"boards": boards, "current": current}
@ -2164,6 +2238,11 @@ def create_board_endpoint(payload: CreateBoardBody):
default_workdir = None
if payload.default_workdir:
default_workdir = _validate_workdir(payload.default_workdir)
# A chosen project scopes the board: its primary repo becomes the default
# workdir (unless one was passed explicitly) and the link is stored.
project_id, _pname, primary_path = _resolve_project(payload.project_id)
if primary_path and not default_workdir:
default_workdir = primary_path
try:
meta = kanban_db.create_board(
payload.slug,
@ -2172,6 +2251,7 @@ def create_board_endpoint(payload: CreateBoardBody):
icon=payload.icon,
color=payload.color,
default_workdir=default_workdir,
project_id=project_id,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc))
@ -2181,6 +2261,7 @@ def create_board_endpoint(payload: CreateBoardBody):
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc))
meta["default_workspace_kind"] = _default_workspace_kind(meta)
_, meta["project_name"], _ = _resolve_project(meta.get("project_id"))
return {"board": meta, "current": kanban_db.get_current_board()}
@ -2199,6 +2280,17 @@ def rename_board(slug: str, payload: RenameBoardBody):
if payload.default_workdir is not None:
raw = payload.default_workdir.strip()
default_workdir = _validate_workdir(raw) if raw else ""
# project_id: None = leave; "" = clear; value = resolve + mirror its repo
# into default_workdir (unless the caller set default_workdir explicitly).
project_id: Optional[str] = None
project_name: Optional[str] = None
if payload.project_id is not None:
if payload.project_id.strip():
project_id, project_name, primary_path = _resolve_project(payload.project_id)
if primary_path and default_workdir is None:
default_workdir = primary_path
else:
project_id = "" # clear the scope
meta = kanban_db.write_board_metadata(
normed,
name=payload.name,
@ -2206,8 +2298,10 @@ def rename_board(slug: str, payload: RenameBoardBody):
icon=payload.icon,
color=payload.color,
default_workdir=default_workdir,
project_id=project_id,
)
meta["default_workspace_kind"] = _default_workspace_kind(meta)
_, meta["project_name"], _ = _resolve_project(meta.get("project_id"))
return {"board": meta}

View file

@ -0,0 +1,87 @@
"""Board→project scoping in kanban_db.
A kanban board can be scoped to a first-class Hermes project so every task on
it anchors to that project (deterministic worktree + branch). Covers the
metadata round-trip and the create-time inheritance.
"""
from __future__ import annotations
import sys
from pathlib import Path
import pytest
_WORKTREE = Path(__file__).resolve().parents[2]
if str(_WORKTREE) not in sys.path:
sys.path.insert(0, str(_WORKTREE))
from hermes_cli import kanban_db as kb
from hermes_cli import projects_db as pdb
@pytest.fixture
def fresh_home(tmp_path, monkeypatch):
home = tmp_path / "hermes_home"
home.mkdir()
monkeypatch.setenv("HERMES_HOME", str(home))
monkeypatch.setattr(Path, "home", lambda: tmp_path)
for var in ("HERMES_KANBAN_DB", "HERMES_KANBAN_WORKSPACES_ROOT", "HERMES_KANBAN_HOME", "HERMES_KANBAN_BOARD"):
monkeypatch.delenv(var, raising=False)
try:
import hermes_constants
hermes_constants._cached_default_hermes_root = None # type: ignore[attr-defined]
except Exception:
pass
kb._INITIALIZED_PATHS.clear()
return home
def test_board_metadata_project_id_roundtrip(fresh_home):
assert kb.read_board_metadata("default").get("project_id") is None
kb.write_board_metadata("default", project_id="p_abc123")
assert kb.read_board_metadata("default")["project_id"] == "p_abc123"
# None leaves unchanged; "" clears.
kb.write_board_metadata("default", name="Still Here")
assert kb.read_board_metadata("default")["project_id"] == "p_abc123"
kb.write_board_metadata("default", project_id="")
assert kb.read_board_metadata("default")["project_id"] is None
def test_create_board_accepts_project_id(fresh_home):
meta = kb.create_board("proj-board", name="Proj Board", project_id="p_xyz")
assert meta["project_id"] == "p_xyz"
assert kb.read_board_metadata("proj-board")["project_id"] == "p_xyz"
def test_create_task_inherits_board_project(fresh_home, tmp_path):
repo = tmp_path / "repo"
repo.mkdir()
with pdb.connect_closing() as pconn:
proj_id = pdb.create_project(pconn, name="Widget", primary_path=str(repo))
kb.create_board("scoped", name="Scoped", project_id=proj_id)
conn = kb.connect(board="scoped")
try:
tid = kb.create_task(conn, title="inherit me", board="scoped")
assert kb.get_task(conn, tid).project_id == proj_id
finally:
conn.close()
def test_create_task_explicit_project_beats_board(fresh_home, tmp_path):
(tmp_path / "a").mkdir()
(tmp_path / "b").mkdir()
with pdb.connect_closing() as pconn:
board_proj = pdb.create_project(pconn, name="BoardProj", primary_path=str(tmp_path / "a"))
task_proj = pdb.create_project(pconn, name="TaskProj", primary_path=str(tmp_path / "b"))
kb.create_board("scoped2", name="Scoped2", project_id=board_proj)
conn = kb.connect(board="scoped2")
try:
tid = kb.create_task(conn, title="explicit", board="scoped2", project_id=task_proj)
assert kb.get_task(conn, tid).project_id == task_proj
finally:
conn.close()

View file

@ -0,0 +1,119 @@
"""Kanban dashboard plugin: project listing + project-scoped boards.
Attaches the plugin router to a bare FastAPI app (as in
test_kanban_dashboard_plugin.py) and exercises the project surface:
GET /projects, board create/patch/list carrying project scope, and a task
on a scoped board inheriting the project.
"""
from __future__ import annotations
import importlib.util
import sys
from pathlib import Path
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from hermes_cli import kanban_db as kb
from hermes_cli import projects_db as pdb
def _load_plugin_router():
repo_root = Path(__file__).resolve().parents[2]
plugin_file = repo_root / "plugins" / "kanban" / "dashboard" / "plugin_api.py"
spec = importlib.util.spec_from_file_location("hermes_kanban_plugin_proj_test", plugin_file)
mod = importlib.util.module_from_spec(spec)
sys.modules[spec.name] = mod
spec.loader.exec_module(mod)
return mod.router
@pytest.fixture
def kanban_home(tmp_path, monkeypatch):
home = tmp_path / ".hermes"
home.mkdir()
monkeypatch.setenv("HERMES_HOME", str(home))
monkeypatch.setattr(Path, "home", lambda: tmp_path)
kb.init_db()
return home
@pytest.fixture
def client(kanban_home):
app = FastAPI()
app.include_router(_load_plugin_router(), prefix="/api/plugins/kanban")
return TestClient(app)
@pytest.fixture
def project(tmp_path):
repo = tmp_path / "widget-repo"
repo.mkdir()
with pdb.connect_closing() as conn:
pid = pdb.create_project(conn, name="Widget", primary_path=str(repo))
return {"id": pid, "primary_path": str(repo)}
def test_list_projects(client, project):
r = client.get("/api/plugins/kanban/projects")
assert r.status_code == 200
hit = next(p for p in r.json()["projects"] if p["id"] == project["id"])
assert hit["name"] == "Widget"
assert hit["primary_path"] == project["primary_path"]
def test_create_board_with_project_mirrors_workdir(client, project):
r = client.post(
"/api/plugins/kanban/boards",
json={"slug": "widget", "name": "Widget", "project_id": project["id"]},
)
assert r.status_code == 200, r.text
board = r.json()["board"]
assert board["project_id"] == project["id"]
assert board["project_name"] == "Widget"
assert board["default_workdir"] == project["primary_path"]
def test_create_board_rejects_unknown_project(client):
r = client.post("/api/plugins/kanban/boards", json={"slug": "bad", "project_id": "p_nope"})
assert r.status_code == 400
def test_patch_board_set_and_clear_project(client, project):
client.post("/api/plugins/kanban/boards", json={"slug": "widget", "name": "Widget"})
r = client.patch("/api/plugins/kanban/boards/widget", json={"project_id": project["id"]})
assert r.status_code == 200, r.text
assert r.json()["board"]["project_id"] == project["id"]
r = client.patch("/api/plugins/kanban/boards/widget", json={"project_id": ""})
assert r.status_code == 200
assert r.json()["board"]["project_id"] is None
def test_boards_list_surfaces_project(client, project):
client.post(
"/api/plugins/kanban/boards",
json={"slug": "widget", "name": "Widget", "project_id": project["id"]},
)
widget = next(b for b in client.get("/api/plugins/kanban/boards").json()["boards"] if b["slug"] == "widget")
assert widget["project_id"] == project["id"]
assert widget["project_name"] == "Widget"
def test_task_on_scoped_board_inherits_project(client, project):
client.post(
"/api/plugins/kanban/boards",
json={"slug": "widget", "name": "Widget", "project_id": project["id"]},
)
r = client.post("/api/plugins/kanban/tasks?board=widget", json={"title": "do the thing"})
assert r.status_code == 200, r.text
task_id = r.json()["task"]["id"]
conn = kb.connect(board="widget")
try:
assert kb.get_task(conn, task_id).project_id == project["id"]
finally:
conn.close()