feat: project overview + timeline + docs center

- services/task_project_linker.py: task<->project mapping (title keywords + workspace_path)
- routes/pages.py: /timeline, /docs, /docs/<path>, enhanced /projects/<name> and /tasks/<id>
- templates: timeline.html, docs.html, doc_view.html (new), project_detail.html + task_detail.html (overhauled)
- config.py: DOCS_DIRS + ProjectConfig color
- base.html: Timeline + Docs nav links
This commit is contained in:
hermes-dashboard
2026-08-17 20:00:37 +08:00
parent 1e5e30ffcb
commit c0cbd683f4
11 changed files with 813 additions and 16 deletions

View File

@@ -6,6 +6,7 @@ import subprocess
from typing import Any
import config
from services import task_project_linker
logger = logging.getLogger(__name__)
@@ -159,3 +160,20 @@ def get_project_detail(name: str) -> dict[str, Any]:
info["status_porcelain"] = status_porcelain
return info
def get_project_tasks(name: str) -> list[dict[str, Any]]:
"""Tasks linked to a project (via task_project_linker)."""
return task_project_linker.link_project_tasks(name)
def get_project_commits_in_window(
name: str,
start_ts: int | None = None,
end_ts: int | None = None,
limit: int = 20,
) -> list[dict[str, Any]]:
"""Commits for a project, optionally within a time window."""
return task_project_linker.link_project_commits(
name, limit=limit, since_ts=start_ts, until_ts=end_ts
)

View File

@@ -0,0 +1,198 @@
"""Task <-> Project linker.
Maps kanban tasks to managed projects by title keywords + workspace_path
substring matching. Used by /projects/<name>, /timeline, and /tasks/<id>.
"""
import logging
import sqlite3
import subprocess
from typing import Any
import config
from extensions import get_db
logger = logging.getLogger(__name__)
_TITLE_KEYWORDS: list[tuple[str, str]] = [
("blog-app", "blog-app"),
("novel-app", "novel-app"),
("novel-writer", "novel-app"),
("hermes-dashboard", "hermes-dashboard"),
("gitea", "hermes-dashboard"),
("omo-openagent", "omo-openagent"),
("omo-config", "omo-config"),
("opencode", "omo-config"),
]
def link_task_to_project(task_id: str) -> str | None:
"""Return project name for a task_id, or None if no match.
Match priority: workspace_path substring > title keyword.
"""
try:
db = get_db()
row = db.execute(
"SELECT title, workspace_path FROM tasks WHERE id = ?", (task_id,)
).fetchone()
except sqlite3.Error:
logger.exception("link_task_to_project db error for %s", task_id)
return None
if not row:
return None
title = (row["title"] or "").lower()
workspace = (row["workspace_path"] or "").lower()
for proj in config.MANAGED_PROJECTS:
proj_path = proj["path"].lower()
if proj_path and proj_path in workspace:
return proj["name"]
for keyword, project_name in _TITLE_KEYWORDS:
if keyword in title:
return project_name
return None
def link_project_tasks(project_name: str) -> list[dict[str, Any]]:
"""Return all tasks linked to a given project, newest first."""
try:
db = get_db()
rows = db.execute(
"SELECT id, title, status, created_at, started_at, completed_at, "
"workspace_path, result FROM tasks ORDER BY created_at DESC"
).fetchall()
except sqlite3.Error:
logger.exception("link_project_tasks db error")
return []
proj = next((p for p in config.MANAGED_PROJECTS if p["name"] == project_name), None)
proj_path = (proj["path"] if proj else "").lower()
matched: list[dict[str, Any]] = []
for r in rows:
title = (r["title"] or "").lower()
workspace = (r["workspace_path"] or "").lower()
is_match = False
if proj_path and proj_path in workspace:
is_match = True
else:
for keyword, pname in _TITLE_KEYWORDS:
if pname == project_name and keyword in title:
is_match = True
break
if not is_match:
continue
duration_s = None
if r["started_at"] and r["completed_at"]:
duration_s = int(r["completed_at"]) - int(r["started_at"])
matched.append({
"task_id": r["id"],
"title": r["title"],
"status": r["status"],
"created_at": r["created_at"],
"started_at": r["started_at"],
"completed_at": r["completed_at"],
"duration_s": duration_s,
"outcome_summary": (r["result"] or "")[:200],
"workspace_path": r["workspace_path"],
})
return matched
def _run_git(cwd: str, args: list[str]) -> str:
try:
result = subprocess.run(
["git"] + args, cwd=cwd,
capture_output=True, text=True, check=False, timeout=5,
)
return result.stdout.strip() if result.returncode == 0 else ""
except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
return ""
def link_project_commits(
project_name: str,
limit: int = 20,
since_ts: int | None = None,
until_ts: int | None = None,
) -> list[dict[str, Any]]:
"""Return recent commits for a project, optionally filtered by time window."""
proj = next((p for p in config.MANAGED_PROJECTS if p["name"] == project_name), None)
if not proj:
return []
path = proj["path"]
fmt = "%H|%an|%ct|%s"
args = ["log", f"-{limit}", f"--format={fmt}"]
if since_ts:
args.append(f"--since=@{since_ts}")
if until_ts:
args.append(f"--until=@{until_ts}")
out = _run_git(path, args)
if not out:
return []
commits: list[dict[str, Any]] = []
for line in out.splitlines():
parts = line.split("|", 3)
if len(parts) < 4:
continue
full_hash, author, ts, subject = parts
commits.append({
"hash": full_hash,
"short": full_hash[:7],
"author": author,
"ts": int(ts) if ts.isdigit() else 0,
"subject": subject,
"gitea_url": f"http://localhost:3000/admin/{proj['gitea_repo']}/commit/{full_hash}",
})
return commits
def get_task_files_changed(
project_name: str,
since_ts: int | None,
until_ts: int | None,
) -> dict[str, list[str]]:
"""Return {commit_short: [file1, file2, ...]} for commits in time window."""
proj = next((p for p in config.MANAGED_PROJECTS if p["name"] == project_name), None)
if not proj or not since_ts or not until_ts:
return {}
path = proj["path"]
args = [
"log",
f"--since=@{since_ts}",
f"--until=@{until_ts}",
"--format=%h",
"--name-only",
]
out = _run_git(path, args)
if not out:
return {}
result: dict[str, list[str]] = {}
current_hash: str | None = None
for line in out.splitlines():
line = line.strip()
if not line:
continue
if len(line) <= 10 and "/" not in line and "." not in line and not line.startswith(" "):
if line.isalnum():
current_hash = line
result.setdefault(current_hash, [])
continue
if current_hash:
result[current_hash].append(line)
return result