diff --git a/config.py b/config.py index e21eacd..72f09a3 100644 --- a/config.py +++ b/config.py @@ -43,15 +43,22 @@ class ProjectConfig(TypedDict): name: str path: str gitea_repo: str + color: str # Managed projects to monitor MANAGED_PROJECTS: list[ProjectConfig] = [ - {"name": "blog-app", "path": "/home/yi/opencode-blog-showcase/blog-app", "gitea_repo": "blog-app"}, - {"name": "novel-app", "path": "/home/yi/novel-workspace/novel-app", "gitea_repo": "novel-app"}, - {"name": "hermes-dashboard", "path": "/home/yi/hermes-dashboard", "gitea_repo": "hermes-dashboard"}, - {"name": "omo-openagent", "path": "/home/yi/.cache/opencode/packages/oh-my-openagent@latest", "gitea_repo": "omo-openagent-config"}, - {"name": "omo-config", "path": "/home/yi/.config/opencode", "gitea_repo": "omo-config"}, + {"name": "blog-app", "path": "/home/yi/opencode-blog-showcase/blog-app", "gitea_repo": "blog-app", "color": "#4a9eff"}, + {"name": "novel-app", "path": "/home/yi/novel-workspace/novel-app", "gitea_repo": "novel-app", "color": "#9e4aff"}, + {"name": "hermes-dashboard", "path": "/home/yi/hermes-dashboard", "gitea_repo": "hermes-dashboard", "color": "#ff9e4a"}, + {"name": "omo-openagent", "path": "/home/yi/.cache/opencode/packages/oh-my-openagent@latest", "gitea_repo": "omo-openagent-config", "color": "#4aff9e"}, + {"name": "omo-config", "path": "/home/yi/.config/opencode", "gitea_repo": "omo-config", "color": "#ff4a9e"}, +] + +# Docs directories scanned by /docs (browser-readable documentation center) +DOCS_DIRS: list[str] = [ + "/home/yi/novel-workspace/docs", + "/home/yi/opencode-blog-showcase/docs", ] diff --git a/routes/pages.py b/routes/pages.py index 363f1c3..aece02f 100644 --- a/routes/pages.py +++ b/routes/pages.py @@ -1,10 +1,14 @@ """Pages blueprint: server-rendered HTML pages.""" import logging +import os +import time +from pathlib import Path -from flask import Blueprint, render_template, request +from flask import Blueprint, abort, render_template, request -from services import kanban, projects, worker +import config +from services import kanban, projects, task_project_linker, worker logger = logging.getLogger(__name__) @@ -56,14 +60,43 @@ def projects_page(): @bp.route("/projects/") def project_detail_page(name: str): - """Project detail page.""" + """Project detail page with git info, tasks timeline, and commits.""" try: detail = projects.get_project_detail(name) except Exception as exc: logger.exception("project_detail_page failed") detail = {"error": str(exc)} - return render_template("project_detail.html", project=detail, name=name) + try: + tasks = projects.get_project_tasks(name) + except Exception as exc: + logger.exception("project tasks failed for %s", name) + tasks = [] + + try: + commits = projects.get_project_commits_in_window(name, limit=20) + except Exception as exc: + logger.exception("project commits failed for %s", name) + commits = [] + + # Files changed per task (use task time window) + task_files: dict[str, dict[str, list[str]]] = {} + for t in tasks: + if t.get("started_at") and t.get("completed_at"): + files = task_project_linker.get_task_files_changed( + name, t["started_at"], t["completed_at"] + ) + if files: + task_files[t["task_id"]] = files + + return render_template( + "project_detail.html", + project=detail, + name=name, + tasks=tasks, + commits=commits, + task_files=task_files, + ) @bp.route("/tasks") @@ -89,11 +122,211 @@ def tasks_page(): @bp.route("/tasks/") def task_detail_page(task_id: str): - """Task detail page with timeline, comments, runs, attachments.""" + """Task detail page with timeline, comments, runs, attachments, project link.""" try: data = kanban.get_task_detail(task_id) except Exception as exc: logger.exception("task_detail_page failed") data = {"error": str(exc)} - return render_template("task_detail.html", data=data, task_id=task_id) + project_name: str | None = None + commits: list[dict] = [] + files_changed: dict[str, list[str]] = {} + + if not data.get("error"): + try: + project_name = task_project_linker.link_task_to_project(task_id) + except Exception: + logger.exception("link_task_to_project failed for %s", task_id) + + task = data.get("task", {}) + started = task.get("started_at") + completed = task.get("completed_at") + if project_name and started: + end_ts = completed or int(time.time()) + try: + commits = projects.get_project_commits_in_window( + project_name, start_ts=int(started), end_ts=int(end_ts), limit=20 + ) + files_changed = task_project_linker.get_task_files_changed( + project_name, int(started), int(end_ts) + ) + except Exception: + logger.exception("task commits failed for %s", task_id) + + return render_template( + "task_detail.html", + data=data, + task_id=task_id, + project_name=project_name, + commits=commits, + files_changed=files_changed, + ) + + +@bp.route("/timeline") +def timeline_page(): + """Full task timeline grouped by project.""" + try: + data = kanban.get_tasks() + all_tasks = data.get("tasks", []) + except Exception as exc: + logger.exception("timeline tasks failed") + all_tasks = [] + + # Bucket each task into a project via linker + by_project: dict[str, list[dict]] = {p["name"]: [] for p in config.MANAGED_PROJECTS} + by_project["unassigned"] = [] + + for t in all_tasks: + try: + pname = task_project_linker.link_task_to_project(t["id"]) or "unassigned" + except Exception: + pname = "unassigned" + + duration_s = None + if t.get("started_at") and t.get("completed_at"): + duration_s = int(t["completed_at"]) - int(t["started_at"]) + + by_project.setdefault(pname, []).append({ + "task_id": t["id"], + "title": t["title"], + "status": t["status"], + "created_at": t.get("created_at"), + "started_at": t.get("started_at"), + "completed_at": t.get("completed_at"), + "duration_s": duration_s, + "outcome_summary": (t.get("result") or "")[:150], + }) + + stats = { + "total": len(all_tasks), + "done": sum(1 for t in all_tasks if t.get("status") == "done"), + "blocked": sum(1 for t in all_tasks if t.get("status") == "blocked"), + "running": sum(1 for t in all_tasks if t.get("status") == "running"), + "ready": sum(1 for t in all_tasks if t.get("status") == "ready"), + "failed": sum(1 for t in all_tasks if t.get("status") == "failed"), + } + + proj_colors = {p["name"]: p.get("color", "#888") for p in config.MANAGED_PROJECTS} + + return render_template( + "timeline.html", + by_project=by_project, + stats=stats, + proj_colors=proj_colors, + ) + + +def _scan_docs() -> list[dict]: + """Scan DOCS_DIRS for .md files; return metadata list.""" + items: list[dict] = [] + for docs_dir in config.DOCS_DIRS: + root = Path(docs_dir) + if not root.is_dir(): + continue + for md in sorted(root.rglob("*.md")): + try: + stat = md.stat() + rel = md.relative_to(root) + with open(md, "r", encoding="utf-8", errors="replace") as fh: + content = fh.read() + title = rel.stem + for line in content.splitlines(): + line_s = line.strip() + if line_s.startswith("# "): + title = line_s[2:].strip() + break + snippet = "" + for line in content.splitlines(): + line_s = line.strip() + if line_s and not line_s.startswith("#"): + snippet = line_s[:100] + break + items.append({ + "abs_path": str(md), + "rel_path": f"{root.name}/{rel}", + "title": title, + "size": stat.st_size, + "mtime": int(stat.st_mtime), + "snippet": snippet, + }) + except OSError: + continue + items.sort(key=lambda x: x["mtime"], reverse=True) + return items + + +def _resolve_doc_path(rel_path: str) -> Path | None: + """Resolve user-supplied rel_path against DOCS_DIRS with traversal protection.""" + for docs_dir in config.DOCS_DIRS: + root = Path(docs_dir).resolve() + candidate = (root / rel_path).resolve() + try: + candidate.relative_to(root) + except ValueError: + continue + if candidate.is_file() and candidate.suffix == ".md": + return candidate + # Also support root-name-prefixed form (e.g. "docs/foo.md" -> root/docs/foo.md skipped; "foo.md" tried) + for docs_dir in config.DOCS_DIRS: + root = Path(docs_dir).resolve() + if rel_path.startswith(root.name + "/"): + stripped = rel_path[len(root.name) + 1:] + candidate = (root / stripped).resolve() + try: + candidate.relative_to(root) + except ValueError: + continue + if candidate.is_file() and candidate.suffix == ".md": + return candidate + return None + + +@bp.route("/docs") +def docs_page(): + """Docs list page.""" + try: + docs = _scan_docs() + except Exception as exc: + logger.exception("docs scan failed") + docs = [] + return render_template("docs.html", docs=docs) + + +@bp.route("/docs/") +def doc_view_page(rel_path: str): + """Render a single markdown doc.""" + doc_path = _resolve_doc_path(rel_path) + if doc_path is None: + abort(404) + + try: + with open(doc_path, "r", encoding="utf-8", errors="replace") as fh: + raw = fh.read() + except OSError: + abort(404) + + rendered_html: str | None = None + try: + import markdown # type: ignore + rendered_html = markdown.markdown( + raw, extensions=["fenced_code", "tables", "toc"] + ) + except ImportError: + rendered_html = None + + title = doc_path.stem + for line in raw.splitlines(): + line_s = line.strip() + if line_s.startswith("# "): + title = line_s[2:].strip() + break + + return render_template( + "doc_view.html", + rel_path=rel_path, + title=title, + raw=raw, + rendered_html=rendered_html, + ) diff --git a/services/projects.py b/services/projects.py index 16af252..c9f6f5a 100644 --- a/services/projects.py +++ b/services/projects.py @@ -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 + ) diff --git a/services/task_project_linker.py b/services/task_project_linker.py new file mode 100644 index 0000000..df9bb99 --- /dev/null +++ b/services/task_project_linker.py @@ -0,0 +1,198 @@ +"""Task <-> Project linker. + +Maps kanban tasks to managed projects by title keywords + workspace_path +substring matching. Used by /projects/, /timeline, and /tasks/. +""" + +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 diff --git a/templates/base.html b/templates/base.html index af2d63f..9c6a441 100644 --- a/templates/base.html +++ b/templates/base.html @@ -342,6 +342,8 @@ Dashboard Projects Tasks + Timeline + Docs {% block content %}{% endblock %} diff --git a/templates/dashboard.html b/templates/dashboard.html index 339179d..7998948 100644 --- a/templates/dashboard.html +++ b/templates/dashboard.html @@ -123,7 +123,7 @@ -

📦 Project Status

+

📦 Project Overview

diff --git a/templates/doc_view.html b/templates/doc_view.html new file mode 100644 index 0000000..73831a3 --- /dev/null +++ b/templates/doc_view.html @@ -0,0 +1,81 @@ +{% extends "base.html" %} + +{% block title %}{{ title }} - Docs{% endblock %} + +{% block content %} +

+ Docs > {{ rel_path }} +

+ +

📄 {{ title }}

+ +
+ {% if rendered_html %} +
+ {{ rendered_html|safe }} +
+ {% else %} +
{{ raw }}
+ {% endif %} +
+ +

+ ← Back to Docs +

+ + +{% endblock %} diff --git a/templates/docs.html b/templates/docs.html new file mode 100644 index 0000000..aa5b2e9 --- /dev/null +++ b/templates/docs.html @@ -0,0 +1,44 @@ +{% extends "base.html" %} + +{% block title %}Docs - Hermes{% endblock %} + +{% block content %} +

📚 Documentation Center

+ +
+

{{ docs|length }} documents across all docs directories

+
+ +
+ {% if docs %} +
+ + + + + + + + + + + {% for d in docs %} + + + + + + + + {% endfor %} + +
TitlePathSizeModifiedSnippet
{{ d.title }}{{ d.rel_path }}{{ d.size }} B{{ d.mtime|int|dt }}{{ d.snippet[:80] }}
+ {% else %} +

No documents found

+ {% endif %} +
+ +

+ ← Back to Dashboard +

+{% endblock %} diff --git a/templates/project_detail.html b/templates/project_detail.html index 9973865..10d2f4f 100644 --- a/templates/project_detail.html +++ b/templates/project_detail.html @@ -54,14 +54,92 @@
-

Recent Commits (last 10)

- {% if project.recent_commits %} -
{% for commit in project.recent_commits %}{{ commit }}
-{% endfor %}
+

📋 Task Timeline ({{ tasks|length }} tasks)

+ {% if tasks %} + + + + + + + + + + + + + {% for t in tasks %} + + + + + + + + + {% endfor %} + +
IDTitleStatusCreatedDurationOutcome
{{ t.task_id }}{{ t.title[:80] }}{{ t.status }}{% if t.created_at %}{{ t.created_at|int|dt }}{% endif %} + {% if t.duration_s %} + {% if t.duration_s < 60 %}{{ t.duration_s }}s + {% elif t.duration_s < 3600 %}{{ (t.duration_s / 60)|round(1) }}m + {% else %}{{ (t.duration_s / 3600)|round(1) }}h{% endif %} + {% else %}-{% endif %} + {{ t.outcome_summary[:80] }}
+ {% else %} +

No tasks linked to this project

+ {% endif %} +
+ +
+

🔀 Recent Commits ({{ commits|length }})

+ {% if commits %} + + + + + + + + + + + {% for c in commits %} + + + + + + + {% endfor %} + +
HashAuthorTimeSubject
{{ c.short }}{{ c.author }}{{ c.ts|int|dt }}{{ c.subject }}
{% else %}

No commits found

{% endif %}
+ +{% if task_files %} +
+

📁 Files Changed (per task time window)

+ {% for task_id, commit_files in task_files.items() %} +
+

+ {{ task_id }} +

+ {% for chash, files in commit_files.items() %} +
+ {{ chash }}: {{ files|length }} file(s) +
    + {% for f in files[:10] %}
  • {{ f }}
  • {% endfor %} + {% if files|length > 10 %}
  • ... {{ files|length - 10 }} more
  • {% endif %} +
+
+ {% endfor %} +
+ {% endfor %} +
+{% endif %} {% endif %}

diff --git a/templates/task_detail.html b/templates/task_detail.html index 8d5fa10..ccad6c7 100644 --- a/templates/task_detail.html +++ b/templates/task_detail.html @@ -12,6 +12,12 @@ {% set task = data.task %}

📋 Task #{{ task.id }}: {{ task.title }}

+{% if project_name %} +
+

📦 Project: {{ project_name }}

+
+{% endif %} +
@@ -44,6 +50,41 @@
{{ task.result }}
{% endif %} + + {% if commits %} +
+

🔀 Commits During Task ({{ commits|length }})

+ + + + + + {% for c in commits %} + + + + + + {% endfor %} + +
HashTimeSubject
{{ c.short }}{{ c.ts|int|dt }}{{ c.subject }}
+
+ {% endif %} + + {% if files_changed %} +
+

📁 Files Changed

+ {% for chash, files in files_changed.items() %} +
+ {{ chash }}: +
    + {% for f in files[:15] %}
  • {{ f }}
  • {% endfor %} + {% if files|length > 15 %}
  • ... {{ files|length - 15 }} more
  • {% endif %} +
+
+ {% endfor %} +
+ {% endif %}
diff --git a/templates/timeline.html b/templates/timeline.html new file mode 100644 index 0000000..4cc1bff --- /dev/null +++ b/templates/timeline.html @@ -0,0 +1,95 @@ +{% extends "base.html" %} + +{% block title %}Timeline - Hermes{% endblock %} + +{% block content %} +

🕐 Full Task Timeline

+ +
+

Summary

+

+ Total: {{ stats.total }} | + Done: {{ stats.done }} | + Blocked: {{ stats.blocked }} | + Running: {{ stats.running }} | + Ready: {{ stats.ready }} | + Failed: {{ stats.failed }} +

+
+ +{% for pname, tasks in by_project.items() %} +{% if tasks %} +
+

+ {% if pname != 'unassigned' %} + 📦 {{ pname }} + {% else %} + 📦 {{ pname }} + {% endif %} + ({{ tasks|length }} tasks) +

+ + {% set ns = namespace(min_ts=99999999999, max_ts=0) %} + {% for t in tasks %} + {% if t.created_at and t.created_at < ns.min_ts %}{% set ns.min_ts = t.created_at %}{% endif %} + {% if t.completed_at and t.completed_at > ns.max_ts %}{% set ns.max_ts = t.completed_at %}{% endif %} + {% if t.created_at and not t.completed_at and t.created_at > ns.max_ts %}{% set ns.max_ts = t.created_at %}{% endif %} + {% endfor %} + {% set span = (ns.max_ts - ns.min_ts) if ns.max_ts > ns.min_ts else 1 %} + + {% set status_color = {'done': '#4aff4a', 'blocked': '#ff4a4a', 'running': '#4a9eff', 'ready': '#aaaaaa', 'failed': '#ff9e4a'} %} +
+ {% for t in tasks[:20] %} + {% if t.created_at %} + {% set left = ((t.created_at - ns.min_ts) / span * 100)|round(2) %} + {% set width = (((t.completed_at or t.created_at) - t.created_at) / span * 100)|round(2) %} + {% if width < 0.5 %}{% set width = 0.5 %}{% endif %} + {% set bar_color = status_color.get(t.status, '#888') %} +
+ +
+ {% endif %} + {% endfor %} +
+ {% if ns.min_ts < 99999999999 %}{{ ns.min_ts|int|dt }} → {{ ns.max_ts|int|dt }}{% endif %} +
+
+ + + + + + + + + + + + + + {% for t in tasks %} + + + + + + + + + {% endfor %} + +
IDTitleStatusCreatedDurationOutcome
{{ t.task_id }}{{ t.title[:80] }}{{ t.status }}{% if t.created_at %}{{ t.created_at|int|dt }}{% endif %} + {% if t.duration_s %} + {% if t.duration_s < 60 %}{{ t.duration_s }}s + {% elif t.duration_s < 3600 %}{{ (t.duration_s / 60)|round(1) }}m + {% else %}{{ (t.duration_s / 3600)|round(1) }}h{% endif %} + {% else %}-{% endif %} + {{ t.outcome_summary[:80] }}
+
+{% endif %} +{% endfor %} + +

+ ← Back to Dashboard +

+{% endblock %}