mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-31 19:16:29 +00:00
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:
parent
9be67b7762
commit
027ef381a4
8 changed files with 518 additions and 20 deletions
|
|
@ -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: {} })
|
||||
|
||||
|
|
|
|||
|
|
@ -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 project’s 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)} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 board’s project directory'}
|
||||
value={workspacePath}
|
||||
/>
|
||||
<span className="text-[0.625rem] text-(--ui-text-quaternary)">
|
||||
{boardDefaultDir
|
||||
? `Leave empty to inherit ${boardDefaultDir}`
|
||||
: 'Leave empty to inherit the board’s project directory.'}
|
||||
</span>
|
||||
</Field>
|
||||
)}
|
||||
|
||||
<Field label="Assignee">
|
||||
<Select onValueChange={v => setAssignee(v === NO_PARENT ? '' : v)} value={assignee || NO_PARENT}>
|
||||
<SelectTrigger>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
||||
|
||||
|
|
|
|||
87
tests/hermes_cli/test_kanban_board_project.py
Normal file
87
tests/hermes_cli/test_kanban_board_project.py
Normal 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()
|
||||
119
tests/plugins/test_kanban_board_project_api.py
Normal file
119
tests/plugins/test_kanban_board_project_api.py
Normal 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()
|
||||
Loading…
Add table
Add a link
Reference in a new issue