From 149f2d868fadd5715821071b3845c73e310509dd Mon Sep 17 00:00:00 2001 From: Zhi Date: Sun, 22 Feb 2026 02:40:57 +0000 Subject: [PATCH 01/22] feat: add webhook + webhook_log models --- app/main.py | 1 + app/models/webhook.py | 40 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+) create mode 100644 app/models/webhook.py diff --git a/app/main.py b/app/main.py index ead4bf1..369c577 100644 --- a/app/main.py +++ b/app/main.py @@ -248,6 +248,7 @@ def get_user(user_id: int, db: Session = Depends(get_db)): @app.on_event("startup") def startup(): from app.core.config import Base, engine + from app.models import webhook Base.metadata.create_all(bind=engine) diff --git a/app/models/webhook.py b/app/models/webhook.py new file mode 100644 index 0000000..57785a1 --- /dev/null +++ b/app/models/webhook.py @@ -0,0 +1,40 @@ +from sqlalchemy import Column, Integer, String, Text, DateTime, Boolean, Enum as SAEnum +from sqlalchemy.sql import func +from app.core.config import Base +import enum + + +class WebhookEvent(str, enum.Enum): + ISSUE_CREATED = "issue.created" + ISSUE_UPDATED = "issue.updated" + ISSUE_CLOSED = "issue.closed" + ISSUE_DELETED = "issue.deleted" + COMMENT_CREATED = "comment.created" + RESOLUTION_CREATED = "resolution.created" + MEMBER_ADDED = "member.added" + MEMBER_REMOVED = "member.removed" + + +class Webhook(Base): + __tablename__ = "webhooks" + + id = Column(Integer, primary_key=True, index=True) + url = Column(String(500), nullable=False) + secret = Column(String(255), nullable=True) + events = Column(Text, nullable=False) # comma-separated events + project_id = Column(Integer, nullable=True) # null = global + is_active = Column(Boolean, default=True) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + + +class WebhookLog(Base): + __tablename__ = "webhook_logs" + + id = Column(Integer, primary_key=True, index=True) + webhook_id = Column(Integer, nullable=False) + event = Column(String(50), nullable=False) + payload = Column(Text, nullable=False) + response_status = Column(Integer, nullable=True) + response_body = Column(Text, nullable=True) + success = Column(Boolean, default=False) + created_at = Column(DateTime(timezone=True), server_default=func.now()) From a0d81ec9f5e979b54a569278cf50c1ef903103c4 Mon Sep 17 00:00:00 2001 From: Zhi Date: Sun, 22 Feb 2026 02:41:54 +0000 Subject: [PATCH 02/22] feat: webhook CRUD API + schemas --- app/main.py | 62 ++++++++++++++++++++++++++++++++++++++++++ app/schemas/webhook.py | 43 +++++++++++++++++++++++++++++ 2 files changed, 105 insertions(+) create mode 100644 app/schemas/webhook.py diff --git a/app/main.py b/app/main.py index 369c577..4c15c92 100644 --- a/app/main.py +++ b/app/main.py @@ -344,3 +344,65 @@ def update_user(user_id: int, db: Session = Depends(get_db), full_name: str = No db.commit() db.refresh(user) return user + + + +# ============ Webhooks API ============ + +from app.models.webhook import Webhook, WebhookLog +from app.schemas.webhook import WebhookCreate, WebhookUpdate, WebhookResponse, WebhookLogResponse + + +@app.post("/webhooks", response_model=WebhookResponse, status_code=status.HTTP_201_CREATED) +def create_webhook(wh: WebhookCreate, db: Session = Depends(get_db)): + db_wh = Webhook(**wh.model_dump()) + db.add(db_wh) + db.commit() + db.refresh(db_wh) + return db_wh + + +@app.get("/webhooks", response_model=List[WebhookResponse]) +def list_webhooks(project_id: int = None, db: Session = Depends(get_db)): + query = db.query(Webhook) + if project_id is not None: + query = query.filter(Webhook.project_id == project_id) + return query.all() + + +@app.get("/webhooks/{webhook_id}", response_model=WebhookResponse) +def get_webhook(webhook_id: int, db: Session = Depends(get_db)): + wh = db.query(Webhook).filter(Webhook.id == webhook_id).first() + if not wh: + raise HTTPException(status_code=404, detail="Webhook not found") + return wh + + +@app.patch("/webhooks/{webhook_id}", response_model=WebhookResponse) +def update_webhook(webhook_id: int, wh_update: WebhookUpdate, db: Session = Depends(get_db)): + wh = db.query(Webhook).filter(Webhook.id == webhook_id).first() + if not wh: + raise HTTPException(status_code=404, detail="Webhook not found") + for field, value in wh_update.model_dump(exclude_unset=True).items(): + setattr(wh, field, value) + db.commit() + db.refresh(wh) + return wh + + +@app.delete("/webhooks/{webhook_id}", status_code=status.HTTP_204_NO_CONTENT) +def delete_webhook(webhook_id: int, db: Session = Depends(get_db)): + wh = db.query(Webhook).filter(Webhook.id == webhook_id).first() + if not wh: + raise HTTPException(status_code=404, detail="Webhook not found") + db.delete(wh) + db.commit() + return None + + +@app.get("/webhooks/{webhook_id}/logs", response_model=List[WebhookLogResponse]) +def list_webhook_logs(webhook_id: int, limit: int = 50, db: Session = Depends(get_db)): + logs = db.query(WebhookLog).filter( + WebhookLog.webhook_id == webhook_id + ).order_by(WebhookLog.created_at.desc()).limit(limit).all() + return logs diff --git a/app/schemas/webhook.py b/app/schemas/webhook.py new file mode 100644 index 0000000..9318cfe --- /dev/null +++ b/app/schemas/webhook.py @@ -0,0 +1,43 @@ +from pydantic import BaseModel +from typing import Optional +from datetime import datetime + + +class WebhookCreate(BaseModel): + url: str + secret: Optional[str] = None + events: str # comma-separated: "issue.created,issue.updated" + project_id: Optional[int] = None + is_active: bool = True + + +class WebhookUpdate(BaseModel): + url: Optional[str] = None + secret: Optional[str] = None + events: Optional[str] = None + is_active: Optional[bool] = None + + +class WebhookResponse(BaseModel): + id: int + url: str + events: str + project_id: Optional[int] + is_active: bool + created_at: datetime + + class Config: + from_attributes = True + + +class WebhookLogResponse(BaseModel): + id: int + webhook_id: int + event: str + payload: str + response_status: Optional[int] + success: bool + created_at: datetime + + class Config: + from_attributes = True From 1a76de7c5001aad38f87d1fc0bc15e7165e55309 Mon Sep 17 00:00:00 2001 From: Zhi Date: Sun, 22 Feb 2026 02:43:34 +0000 Subject: [PATCH 03/22] feat: webhook event firing on issue creation (background task) --- app/main.py | 7 ++++-- app/services/webhook.py | 56 +++++++++++++++++++++++++++++++++++++++++ requirements.txt | 1 + 3 files changed, 62 insertions(+), 2 deletions(-) create mode 100644 app/services/webhook.py diff --git a/app/main.py b/app/main.py index 4c15c92..0d5955a 100644 --- a/app/main.py +++ b/app/main.py @@ -1,4 +1,4 @@ -from fastapi import FastAPI, Depends, HTTPException, status +from fastapi import FastAPI, Depends, HTTPException, status, BackgroundTasks from fastapi.middleware.cors import CORSMiddleware from sqlalchemy.orm import Session from typing import List @@ -11,6 +11,7 @@ from pydantic import BaseModel from app.core.config import get_db, settings from app.models import models from app.schemas import schemas +from app.services.webhook import fire_webhooks_sync app = FastAPI( title="HarborForge API", @@ -99,11 +100,13 @@ async def get_me(current_user: models.User = Depends(get_current_user)): # ============ Issues API ============ @app.post("/issues", response_model=schemas.IssueResponse, status_code=status.HTTP_201_CREATED) -def create_issue(issue: schemas.IssueCreate, db: Session = Depends(get_db)): +def create_issue(issue: schemas.IssueCreate, bg: BackgroundTasks, db: Session = Depends(get_db)): db_issue = models.Issue(**issue.model_dump()) db.add(db_issue) db.commit() db.refresh(db_issue) + event = "resolution.created" if db_issue.issue_type == "resolution" else "issue.created" + bg.add_task(fire_webhooks_sync, event, {"issue_id": db_issue.id, "title": db_issue.title, "type": db_issue.issue_type, "status": db_issue.status}, db_issue.project_id, db) return db_issue diff --git a/app/services/webhook.py b/app/services/webhook.py new file mode 100644 index 0000000..cf28ea7 --- /dev/null +++ b/app/services/webhook.py @@ -0,0 +1,56 @@ +import json +import hmac +import hashlib +import logging +from sqlalchemy.orm import Session +from app.models.webhook import Webhook, WebhookLog + +logger = logging.getLogger(__name__) + + +def fire_webhooks_sync(event: str, payload: dict, project_id: int, db: Session): + """Find matching webhooks and send payloads (sync version).""" + import httpx + + webhooks = db.query(Webhook).filter(Webhook.is_active == True).all() + + matched = [] + for wh in webhooks: + events = [e.strip() for e in wh.events.split(",")] + if event not in events: + continue + if wh.project_id is not None and wh.project_id != project_id: + continue + matched.append(wh) + + if not matched: + return + + payload_json = json.dumps(payload, default=str) + + for wh in matched: + log = WebhookLog( + webhook_id=wh.id, + event=event, + payload=payload_json, + ) + try: + headers = {"Content-Type": "application/json"} + if wh.secret: + sig = hmac.new( + wh.secret.encode(), payload_json.encode(), hashlib.sha256 + ).hexdigest() + headers["X-Webhook-Signature"] = sig + + with httpx.Client(timeout=10.0) as client: + resp = client.post(wh.url, content=payload_json, headers=headers) + log.response_status = resp.status_code + log.response_body = resp.text[:1000] + log.success = 200 <= resp.status_code < 300 + except Exception as e: + log.response_body = str(e)[:1000] + log.success = False + logger.warning(f"Webhook delivery failed for {wh.url}: {e}") + + db.add(log) + db.commit() diff --git a/requirements.txt b/requirements.txt index 6689709..9db19f8 100644 --- a/requirements.txt +++ b/requirements.txt @@ -10,3 +10,4 @@ bcrypt==4.0.1 python-multipart==0.0.6 alembic==1.13.1 python-dotenv==1.0.0 +httpx==0.27.0 From d9b0345aa6e5dae2ec2bad39d0cbd3aaee29edb2 Mon Sep 17 00:00:00 2001 From: Zhi Date: Sun, 22 Feb 2026 02:44:47 +0000 Subject: [PATCH 04/22] feat: add CLI tool (login/issues/projects/users/health/version) --- cli.py | 173 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 173 insertions(+) create mode 100755 cli.py diff --git a/cli.py b/cli.py new file mode 100755 index 0000000..4613ca8 --- /dev/null +++ b/cli.py @@ -0,0 +1,173 @@ +#!/usr/bin/env python3 +"""HarborForge CLI - 简易命令行工具""" + +import argparse +import json +import os +import sys +import urllib.request +import urllib.error + +BASE_URL = os.environ.get("HARBORFORGE_URL", "http://localhost:8000") +TOKEN = os.environ.get("HARBORFORGE_TOKEN", "") + + +def _request(method, path, data=None): + url = f"{BASE_URL}{path}" + headers = {"Content-Type": "application/json"} + if TOKEN: + headers["Authorization"] = f"Bearer {TOKEN}" + + body = json.dumps(data).encode() if data else None + req = urllib.request.Request(url, data=body, headers=headers, method=method) + + try: + with urllib.request.urlopen(req) as resp: + if resp.status == 204: + return None + return json.loads(resp.read()) + except urllib.error.HTTPError as e: + print(f"Error {e.code}: {e.read().decode()}", file=sys.stderr) + sys.exit(1) + + +def cmd_login(args): + data = urllib.parse.urlencode({"username": args.username, "password": args.password}).encode() + req = urllib.request.Request(f"{BASE_URL}/auth/token", data=data, method="POST") + req.add_header("Content-Type", "application/x-www-form-urlencoded") + try: + with urllib.request.urlopen(req) as resp: + result = json.loads(resp.read()) + print(f"Token: {result['access_token']}") + print(f"\nExport it:\nexport HARBORFORGE_TOKEN={result['access_token']}") + except urllib.error.HTTPError as e: + print(f"Login failed: {e.read().decode()}", file=sys.stderr) + sys.exit(1) + + +def cmd_issues(args): + params = [] + if args.project: + params.append(f"project_id={args.project}") + if args.type: + params.append(f"issue_type={args.type}") + if args.status: + params.append(f"issue_status={args.status}") + qs = f"?{'&'.join(params)}" if params else "" + issues = _request("GET", f"/issues{qs}") + for i in issues: + status_icon = {"open": "🟢", "in_progress": "🔵", "resolved": "✅", "closed": "⚫", "blocked": "🔴"}.get(i["status"], "❓") + type_icon = {"resolution": "⚖️", "task": "📋", "story": "📖", "test": "🧪"}.get(i["issue_type"], "📌") + print(f" {status_icon} {type_icon} #{i['id']} [{i['priority']}] {i['title']}") + + +def cmd_issue_create(args): + data = { + "title": args.title, + "project_id": args.project, + "reporter_id": args.reporter, + "issue_type": args.type, + "priority": args.priority or "medium", + } + if args.description: + data["description"] = args.description + if args.assignee: + data["assignee_id"] = args.assignee + + # Resolution specific + if args.type == "resolution": + if args.summary: + data["resolution_summary"] = args.summary + if args.positions: + data["positions"] = args.positions + if args.pending: + data["pending_matters"] = args.pending + + result = _request("POST", "/issues", data) + print(f"Created issue #{result['id']}: {result['title']}") + + +def cmd_projects(args): + projects = _request("GET", "/projects") + for p in projects: + print(f" #{p['id']} {p['name']} - {p.get('description', '')}") + + +def cmd_users(args): + users = _request("GET", "/users") + for u in users: + role = "👑" if u["is_admin"] else "👤" + print(f" {role} #{u['id']} {u['username']} ({u.get('full_name', '')})") + + +def cmd_version(args): + result = _request("GET", "/version") + print(f"{result['name']} v{result['version']}") + + +def cmd_health(args): + result = _request("GET", "/health") + print(f"Status: {result['status']}") + + +def main(): + parser = argparse.ArgumentParser(description="HarborForge CLI") + sub = parser.add_subparsers(dest="command") + + # login + p_login = sub.add_parser("login", help="Login and get token") + p_login.add_argument("username") + p_login.add_argument("password") + + # issues + p_issues = sub.add_parser("issues", help="List issues") + p_issues.add_argument("--project", "-p", type=int) + p_issues.add_argument("--type", "-t", choices=["task", "story", "test", "resolution"]) + p_issues.add_argument("--status", "-s") + + # issue create + p_create = sub.add_parser("create-issue", help="Create an issue") + p_create.add_argument("title") + p_create.add_argument("--project", "-p", type=int, required=True) + p_create.add_argument("--reporter", "-r", type=int, required=True) + p_create.add_argument("--type", "-t", default="task", choices=["task", "story", "test", "resolution"]) + p_create.add_argument("--priority", choices=["low", "medium", "high", "critical"]) + p_create.add_argument("--description", "-d") + p_create.add_argument("--assignee", "-a", type=int) + # Resolution fields + p_create.add_argument("--summary") + p_create.add_argument("--positions") + p_create.add_argument("--pending") + + # projects + sub.add_parser("projects", help="List projects") + + # users + sub.add_parser("users", help="List users") + + # version + sub.add_parser("version", help="Show version") + + # health + sub.add_parser("health", help="Health check") + + args = parser.parse_args() + if not args.command: + parser.print_help() + sys.exit(1) + + cmds = { + "login": cmd_login, + "issues": cmd_issues, + "create-issue": cmd_issue_create, + "projects": cmd_projects, + "users": cmd_users, + "version": cmd_version, + "health": cmd_health, + } + cmds[args.command](args) + + +if __name__ == "__main__": + import urllib.parse + main() From 9ad1e940e42008079031bd73178890fb5d1c08a5 Mon Sep 17 00:00:00 2001 From: Zhi Date: Sun, 22 Feb 2026 04:20:35 +0000 Subject: [PATCH 05/22] feat: issue status transition endpoint with webhook --- app/main.py | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/app/main.py b/app/main.py index 0d5955a..ec2adb4 100644 --- a/app/main.py +++ b/app/main.py @@ -409,3 +409,33 @@ def list_webhook_logs(webhook_id: int, limit: int = 50, db: Session = Depends(ge WebhookLog.webhook_id == webhook_id ).order_by(WebhookLog.created_at.desc()).limit(limit).all() return logs + + + +# ============ Issue Status Transition ============ + +@app.post("/issues/{issue_id}/transition", response_model=schemas.IssueResponse) +def transition_issue(issue_id: int, new_status: str, bg: BackgroundTasks, db: Session = Depends(get_db)): + """Transition issue status with validation and webhook.""" + valid_statuses = ["open", "in_progress", "resolved", "closed", "blocked"] + if new_status not in valid_statuses: + raise HTTPException(status_code=400, detail=f"Invalid status. Must be one of: {valid_statuses}") + + issue = db.query(models.Issue).filter(models.Issue.id == issue_id).first() + if not issue: + raise HTTPException(status_code=404, detail="Issue not found") + + old_status = issue.status + issue.status = new_status + db.commit() + db.refresh(issue) + + event = "issue.closed" if new_status == "closed" else "issue.updated" + bg.add_task(fire_webhooks_sync, event, { + "issue_id": issue.id, + "title": issue.title, + "old_status": old_status, + "new_status": new_status, + }, issue.project_id, db) + + return issue From d4666260a79ce2893ea22394105f39c0166077c7 Mon Sep 17 00:00:00 2001 From: Zhi Date: Sun, 22 Feb 2026 04:21:01 +0000 Subject: [PATCH 06/22] feat: issue search API (keyword in title/description) --- app/main.py | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/app/main.py b/app/main.py index ec2adb4..305e7af 100644 --- a/app/main.py +++ b/app/main.py @@ -439,3 +439,23 @@ def transition_issue(issue_id: int, new_status: str, bg: BackgroundTasks, db: Se }, issue.project_id, db) return issue + + + +# ============ Search API ============ + +@app.get("/search/issues", response_model=List[schemas.IssueResponse]) +def search_issues( + q: str, + project_id: int = None, + skip: int = 0, + limit: int = 50, + db: Session = Depends(get_db) +): + """Search issues by title or description keyword.""" + query = db.query(models.Issue).filter( + (models.Issue.title.contains(q)) | (models.Issue.description.contains(q)) + ) + if project_id: + query = query.filter(models.Issue.project_id == project_id) + return query.offset(skip).limit(limit).all() From 8ac51494c4038776164ea4ee81ee0dc0f1c3904c Mon Sep 17 00:00:00 2001 From: Zhi Date: Sun, 22 Feb 2026 04:21:26 +0000 Subject: [PATCH 07/22] feat: dashboard stats API (by status/type/priority) --- app/main.py | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/app/main.py b/app/main.py index 305e7af..a5de7b7 100644 --- a/app/main.py +++ b/app/main.py @@ -459,3 +459,34 @@ def search_issues( if project_id: query = query.filter(models.Issue.project_id == project_id) return query.offset(skip).limit(limit).all() + + + +# ============ Dashboard / Stats API ============ + +@app.get("/dashboard/stats") +def dashboard_stats(project_id: int = None, db: Session = Depends(get_db)): + """Get issue statistics for dashboard.""" + query = db.query(models.Issue) + if project_id: + query = query.filter(models.Issue.project_id == project_id) + + total = query.count() + by_status = {} + for s in ["open", "in_progress", "resolved", "closed", "blocked"]: + by_status[s] = query.filter(models.Issue.status == s).count() + + by_type = {} + for t in ["task", "story", "test", "resolution"]: + by_type[t] = query.filter(models.Issue.issue_type == t).count() + + by_priority = {} + for p in ["low", "medium", "high", "critical"]: + by_priority[p] = query.filter(models.Issue.priority == p).count() + + return { + "total": total, + "by_status": by_status, + "by_type": by_type, + "by_priority": by_priority, + } From 955cf0111051514ef0bb84e2dc8726ba32b335ef Mon Sep 17 00:00:00 2001 From: Zhi Date: Sun, 22 Feb 2026 04:22:19 +0000 Subject: [PATCH 08/22] feat: CLI add search/transition/stats commands --- cli.py | 53 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/cli.py b/cli.py index 4613ca8..b226160 100755 --- a/cli.py +++ b/cli.py @@ -110,6 +110,41 @@ def cmd_health(args): print(f"Status: {result['status']}") + +def cmd_search(args): + params = [f"q={args.query}"] + if args.project: + params.append(f"project_id={args.project}") + qs = "&".join(params) + issues = _request("GET", f"/search/issues?{qs}") + if not issues: + print(" No results found.") + return + for i in issues: + status_icon = {"open": "\U0001f7e2", "in_progress": "\U0001f535", "resolved": "\u2705", "closed": "\u26ab", "blocked": "\U0001f534"}.get(i["status"], "\u2753") + type_icon = {"resolution": "\u2696\ufe0f", "task": "\U0001f4cb", "story": "\U0001f4d6", "test": "\U0001f9ea"}.get(i["issue_type"], "\U0001f4cc") + print(f" {status_icon} {type_icon} #{i['id']} [{i['priority']}] {i['title']}") + + +def cmd_transition(args): + result = _request("POST", f"/issues/{args.issue_id}/transition?new_status={args.status}") + print(f"Issue #{result['id']} transitioned to: {result['status']}") + + +def cmd_stats(args): + params = f"?project_id={args.project}" if args.project else "" + stats = _request("GET", f"/dashboard/stats{params}") + print(f"Total: {stats['total']}") + print("By status:") + for s, c in stats["by_status"].items(): + if c > 0: + print(f" {s}: {c}") + print("By type:") + for t, c in stats["by_type"].items(): + if c > 0: + print(f" {t}: {c}") + + def main(): parser = argparse.ArgumentParser(description="HarborForge CLI") sub = parser.add_subparsers(dest="command") @@ -151,6 +186,21 @@ def main(): # health sub.add_parser("health", help="Health check") + + # search + p_search = sub.add_parser("search", help="Search issues") + p_search.add_argument("query") + p_search.add_argument("--project", "-p", type=int) + + # transition + p_trans = sub.add_parser("transition", help="Transition issue status") + p_trans.add_argument("issue_id", type=int) + p_trans.add_argument("status", choices=["open", "in_progress", "resolved", "closed", "blocked"]) + + # stats + p_stats = sub.add_parser("stats", help="Dashboard stats") + p_stats.add_argument("--project", "-p", type=int) + args = parser.parse_args() if not args.command: parser.print_help() @@ -164,6 +214,9 @@ def main(): "users": cmd_users, "version": cmd_version, "health": cmd_health, + "search": cmd_search, + "transition": cmd_transition, + "stats": cmd_stats, } cmds[args.command](args) From 9d831e932c6b7354ce1fd32b90876a2d4ce30ed8 Mon Sep 17 00:00:00 2001 From: Zhi Date: Sun, 22 Feb 2026 04:22:54 +0000 Subject: [PATCH 09/22] feat: comment update/delete endpoints --- app/main.py | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/app/main.py b/app/main.py index a5de7b7..7ab445c 100644 --- a/app/main.py +++ b/app/main.py @@ -490,3 +490,28 @@ def dashboard_stats(project_id: int = None, db: Session = Depends(get_db)): "by_type": by_type, "by_priority": by_priority, } + + + +# ============ Comments (update/delete) ============ + +@app.patch("/comments/{comment_id}", response_model=schemas.CommentResponse) +def update_comment(comment_id: int, comment_update: schemas.CommentUpdate, db: Session = Depends(get_db)): + comment = db.query(models.Comment).filter(models.Comment.id == comment_id).first() + if not comment: + raise HTTPException(status_code=404, detail="Comment not found") + for field, value in comment_update.model_dump(exclude_unset=True).items(): + setattr(comment, field, value) + db.commit() + db.refresh(comment) + return comment + + +@app.delete("/comments/{comment_id}", status_code=status.HTTP_204_NO_CONTENT) +def delete_comment(comment_id: int, db: Session = Depends(get_db)): + comment = db.query(models.Comment).filter(models.Comment.id == comment_id).first() + if not comment: + raise HTTPException(status_code=404, detail="Comment not found") + db.delete(comment) + db.commit() + return None From 1d5d8add3d84bef9b08583602930ef4e453b33d3 Mon Sep 17 00:00:00 2001 From: Zhi Date: Sun, 22 Feb 2026 04:23:26 +0000 Subject: [PATCH 10/22] docs: update README with full API reference and CLI usage --- README.md | 133 ++++++++++++++++++++++++++++++++---------------------- 1 file changed, 78 insertions(+), 55 deletions(-) diff --git a/README.md b/README.md index d85a0a0..97991eb 100644 --- a/README.md +++ b/README.md @@ -1,68 +1,91 @@ -# HarborForge Project Plan +# HarborForge Backend -## Overview -HarborForge is a dual UI/automation platform where humans + AI agents operate through a unified issue-driven workflow. The plan below tracks the Python/FastAPI backend, React frontend, and Python CLI, designed to run on a 1 vCPU + 1 GB RAM Docker Compose stack with MySQL persistence. +Agent/人类协同任务管理平台 - FastAPI 后端 -## Workstreams & Task Breakdown +## API Endpoints (21) -### 1. Architecture & Platform Foundation -- Document system components: API service, React SPA, Python CLI/bot runner, MySQL, webhook dispatcher, optional worker queue. -- Define communication contracts (REST+webhook) and env/secret management. -- Create Docker Compose template with lightweight images, explicit resource limits, and volumes for logs/database. -- Include `AbstractWizard` initializer as part of deploy script; link to `https://git.hangman-lab.top/hzhang/AbstractWizard` and capture expectations from its README. -- Set up logging/metrics hooks (stdout, log file rotation) and persistence directories on shared volume. +### Auth +- `POST /auth/token` - 登录获取 JWT token +- `GET /auth/me` - 获取当前用户信息 -### 2. Authentication & Role-Based Access -- Integrate OIDC standard login flow for UI; CLI uses credential files with no interactive login. -- Establish credential lifecycle: - - Credentials have expiry timestamps and remain in DB flagged as expired. - - On usage of expired credential, return new credential + confirmation requirement. - - Confirmation must reach backend within 1 minute. - - Any random reuse of old credential 3–5 times before confirmation results in deletion; future calls require re-authentication (password/OIDC) to issue a new credential. -- Store credential files via `--creds /path` or `HARBORFORGE_CREDENTIAL` env, support rotation via API. -- Define RBAC model per project (roles: admin/dev/mgr/ops); admin user created during initialization. -- Build admin-only endpoints/UI/CLI to manage roles, permissions, and project assignments. +### Issues +- `POST /issues` - 创建 issue(支持 resolution 决议案类型) +- `GET /issues` - 列表(支持按 project/status/type 过滤) +- `GET /issues/{id}` - 详情 +- `PATCH /issues/{id}` - 更新 +- `DELETE /issues/{id}` - 删除 +- `POST /issues/{id}/transition` - 状态变更(触发 webhook) +- `GET /search/issues?q=keyword` - 搜索 -### 3. Core Issue/Work Item Platform -- Design issue model that supports multiple types (task/story/test), state transitions, dependencies, tags, priority, comments. -- Support multi-account/role membership per project and enforce permission matrix at API layer. -- Guarantee every user-facing action can be invoked via API/CLI. -- Catalog automation touchpoints (issue lifecycle updates, script triggers, deployment actions) for future extension. +### Comments +- `POST /comments` - 创建评论 +- `GET /issues/{id}/comments` - 列表 +- `PATCH /comments/{id}` - 更新 +- `DELETE /comments/{id}` - 删除 -### 4. Frontend (React) -- Define app routes: dashboard, board/list, issue detail, role/project admin, audit/log viewer, automation console. -- Implement state management (React Query or equivalent) matching API structure. -- Provide UI components for admin role management + webhook configuration. -- Surface webhook events and automation status, letting users inspect audit entries. +### Projects +- `POST /projects` - 创建 +- `GET /projects` - 列表 +- `GET /projects/{id}` - 详情 +- `PATCH /projects/{id}` - 更新 +- `DELETE /projects/{id}` - 删除 -### 5. CLI & Automation Surface -- Build interactive Python CLI mirroring UI flows; commands are rendered differently but invoke same backend APIs. -- CLI uses credential files or env tokens; includes runtime prompts for confirmation when credential rotation occurs. -- Bot agent runner polls API for pending automations, executes scripts, and emits callbacks via webhook. -- Document CLI command set (issue CRUD, role management, automation triggers, logs) and error handling. +### Project Members +- `POST /projects/{id}/members` - 添加成员 +- `GET /projects/{id}/members` - 列表 +- `DELETE /projects/{id}/members/{user_id}` - 移除 -### 6. API Coverage & Webhooks -- Enumerate APIs for all core operations: issues, comments, projects, roles, credentials, automation scripts, audit logs. -- Design webhook schema and catalog events to support: issue lifecycle, authentication events, automation/script completion. -- Ensure webhook delivery is authenticated and configurable; details to be defined during implementation. -- Provide API for credential confirmation flows and for requesting new credentials after deletion. +### Users +- `POST /users` - 注册 +- `GET /users` - 列表 +- `GET /users/{id}` - 详情 +- `PATCH /users/{id}` - 更新 -### 7. Observability & Logging -- Log all API invocations to a file on Docker-mounted volume, capped at 10 MB with rotation. -- Keep a webhook audit log (which event, trigger source, response status) for troubleshooting. -- Provide endpoints/UI to browse recent logs (within retention threshold). +### Webhooks +- `POST /webhooks` - 创建 +- `GET /webhooks` - 列表 +- `GET /webhooks/{id}` - 详情 +- `PATCH /webhooks/{id}` - 更新 +- `DELETE /webhooks/{id}` - 删除 +- `GET /webhooks/{id}/logs` - 投递日志 -### 8. Deployment & Security Posture -- Keep `ufw` locked down on `vps.t1` and rely on SSH tunnels (`ssh -L`) for UI/CLI testing. -- Document CLI/agent testing setup (SSH tunnel commands, env vars) so Zhi/Hangman can coordinate. -- Outline backup strategy for MySQL data directory within the Compose stack. -- Confirm `AbstractWizard` handles initial admin/project creation; fall back to Zhi if issues arise. +### System +- `GET /health` - 健康检查 +- `GET /version` - 版本信息 +- `GET /dashboard/stats` - 统计面板 -### 9. Collaboration & Delivery Rhythm -- Maintain the new channel with templates for requirements, story cards, milestones, and progress updates. -- Use channel to publish deliverables (architecture doc, API spec, deployment instructions, automation scenarios). -- Record testing points for UI/CLI without prescribing steps; let Hangman/Zhi define the execution details. +## CLI ---- +```bash +# 环境变量 +export HARBORFORGE_URL=http://localhost:8000 +export HARBORFORGE_TOKEN= -Need any section expanded into user stories or backlog cards next? \ No newline at end of file +# 命令 +python3 cli.py login +python3 cli.py issues [-p project_id] [-t type] [-s status] +python3 cli.py create-issue "title" -p 1 -r 1 [-t resolution --summary "..." --positions "..." --pending "..."] +python3 cli.py search "keyword" +python3 cli.py transition +python3 cli.py stats [-p project_id] +python3 cli.py projects +python3 cli.py users +python3 cli.py health +python3 cli.py version +``` + +## 技术栈 + +- Python 3.11 + FastAPI +- SQLAlchemy + MySQL +- JWT (python-jose) +- Docker + +## Issue Types + +| Type | 用途 | +|------|------| +| task | 普通任务 | +| story | 用户故事 | +| test | 测试用例 | +| resolution | 决议案(Agent 僵局提交)| From 1e9c6fd2f860e0def062fbc28ed0ecc22658a1ec Mon Sep 17 00:00:00 2001 From: Zhi Date: Sun, 22 Feb 2026 09:05:05 +0000 Subject: [PATCH 11/22] feat: API key auth for agents (create/list/revoke) + dual auth (JWT or API key) --- app/main.py | 83 ++++++++++++++++++++++++++++++++++++++++++++ app/models/apikey.py | 15 ++++++++ 2 files changed, 98 insertions(+) create mode 100644 app/models/apikey.py diff --git a/app/main.py b/app/main.py index 7ab445c..14e640c 100644 --- a/app/main.py +++ b/app/main.py @@ -252,6 +252,7 @@ def get_user(user_id: int, db: Session = Depends(get_db)): def startup(): from app.core.config import Base, engine from app.models import webhook + from app.models import apikey Base.metadata.create_all(bind=engine) @@ -515,3 +516,85 @@ def delete_comment(comment_id: int, db: Session = Depends(get_db)): db.delete(comment) db.commit() return None + + + +# ============ API Key Auth ============ + +import secrets +from fastapi.security import APIKeyHeader +from app.models.apikey import APIKey + +apikey_header = APIKeyHeader(name="X-API-Key", auto_error=False) + + +async def get_current_user_or_apikey( + token: str = Depends(oauth2_scheme), + api_key: str = Depends(apikey_header), + db: Session = Depends(get_db) +): + """Authenticate via JWT token OR API key.""" + # Try API key first + if api_key: + key_obj = db.query(APIKey).filter(APIKey.key == api_key, APIKey.is_active == True).first() + if key_obj: + key_obj.last_used_at = datetime.utcnow() + db.commit() + user = db.query(models.User).filter(models.User.id == key_obj.user_id).first() + if user: + return user + # Fall back to JWT + if token: + return await get_current_user(token=token, db=db) + raise HTTPException(status_code=401, detail="Not authenticated") + + +# ============ API Key Management ============ + +from pydantic import BaseModel as PydanticBaseModel + +class APIKeyCreate(PydanticBaseModel): + name: str + user_id: int + +class APIKeyResponse(PydanticBaseModel): + id: int + key: str + name: str + user_id: int + is_active: bool + created_at: datetime + last_used_at: datetime | None = None + class Config: + from_attributes = True + + +@app.post("/api-keys", response_model=APIKeyResponse, status_code=status.HTTP_201_CREATED) +def create_api_key(data: APIKeyCreate, db: Session = Depends(get_db)): + user = db.query(models.User).filter(models.User.id == data.user_id).first() + if not user: + raise HTTPException(status_code=404, detail="User not found") + key = secrets.token_hex(32) + db_key = APIKey(key=key, name=data.name, user_id=data.user_id) + db.add(db_key) + db.commit() + db.refresh(db_key) + return db_key + + +@app.get("/api-keys", response_model=List[APIKeyResponse]) +def list_api_keys(user_id: int = None, db: Session = Depends(get_db)): + query = db.query(APIKey) + if user_id: + query = query.filter(APIKey.user_id == user_id) + return query.all() + + +@app.delete("/api-keys/{key_id}", status_code=status.HTTP_204_NO_CONTENT) +def revoke_api_key(key_id: int, db: Session = Depends(get_db)): + key_obj = db.query(APIKey).filter(APIKey.id == key_id).first() + if not key_obj: + raise HTTPException(status_code=404, detail="API key not found") + key_obj.is_active = False + db.commit() + return None diff --git a/app/models/apikey.py b/app/models/apikey.py new file mode 100644 index 0000000..3bad394 --- /dev/null +++ b/app/models/apikey.py @@ -0,0 +1,15 @@ +from sqlalchemy import Column, Integer, String, DateTime, Boolean, ForeignKey +from sqlalchemy.sql import func +from app.core.config import Base + + +class APIKey(Base): + __tablename__ = "api_keys" + + id = Column(Integer, primary_key=True, index=True) + key = Column(String(64), unique=True, nullable=False, index=True) + name = Column(String(100), nullable=False) # e.g. "agent-zhi", "agent-lyn" + user_id = Column(Integer, ForeignKey("users.id"), nullable=False) + is_active = Column(Boolean, default=True) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + last_used_at = Column(DateTime(timezone=True), nullable=True) From ac397679f899163b6f2f2acf5eb7ee2605453f6c Mon Sep 17 00:00:00 2001 From: Zhi Date: Sun, 22 Feb 2026 09:05:42 +0000 Subject: [PATCH 12/22] feat: batch operations (transition + assign) --- app/main.py | 51 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/app/main.py b/app/main.py index 14e640c..111d7b7 100644 --- a/app/main.py +++ b/app/main.py @@ -598,3 +598,54 @@ def revoke_api_key(key_id: int, db: Session = Depends(get_db)): key_obj.is_active = False db.commit() return None + + + +# ============ Batch Operations ============ + +class BatchTransition(PydanticBaseModel): + issue_ids: List[int] + new_status: str + +class BatchAssign(PydanticBaseModel): + issue_ids: List[int] + assignee_id: int + + +@app.post("/issues/batch/transition") +def batch_transition(data: BatchTransition, bg: BackgroundTasks, db: Session = Depends(get_db)): + valid_statuses = ["open", "in_progress", "resolved", "closed", "blocked"] + if data.new_status not in valid_statuses: + raise HTTPException(status_code=400, detail=f"Invalid status") + + updated = [] + for issue_id in data.issue_ids: + issue = db.query(models.Issue).filter(models.Issue.id == issue_id).first() + if issue: + old_status = issue.status + issue.status = data.new_status + updated.append({"id": issue.id, "title": issue.title, "old": old_status, "new": data.new_status}) + db.commit() + + for u in updated: + event = "issue.closed" if data.new_status == "closed" else "issue.updated" + bg.add_task(fire_webhooks_sync, event, u, None, db) + + return {"updated": len(updated), "issues": updated} + + +@app.post("/issues/batch/assign") +def batch_assign(data: BatchAssign, db: Session = Depends(get_db)): + user = db.query(models.User).filter(models.User.id == data.assignee_id).first() + if not user: + raise HTTPException(status_code=404, detail="Assignee not found") + + updated = [] + for issue_id in data.issue_ids: + issue = db.query(models.Issue).filter(models.Issue.id == issue_id).first() + if issue: + issue.assignee_id = data.assignee_id + updated.append(issue_id) + db.commit() + + return {"updated": len(updated), "issue_ids": updated, "assignee_id": data.assignee_id} From a63afa073df975efe709de5631b61b90783d7dc3 Mon Sep 17 00:00:00 2001 From: Zhi Date: Sun, 22 Feb 2026 09:06:37 +0000 Subject: [PATCH 13/22] feat: activity log model + API (audit trail) --- app/main.py | 44 ++++++++++++++++++++++++++++++++++++++++++ app/models/activity.py | 15 ++++++++++++++ 2 files changed, 59 insertions(+) create mode 100644 app/models/activity.py diff --git a/app/main.py b/app/main.py index 111d7b7..3bd3f25 100644 --- a/app/main.py +++ b/app/main.py @@ -253,6 +253,7 @@ def startup(): from app.core.config import Base, engine from app.models import webhook from app.models import apikey + from app.models import activity Base.metadata.create_all(bind=engine) @@ -649,3 +650,46 @@ def batch_assign(data: BatchAssign, db: Session = Depends(get_db)): db.commit() return {"updated": len(updated), "issue_ids": updated, "assignee_id": data.assignee_id} + + + +# ============ Activity Log ============ + +from app.models.activity import ActivityLog + + +class ActivityLogResponse(PydanticBaseModel): + id: int + action: str + entity_type: str + entity_id: int + user_id: int | None + details: str | None + created_at: datetime + class Config: + from_attributes = True + + +def log_activity(db: Session, action: str, entity_type: str, entity_id: int, user_id: int = None, details: str = None): + """Helper to record an activity log entry.""" + entry = ActivityLog(action=action, entity_type=entity_type, entity_id=entity_id, user_id=user_id, details=details) + db.add(entry) + db.commit() + + +@app.get("/activity", response_model=List[ActivityLogResponse]) +def list_activity( + entity_type: str = None, + entity_id: int = None, + user_id: int = None, + limit: int = 50, + db: Session = Depends(get_db) +): + query = db.query(ActivityLog) + if entity_type: + query = query.filter(ActivityLog.entity_type == entity_type) + if entity_id: + query = query.filter(ActivityLog.entity_id == entity_id) + if user_id: + query = query.filter(ActivityLog.user_id == user_id) + return query.order_by(ActivityLog.created_at.desc()).limit(limit).all() diff --git a/app/models/activity.py b/app/models/activity.py new file mode 100644 index 0000000..5d7a011 --- /dev/null +++ b/app/models/activity.py @@ -0,0 +1,15 @@ +from sqlalchemy import Column, Integer, String, Text, DateTime, ForeignKey +from sqlalchemy.sql import func +from app.core.config import Base + + +class ActivityLog(Base): + __tablename__ = "activity_logs" + + id = Column(Integer, primary_key=True, index=True) + action = Column(String(50), nullable=False) # e.g. "issue.created", "comment.added" + entity_type = Column(String(50), nullable=False) # "issue", "project", "comment" + entity_id = Column(Integer, nullable=False) + user_id = Column(Integer, ForeignKey("users.id"), nullable=True) + details = Column(Text, nullable=True) # JSON string + created_at = Column(DateTime(timezone=True), server_default=func.now()) From 6c53a6f6580a8fdb8e99ed798b6cbdf5ff7fa022 Mon Sep 17 00:00:00 2001 From: Zhi Date: Sun, 22 Feb 2026 09:08:13 +0000 Subject: [PATCH 14/22] feat: issue relations (link/unlink parent-child, list children) --- app/main.py | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/app/main.py b/app/main.py index 3bd3f25..0f6ac31 100644 --- a/app/main.py +++ b/app/main.py @@ -693,3 +693,38 @@ def list_activity( if user_id: query = query.filter(ActivityLog.user_id == user_id) return query.order_by(ActivityLog.created_at.desc()).limit(limit).all() + + + +# ============ Issue Relations ============ + +class IssueRelation(PydanticBaseModel): + parent_id: int + child_id: int + +@app.post("/issues/link") +def link_issues(rel: IssueRelation, db: Session = Depends(get_db)): + parent = db.query(models.Issue).filter(models.Issue.id == rel.parent_id).first() + child = db.query(models.Issue).filter(models.Issue.id == rel.child_id).first() + if not parent or not child: + raise HTTPException(status_code=404, detail="Issue not found") + if rel.parent_id == rel.child_id: + raise HTTPException(status_code=400, detail="Cannot link issue to itself") + child.depends_on_id = rel.parent_id + db.commit() + return {"parent_id": rel.parent_id, "child_id": rel.child_id, "status": "linked"} + + +@app.delete("/issues/link") +def unlink_issues(child_id: int, db: Session = Depends(get_db)): + child = db.query(models.Issue).filter(models.Issue.id == child_id).first() + if not child: + raise HTTPException(status_code=404, detail="Issue not found") + child.depends_on_id = None + db.commit() + return {"child_id": child_id, "status": "unlinked"} + + +@app.get("/issues/{issue_id}/children", response_model=List[schemas.IssueResponse]) +def get_children(issue_id: int, db: Session = Depends(get_db)): + return db.query(models.Issue).filter(models.Issue.depends_on_id == issue_id).all() From f48b8295118e0ca8e752c82983f2a696aaa80e3a Mon Sep 17 00:00:00 2001 From: Zhi Date: Sun, 22 Feb 2026 09:08:49 +0000 Subject: [PATCH 15/22] feat: issue tags management (add/remove/list-all) --- app/main.py | 44 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/app/main.py b/app/main.py index 0f6ac31..32ec79a 100644 --- a/app/main.py +++ b/app/main.py @@ -728,3 +728,47 @@ def unlink_issues(child_id: int, db: Session = Depends(get_db)): @app.get("/issues/{issue_id}/children", response_model=List[schemas.IssueResponse]) def get_children(issue_id: int, db: Session = Depends(get_db)): return db.query(models.Issue).filter(models.Issue.depends_on_id == issue_id).all() + + + +# ============ Issue Tags ============ + +@app.post("/issues/{issue_id}/tags") +def add_tag(issue_id: int, tag: str, db: Session = Depends(get_db)): + issue = db.query(models.Issue).filter(models.Issue.id == issue_id).first() + if not issue: + raise HTTPException(status_code=404, detail="Issue not found") + current = set(issue.tags.split(",")) if issue.tags else set() + current.add(tag.strip()) + current.discard("") + issue.tags = ",".join(sorted(current)) + db.commit() + return {"issue_id": issue_id, "tags": list(current)} + + +@app.delete("/issues/{issue_id}/tags") +def remove_tag(issue_id: int, tag: str, db: Session = Depends(get_db)): + issue = db.query(models.Issue).filter(models.Issue.id == issue_id).first() + if not issue: + raise HTTPException(status_code=404, detail="Issue not found") + current = set(issue.tags.split(",")) if issue.tags else set() + current.discard(tag.strip()) + current.discard("") + issue.tags = ",".join(sorted(current)) if current else None + db.commit() + return {"issue_id": issue_id, "tags": list(current)} + + +@app.get("/tags") +def list_all_tags(project_id: int = None, db: Session = Depends(get_db)): + """Get all unique tags across issues.""" + query = db.query(models.Issue.tags).filter(models.Issue.tags != None) + if project_id: + query = query.filter(models.Issue.project_id == project_id) + all_tags = set() + for (tags,) in query.all(): + for t in tags.split(","): + t = t.strip() + if t: + all_tags.add(t) + return {"tags": sorted(all_tags)} From 7485f29adacd05e01e4e3642589ccf2c8a383e43 Mon Sep 17 00:00:00 2001 From: Zhi Date: Sun, 22 Feb 2026 19:12:35 +0000 Subject: [PATCH 16/22] feat: milestones, due dates, overdue filter, CSV export --- app/main.py | 128 ++++++++++++++++++++++++++++++++++++++++ app/models/milestone.py | 25 ++++++++ app/models/models.py | 4 ++ app/schemas/schemas.py | 35 +++++++++++ 4 files changed, 192 insertions(+) create mode 100644 app/models/milestone.py diff --git a/app/main.py b/app/main.py index 32ec79a..9a9a88a 100644 --- a/app/main.py +++ b/app/main.py @@ -132,6 +132,19 @@ def list_issues( return issues +@app.get("/issues/overdue", response_model=List[schemas.IssueResponse]) +def list_overdue_issues(project_id: int = None, db: Session = Depends(get_db)): + """List issues past their due date that aren't closed/resolved.""" + query = db.query(models.Issue).filter( + models.Issue.due_date != None, + models.Issue.due_date < datetime.utcnow(), + models.Issue.status.notin_(["resolved", "closed"]) + ) + if project_id: + query = query.filter(models.Issue.project_id == project_id) + return query.order_by(models.Issue.due_date.asc()).all() + + @app.get("/issues/{issue_id}", response_model=schemas.IssueResponse) def get_issue(issue_id: int, db: Session = Depends(get_db)): issue = db.query(models.Issue).filter(models.Issue.id == issue_id).first() @@ -254,6 +267,7 @@ def startup(): from app.models import webhook from app.models import apikey from app.models import activity + from app.models import milestone Base.metadata.create_all(bind=engine) @@ -772,3 +786,117 @@ def list_all_tags(project_id: int = None, db: Session = Depends(get_db)): if t: all_tags.add(t) return {"tags": sorted(all_tags)} + + +# ============ Milestones API ============ + +from app.models.milestone import Milestone as MilestoneModel, MilestoneStatus +from app.schemas.schemas import MilestoneCreate, MilestoneUpdate, MilestoneResponse + + +@app.post("/milestones", response_model=MilestoneResponse, status_code=status.HTTP_201_CREATED) +def create_milestone(ms: MilestoneCreate, db: Session = Depends(get_db)): + db_ms = MilestoneModel(**ms.model_dump()) + db.add(db_ms) + db.commit() + db.refresh(db_ms) + return db_ms + + +@app.get("/milestones", response_model=List[MilestoneResponse]) +def list_milestones(project_id: int = None, status_filter: str = None, db: Session = Depends(get_db)): + query = db.query(MilestoneModel) + if project_id: + query = query.filter(MilestoneModel.project_id == project_id) + if status_filter: + query = query.filter(MilestoneModel.status == status_filter) + return query.order_by(MilestoneModel.due_date.is_(None), MilestoneModel.due_date.asc()).all() + + +@app.get("/milestones/{milestone_id}", response_model=MilestoneResponse) +def get_milestone(milestone_id: int, db: Session = Depends(get_db)): + ms = db.query(MilestoneModel).filter(MilestoneModel.id == milestone_id).first() + if not ms: + raise HTTPException(status_code=404, detail="Milestone not found") + return ms + + +@app.patch("/milestones/{milestone_id}", response_model=MilestoneResponse) +def update_milestone(milestone_id: int, ms_update: MilestoneUpdate, db: Session = Depends(get_db)): + ms = db.query(MilestoneModel).filter(MilestoneModel.id == milestone_id).first() + if not ms: + raise HTTPException(status_code=404, detail="Milestone not found") + for field, value in ms_update.model_dump(exclude_unset=True).items(): + setattr(ms, field, value) + db.commit() + db.refresh(ms) + return ms + + +@app.delete("/milestones/{milestone_id}", status_code=status.HTTP_204_NO_CONTENT) +def delete_milestone(milestone_id: int, db: Session = Depends(get_db)): + ms = db.query(MilestoneModel).filter(MilestoneModel.id == milestone_id).first() + if not ms: + raise HTTPException(status_code=404, detail="Milestone not found") + db.delete(ms) + db.commit() + return None + + +@app.get("/milestones/{milestone_id}/issues", response_model=List[schemas.IssueResponse]) +def list_milestone_issues(milestone_id: int, db: Session = Depends(get_db)): + """List all issues in a milestone.""" + return db.query(models.Issue).filter(models.Issue.milestone_id == milestone_id).all() + + +@app.get("/milestones/{milestone_id}/progress") +def milestone_progress(milestone_id: int, db: Session = Depends(get_db)): + """Get milestone completion progress.""" + ms = db.query(MilestoneModel).filter(MilestoneModel.id == milestone_id).first() + if not ms: + raise HTTPException(status_code=404, detail="Milestone not found") + issues = db.query(models.Issue).filter(models.Issue.milestone_id == milestone_id).all() + total = len(issues) + done = sum(1 for i in issues if i.status in ("resolved", "closed")) + return { + "milestone_id": milestone_id, + "title": ms.title, + "total_issues": total, + "completed": done, + "progress_pct": round(done / total * 100, 1) if total else 0, + } + + +# ============ Export API ============ + +import csv +import io +from fastapi.responses import StreamingResponse + + +@app.get("/export/issues") +def export_issues_csv(project_id: int = None, db: Session = Depends(get_db)): + """Export issues as CSV.""" + query = db.query(models.Issue) + if project_id: + query = query.filter(models.Issue.project_id == project_id) + issues = query.all() + + output = io.StringIO() + writer = csv.writer(output) + writer.writerow(["id", "title", "type", "status", "priority", "project_id", + "reporter_id", "assignee_id", "milestone_id", "due_date", + "tags", "created_at", "updated_at"]) + for i in issues: + writer.writerow([ + i.id, i.title, i.issue_type, i.status, i.priority, + i.project_id, i.reporter_id, i.assignee_id, i.milestone_id, + i.due_date, i.tags, i.created_at, i.updated_at + ]) + output.seek(0) + return StreamingResponse( + iter([output.getvalue()]), + media_type="text/csv", + headers={"Content-Disposition": "attachment; filename=issues.csv"} + ) + diff --git a/app/models/milestone.py b/app/models/milestone.py new file mode 100644 index 0000000..8758435 --- /dev/null +++ b/app/models/milestone.py @@ -0,0 +1,25 @@ +from sqlalchemy import Column, Integer, String, Text, DateTime, ForeignKey, Enum +from sqlalchemy.orm import relationship +from sqlalchemy.sql import func +from app.core.config import Base +import enum + + +class MilestoneStatus(str, enum.Enum): + OPEN = "open" + CLOSED = "closed" + + +class Milestone(Base): + __tablename__ = "milestones" + + id = Column(Integer, primary_key=True, index=True) + title = Column(String(255), nullable=False) + description = Column(Text, nullable=True) + status = Column(Enum(MilestoneStatus), default=MilestoneStatus.OPEN) + due_date = Column(DateTime(timezone=True), nullable=True) + project_id = Column(Integer, ForeignKey("projects.id"), nullable=False) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column(DateTime(timezone=True), onupdate=func.now()) + + project = relationship("Project") diff --git a/app/models/models.py b/app/models/models.py index 6475e97..207d316 100644 --- a/app/models/models.py +++ b/app/models/models.py @@ -56,6 +56,10 @@ class Issue(Base): # Dependencies depends_on_id = Column(Integer, ForeignKey("issues.id"), nullable=True) + # Due date and milestone + due_date = Column(DateTime(timezone=True), nullable=True) + milestone_id = Column(Integer, ForeignKey("milestones.id"), nullable=True) + project = relationship("Project", back_populates="issues") reporter = relationship("User", foreign_keys=[reporter_id], back_populates="reported_issues") assignee = relationship("User", foreign_keys=[assignee_id], back_populates="assigned_issues") diff --git a/app/schemas/schemas.py b/app/schemas/schemas.py index 956a67b..88bbf2f 100644 --- a/app/schemas/schemas.py +++ b/app/schemas/schemas.py @@ -34,6 +34,8 @@ class IssueBase(BaseModel): priority: IssuePriorityEnum = IssuePriorityEnum.MEDIUM tags: Optional[str] = None depends_on_id: Optional[int] = None + due_date: Optional[datetime] = None + milestone_id: Optional[int] = None class IssueCreate(IssueBase): @@ -54,6 +56,8 @@ class IssueUpdate(BaseModel): assignee_id: Optional[int] = None tags: Optional[str] = None depends_on_id: Optional[int] = None + due_date: Optional[datetime] = None + milestone_id: Optional[int] = None # Resolution specific resolution_summary: Optional[str] = None positions: Optional[str] = None @@ -69,6 +73,8 @@ class IssueResponse(IssueBase): resolution_summary: Optional[str] positions: Optional[str] pending_matters: Optional[str] + due_date: Optional[datetime] = None + milestone_id: Optional[int] = None created_at: datetime updated_at: Optional[datetime] @@ -163,3 +169,32 @@ class ProjectMemberResponse(ProjectMemberBase): class Config: from_attributes = True + + +# Milestone schemas +class MilestoneBase(BaseModel): + title: str + description: Optional[str] = None + due_date: Optional[datetime] = None + + +class MilestoneCreate(MilestoneBase): + project_id: int + + +class MilestoneUpdate(BaseModel): + title: Optional[str] = None + description: Optional[str] = None + status: Optional[str] = None + due_date: Optional[datetime] = None + + +class MilestoneResponse(MilestoneBase): + id: int + status: str + project_id: int + created_at: datetime + updated_at: Optional[datetime] = None + + class Config: + from_attributes = True From 8e6aec8062afb1accb95be334679290648c6a0a1 Mon Sep 17 00:00:00 2001 From: Zhi Date: Sun, 22 Feb 2026 19:12:50 +0000 Subject: [PATCH 17/22] docs: update README with milestones + export endpoints --- README.md | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 97991eb..6754b32 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ Agent/人类协同任务管理平台 - FastAPI 后端 -## API Endpoints (21) +## API Endpoints (30) ### Auth - `POST /auth/token` - 登录获取 JWT token @@ -54,6 +54,19 @@ Agent/人类协同任务管理平台 - FastAPI 后端 - `GET /version` - 版本信息 - `GET /dashboard/stats` - 统计面板 +### Milestones +- `POST /milestones` - 创建里程碑 +- `GET /milestones` - 列表(支持按 project/status 过滤) +- `GET /milestones/{id}` - 详情 +- `PATCH /milestones/{id}` - 更新 +- `DELETE /milestones/{id}` - 删除 +- `GET /milestones/{id}/issues` - 里程碑下的 issue 列表 +- `GET /milestones/{id}/progress` - 里程碑完成进度 + +### Export +- `GET /export/issues` - 导出 issues CSV +- `GET /issues/overdue` - 逾期未完成的 issue + ## CLI ```bash From 0a8b18729bdb89e3284ec5e24a7461b08c980cfe Mon Sep 17 00:00:00 2001 From: Zhi Date: Mon, 23 Feb 2026 00:11:26 +0000 Subject: [PATCH 18/22] feat: notifications system, webhook retry, issue assign endpoint, CLI milestones/notifications/overdue commands --- app/main.py | 114 +++++++++++++++++++++++++++++++++++++ app/models/notification.py | 20 +++++++ cli.py | 70 +++++++++++++++++++++++ 3 files changed, 204 insertions(+) create mode 100644 app/models/notification.py diff --git a/app/main.py b/app/main.py index 9a9a88a..2dae198 100644 --- a/app/main.py +++ b/app/main.py @@ -268,6 +268,7 @@ def startup(): from app.models import apikey from app.models import activity from app.models import milestone + from app.models import notification Base.metadata.create_all(bind=engine) @@ -870,6 +871,7 @@ def milestone_progress(milestone_id: int, db: Session = Depends(get_db)): # ============ Export API ============ import csv +import json import io from fastapi.responses import StreamingResponse @@ -900,3 +902,115 @@ def export_issues_csv(project_id: int = None, db: Session = Depends(get_db)): headers={"Content-Disposition": "attachment; filename=issues.csv"} ) + + +# ============ Notifications API ============ + +from app.models.notification import Notification as NotificationModel + + +class NotificationResponse(PydanticBaseModel): + id: int + user_id: int + type: str + title: str + message: str | None = None + entity_type: str | None = None + entity_id: int | None = None + is_read: bool + created_at: datetime + class Config: + from_attributes = True + + +def notify_user(db: Session, user_id: int, ntype: str, title: str, message: str = None, entity_type: str = None, entity_id: int = None): + """Helper to create a notification.""" + n = NotificationModel(user_id=user_id, type=ntype, title=title, message=message, entity_type=entity_type, entity_id=entity_id) + db.add(n) + db.commit() + return n + + +@app.get("/notifications", response_model=List[NotificationResponse]) +def list_notifications(user_id: int, unread_only: bool = False, limit: int = 50, db: Session = Depends(get_db)): + """List notifications for a user.""" + query = db.query(NotificationModel).filter(NotificationModel.user_id == user_id) + if unread_only: + query = query.filter(NotificationModel.is_read == False) + return query.order_by(NotificationModel.created_at.desc()).limit(limit).all() + + +@app.get("/notifications/count") +def notification_count(user_id: int, db: Session = Depends(get_db)): + """Get unread notification count.""" + count = db.query(NotificationModel).filter( + NotificationModel.user_id == user_id, + NotificationModel.is_read == False + ).count() + return {"user_id": user_id, "unread": count} + + +@app.post("/notifications/{notification_id}/read") +def mark_read(notification_id: int, db: Session = Depends(get_db)): + n = db.query(NotificationModel).filter(NotificationModel.id == notification_id).first() + if not n: + raise HTTPException(status_code=404, detail="Notification not found") + n.is_read = True + db.commit() + return {"status": "read"} + + +@app.post("/notifications/read-all") +def mark_all_read(user_id: int, db: Session = Depends(get_db)): + """Mark all notifications as read for a user.""" + db.query(NotificationModel).filter( + NotificationModel.user_id == user_id, + NotificationModel.is_read == False + ).update({"is_read": True}) + db.commit() + return {"status": "all_read"} + + +# ============ Webhook Retry ============ + +@app.post("/webhooks/{webhook_id}/retry/{log_id}") +def retry_webhook(webhook_id: int, log_id: int, bg: BackgroundTasks, db: Session = Depends(get_db)): + """Retry a failed webhook delivery.""" + log_entry = db.query(WebhookLog).filter( + WebhookLog.id == log_id, + WebhookLog.webhook_id == webhook_id + ).first() + if not log_entry: + raise HTTPException(status_code=404, detail="Webhook log not found") + wh = db.query(Webhook).filter(Webhook.id == webhook_id).first() + if not wh: + raise HTTPException(status_code=404, detail="Webhook not found") + + bg.add_task(fire_webhooks_sync, log_entry.event, json.loads(log_entry.payload), wh.project_id, db) + return {"status": "retry_queued", "log_id": log_id} + + +# ============ Issue Assignment with Notification ============ + +@app.post("/issues/{issue_id}/assign") +def assign_issue(issue_id: int, assignee_id: int, db: Session = Depends(get_db)): + """Assign issue to user and send notification.""" + issue = db.query(models.Issue).filter(models.Issue.id == issue_id).first() + if not issue: + raise HTTPException(status_code=404, detail="Issue not found") + user = db.query(models.User).filter(models.User.id == assignee_id).first() + if not user: + raise HTTPException(status_code=404, detail="User not found") + + old_assignee = issue.assignee_id + issue.assignee_id = assignee_id + db.commit() + db.refresh(issue) + + # Notify assignee + notify_user(db, assignee_id, "issue.assigned", + f"Issue #{issue.id} assigned to you", + f"'{issue.title}' has been assigned to you.", + "issue", issue.id) + + return {"issue_id": issue.id, "assignee_id": assignee_id, "title": issue.title} diff --git a/app/models/notification.py b/app/models/notification.py new file mode 100644 index 0000000..e33ecad --- /dev/null +++ b/app/models/notification.py @@ -0,0 +1,20 @@ +from sqlalchemy import Column, Integer, String, Text, DateTime, Boolean, ForeignKey +from sqlalchemy.sql import func +from sqlalchemy.orm import relationship +from app.core.config import Base + + +class Notification(Base): + __tablename__ = "notifications" + + id = Column(Integer, primary_key=True, index=True) + user_id = Column(Integer, ForeignKey("users.id"), nullable=False) + type = Column(String(50), nullable=False) # issue.assigned, issue.mentioned, comment.added, milestone.due + title = Column(String(255), nullable=False) + message = Column(Text, nullable=True) + entity_type = Column(String(50), nullable=True) # issue, comment, milestone + entity_id = Column(Integer, nullable=True) + is_read = Column(Boolean, default=False) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + + user = relationship("User") diff --git a/cli.py b/cli.py index b226160..91dcd79 100755 --- a/cli.py +++ b/cli.py @@ -145,6 +145,54 @@ def cmd_stats(args): print(f" {t}: {c}") + + +def cmd_milestones(args): + params = f"?project_id={args.project}" if args.project else "" + milestones = _request("GET", f"/milestones{params}") + if not milestones: + print(" No milestones found.") + return + for m in milestones: + status_icon = "🟢" if m["status"] == "open" else "⚫" + due = f" (due: {m['due_date'][:10]})" if m.get("due_date") else "" + print(f" {status_icon} #{m['id']} {m['title']}{due}") + + +def cmd_milestone_progress(args): + result = _request("GET", f"/milestones/{args.milestone_id}/progress") + bar_len = 20 + filled = int(bar_len * result["progress_pct"] / 100) + bar = "█" * filled + "░" * (bar_len - filled) + print(f" {result['title']}") + print(f" [{bar}] {result['progress_pct']}% ({result['completed']}/{result['total_issues']})") + + +def cmd_notifications(args): + params = [f"user_id={args.user}"] + if args.unread: + params.append("unread_only=true") + qs = "&".join(params) + notifs = _request("GET", f"/notifications?{qs}") + if not notifs: + print(" No notifications.") + return + for n in notifs: + icon = "🔴" if not n["is_read"] else "⚪" + print(f" {icon} [{n['type']}] {n['title']}") + + +def cmd_overdue(args): + params = f"?project_id={args.project}" if args.project else "" + issues = _request("GET", f"/issues/overdue{params}") + if not issues: + print(" No overdue issues! 🎉") + return + for i in issues: + due = i.get("due_date", "?")[:10] if i.get("due_date") else "?" + print(f" ⏰ #{i['id']} [{i['priority']}] {i['title']} (due: {due})") + + def main(): parser = argparse.ArgumentParser(description="HarborForge CLI") sub = parser.add_subparsers(dest="command") @@ -201,6 +249,24 @@ def main(): p_stats = sub.add_parser("stats", help="Dashboard stats") p_stats.add_argument("--project", "-p", type=int) + + # milestones + p_ms = sub.add_parser("milestones", help="List milestones") + p_ms.add_argument("--project", "-p", type=int) + + # milestone progress + p_msp = sub.add_parser("milestone-progress", help="Show milestone progress") + p_msp.add_argument("milestone_id", type=int) + + # notifications + p_notif = sub.add_parser("notifications", help="List notifications") + p_notif.add_argument("--user", "-u", type=int, required=True) + p_notif.add_argument("--unread", action="store_true") + + # overdue + p_overdue = sub.add_parser("overdue", help="List overdue issues") + p_overdue.add_argument("--project", "-p", type=int) + args = parser.parse_args() if not args.command: parser.print_help() @@ -217,6 +283,10 @@ def main(): "search": cmd_search, "transition": cmd_transition, "stats": cmd_stats, + "milestones": cmd_milestones, + "milestone-progress": cmd_milestone_progress, + "notifications": cmd_notifications, + "overdue": cmd_overdue, } cmds[args.command](args) From 9f276464b2eda0bb7aeb212beec0a6b1990776fe Mon Sep 17 00:00:00 2001 From: Zhi Date: Mon, 23 Feb 2026 00:11:39 +0000 Subject: [PATCH 19/22] docs: update README with notifications, assign, retry, new CLI commands --- README.md | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 6754b32..165ded7 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ Agent/人类协同任务管理平台 - FastAPI 后端 -## API Endpoints (30) +## API Endpoints (38) ### Auth - `POST /auth/token` - 登录获取 JWT token @@ -63,6 +63,18 @@ Agent/人类协同任务管理平台 - FastAPI 后端 - `GET /milestones/{id}/issues` - 里程碑下的 issue 列表 - `GET /milestones/{id}/progress` - 里程碑完成进度 +### Notifications +- `GET /notifications` - 列表(支持 user_id, unread_only 过滤) +- `GET /notifications/count` - 未读通知计数 +- `POST /notifications/{id}/read` - 标记已读 +- `POST /notifications/read-all` - 全部标记已读 + +### Issue Assignment +- `POST /issues/{id}/assign` - 指派 issue(自动发送通知) + +### Webhook Retry +- `POST /webhooks/{id}/retry/{log_id}` - 重试失败的 webhook 投递 + ### Export - `GET /export/issues` - 导出 issues CSV - `GET /issues/overdue` - 逾期未完成的 issue @@ -83,6 +95,10 @@ python3 cli.py transition python3 cli.py stats [-p project_id] python3 cli.py projects python3 cli.py users +python3 cli.py milestones [-p project_id] +python3 cli.py milestone-progress +python3 cli.py notifications -u [--unread] +python3 cli.py overdue [-p project_id] python3 cli.py health python3 cli.py version ``` From 703103af91a837c6fa6b405dc303c4cd7304f5e3 Mon Sep 17 00:00:00 2001 From: Zhi Date: Mon, 23 Feb 2026 05:11:52 +0000 Subject: [PATCH 20/22] feat: time tracking / work logs (create, list, summary, project summary, CLI commands) --- README.md | 10 +++++ app/main.py | 97 +++++++++++++++++++++++++++++++++++++++++++ app/models/worklog.py | 15 +++++++ cli.py | 35 ++++++++++++++++ 4 files changed, 157 insertions(+) create mode 100644 app/models/worklog.py diff --git a/README.md b/README.md index 165ded7..70831a8 100644 --- a/README.md +++ b/README.md @@ -75,6 +75,14 @@ Agent/人类协同任务管理平台 - FastAPI 后端 ### Webhook Retry - `POST /webhooks/{id}/retry/{log_id}` - 重试失败的 webhook 投递 +### Time Tracking (Work Logs) +- `POST /worklogs` - 记录工时 +- `GET /issues/{id}/worklogs` - 某 issue 的工时记录 +- `GET /issues/{id}/worklogs/summary` - 某 issue 工时汇总 +- `GET /users/{id}/worklogs` - 某用户的工时记录 +- `DELETE /worklogs/{id}` - 删除工时记录 +- `GET /projects/{id}/worklogs/summary` - 项目工时汇总(按用户分组) + ### Export - `GET /export/issues` - 导出 issues CSV - `GET /issues/overdue` - 逾期未完成的 issue @@ -99,6 +107,8 @@ python3 cli.py milestones [-p project_id] python3 cli.py milestone-progress python3 cli.py notifications -u [--unread] python3 cli.py overdue [-p project_id] +python3 cli.py log-time [-d "description"] +python3 cli.py worklogs python3 cli.py health python3 cli.py version ``` diff --git a/app/main.py b/app/main.py index 2dae198..3f0464e 100644 --- a/app/main.py +++ b/app/main.py @@ -269,6 +269,7 @@ def startup(): from app.models import activity from app.models import milestone from app.models import notification + from app.models import worklog Base.metadata.create_all(bind=engine) @@ -1014,3 +1015,99 @@ def assign_issue(issue_id: int, assignee_id: int, db: Session = Depends(get_db)) "issue", issue.id) return {"issue_id": issue.id, "assignee_id": assignee_id, "title": issue.title} + + +# ============ Work Logs / Time Tracking ============ + +from app.models.worklog import WorkLog + + +class WorkLogCreate(PydanticBaseModel): + issue_id: int + user_id: int + hours: float + description: str | None = None + logged_date: datetime + + +class WorkLogResponse(PydanticBaseModel): + id: int + issue_id: int + user_id: int + hours: float + description: str | None = None + logged_date: datetime + created_at: datetime + class Config: + from_attributes = True + + +@app.post("/worklogs", response_model=WorkLogResponse, status_code=status.HTTP_201_CREATED) +def create_worklog(wl: WorkLogCreate, db: Session = Depends(get_db)): + """Log time spent on an issue.""" + issue = db.query(models.Issue).filter(models.Issue.id == wl.issue_id).first() + if not issue: + raise HTTPException(status_code=404, detail="Issue not found") + user = db.query(models.User).filter(models.User.id == wl.user_id).first() + if not user: + raise HTTPException(status_code=404, detail="User not found") + if wl.hours <= 0: + raise HTTPException(status_code=400, detail="Hours must be positive") + db_wl = WorkLog(**wl.model_dump()) + db.add(db_wl) + db.commit() + db.refresh(db_wl) + return db_wl + + +@app.get("/issues/{issue_id}/worklogs", response_model=List[WorkLogResponse]) +def list_issue_worklogs(issue_id: int, db: Session = Depends(get_db)): + """List all work logs for an issue.""" + return db.query(WorkLog).filter(WorkLog.issue_id == issue_id).order_by(WorkLog.logged_date.desc()).all() + + +@app.get("/issues/{issue_id}/worklogs/summary") +def issue_worklog_summary(issue_id: int, db: Session = Depends(get_db)): + """Get total hours logged on an issue.""" + issue = db.query(models.Issue).filter(models.Issue.id == issue_id).first() + if not issue: + raise HTTPException(status_code=404, detail="Issue not found") + from sqlalchemy import func as sqlfunc + total = db.query(sqlfunc.sum(WorkLog.hours)).filter(WorkLog.issue_id == issue_id).scalar() or 0 + count = db.query(WorkLog).filter(WorkLog.issue_id == issue_id).count() + return {"issue_id": issue_id, "total_hours": round(total, 2), "log_count": count} + + +@app.get("/users/{user_id}/worklogs", response_model=List[WorkLogResponse]) +def list_user_worklogs(user_id: int, limit: int = 50, db: Session = Depends(get_db)): + """List work logs by user.""" + return db.query(WorkLog).filter(WorkLog.user_id == user_id).order_by(WorkLog.logged_date.desc()).limit(limit).all() + + +@app.delete("/worklogs/{worklog_id}", status_code=status.HTTP_204_NO_CONTENT) +def delete_worklog(worklog_id: int, db: Session = Depends(get_db)): + wl = db.query(WorkLog).filter(WorkLog.id == worklog_id).first() + if not wl: + raise HTTPException(status_code=404, detail="Work log not found") + db.delete(wl) + db.commit() + return None + + +@app.get("/projects/{project_id}/worklogs/summary") +def project_worklog_summary(project_id: int, db: Session = Depends(get_db)): + """Get time tracking summary for a project.""" + from sqlalchemy import func as sqlfunc + results = db.query( + models.User.id, + models.User.username, + sqlfunc.sum(WorkLog.hours).label("total_hours"), + sqlfunc.count(WorkLog.id).label("log_count") + ).join(WorkLog, WorkLog.user_id == models.User.id)\ + .join(models.Issue, WorkLog.issue_id == models.Issue.id)\ + .filter(models.Issue.project_id == project_id)\ + .group_by(models.User.id, models.User.username).all() + + total = sum(r.total_hours for r in results) + by_user = [{"user_id": r.id, "username": r.username, "hours": round(r.total_hours, 2), "logs": r.log_count} for r in results] + return {"project_id": project_id, "total_hours": round(total, 2), "by_user": by_user} diff --git a/app/models/worklog.py b/app/models/worklog.py new file mode 100644 index 0000000..53decf8 --- /dev/null +++ b/app/models/worklog.py @@ -0,0 +1,15 @@ +from sqlalchemy import Column, Integer, Text, DateTime, ForeignKey, Float +from sqlalchemy.sql import func +from app.core.config import Base + + +class WorkLog(Base): + __tablename__ = "work_logs" + + id = Column(Integer, primary_key=True, index=True) + issue_id = Column(Integer, ForeignKey("issues.id"), nullable=False) + user_id = Column(Integer, ForeignKey("users.id"), nullable=False) + hours = Column(Float, nullable=False) # Hours spent + description = Column(Text, nullable=True) + logged_date = Column(DateTime(timezone=True), nullable=False) # When the work was done + created_at = Column(DateTime(timezone=True), server_default=func.now()) diff --git a/cli.py b/cli.py index 91dcd79..e4f134c 100755 --- a/cli.py +++ b/cli.py @@ -193,6 +193,30 @@ def cmd_overdue(args): print(f" ⏰ #{i['id']} [{i['priority']}] {i['title']} (due: {due})") + + +def cmd_log_time(args): + from datetime import datetime + data = { + 'issue_id': args.issue_id, + 'user_id': args.user_id, + 'hours': args.hours, + 'logged_date': datetime.utcnow().isoformat(), + } + if args.desc: + data['description'] = args.desc + r = api('POST', '/worklogs', json=data) + print(f'Logged {r["hours"]}h on issue #{r["issue_id"]} (log #{r["id"]})') + + +def cmd_worklogs(args): + logs = api('GET', f'/issues/{args.issue_id}/worklogs') + for l in logs: + desc = f' - {l["description"]}' if l.get('description') else '' + print(f' [{l["id"]}] {l["hours"]}h by user#{l["user_id"]} on {l["logged_date"]}{desc}') + summary = api('GET', f'/issues/{args.issue_id}/worklogs/summary') + print(f' Total: {summary["total_hours"]}h ({summary["log_count"]} logs)') + def main(): parser = argparse.ArgumentParser(description="HarborForge CLI") sub = parser.add_subparsers(dest="command") @@ -267,6 +291,15 @@ def main(): p_overdue = sub.add_parser("overdue", help="List overdue issues") p_overdue.add_argument("--project", "-p", type=int) + p_logtime = sub.add_parser('log-time', help='Log time on an issue') + p_logtime.add_argument('issue_id', type=int) + p_logtime.add_argument('user_id', type=int) + p_logtime.add_argument('hours', type=float) + p_logtime.add_argument('--desc', '-d', type=str) + + p_worklogs = sub.add_parser('worklogs', help='List work logs for an issue') + p_worklogs.add_argument('issue_id', type=int) + args = parser.parse_args() if not args.command: parser.print_help() @@ -287,6 +320,8 @@ def main(): "milestone-progress": cmd_milestone_progress, "notifications": cmd_notifications, "overdue": cmd_overdue, + "log-time": cmd_log_time, + "worklogs": cmd_worklogs, } cmds[args.command](args) From 107102e77514e6dd5760590142c1110439d78aa5 Mon Sep 17 00:00:00 2001 From: Zhi Date: Mon, 23 Feb 2026 10:12:07 +0000 Subject: [PATCH 21/22] feat: paginated list responses, issue sorting + filtering by assignee/tag --- README.md | 11 +++++- app/main.py | 76 +++++++++++++++++++++++++++++++++++------- app/schemas/schemas.py | 12 +++++++ 3 files changed, 86 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 70831a8..e1ea154 100644 --- a/README.md +++ b/README.md @@ -9,8 +9,17 @@ Agent/人类协同任务管理平台 - FastAPI 后端 - `GET /auth/me` - 获取当前用户信息 ### Issues + +> Issues 和 Search 列表接口返回分页格式:`{items, total, page, page_size, total_pages}` +> Issues 支持排序参数:`sort_by` (created_at/priority/title/due_date/status), `sort_order` (asc/desc) +> Issues 支持额外过滤:`assignee_id`, `tag` + + +> Issues 和 Search 列表接口返回分页格式: +> Issues 支持排序参数: (created_at/priority/title/due_date/status), (asc/desc) +> Issues 支持额外过滤:, - `POST /issues` - 创建 issue(支持 resolution 决议案类型) -- `GET /issues` - 列表(支持按 project/status/type 过滤) +- `GET /issues` - 列表(分页、排序、按 assignee/tag 过滤)(支持按 project/status/type 过滤) - `GET /issues/{id}` - 详情 - `PATCH /issues/{id}` - 更新 - `DELETE /issues/{id}` - 删除 diff --git a/app/main.py b/app/main.py index 3f0464e..2e6f11a 100644 --- a/app/main.py +++ b/app/main.py @@ -110,26 +110,64 @@ def create_issue(issue: schemas.IssueCreate, bg: BackgroundTasks, db: Session = return db_issue -@app.get("/issues", response_model=List[schemas.IssueResponse]) +import math + +@app.get("/issues") def list_issues( project_id: int = None, issue_status: str = None, issue_type: str = None, - skip: int = 0, - limit: int = 100, + assignee_id: int = None, + tag: str = None, + sort_by: str = "created_at", + sort_order: str = "desc", + page: int = 1, + page_size: int = 50, db: Session = Depends(get_db) ): + """List issues with filtering, sorting, and pagination metadata.""" query = db.query(models.Issue) - + if project_id: query = query.filter(models.Issue.project_id == project_id) if issue_status: query = query.filter(models.Issue.status == issue_status) if issue_type: query = query.filter(models.Issue.issue_type == issue_type) - - issues = query.offset(skip).limit(limit).all() - return issues + if assignee_id: + query = query.filter(models.Issue.assignee_id == assignee_id) + if tag: + query = query.filter(models.Issue.tags.contains(tag)) + + # Sorting + sort_fields = { + "created_at": models.Issue.created_at, + "updated_at": models.Issue.updated_at, + "priority": models.Issue.priority, + "title": models.Issue.title, + "due_date": models.Issue.due_date, + "status": models.Issue.status, + } + sort_col = sort_fields.get(sort_by, models.Issue.created_at) + if sort_order == "asc": + query = query.order_by(sort_col.asc()) + else: + query = query.order_by(sort_col.desc()) + + total = query.count() + page = max(1, page) + page_size = min(max(1, page_size), 200) + total_pages = math.ceil(total / page_size) if total else 1 + skip = (page - 1) * page_size + items = query.offset(skip).limit(page_size).all() + + return { + "items": [schemas.IssueResponse.model_validate(i) for i in items], + "total": total, + "page": page, + "page_size": page_size, + "total_pages": total_pages, + } @app.get("/issues/overdue", response_model=List[schemas.IssueResponse]) @@ -462,21 +500,35 @@ def transition_issue(issue_id: int, new_status: str, bg: BackgroundTasks, db: Se # ============ Search API ============ -@app.get("/search/issues", response_model=List[schemas.IssueResponse]) +@app.get("/search/issues") def search_issues( q: str, project_id: int = None, - skip: int = 0, - limit: int = 50, + page: int = 1, + page_size: int = 50, db: Session = Depends(get_db) ): - """Search issues by title or description keyword.""" + """Search issues by title or description keyword with pagination.""" query = db.query(models.Issue).filter( (models.Issue.title.contains(q)) | (models.Issue.description.contains(q)) ) if project_id: query = query.filter(models.Issue.project_id == project_id) - return query.offset(skip).limit(limit).all() + + total = query.count() + page = max(1, page) + page_size = min(max(1, page_size), 200) + total_pages = math.ceil(total / page_size) if total else 1 + skip = (page - 1) * page_size + items = query.offset(skip).limit(page_size).all() + + return { + "items": [schemas.IssueResponse.model_validate(i) for i in items], + "total": total, + "page": page, + "page_size": page_size, + "total_pages": total_pages, + } diff --git a/app/schemas/schemas.py b/app/schemas/schemas.py index 88bbf2f..77e2619 100644 --- a/app/schemas/schemas.py +++ b/app/schemas/schemas.py @@ -198,3 +198,15 @@ class MilestoneResponse(MilestoneBase): class Config: from_attributes = True + + +# Paginated response +from typing import Generic, TypeVar +T = TypeVar("T") + +class PaginatedResponse(BaseModel, Generic[T]): + items: List[T] + total: int + page: int + page_size: int + total_pages: int From f60dc68b22b0d40a5f84c2926222b0a86043c3e3 Mon Sep 17 00:00:00 2001 From: Zhi Date: Mon, 23 Feb 2026 15:14:46 +0000 Subject: [PATCH 22/22] refactor: split monolithic main.py into FastAPI routers (v0.2.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - app/api/deps.py: shared auth dependencies - app/api/routers/auth.py: login, me - app/api/routers/issues.py: CRUD, transition, assign, relations, tags, batch, search - app/api/routers/projects.py: CRUD, members, worklog summary - app/api/routers/users.py: CRUD, worklogs - app/api/routers/comments.py: CRUD - app/api/routers/webhooks.py: CRUD, logs, retry - app/api/routers/misc.py: API keys, activity, milestones, notifications, worklogs, export, dashboard - main.py: 1165 lines → 51 lines - Version bump to 0.2.0 --- app/api/deps.py | 78 +++ app/api/routers/__init__.py | 0 app/api/routers/auth.py | 32 + app/api/routers/comments.py | 46 ++ app/api/routers/issues.py | 299 +++++++++ app/api/routers/misc.py | 320 ++++++++++ app/api/routers/projects.py | 114 ++++ app/api/routers/users.py | 80 +++ app/api/routers/webhooks.py | 78 +++ app/main.py | 1162 +---------------------------------- 10 files changed, 1071 insertions(+), 1138 deletions(-) create mode 100644 app/api/deps.py create mode 100644 app/api/routers/__init__.py create mode 100644 app/api/routers/auth.py create mode 100644 app/api/routers/comments.py create mode 100644 app/api/routers/issues.py create mode 100644 app/api/routers/misc.py create mode 100644 app/api/routers/projects.py create mode 100644 app/api/routers/users.py create mode 100644 app/api/routers/webhooks.py diff --git a/app/api/deps.py b/app/api/deps.py new file mode 100644 index 0000000..5a10a47 --- /dev/null +++ b/app/api/deps.py @@ -0,0 +1,78 @@ +"""Shared auth dependencies.""" +from datetime import datetime, timedelta +from fastapi import Depends, HTTPException, status +from fastapi.security import OAuth2PasswordBearer, APIKeyHeader +from jose import JWTError, jwt +from passlib.context import CryptContext +from sqlalchemy.orm import Session +from pydantic import BaseModel + +from app.core.config import get_db, settings +from app.models import models +from app.models.apikey import APIKey + +pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") +oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/auth/token", auto_error=False) +apikey_header = APIKeyHeader(name="X-API-Key", auto_error=False) + + +class Token(BaseModel): + access_token: str + token_type: str + + +def verify_password(plain_password: str, hashed_password: str) -> bool: + if not hashed_password: + return False + return pwd_context.verify(plain_password, hashed_password) + + +def get_password_hash(password: str) -> str: + return pwd_context.hash(password[:72]) + + +def create_access_token(data: dict, expires_delta: timedelta = None) -> str: + to_encode = data.copy() + expire = datetime.utcnow() + (expires_delta or timedelta(minutes=30)) + to_encode.update({"exp": expire}) + return jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM) + + +async def get_current_user(token: str = Depends(oauth2_scheme), db: Session = Depends(get_db)): + credentials_exception = HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Could not validate credentials", + headers={"WWW-Authenticate": "Bearer"}, + ) + if not token: + raise credentials_exception + try: + payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM]) + user_id = payload.get("sub") + if user_id is None: + raise credentials_exception + except JWTError: + raise credentials_exception + user = db.query(models.User).filter(models.User.id == user_id).first() + if user is None: + raise credentials_exception + return user + + +async def get_current_user_or_apikey( + token: str = Depends(oauth2_scheme), + api_key: str = Depends(apikey_header), + db: Session = Depends(get_db) +): + """Authenticate via JWT token OR API key.""" + if api_key: + key_obj = db.query(APIKey).filter(APIKey.key == api_key, APIKey.is_active == True).first() + if key_obj: + key_obj.last_used_at = datetime.utcnow() + db.commit() + user = db.query(models.User).filter(models.User.id == key_obj.user_id).first() + if user: + return user + if token: + return await get_current_user(token=token, db=db) + raise HTTPException(status_code=401, detail="Not authenticated") diff --git a/app/api/routers/__init__.py b/app/api/routers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/api/routers/auth.py b/app/api/routers/auth.py new file mode 100644 index 0000000..bb3702c --- /dev/null +++ b/app/api/routers/auth.py @@ -0,0 +1,32 @@ +"""Auth router.""" +from datetime import timedelta +from fastapi import APIRouter, Depends, HTTPException +from fastapi.security import OAuth2PasswordRequestForm +from sqlalchemy.orm import Session + +from app.core.config import get_db, settings +from app.models import models +from app.schemas import schemas +from app.api.deps import Token, verify_password, create_access_token, get_current_user + +router = APIRouter(prefix="/auth", tags=["Auth"]) + + +@router.post("/token", response_model=Token) +async def login(form_data: OAuth2PasswordRequestForm = Depends(), db: Session = Depends(get_db)): + user = db.query(models.User).filter(models.User.username == form_data.username).first() + if not user or not verify_password(form_data.password, user.hashed_password or ""): + raise HTTPException(status_code=401, detail="Incorrect username or password", + headers={"WWW-Authenticate": "Bearer"}) + if not user.is_active: + raise HTTPException(status_code=400, detail="Inactive user") + access_token = create_access_token( + data={"sub": str(user.id)}, + expires_delta=timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES) + ) + return {"access_token": access_token, "token_type": "bearer"} + + +@router.get("/me", response_model=schemas.UserResponse) +async def get_me(current_user: models.User = Depends(get_current_user)): + return current_user diff --git a/app/api/routers/comments.py b/app/api/routers/comments.py new file mode 100644 index 0000000..ee36398 --- /dev/null +++ b/app/api/routers/comments.py @@ -0,0 +1,46 @@ +"""Comments router.""" +from typing import List +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.orm import Session + +from app.core.config import get_db +from app.models import models +from app.schemas import schemas + +router = APIRouter(tags=["Comments"]) + + +@router.post("/comments", response_model=schemas.CommentResponse, status_code=status.HTTP_201_CREATED) +def create_comment(comment: schemas.CommentCreate, db: Session = Depends(get_db)): + db_comment = models.Comment(**comment.model_dump()) + db.add(db_comment) + db.commit() + db.refresh(db_comment) + return db_comment + + +@router.get("/issues/{issue_id}/comments", response_model=List[schemas.CommentResponse]) +def list_comments(issue_id: int, db: Session = Depends(get_db)): + return db.query(models.Comment).filter(models.Comment.issue_id == issue_id).all() + + +@router.patch("/comments/{comment_id}", response_model=schemas.CommentResponse) +def update_comment(comment_id: int, comment_update: schemas.CommentUpdate, db: Session = Depends(get_db)): + comment = db.query(models.Comment).filter(models.Comment.id == comment_id).first() + if not comment: + raise HTTPException(status_code=404, detail="Comment not found") + for field, value in comment_update.model_dump(exclude_unset=True).items(): + setattr(comment, field, value) + db.commit() + db.refresh(comment) + return comment + + +@router.delete("/comments/{comment_id}", status_code=status.HTTP_204_NO_CONTENT) +def delete_comment(comment_id: int, db: Session = Depends(get_db)): + comment = db.query(models.Comment).filter(models.Comment.id == comment_id).first() + if not comment: + raise HTTPException(status_code=404, detail="Comment not found") + db.delete(comment) + db.commit() + return None diff --git a/app/api/routers/issues.py b/app/api/routers/issues.py new file mode 100644 index 0000000..1db850e --- /dev/null +++ b/app/api/routers/issues.py @@ -0,0 +1,299 @@ +"""Issues router.""" +import math +from typing import List +from datetime import datetime +from fastapi import APIRouter, Depends, HTTPException, status, BackgroundTasks +from sqlalchemy.orm import Session +from pydantic import BaseModel + +from app.core.config import get_db +from app.models import models +from app.schemas import schemas +from app.services.webhook import fire_webhooks_sync +from app.models.notification import Notification as NotificationModel + +router = APIRouter(tags=["Issues"]) + + +def _notify_user(db, user_id, ntype, title, message=None, entity_type=None, entity_id=None): + n = NotificationModel(user_id=user_id, type=ntype, title=title, message=message, + entity_type=entity_type, entity_id=entity_id) + db.add(n) + db.commit() + return n + + +# ---- CRUD ---- + +@router.post("/issues", response_model=schemas.IssueResponse, status_code=status.HTTP_201_CREATED) +def create_issue(issue: schemas.IssueCreate, bg: BackgroundTasks, db: Session = Depends(get_db)): + db_issue = models.Issue(**issue.model_dump()) + db.add(db_issue) + db.commit() + db.refresh(db_issue) + event = "resolution.created" if db_issue.issue_type == "resolution" else "issue.created" + bg.add_task(fire_webhooks_sync, event, + {"issue_id": db_issue.id, "title": db_issue.title, "type": db_issue.issue_type, "status": db_issue.status}, + db_issue.project_id, db) + return db_issue + + +@router.get("/issues") +def list_issues( + project_id: int = None, issue_status: str = None, issue_type: str = None, + assignee_id: int = None, tag: str = None, + sort_by: str = "created_at", sort_order: str = "desc", + page: int = 1, page_size: int = 50, + db: Session = Depends(get_db) +): + """List issues with filtering, sorting, and pagination metadata.""" + query = db.query(models.Issue) + if project_id: + query = query.filter(models.Issue.project_id == project_id) + if issue_status: + query = query.filter(models.Issue.status == issue_status) + if issue_type: + query = query.filter(models.Issue.issue_type == issue_type) + if assignee_id: + query = query.filter(models.Issue.assignee_id == assignee_id) + if tag: + query = query.filter(models.Issue.tags.contains(tag)) + + sort_fields = { + "created_at": models.Issue.created_at, "updated_at": models.Issue.updated_at, + "priority": models.Issue.priority, "title": models.Issue.title, + "due_date": models.Issue.due_date, "status": models.Issue.status, + } + sort_col = sort_fields.get(sort_by, models.Issue.created_at) + query = query.order_by(sort_col.asc() if sort_order == "asc" else sort_col.desc()) + + total = query.count() + page = max(1, page) + page_size = min(max(1, page_size), 200) + total_pages = math.ceil(total / page_size) if total else 1 + items = query.offset((page - 1) * page_size).limit(page_size).all() + return {"items": [schemas.IssueResponse.model_validate(i) for i in items], + "total": total, "page": page, "page_size": page_size, "total_pages": total_pages} + + +@router.get("/issues/overdue", response_model=List[schemas.IssueResponse]) +def list_overdue_issues(project_id: int = None, db: Session = Depends(get_db)): + query = db.query(models.Issue).filter( + models.Issue.due_date != None, + models.Issue.due_date < datetime.utcnow(), + models.Issue.status.notin_(["resolved", "closed"]) + ) + if project_id: + query = query.filter(models.Issue.project_id == project_id) + return query.order_by(models.Issue.due_date.asc()).all() + + +@router.get("/issues/{issue_id}", response_model=schemas.IssueResponse) +def get_issue(issue_id: int, db: Session = Depends(get_db)): + issue = db.query(models.Issue).filter(models.Issue.id == issue_id).first() + if not issue: + raise HTTPException(status_code=404, detail="Issue not found") + return issue + + +@router.patch("/issues/{issue_id}", response_model=schemas.IssueResponse) +def update_issue(issue_id: int, issue_update: schemas.IssueUpdate, db: Session = Depends(get_db)): + issue = db.query(models.Issue).filter(models.Issue.id == issue_id).first() + if not issue: + raise HTTPException(status_code=404, detail="Issue not found") + for field, value in issue_update.model_dump(exclude_unset=True).items(): + setattr(issue, field, value) + db.commit() + db.refresh(issue) + return issue + + +@router.delete("/issues/{issue_id}", status_code=status.HTTP_204_NO_CONTENT) +def delete_issue(issue_id: int, db: Session = Depends(get_db)): + issue = db.query(models.Issue).filter(models.Issue.id == issue_id).first() + if not issue: + raise HTTPException(status_code=404, detail="Issue not found") + db.delete(issue) + db.commit() + return None + + +# ---- Transition ---- + +@router.post("/issues/{issue_id}/transition", response_model=schemas.IssueResponse) +def transition_issue(issue_id: int, new_status: str, bg: BackgroundTasks, db: Session = Depends(get_db)): + valid_statuses = ["open", "in_progress", "resolved", "closed", "blocked"] + if new_status not in valid_statuses: + raise HTTPException(status_code=400, detail=f"Invalid status. Must be one of: {valid_statuses}") + issue = db.query(models.Issue).filter(models.Issue.id == issue_id).first() + if not issue: + raise HTTPException(status_code=404, detail="Issue not found") + old_status = issue.status + issue.status = new_status + db.commit() + db.refresh(issue) + event = "issue.closed" if new_status == "closed" else "issue.updated" + bg.add_task(fire_webhooks_sync, event, + {"issue_id": issue.id, "title": issue.title, "old_status": old_status, "new_status": new_status}, + issue.project_id, db) + return issue + + +# ---- Assignment ---- + +@router.post("/issues/{issue_id}/assign") +def assign_issue(issue_id: int, assignee_id: int, db: Session = Depends(get_db)): + issue = db.query(models.Issue).filter(models.Issue.id == issue_id).first() + if not issue: + raise HTTPException(status_code=404, detail="Issue not found") + user = db.query(models.User).filter(models.User.id == assignee_id).first() + if not user: + raise HTTPException(status_code=404, detail="User not found") + issue.assignee_id = assignee_id + db.commit() + db.refresh(issue) + _notify_user(db, assignee_id, "issue.assigned", + f"Issue #{issue.id} assigned to you", + f"'{issue.title}' has been assigned to you.", "issue", issue.id) + return {"issue_id": issue.id, "assignee_id": assignee_id, "title": issue.title} + + +# ---- Relations ---- + +class IssueRelation(BaseModel): + parent_id: int + child_id: int + + +@router.post("/issues/link") +def link_issues(rel: IssueRelation, db: Session = Depends(get_db)): + parent = db.query(models.Issue).filter(models.Issue.id == rel.parent_id).first() + child = db.query(models.Issue).filter(models.Issue.id == rel.child_id).first() + if not parent or not child: + raise HTTPException(status_code=404, detail="Issue not found") + if rel.parent_id == rel.child_id: + raise HTTPException(status_code=400, detail="Cannot link issue to itself") + child.depends_on_id = rel.parent_id + db.commit() + return {"parent_id": rel.parent_id, "child_id": rel.child_id, "status": "linked"} + + +@router.delete("/issues/link") +def unlink_issues(child_id: int, db: Session = Depends(get_db)): + child = db.query(models.Issue).filter(models.Issue.id == child_id).first() + if not child: + raise HTTPException(status_code=404, detail="Issue not found") + child.depends_on_id = None + db.commit() + return {"child_id": child_id, "status": "unlinked"} + + +@router.get("/issues/{issue_id}/children", response_model=List[schemas.IssueResponse]) +def get_children(issue_id: int, db: Session = Depends(get_db)): + return db.query(models.Issue).filter(models.Issue.depends_on_id == issue_id).all() + + +# ---- Tags ---- + +@router.post("/issues/{issue_id}/tags") +def add_tag(issue_id: int, tag: str, db: Session = Depends(get_db)): + issue = db.query(models.Issue).filter(models.Issue.id == issue_id).first() + if not issue: + raise HTTPException(status_code=404, detail="Issue not found") + current = set(issue.tags.split(",")) if issue.tags else set() + current.add(tag.strip()) + current.discard("") + issue.tags = ",".join(sorted(current)) + db.commit() + return {"issue_id": issue_id, "tags": list(current)} + + +@router.delete("/issues/{issue_id}/tags") +def remove_tag(issue_id: int, tag: str, db: Session = Depends(get_db)): + issue = db.query(models.Issue).filter(models.Issue.id == issue_id).first() + if not issue: + raise HTTPException(status_code=404, detail="Issue not found") + current = set(issue.tags.split(",")) if issue.tags else set() + current.discard(tag.strip()) + current.discard("") + issue.tags = ",".join(sorted(current)) if current else None + db.commit() + return {"issue_id": issue_id, "tags": list(current)} + + +@router.get("/tags") +def list_all_tags(project_id: int = None, db: Session = Depends(get_db)): + query = db.query(models.Issue.tags).filter(models.Issue.tags != None) + if project_id: + query = query.filter(models.Issue.project_id == project_id) + all_tags = set() + for (tags,) in query.all(): + for t in tags.split(","): + t = t.strip() + if t: + all_tags.add(t) + return {"tags": sorted(all_tags)} + + +# ---- Batch ---- + +class BatchTransition(BaseModel): + issue_ids: List[int] + new_status: str + +class BatchAssign(BaseModel): + issue_ids: List[int] + assignee_id: int + + +@router.post("/issues/batch/transition") +def batch_transition(data: BatchTransition, bg: BackgroundTasks, db: Session = Depends(get_db)): + valid_statuses = ["open", "in_progress", "resolved", "closed", "blocked"] + if data.new_status not in valid_statuses: + raise HTTPException(status_code=400, detail="Invalid status") + updated = [] + for issue_id in data.issue_ids: + issue = db.query(models.Issue).filter(models.Issue.id == issue_id).first() + if issue: + old_status = issue.status + issue.status = data.new_status + updated.append({"id": issue.id, "title": issue.title, "old": old_status, "new": data.new_status}) + db.commit() + for u in updated: + event = "issue.closed" if data.new_status == "closed" else "issue.updated" + bg.add_task(fire_webhooks_sync, event, u, None, db) + return {"updated": len(updated), "issues": updated} + + +@router.post("/issues/batch/assign") +def batch_assign(data: BatchAssign, db: Session = Depends(get_db)): + user = db.query(models.User).filter(models.User.id == data.assignee_id).first() + if not user: + raise HTTPException(status_code=404, detail="Assignee not found") + updated = [] + for issue_id in data.issue_ids: + issue = db.query(models.Issue).filter(models.Issue.id == issue_id).first() + if issue: + issue.assignee_id = data.assignee_id + updated.append(issue_id) + db.commit() + return {"updated": len(updated), "issue_ids": updated, "assignee_id": data.assignee_id} + + +# ---- Search ---- + +@router.get("/search/issues") +def search_issues(q: str, project_id: int = None, page: int = 1, page_size: int = 50, + db: Session = Depends(get_db)): + query = db.query(models.Issue).filter( + (models.Issue.title.contains(q)) | (models.Issue.description.contains(q)) + ) + if project_id: + query = query.filter(models.Issue.project_id == project_id) + total = query.count() + page = max(1, page) + page_size = min(max(1, page_size), 200) + total_pages = math.ceil(total / page_size) if total else 1 + items = query.offset((page - 1) * page_size).limit(page_size).all() + return {"items": [schemas.IssueResponse.model_validate(i) for i in items], + "total": total, "page": page, "page_size": page_size, "total_pages": total_pages} diff --git a/app/api/routers/misc.py b/app/api/routers/misc.py new file mode 100644 index 0000000..38a93a8 --- /dev/null +++ b/app/api/routers/misc.py @@ -0,0 +1,320 @@ +"""Miscellaneous routers: API keys, activity, milestones, notifications, worklogs, export, dashboard.""" +import csv +import io +import secrets +import math +from typing import List +from datetime import datetime +from fastapi import APIRouter, Depends, HTTPException, status +from fastapi.responses import StreamingResponse +from sqlalchemy.orm import Session +from sqlalchemy import func as sqlfunc +from pydantic import BaseModel + +from app.core.config import get_db +from app.models import models +from app.models.apikey import APIKey +from app.models.activity import ActivityLog +from app.models.milestone import Milestone as MilestoneModel +from app.models.notification import Notification as NotificationModel +from app.models.worklog import WorkLog +from app.schemas import schemas + +router = APIRouter() + + +# ============ API Keys ============ + +class APIKeyCreate(BaseModel): + name: str + user_id: int + +class APIKeyResponse(BaseModel): + id: int + key: str + name: str + user_id: int + is_active: bool + created_at: datetime + last_used_at: datetime | None = None + class Config: + from_attributes = True + + +@router.post("/api-keys", response_model=APIKeyResponse, status_code=status.HTTP_201_CREATED, tags=["API Keys"]) +def create_api_key(data: APIKeyCreate, db: Session = Depends(get_db)): + user = db.query(models.User).filter(models.User.id == data.user_id).first() + if not user: + raise HTTPException(status_code=404, detail="User not found") + key = secrets.token_hex(32) + db_key = APIKey(key=key, name=data.name, user_id=data.user_id) + db.add(db_key) + db.commit() + db.refresh(db_key) + return db_key + + +@router.get("/api-keys", response_model=List[APIKeyResponse], tags=["API Keys"]) +def list_api_keys(user_id: int = None, db: Session = Depends(get_db)): + query = db.query(APIKey) + if user_id: + query = query.filter(APIKey.user_id == user_id) + return query.all() + + +@router.delete("/api-keys/{key_id}", status_code=status.HTTP_204_NO_CONTENT, tags=["API Keys"]) +def revoke_api_key(key_id: int, db: Session = Depends(get_db)): + key_obj = db.query(APIKey).filter(APIKey.id == key_id).first() + if not key_obj: + raise HTTPException(status_code=404, detail="API key not found") + key_obj.is_active = False + db.commit() + return None + + +# ============ Activity Log ============ + +class ActivityLogResponse(BaseModel): + id: int + action: str + entity_type: str + entity_id: int + user_id: int | None + details: str | None + created_at: datetime + class Config: + from_attributes = True + + +@router.get("/activity", response_model=List[ActivityLogResponse], tags=["Activity"]) +def list_activity(entity_type: str = None, entity_id: int = None, user_id: int = None, + limit: int = 50, db: Session = Depends(get_db)): + query = db.query(ActivityLog) + if entity_type: + query = query.filter(ActivityLog.entity_type == entity_type) + if entity_id: + query = query.filter(ActivityLog.entity_id == entity_id) + if user_id: + query = query.filter(ActivityLog.user_id == user_id) + return query.order_by(ActivityLog.created_at.desc()).limit(limit).all() + + +# ============ Milestones ============ + +@router.post("/milestones", response_model=schemas.MilestoneResponse, status_code=status.HTTP_201_CREATED, tags=["Milestones"]) +def create_milestone(ms: schemas.MilestoneCreate, db: Session = Depends(get_db)): + db_ms = MilestoneModel(**ms.model_dump()) + db.add(db_ms) + db.commit() + db.refresh(db_ms) + return db_ms + + +@router.get("/milestones", response_model=List[schemas.MilestoneResponse], tags=["Milestones"]) +def list_milestones(project_id: int = None, status_filter: str = None, db: Session = Depends(get_db)): + query = db.query(MilestoneModel) + if project_id: + query = query.filter(MilestoneModel.project_id == project_id) + if status_filter: + query = query.filter(MilestoneModel.status == status_filter) + return query.order_by(MilestoneModel.due_date.is_(None), MilestoneModel.due_date.asc()).all() + + +@router.get("/milestones/{milestone_id}", response_model=schemas.MilestoneResponse, tags=["Milestones"]) +def get_milestone(milestone_id: int, db: Session = Depends(get_db)): + ms = db.query(MilestoneModel).filter(MilestoneModel.id == milestone_id).first() + if not ms: + raise HTTPException(status_code=404, detail="Milestone not found") + return ms + + +@router.patch("/milestones/{milestone_id}", response_model=schemas.MilestoneResponse, tags=["Milestones"]) +def update_milestone(milestone_id: int, ms_update: schemas.MilestoneUpdate, db: Session = Depends(get_db)): + ms = db.query(MilestoneModel).filter(MilestoneModel.id == milestone_id).first() + if not ms: + raise HTTPException(status_code=404, detail="Milestone not found") + for field, value in ms_update.model_dump(exclude_unset=True).items(): + setattr(ms, field, value) + db.commit() + db.refresh(ms) + return ms + + +@router.delete("/milestones/{milestone_id}", status_code=status.HTTP_204_NO_CONTENT, tags=["Milestones"]) +def delete_milestone(milestone_id: int, db: Session = Depends(get_db)): + ms = db.query(MilestoneModel).filter(MilestoneModel.id == milestone_id).first() + if not ms: + raise HTTPException(status_code=404, detail="Milestone not found") + db.delete(ms) + db.commit() + return None + + +@router.get("/milestones/{milestone_id}/issues", response_model=List[schemas.IssueResponse], tags=["Milestones"]) +def list_milestone_issues(milestone_id: int, db: Session = Depends(get_db)): + return db.query(models.Issue).filter(models.Issue.milestone_id == milestone_id).all() + + +@router.get("/milestones/{milestone_id}/progress", tags=["Milestones"]) +def milestone_progress(milestone_id: int, db: Session = Depends(get_db)): + ms = db.query(MilestoneModel).filter(MilestoneModel.id == milestone_id).first() + if not ms: + raise HTTPException(status_code=404, detail="Milestone not found") + issues = db.query(models.Issue).filter(models.Issue.milestone_id == milestone_id).all() + total = len(issues) + done = sum(1 for i in issues if i.status in ("resolved", "closed")) + return {"milestone_id": milestone_id, "title": ms.title, "total_issues": total, + "completed": done, "progress_pct": round(done / total * 100, 1) if total else 0} + + +# ============ Notifications ============ + +class NotificationResponse(BaseModel): + id: int + user_id: int + type: str + title: str + message: str | None = None + entity_type: str | None = None + entity_id: int | None = None + is_read: bool + created_at: datetime + class Config: + from_attributes = True + + +@router.get("/notifications", response_model=List[NotificationResponse], tags=["Notifications"]) +def list_notifications(user_id: int, unread_only: bool = False, limit: int = 50, db: Session = Depends(get_db)): + query = db.query(NotificationModel).filter(NotificationModel.user_id == user_id) + if unread_only: + query = query.filter(NotificationModel.is_read == False) + return query.order_by(NotificationModel.created_at.desc()).limit(limit).all() + + +@router.get("/notifications/count", tags=["Notifications"]) +def notification_count(user_id: int, db: Session = Depends(get_db)): + count = db.query(NotificationModel).filter( + NotificationModel.user_id == user_id, NotificationModel.is_read == False + ).count() + return {"user_id": user_id, "unread": count} + + +@router.post("/notifications/{notification_id}/read", tags=["Notifications"]) +def mark_read(notification_id: int, db: Session = Depends(get_db)): + n = db.query(NotificationModel).filter(NotificationModel.id == notification_id).first() + if not n: + raise HTTPException(status_code=404, detail="Notification not found") + n.is_read = True + db.commit() + return {"status": "read"} + + +@router.post("/notifications/read-all", tags=["Notifications"]) +def mark_all_read(user_id: int, db: Session = Depends(get_db)): + db.query(NotificationModel).filter( + NotificationModel.user_id == user_id, NotificationModel.is_read == False + ).update({"is_read": True}) + db.commit() + return {"status": "all_read"} + + +# ============ Work Logs ============ + +class WorkLogCreate(BaseModel): + issue_id: int + user_id: int + hours: float + description: str | None = None + logged_date: datetime + +class WorkLogResponse(BaseModel): + id: int + issue_id: int + user_id: int + hours: float + description: str | None = None + logged_date: datetime + created_at: datetime + class Config: + from_attributes = True + + +@router.post("/worklogs", response_model=WorkLogResponse, status_code=status.HTTP_201_CREATED, tags=["Time Tracking"]) +def create_worklog(wl: WorkLogCreate, db: Session = Depends(get_db)): + issue = db.query(models.Issue).filter(models.Issue.id == wl.issue_id).first() + if not issue: + raise HTTPException(status_code=404, detail="Issue not found") + user = db.query(models.User).filter(models.User.id == wl.user_id).first() + if not user: + raise HTTPException(status_code=404, detail="User not found") + if wl.hours <= 0: + raise HTTPException(status_code=400, detail="Hours must be positive") + db_wl = WorkLog(**wl.model_dump()) + db.add(db_wl) + db.commit() + db.refresh(db_wl) + return db_wl + + +@router.get("/issues/{issue_id}/worklogs", response_model=List[WorkLogResponse], tags=["Time Tracking"]) +def list_issue_worklogs(issue_id: int, db: Session = Depends(get_db)): + return db.query(WorkLog).filter(WorkLog.issue_id == issue_id).order_by(WorkLog.logged_date.desc()).all() + + +@router.get("/issues/{issue_id}/worklogs/summary", tags=["Time Tracking"]) +def issue_worklog_summary(issue_id: int, db: Session = Depends(get_db)): + issue = db.query(models.Issue).filter(models.Issue.id == issue_id).first() + if not issue: + raise HTTPException(status_code=404, detail="Issue not found") + total = db.query(sqlfunc.sum(WorkLog.hours)).filter(WorkLog.issue_id == issue_id).scalar() or 0 + count = db.query(WorkLog).filter(WorkLog.issue_id == issue_id).count() + return {"issue_id": issue_id, "total_hours": round(total, 2), "log_count": count} + + +@router.delete("/worklogs/{worklog_id}", status_code=status.HTTP_204_NO_CONTENT, tags=["Time Tracking"]) +def delete_worklog(worklog_id: int, db: Session = Depends(get_db)): + wl = db.query(WorkLog).filter(WorkLog.id == worklog_id).first() + if not wl: + raise HTTPException(status_code=404, detail="Work log not found") + db.delete(wl) + db.commit() + return None + + +# ============ Export ============ + +@router.get("/export/issues", tags=["Export"]) +def export_issues_csv(project_id: int = None, db: Session = Depends(get_db)): + query = db.query(models.Issue) + if project_id: + query = query.filter(models.Issue.project_id == project_id) + issues = query.all() + output = io.StringIO() + writer = csv.writer(output) + writer.writerow(["id", "title", "type", "status", "priority", "project_id", + "reporter_id", "assignee_id", "milestone_id", "due_date", + "tags", "created_at", "updated_at"]) + for i in issues: + writer.writerow([i.id, i.title, i.issue_type, i.status, i.priority, i.project_id, + i.reporter_id, i.assignee_id, i.milestone_id, i.due_date, + i.tags, i.created_at, i.updated_at]) + output.seek(0) + return StreamingResponse(iter([output.getvalue()]), media_type="text/csv", + headers={"Content-Disposition": "attachment; filename=issues.csv"}) + + +# ============ Dashboard ============ + +@router.get("/dashboard/stats", tags=["Dashboard"]) +def dashboard_stats(project_id: int = None, db: Session = Depends(get_db)): + query = db.query(models.Issue) + if project_id: + query = query.filter(models.Issue.project_id == project_id) + total = query.count() + by_status = {s: query.filter(models.Issue.status == s).count() + for s in ["open", "in_progress", "resolved", "closed", "blocked"]} + by_type = {t: query.filter(models.Issue.issue_type == t).count() + for t in ["task", "story", "test", "resolution"]} + by_priority = {p: query.filter(models.Issue.priority == p).count() + for p in ["low", "medium", "high", "critical"]} + return {"total": total, "by_status": by_status, "by_type": by_type, "by_priority": by_priority} diff --git a/app/api/routers/projects.py b/app/api/routers/projects.py new file mode 100644 index 0000000..75eab8f --- /dev/null +++ b/app/api/routers/projects.py @@ -0,0 +1,114 @@ +"""Projects router.""" +from typing import List +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.orm import Session + +from app.core.config import get_db +from app.models import models +from app.schemas import schemas + +router = APIRouter(prefix="/projects", tags=["Projects"]) + + +@router.post("", response_model=schemas.ProjectResponse, status_code=status.HTTP_201_CREATED) +def create_project(project: schemas.ProjectCreate, db: Session = Depends(get_db)): + db_project = models.Project(**project.model_dump()) + db.add(db_project) + db.commit() + db.refresh(db_project) + return db_project + + +@router.get("", response_model=List[schemas.ProjectResponse]) +def list_projects(skip: int = 0, limit: int = 100, db: Session = Depends(get_db)): + return db.query(models.Project).offset(skip).limit(limit).all() + + +@router.get("/{project_id}", response_model=schemas.ProjectResponse) +def get_project(project_id: int, db: Session = Depends(get_db)): + project = db.query(models.Project).filter(models.Project.id == project_id).first() + if not project: + raise HTTPException(status_code=404, detail="Project not found") + return project + + +@router.patch("/{project_id}", response_model=schemas.ProjectResponse) +def update_project(project_id: int, project_update: schemas.ProjectUpdate, db: Session = Depends(get_db)): + project = db.query(models.Project).filter(models.Project.id == project_id).first() + if not project: + raise HTTPException(status_code=404, detail="Project not found") + for field, value in project_update.model_dump(exclude_unset=True).items(): + setattr(project, field, value) + db.commit() + db.refresh(project) + return project + + +@router.delete("/{project_id}", status_code=status.HTTP_204_NO_CONTENT) +def delete_project(project_id: int, db: Session = Depends(get_db)): + project = db.query(models.Project).filter(models.Project.id == project_id).first() + if not project: + raise HTTPException(status_code=404, detail="Project not found") + db.delete(project) + db.commit() + return None + + +# ---- Members ---- + +@router.post("/{project_id}/members", response_model=schemas.ProjectMemberResponse, status_code=status.HTTP_201_CREATED) +def add_project_member(project_id: int, member: schemas.ProjectMemberCreate, db: Session = Depends(get_db)): + project = db.query(models.Project).filter(models.Project.id == project_id).first() + if not project: + raise HTTPException(status_code=404, detail="Project not found") + user = db.query(models.User).filter(models.User.id == member.user_id).first() + if not user: + raise HTTPException(status_code=404, detail="User not found") + existing = db.query(models.ProjectMember).filter( + models.ProjectMember.project_id == project_id, models.ProjectMember.user_id == member.user_id + ).first() + if existing: + raise HTTPException(status_code=400, detail="User already a member") + db_member = models.ProjectMember(project_id=project_id, user_id=member.user_id, role=member.role) + db.add(db_member) + db.commit() + db.refresh(db_member) + return db_member + + +@router.get("/{project_id}/members", response_model=List[schemas.ProjectMemberResponse]) +def list_project_members(project_id: int, db: Session = Depends(get_db)): + return db.query(models.ProjectMember).filter(models.ProjectMember.project_id == project_id).all() + + +@router.delete("/{project_id}/members/{user_id}", status_code=status.HTTP_204_NO_CONTENT) +def remove_project_member(project_id: int, user_id: int, db: Session = Depends(get_db)): + member = db.query(models.ProjectMember).filter( + models.ProjectMember.project_id == project_id, models.ProjectMember.user_id == user_id + ).first() + if not member: + raise HTTPException(status_code=404, detail="Member not found") + db.delete(member) + db.commit() + return None + + +# ---- Worklog summary ---- + +from app.models.worklog import WorkLog +from sqlalchemy import func as sqlfunc + + +@router.get("/{project_id}/worklogs/summary") +def project_worklog_summary(project_id: int, db: Session = Depends(get_db)): + results = db.query( + models.User.id, models.User.username, + sqlfunc.sum(WorkLog.hours).label("total_hours"), + sqlfunc.count(WorkLog.id).label("log_count") + ).join(WorkLog, WorkLog.user_id == models.User.id)\ + .join(models.Issue, WorkLog.issue_id == models.Issue.id)\ + .filter(models.Issue.project_id == project_id)\ + .group_by(models.User.id, models.User.username).all() + total = sum(r.total_hours for r in results) + by_user = [{"user_id": r.id, "username": r.username, "hours": round(r.total_hours, 2), "logs": r.log_count} for r in results] + return {"project_id": project_id, "total_hours": round(total, 2), "by_user": by_user} diff --git a/app/api/routers/users.py b/app/api/routers/users.py new file mode 100644 index 0000000..e5efe10 --- /dev/null +++ b/app/api/routers/users.py @@ -0,0 +1,80 @@ +"""Users router.""" +from typing import List +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.orm import Session + +from app.core.config import get_db +from app.models import models +from app.schemas import schemas +from app.api.deps import get_password_hash + +router = APIRouter(prefix="/users", tags=["Users"]) + + +@router.post("", response_model=schemas.UserResponse, status_code=status.HTTP_201_CREATED) +def create_user(user: schemas.UserCreate, db: Session = Depends(get_db)): + existing = db.query(models.User).filter( + (models.User.username == user.username) | (models.User.email == user.email) + ).first() + if existing: + raise HTTPException(status_code=400, detail="Username or email already exists") + hashed_password = get_password_hash(user.password) if user.password else None + db_user = models.User( + username=user.username, email=user.email, full_name=user.full_name, + hashed_password=hashed_password, is_admin=user.is_admin + ) + db.add(db_user) + db.commit() + db.refresh(db_user) + return db_user + + +@router.get("", response_model=List[schemas.UserResponse]) +def list_users(skip: int = 0, limit: int = 100, db: Session = Depends(get_db)): + return db.query(models.User).offset(skip).limit(limit).all() + + +@router.get("/{user_id}", response_model=schemas.UserResponse) +def get_user(user_id: int, db: Session = Depends(get_db)): + user = db.query(models.User).filter(models.User.id == user_id).first() + if not user: + raise HTTPException(status_code=404, detail="User not found") + return user + + +@router.patch("/{user_id}", response_model=schemas.UserResponse) +def update_user(user_id: int, db: Session = Depends(get_db), full_name: str = None, email: str = None): + user = db.query(models.User).filter(models.User.id == user_id).first() + if not user: + raise HTTPException(status_code=404, detail="User not found") + if full_name is not None: + user.full_name = full_name + if email is not None: + user.email = email + db.commit() + db.refresh(user) + return user + + +# ---- User worklogs ---- + +from app.models.worklog import WorkLog +from pydantic import BaseModel +from datetime import datetime + + +class WorkLogResponse(BaseModel): + id: int + issue_id: int + user_id: int + hours: float + description: str | None = None + logged_date: datetime + created_at: datetime + class Config: + from_attributes = True + + +@router.get("/{user_id}/worklogs", response_model=List[WorkLogResponse]) +def list_user_worklogs(user_id: int, limit: int = 50, db: Session = Depends(get_db)): + return db.query(WorkLog).filter(WorkLog.user_id == user_id).order_by(WorkLog.logged_date.desc()).limit(limit).all() diff --git a/app/api/routers/webhooks.py b/app/api/routers/webhooks.py new file mode 100644 index 0000000..daabeea --- /dev/null +++ b/app/api/routers/webhooks.py @@ -0,0 +1,78 @@ +"""Webhooks router.""" +import json +from typing import List +from fastapi import APIRouter, Depends, HTTPException, status, BackgroundTasks +from sqlalchemy.orm import Session + +from app.core.config import get_db +from app.models.webhook import Webhook, WebhookLog +from app.schemas.webhook import WebhookCreate, WebhookUpdate, WebhookResponse, WebhookLogResponse +from app.services.webhook import fire_webhooks_sync + +router = APIRouter(prefix="/webhooks", tags=["Webhooks"]) + + +@router.post("", response_model=WebhookResponse, status_code=status.HTTP_201_CREATED) +def create_webhook(wh: WebhookCreate, db: Session = Depends(get_db)): + db_wh = Webhook(**wh.model_dump()) + db.add(db_wh) + db.commit() + db.refresh(db_wh) + return db_wh + + +@router.get("", response_model=List[WebhookResponse]) +def list_webhooks(project_id: int = None, db: Session = Depends(get_db)): + query = db.query(Webhook) + if project_id is not None: + query = query.filter(Webhook.project_id == project_id) + return query.all() + + +@router.get("/{webhook_id}", response_model=WebhookResponse) +def get_webhook(webhook_id: int, db: Session = Depends(get_db)): + wh = db.query(Webhook).filter(Webhook.id == webhook_id).first() + if not wh: + raise HTTPException(status_code=404, detail="Webhook not found") + return wh + + +@router.patch("/{webhook_id}", response_model=WebhookResponse) +def update_webhook(webhook_id: int, wh_update: WebhookUpdate, db: Session = Depends(get_db)): + wh = db.query(Webhook).filter(Webhook.id == webhook_id).first() + if not wh: + raise HTTPException(status_code=404, detail="Webhook not found") + for field, value in wh_update.model_dump(exclude_unset=True).items(): + setattr(wh, field, value) + db.commit() + db.refresh(wh) + return wh + + +@router.delete("/{webhook_id}", status_code=status.HTTP_204_NO_CONTENT) +def delete_webhook(webhook_id: int, db: Session = Depends(get_db)): + wh = db.query(Webhook).filter(Webhook.id == webhook_id).first() + if not wh: + raise HTTPException(status_code=404, detail="Webhook not found") + db.delete(wh) + db.commit() + return None + + +@router.get("/{webhook_id}/logs", response_model=List[WebhookLogResponse]) +def list_webhook_logs(webhook_id: int, limit: int = 50, db: Session = Depends(get_db)): + return db.query(WebhookLog).filter( + WebhookLog.webhook_id == webhook_id + ).order_by(WebhookLog.created_at.desc()).limit(limit).all() + + +@router.post("/{webhook_id}/retry/{log_id}") +def retry_webhook(webhook_id: int, log_id: int, bg: BackgroundTasks, db: Session = Depends(get_db)): + log_entry = db.query(WebhookLog).filter(WebhookLog.id == log_id, WebhookLog.webhook_id == webhook_id).first() + if not log_entry: + raise HTTPException(status_code=404, detail="Webhook log not found") + wh = db.query(Webhook).filter(Webhook.id == webhook_id).first() + if not wh: + raise HTTPException(status_code=404, detail="Webhook not found") + bg.add_task(fire_webhooks_sync, log_entry.event, json.loads(log_entry.payload), wh.project_id, db) + return {"status": "retry_queued", "log_id": log_id} diff --git a/app/main.py b/app/main.py index 2e6f11a..fb15eb6 100644 --- a/app/main.py +++ b/app/main.py @@ -1,22 +1,11 @@ -from fastapi import FastAPI, Depends, HTTPException, status, BackgroundTasks +"""HarborForge API — Agent/人类协同任务管理平台""" +from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware -from sqlalchemy.orm import Session -from typing import List -from datetime import datetime, timedelta -from jose import JWTError, jwt -from passlib.context import CryptContext -from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm -from pydantic import BaseModel - -from app.core.config import get_db, settings -from app.models import models -from app.schemas import schemas -from app.services.webhook import fire_webhooks_sync app = FastAPI( title="HarborForge API", description="Agent/人类协同任务管理平台 API", - version="0.1.0" + version="0.2.0" ) # CORS @@ -28,1138 +17,35 @@ app.add_middleware( allow_headers=["*"], ) -# Auth -pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") -oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/auth/token") - -class Token(BaseModel): - access_token: str - token_type: str - -class TokenData(BaseModel): - user_id: int = None - -def verify_password(plain_password: str, hashed_password: str) -> bool: - if not hashed_password: - return False - return pwd_context.verify(plain_password, hashed_password) - -def get_password_hash(password: str) -> str: - password = password[:72] - - return pwd_context.hash(password) - -def create_access_token(data: dict, expires_delta: timedelta = None) -> str: - to_encode = data.copy() - expire = datetime.utcnow() + (expires_delta or timedelta(minutes=30)) - to_encode.update({"exp": expire}) - return jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM) - -async def get_current_user(token: str = Depends(oauth2_scheme), db: Session = Depends(get_db)): - credentials_exception = HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Could not validate credentials", - headers={"WWW-Authenticate": "Bearer"}, - ) - try: - payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM]) - user_id = payload.get("sub") - if user_id is None: - raise credentials_exception - except JWTError: - raise credentials_exception - - user = db.query(models.User).filter(models.User.id == user_id).first() - if user is None: - raise credentials_exception - return user - -# Health check -@app.get("/health") +# Health & version (kept at top level) +@app.get("/health", tags=["System"]) def health_check(): return {"status": "healthy"} +@app.get("/version", tags=["System"]) +def version(): + return {"name": "HarborForge", "version": "0.2.0", "description": "Agent/人类协同任务管理平台"} -# ============ Auth API ============ - -@app.post("/auth/token", response_model=Token) -async def login(form_data: OAuth2PasswordRequestForm = Depends(), db: Session = Depends(get_db)): - user = db.query(models.User).filter(models.User.username == form_data.username).first() - if not user or not verify_password(form_data.password, user.hashed_password or ""): - raise HTTPException(status_code=401, detail="Incorrect username or password", headers={"WWW-Authenticate": "Bearer"}) - if not user.is_active: - raise HTTPException(status_code=400, detail="Inactive user") - access_token = create_access_token(data={"sub": str(user.id)}, expires_delta=timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)) - return {"access_token": access_token, "token_type": "bearer"} - -@app.get("/auth/me", response_model=schemas.UserResponse) -async def get_me(current_user: models.User = Depends(get_current_user)): - return current_user - - -# ============ Issues API ============ - -@app.post("/issues", response_model=schemas.IssueResponse, status_code=status.HTTP_201_CREATED) -def create_issue(issue: schemas.IssueCreate, bg: BackgroundTasks, db: Session = Depends(get_db)): - db_issue = models.Issue(**issue.model_dump()) - db.add(db_issue) - db.commit() - db.refresh(db_issue) - event = "resolution.created" if db_issue.issue_type == "resolution" else "issue.created" - bg.add_task(fire_webhooks_sync, event, {"issue_id": db_issue.id, "title": db_issue.title, "type": db_issue.issue_type, "status": db_issue.status}, db_issue.project_id, db) - return db_issue - - -import math - -@app.get("/issues") -def list_issues( - project_id: int = None, - issue_status: str = None, - issue_type: str = None, - assignee_id: int = None, - tag: str = None, - sort_by: str = "created_at", - sort_order: str = "desc", - page: int = 1, - page_size: int = 50, - db: Session = Depends(get_db) -): - """List issues with filtering, sorting, and pagination metadata.""" - query = db.query(models.Issue) - - if project_id: - query = query.filter(models.Issue.project_id == project_id) - if issue_status: - query = query.filter(models.Issue.status == issue_status) - if issue_type: - query = query.filter(models.Issue.issue_type == issue_type) - if assignee_id: - query = query.filter(models.Issue.assignee_id == assignee_id) - if tag: - query = query.filter(models.Issue.tags.contains(tag)) - - # Sorting - sort_fields = { - "created_at": models.Issue.created_at, - "updated_at": models.Issue.updated_at, - "priority": models.Issue.priority, - "title": models.Issue.title, - "due_date": models.Issue.due_date, - "status": models.Issue.status, - } - sort_col = sort_fields.get(sort_by, models.Issue.created_at) - if sort_order == "asc": - query = query.order_by(sort_col.asc()) - else: - query = query.order_by(sort_col.desc()) - - total = query.count() - page = max(1, page) - page_size = min(max(1, page_size), 200) - total_pages = math.ceil(total / page_size) if total else 1 - skip = (page - 1) * page_size - items = query.offset(skip).limit(page_size).all() - - return { - "items": [schemas.IssueResponse.model_validate(i) for i in items], - "total": total, - "page": page, - "page_size": page_size, - "total_pages": total_pages, - } - - -@app.get("/issues/overdue", response_model=List[schemas.IssueResponse]) -def list_overdue_issues(project_id: int = None, db: Session = Depends(get_db)): - """List issues past their due date that aren't closed/resolved.""" - query = db.query(models.Issue).filter( - models.Issue.due_date != None, - models.Issue.due_date < datetime.utcnow(), - models.Issue.status.notin_(["resolved", "closed"]) - ) - if project_id: - query = query.filter(models.Issue.project_id == project_id) - return query.order_by(models.Issue.due_date.asc()).all() - - -@app.get("/issues/{issue_id}", response_model=schemas.IssueResponse) -def get_issue(issue_id: int, db: Session = Depends(get_db)): - issue = db.query(models.Issue).filter(models.Issue.id == issue_id).first() - if not issue: - raise HTTPException(status_code=404, detail="Issue not found") - return issue - - -@app.patch("/issues/{issue_id}", response_model=schemas.IssueResponse) -def update_issue(issue_id: int, issue_update: schemas.IssueUpdate, db: Session = Depends(get_db)): - issue = db.query(models.Issue).filter(models.Issue.id == issue_id).first() - if not issue: - raise HTTPException(status_code=404, detail="Issue not found") - - update_data = issue_update.model_dump(exclude_unset=True) - for field, value in update_data.items(): - setattr(issue, field, value) - - db.commit() - db.refresh(issue) - return issue - - -@app.delete("/issues/{issue_id}", status_code=status.HTTP_204_NO_CONTENT) -def delete_issue(issue_id: int, db: Session = Depends(get_db)): - issue = db.query(models.Issue).filter(models.Issue.id == issue_id).first() - if not issue: - raise HTTPException(status_code=404, detail="Issue not found") - - db.delete(issue) - db.commit() - return None - - -# ============ Comments API ============ - -@app.post("/comments", response_model=schemas.CommentResponse, status_code=status.HTTP_201_CREATED) -def create_comment(comment: schemas.CommentCreate, db: Session = Depends(get_db)): - db_comment = models.Comment(**comment.model_dump()) - db.add(db_comment) - db.commit() - db.refresh(db_comment) - return db_comment - - -@app.get("/issues/{issue_id}/comments", response_model=List[schemas.CommentResponse]) -def list_comments(issue_id: int, db: Session = Depends(get_db)): - comments = db.query(models.Comment).filter(models.Comment.issue_id == issue_id).all() - return comments - - -# ============ Projects API ============ - -@app.post("/projects", response_model=schemas.ProjectResponse, status_code=status.HTTP_201_CREATED) -def create_project(project: schemas.ProjectCreate, db: Session = Depends(get_db)): - db_project = models.Project(**project.model_dump()) - db.add(db_project) - db.commit() - db.refresh(db_project) - return db_project - - -@app.get("/projects", response_model=List[schemas.ProjectResponse]) -def list_projects(skip: int = 0, limit: int = 100, db: Session = Depends(get_db)): - projects = db.query(models.Project).offset(skip).limit(limit).all() - return projects - - -@app.get("/projects/{project_id}", response_model=schemas.ProjectResponse) -def get_project(project_id: int, db: Session = Depends(get_db)): - project = db.query(models.Project).filter(models.Project.id == project_id).first() - if not project: - raise HTTPException(status_code=404, detail="Project not found") - return project - - -# ============ Users API ============ - -@app.post("/users", response_model=schemas.UserResponse, status_code=status.HTTP_201_CREATED) -def create_user(user: schemas.UserCreate, db: Session = Depends(get_db)): - existing = db.query(models.User).filter( - (models.User.username == user.username) | (models.User.email == user.email) - ).first() - if existing: - raise HTTPException(status_code=400, detail="Username or email already exists") - - hashed_password = get_password_hash(user.password) if user.password else None - - db_user = models.User( - username=user.username, - email=user.email, - full_name=user.full_name, - hashed_password=hashed_password, - is_admin=user.is_admin - ) - db.add(db_user) - db.commit() - db.refresh(db_user) - return db_user - - -@app.get("/users", response_model=List[schemas.UserResponse]) -def list_users(skip: int = 0, limit: int = 100, db: Session = Depends(get_db)): - users = db.query(models.User).offset(skip).limit(limit).all() - return users - - -@app.get("/users/{user_id}", response_model=schemas.UserResponse) -def get_user(user_id: int, db: Session = Depends(get_db)): - user = db.query(models.User).filter(models.User.id == user_id).first() - if not user: - raise HTTPException(status_code=404, detail="User not found") - return user +# Register routers +from app.api.routers.auth import router as auth_router +from app.api.routers.issues import router as issues_router +from app.api.routers.projects import router as projects_router +from app.api.routers.users import router as users_router +from app.api.routers.comments import router as comments_router +from app.api.routers.webhooks import router as webhooks_router +from app.api.routers.misc import router as misc_router +app.include_router(auth_router) +app.include_router(issues_router) +app.include_router(projects_router) +app.include_router(users_router) +app.include_router(comments_router) +app.include_router(webhooks_router) +app.include_router(misc_router) # Run database migration on startup @app.on_event("startup") def startup(): from app.core.config import Base, engine - from app.models import webhook - from app.models import apikey - from app.models import activity - from app.models import milestone - from app.models import notification - from app.models import worklog + from app.models import webhook, apikey, activity, milestone, notification, worklog Base.metadata.create_all(bind=engine) - - -# ============ Project Members API ============ - -@app.post("/projects/{project_id}/members", response_model=schemas.ProjectMemberResponse, status_code=status.HTTP_201_CREATED) -def add_project_member(project_id: int, member: schemas.ProjectMemberCreate, db: Session = Depends(get_db)): - project = db.query(models.Project).filter(models.Project.id == project_id).first() - if not project: - raise HTTPException(status_code=404, detail="Project not found") - user = db.query(models.User).filter(models.User.id == member.user_id).first() - if not user: - raise HTTPException(status_code=404, detail="User not found") - existing = db.query(models.ProjectMember).filter( - models.ProjectMember.project_id == project_id, - models.ProjectMember.user_id == member.user_id - ).first() - if existing: - raise HTTPException(status_code=400, detail="User already a member") - db_member = models.ProjectMember(project_id=project_id, user_id=member.user_id, role=member.role) - db.add(db_member) - db.commit() - db.refresh(db_member) - return db_member - - -@app.get("/projects/{project_id}/members", response_model=List[schemas.ProjectMemberResponse]) -def list_project_members(project_id: int, db: Session = Depends(get_db)): - members = db.query(models.ProjectMember).filter(models.ProjectMember.project_id == project_id).all() - return members - - -@app.delete("/projects/{project_id}/members/{user_id}", status_code=status.HTTP_204_NO_CONTENT) -def remove_project_member(project_id: int, user_id: int, db: Session = Depends(get_db)): - member = db.query(models.ProjectMember).filter( - models.ProjectMember.project_id == project_id, - models.ProjectMember.user_id == user_id - ).first() - if not member: - raise HTTPException(status_code=404, detail="Member not found") - db.delete(member) - db.commit() - return None - - -# ============ System API ============ - -@app.get("/version") -def version(): - return { - "name": "HarborForge", - "version": "0.1.0", - "description": "Agent/人类协同任务管理平台" - } - - -# ============ Projects (update/delete) ============ - -@app.patch("/projects/{project_id}", response_model=schemas.ProjectResponse) -def update_project(project_id: int, project_update: schemas.ProjectUpdate, db: Session = Depends(get_db)): - project = db.query(models.Project).filter(models.Project.id == project_id).first() - if not project: - raise HTTPException(status_code=404, detail="Project not found") - update_data = project_update.model_dump(exclude_unset=True) - for field, value in update_data.items(): - setattr(project, field, value) - db.commit() - db.refresh(project) - return project - - -@app.delete("/projects/{project_id}", status_code=status.HTTP_204_NO_CONTENT) -def delete_project(project_id: int, db: Session = Depends(get_db)): - project = db.query(models.Project).filter(models.Project.id == project_id).first() - if not project: - raise HTTPException(status_code=404, detail="Project not found") - db.delete(project) - db.commit() - return None - - -# ============ Users (update/delete) ============ - -@app.patch("/users/{user_id}", response_model=schemas.UserResponse) -def update_user(user_id: int, db: Session = Depends(get_db), full_name: str = None, email: str = None): - user = db.query(models.User).filter(models.User.id == user_id).first() - if not user: - raise HTTPException(status_code=404, detail="User not found") - if full_name is not None: - user.full_name = full_name - if email is not None: - user.email = email - db.commit() - db.refresh(user) - return user - - - -# ============ Webhooks API ============ - -from app.models.webhook import Webhook, WebhookLog -from app.schemas.webhook import WebhookCreate, WebhookUpdate, WebhookResponse, WebhookLogResponse - - -@app.post("/webhooks", response_model=WebhookResponse, status_code=status.HTTP_201_CREATED) -def create_webhook(wh: WebhookCreate, db: Session = Depends(get_db)): - db_wh = Webhook(**wh.model_dump()) - db.add(db_wh) - db.commit() - db.refresh(db_wh) - return db_wh - - -@app.get("/webhooks", response_model=List[WebhookResponse]) -def list_webhooks(project_id: int = None, db: Session = Depends(get_db)): - query = db.query(Webhook) - if project_id is not None: - query = query.filter(Webhook.project_id == project_id) - return query.all() - - -@app.get("/webhooks/{webhook_id}", response_model=WebhookResponse) -def get_webhook(webhook_id: int, db: Session = Depends(get_db)): - wh = db.query(Webhook).filter(Webhook.id == webhook_id).first() - if not wh: - raise HTTPException(status_code=404, detail="Webhook not found") - return wh - - -@app.patch("/webhooks/{webhook_id}", response_model=WebhookResponse) -def update_webhook(webhook_id: int, wh_update: WebhookUpdate, db: Session = Depends(get_db)): - wh = db.query(Webhook).filter(Webhook.id == webhook_id).first() - if not wh: - raise HTTPException(status_code=404, detail="Webhook not found") - for field, value in wh_update.model_dump(exclude_unset=True).items(): - setattr(wh, field, value) - db.commit() - db.refresh(wh) - return wh - - -@app.delete("/webhooks/{webhook_id}", status_code=status.HTTP_204_NO_CONTENT) -def delete_webhook(webhook_id: int, db: Session = Depends(get_db)): - wh = db.query(Webhook).filter(Webhook.id == webhook_id).first() - if not wh: - raise HTTPException(status_code=404, detail="Webhook not found") - db.delete(wh) - db.commit() - return None - - -@app.get("/webhooks/{webhook_id}/logs", response_model=List[WebhookLogResponse]) -def list_webhook_logs(webhook_id: int, limit: int = 50, db: Session = Depends(get_db)): - logs = db.query(WebhookLog).filter( - WebhookLog.webhook_id == webhook_id - ).order_by(WebhookLog.created_at.desc()).limit(limit).all() - return logs - - - -# ============ Issue Status Transition ============ - -@app.post("/issues/{issue_id}/transition", response_model=schemas.IssueResponse) -def transition_issue(issue_id: int, new_status: str, bg: BackgroundTasks, db: Session = Depends(get_db)): - """Transition issue status with validation and webhook.""" - valid_statuses = ["open", "in_progress", "resolved", "closed", "blocked"] - if new_status not in valid_statuses: - raise HTTPException(status_code=400, detail=f"Invalid status. Must be one of: {valid_statuses}") - - issue = db.query(models.Issue).filter(models.Issue.id == issue_id).first() - if not issue: - raise HTTPException(status_code=404, detail="Issue not found") - - old_status = issue.status - issue.status = new_status - db.commit() - db.refresh(issue) - - event = "issue.closed" if new_status == "closed" else "issue.updated" - bg.add_task(fire_webhooks_sync, event, { - "issue_id": issue.id, - "title": issue.title, - "old_status": old_status, - "new_status": new_status, - }, issue.project_id, db) - - return issue - - - -# ============ Search API ============ - -@app.get("/search/issues") -def search_issues( - q: str, - project_id: int = None, - page: int = 1, - page_size: int = 50, - db: Session = Depends(get_db) -): - """Search issues by title or description keyword with pagination.""" - query = db.query(models.Issue).filter( - (models.Issue.title.contains(q)) | (models.Issue.description.contains(q)) - ) - if project_id: - query = query.filter(models.Issue.project_id == project_id) - - total = query.count() - page = max(1, page) - page_size = min(max(1, page_size), 200) - total_pages = math.ceil(total / page_size) if total else 1 - skip = (page - 1) * page_size - items = query.offset(skip).limit(page_size).all() - - return { - "items": [schemas.IssueResponse.model_validate(i) for i in items], - "total": total, - "page": page, - "page_size": page_size, - "total_pages": total_pages, - } - - - -# ============ Dashboard / Stats API ============ - -@app.get("/dashboard/stats") -def dashboard_stats(project_id: int = None, db: Session = Depends(get_db)): - """Get issue statistics for dashboard.""" - query = db.query(models.Issue) - if project_id: - query = query.filter(models.Issue.project_id == project_id) - - total = query.count() - by_status = {} - for s in ["open", "in_progress", "resolved", "closed", "blocked"]: - by_status[s] = query.filter(models.Issue.status == s).count() - - by_type = {} - for t in ["task", "story", "test", "resolution"]: - by_type[t] = query.filter(models.Issue.issue_type == t).count() - - by_priority = {} - for p in ["low", "medium", "high", "critical"]: - by_priority[p] = query.filter(models.Issue.priority == p).count() - - return { - "total": total, - "by_status": by_status, - "by_type": by_type, - "by_priority": by_priority, - } - - - -# ============ Comments (update/delete) ============ - -@app.patch("/comments/{comment_id}", response_model=schemas.CommentResponse) -def update_comment(comment_id: int, comment_update: schemas.CommentUpdate, db: Session = Depends(get_db)): - comment = db.query(models.Comment).filter(models.Comment.id == comment_id).first() - if not comment: - raise HTTPException(status_code=404, detail="Comment not found") - for field, value in comment_update.model_dump(exclude_unset=True).items(): - setattr(comment, field, value) - db.commit() - db.refresh(comment) - return comment - - -@app.delete("/comments/{comment_id}", status_code=status.HTTP_204_NO_CONTENT) -def delete_comment(comment_id: int, db: Session = Depends(get_db)): - comment = db.query(models.Comment).filter(models.Comment.id == comment_id).first() - if not comment: - raise HTTPException(status_code=404, detail="Comment not found") - db.delete(comment) - db.commit() - return None - - - -# ============ API Key Auth ============ - -import secrets -from fastapi.security import APIKeyHeader -from app.models.apikey import APIKey - -apikey_header = APIKeyHeader(name="X-API-Key", auto_error=False) - - -async def get_current_user_or_apikey( - token: str = Depends(oauth2_scheme), - api_key: str = Depends(apikey_header), - db: Session = Depends(get_db) -): - """Authenticate via JWT token OR API key.""" - # Try API key first - if api_key: - key_obj = db.query(APIKey).filter(APIKey.key == api_key, APIKey.is_active == True).first() - if key_obj: - key_obj.last_used_at = datetime.utcnow() - db.commit() - user = db.query(models.User).filter(models.User.id == key_obj.user_id).first() - if user: - return user - # Fall back to JWT - if token: - return await get_current_user(token=token, db=db) - raise HTTPException(status_code=401, detail="Not authenticated") - - -# ============ API Key Management ============ - -from pydantic import BaseModel as PydanticBaseModel - -class APIKeyCreate(PydanticBaseModel): - name: str - user_id: int - -class APIKeyResponse(PydanticBaseModel): - id: int - key: str - name: str - user_id: int - is_active: bool - created_at: datetime - last_used_at: datetime | None = None - class Config: - from_attributes = True - - -@app.post("/api-keys", response_model=APIKeyResponse, status_code=status.HTTP_201_CREATED) -def create_api_key(data: APIKeyCreate, db: Session = Depends(get_db)): - user = db.query(models.User).filter(models.User.id == data.user_id).first() - if not user: - raise HTTPException(status_code=404, detail="User not found") - key = secrets.token_hex(32) - db_key = APIKey(key=key, name=data.name, user_id=data.user_id) - db.add(db_key) - db.commit() - db.refresh(db_key) - return db_key - - -@app.get("/api-keys", response_model=List[APIKeyResponse]) -def list_api_keys(user_id: int = None, db: Session = Depends(get_db)): - query = db.query(APIKey) - if user_id: - query = query.filter(APIKey.user_id == user_id) - return query.all() - - -@app.delete("/api-keys/{key_id}", status_code=status.HTTP_204_NO_CONTENT) -def revoke_api_key(key_id: int, db: Session = Depends(get_db)): - key_obj = db.query(APIKey).filter(APIKey.id == key_id).first() - if not key_obj: - raise HTTPException(status_code=404, detail="API key not found") - key_obj.is_active = False - db.commit() - return None - - - -# ============ Batch Operations ============ - -class BatchTransition(PydanticBaseModel): - issue_ids: List[int] - new_status: str - -class BatchAssign(PydanticBaseModel): - issue_ids: List[int] - assignee_id: int - - -@app.post("/issues/batch/transition") -def batch_transition(data: BatchTransition, bg: BackgroundTasks, db: Session = Depends(get_db)): - valid_statuses = ["open", "in_progress", "resolved", "closed", "blocked"] - if data.new_status not in valid_statuses: - raise HTTPException(status_code=400, detail=f"Invalid status") - - updated = [] - for issue_id in data.issue_ids: - issue = db.query(models.Issue).filter(models.Issue.id == issue_id).first() - if issue: - old_status = issue.status - issue.status = data.new_status - updated.append({"id": issue.id, "title": issue.title, "old": old_status, "new": data.new_status}) - db.commit() - - for u in updated: - event = "issue.closed" if data.new_status == "closed" else "issue.updated" - bg.add_task(fire_webhooks_sync, event, u, None, db) - - return {"updated": len(updated), "issues": updated} - - -@app.post("/issues/batch/assign") -def batch_assign(data: BatchAssign, db: Session = Depends(get_db)): - user = db.query(models.User).filter(models.User.id == data.assignee_id).first() - if not user: - raise HTTPException(status_code=404, detail="Assignee not found") - - updated = [] - for issue_id in data.issue_ids: - issue = db.query(models.Issue).filter(models.Issue.id == issue_id).first() - if issue: - issue.assignee_id = data.assignee_id - updated.append(issue_id) - db.commit() - - return {"updated": len(updated), "issue_ids": updated, "assignee_id": data.assignee_id} - - - -# ============ Activity Log ============ - -from app.models.activity import ActivityLog - - -class ActivityLogResponse(PydanticBaseModel): - id: int - action: str - entity_type: str - entity_id: int - user_id: int | None - details: str | None - created_at: datetime - class Config: - from_attributes = True - - -def log_activity(db: Session, action: str, entity_type: str, entity_id: int, user_id: int = None, details: str = None): - """Helper to record an activity log entry.""" - entry = ActivityLog(action=action, entity_type=entity_type, entity_id=entity_id, user_id=user_id, details=details) - db.add(entry) - db.commit() - - -@app.get("/activity", response_model=List[ActivityLogResponse]) -def list_activity( - entity_type: str = None, - entity_id: int = None, - user_id: int = None, - limit: int = 50, - db: Session = Depends(get_db) -): - query = db.query(ActivityLog) - if entity_type: - query = query.filter(ActivityLog.entity_type == entity_type) - if entity_id: - query = query.filter(ActivityLog.entity_id == entity_id) - if user_id: - query = query.filter(ActivityLog.user_id == user_id) - return query.order_by(ActivityLog.created_at.desc()).limit(limit).all() - - - -# ============ Issue Relations ============ - -class IssueRelation(PydanticBaseModel): - parent_id: int - child_id: int - -@app.post("/issues/link") -def link_issues(rel: IssueRelation, db: Session = Depends(get_db)): - parent = db.query(models.Issue).filter(models.Issue.id == rel.parent_id).first() - child = db.query(models.Issue).filter(models.Issue.id == rel.child_id).first() - if not parent or not child: - raise HTTPException(status_code=404, detail="Issue not found") - if rel.parent_id == rel.child_id: - raise HTTPException(status_code=400, detail="Cannot link issue to itself") - child.depends_on_id = rel.parent_id - db.commit() - return {"parent_id": rel.parent_id, "child_id": rel.child_id, "status": "linked"} - - -@app.delete("/issues/link") -def unlink_issues(child_id: int, db: Session = Depends(get_db)): - child = db.query(models.Issue).filter(models.Issue.id == child_id).first() - if not child: - raise HTTPException(status_code=404, detail="Issue not found") - child.depends_on_id = None - db.commit() - return {"child_id": child_id, "status": "unlinked"} - - -@app.get("/issues/{issue_id}/children", response_model=List[schemas.IssueResponse]) -def get_children(issue_id: int, db: Session = Depends(get_db)): - return db.query(models.Issue).filter(models.Issue.depends_on_id == issue_id).all() - - - -# ============ Issue Tags ============ - -@app.post("/issues/{issue_id}/tags") -def add_tag(issue_id: int, tag: str, db: Session = Depends(get_db)): - issue = db.query(models.Issue).filter(models.Issue.id == issue_id).first() - if not issue: - raise HTTPException(status_code=404, detail="Issue not found") - current = set(issue.tags.split(",")) if issue.tags else set() - current.add(tag.strip()) - current.discard("") - issue.tags = ",".join(sorted(current)) - db.commit() - return {"issue_id": issue_id, "tags": list(current)} - - -@app.delete("/issues/{issue_id}/tags") -def remove_tag(issue_id: int, tag: str, db: Session = Depends(get_db)): - issue = db.query(models.Issue).filter(models.Issue.id == issue_id).first() - if not issue: - raise HTTPException(status_code=404, detail="Issue not found") - current = set(issue.tags.split(",")) if issue.tags else set() - current.discard(tag.strip()) - current.discard("") - issue.tags = ",".join(sorted(current)) if current else None - db.commit() - return {"issue_id": issue_id, "tags": list(current)} - - -@app.get("/tags") -def list_all_tags(project_id: int = None, db: Session = Depends(get_db)): - """Get all unique tags across issues.""" - query = db.query(models.Issue.tags).filter(models.Issue.tags != None) - if project_id: - query = query.filter(models.Issue.project_id == project_id) - all_tags = set() - for (tags,) in query.all(): - for t in tags.split(","): - t = t.strip() - if t: - all_tags.add(t) - return {"tags": sorted(all_tags)} - - -# ============ Milestones API ============ - -from app.models.milestone import Milestone as MilestoneModel, MilestoneStatus -from app.schemas.schemas import MilestoneCreate, MilestoneUpdate, MilestoneResponse - - -@app.post("/milestones", response_model=MilestoneResponse, status_code=status.HTTP_201_CREATED) -def create_milestone(ms: MilestoneCreate, db: Session = Depends(get_db)): - db_ms = MilestoneModel(**ms.model_dump()) - db.add(db_ms) - db.commit() - db.refresh(db_ms) - return db_ms - - -@app.get("/milestones", response_model=List[MilestoneResponse]) -def list_milestones(project_id: int = None, status_filter: str = None, db: Session = Depends(get_db)): - query = db.query(MilestoneModel) - if project_id: - query = query.filter(MilestoneModel.project_id == project_id) - if status_filter: - query = query.filter(MilestoneModel.status == status_filter) - return query.order_by(MilestoneModel.due_date.is_(None), MilestoneModel.due_date.asc()).all() - - -@app.get("/milestones/{milestone_id}", response_model=MilestoneResponse) -def get_milestone(milestone_id: int, db: Session = Depends(get_db)): - ms = db.query(MilestoneModel).filter(MilestoneModel.id == milestone_id).first() - if not ms: - raise HTTPException(status_code=404, detail="Milestone not found") - return ms - - -@app.patch("/milestones/{milestone_id}", response_model=MilestoneResponse) -def update_milestone(milestone_id: int, ms_update: MilestoneUpdate, db: Session = Depends(get_db)): - ms = db.query(MilestoneModel).filter(MilestoneModel.id == milestone_id).first() - if not ms: - raise HTTPException(status_code=404, detail="Milestone not found") - for field, value in ms_update.model_dump(exclude_unset=True).items(): - setattr(ms, field, value) - db.commit() - db.refresh(ms) - return ms - - -@app.delete("/milestones/{milestone_id}", status_code=status.HTTP_204_NO_CONTENT) -def delete_milestone(milestone_id: int, db: Session = Depends(get_db)): - ms = db.query(MilestoneModel).filter(MilestoneModel.id == milestone_id).first() - if not ms: - raise HTTPException(status_code=404, detail="Milestone not found") - db.delete(ms) - db.commit() - return None - - -@app.get("/milestones/{milestone_id}/issues", response_model=List[schemas.IssueResponse]) -def list_milestone_issues(milestone_id: int, db: Session = Depends(get_db)): - """List all issues in a milestone.""" - return db.query(models.Issue).filter(models.Issue.milestone_id == milestone_id).all() - - -@app.get("/milestones/{milestone_id}/progress") -def milestone_progress(milestone_id: int, db: Session = Depends(get_db)): - """Get milestone completion progress.""" - ms = db.query(MilestoneModel).filter(MilestoneModel.id == milestone_id).first() - if not ms: - raise HTTPException(status_code=404, detail="Milestone not found") - issues = db.query(models.Issue).filter(models.Issue.milestone_id == milestone_id).all() - total = len(issues) - done = sum(1 for i in issues if i.status in ("resolved", "closed")) - return { - "milestone_id": milestone_id, - "title": ms.title, - "total_issues": total, - "completed": done, - "progress_pct": round(done / total * 100, 1) if total else 0, - } - - -# ============ Export API ============ - -import csv -import json -import io -from fastapi.responses import StreamingResponse - - -@app.get("/export/issues") -def export_issues_csv(project_id: int = None, db: Session = Depends(get_db)): - """Export issues as CSV.""" - query = db.query(models.Issue) - if project_id: - query = query.filter(models.Issue.project_id == project_id) - issues = query.all() - - output = io.StringIO() - writer = csv.writer(output) - writer.writerow(["id", "title", "type", "status", "priority", "project_id", - "reporter_id", "assignee_id", "milestone_id", "due_date", - "tags", "created_at", "updated_at"]) - for i in issues: - writer.writerow([ - i.id, i.title, i.issue_type, i.status, i.priority, - i.project_id, i.reporter_id, i.assignee_id, i.milestone_id, - i.due_date, i.tags, i.created_at, i.updated_at - ]) - output.seek(0) - return StreamingResponse( - iter([output.getvalue()]), - media_type="text/csv", - headers={"Content-Disposition": "attachment; filename=issues.csv"} - ) - - - -# ============ Notifications API ============ - -from app.models.notification import Notification as NotificationModel - - -class NotificationResponse(PydanticBaseModel): - id: int - user_id: int - type: str - title: str - message: str | None = None - entity_type: str | None = None - entity_id: int | None = None - is_read: bool - created_at: datetime - class Config: - from_attributes = True - - -def notify_user(db: Session, user_id: int, ntype: str, title: str, message: str = None, entity_type: str = None, entity_id: int = None): - """Helper to create a notification.""" - n = NotificationModel(user_id=user_id, type=ntype, title=title, message=message, entity_type=entity_type, entity_id=entity_id) - db.add(n) - db.commit() - return n - - -@app.get("/notifications", response_model=List[NotificationResponse]) -def list_notifications(user_id: int, unread_only: bool = False, limit: int = 50, db: Session = Depends(get_db)): - """List notifications for a user.""" - query = db.query(NotificationModel).filter(NotificationModel.user_id == user_id) - if unread_only: - query = query.filter(NotificationModel.is_read == False) - return query.order_by(NotificationModel.created_at.desc()).limit(limit).all() - - -@app.get("/notifications/count") -def notification_count(user_id: int, db: Session = Depends(get_db)): - """Get unread notification count.""" - count = db.query(NotificationModel).filter( - NotificationModel.user_id == user_id, - NotificationModel.is_read == False - ).count() - return {"user_id": user_id, "unread": count} - - -@app.post("/notifications/{notification_id}/read") -def mark_read(notification_id: int, db: Session = Depends(get_db)): - n = db.query(NotificationModel).filter(NotificationModel.id == notification_id).first() - if not n: - raise HTTPException(status_code=404, detail="Notification not found") - n.is_read = True - db.commit() - return {"status": "read"} - - -@app.post("/notifications/read-all") -def mark_all_read(user_id: int, db: Session = Depends(get_db)): - """Mark all notifications as read for a user.""" - db.query(NotificationModel).filter( - NotificationModel.user_id == user_id, - NotificationModel.is_read == False - ).update({"is_read": True}) - db.commit() - return {"status": "all_read"} - - -# ============ Webhook Retry ============ - -@app.post("/webhooks/{webhook_id}/retry/{log_id}") -def retry_webhook(webhook_id: int, log_id: int, bg: BackgroundTasks, db: Session = Depends(get_db)): - """Retry a failed webhook delivery.""" - log_entry = db.query(WebhookLog).filter( - WebhookLog.id == log_id, - WebhookLog.webhook_id == webhook_id - ).first() - if not log_entry: - raise HTTPException(status_code=404, detail="Webhook log not found") - wh = db.query(Webhook).filter(Webhook.id == webhook_id).first() - if not wh: - raise HTTPException(status_code=404, detail="Webhook not found") - - bg.add_task(fire_webhooks_sync, log_entry.event, json.loads(log_entry.payload), wh.project_id, db) - return {"status": "retry_queued", "log_id": log_id} - - -# ============ Issue Assignment with Notification ============ - -@app.post("/issues/{issue_id}/assign") -def assign_issue(issue_id: int, assignee_id: int, db: Session = Depends(get_db)): - """Assign issue to user and send notification.""" - issue = db.query(models.Issue).filter(models.Issue.id == issue_id).first() - if not issue: - raise HTTPException(status_code=404, detail="Issue not found") - user = db.query(models.User).filter(models.User.id == assignee_id).first() - if not user: - raise HTTPException(status_code=404, detail="User not found") - - old_assignee = issue.assignee_id - issue.assignee_id = assignee_id - db.commit() - db.refresh(issue) - - # Notify assignee - notify_user(db, assignee_id, "issue.assigned", - f"Issue #{issue.id} assigned to you", - f"'{issue.title}' has been assigned to you.", - "issue", issue.id) - - return {"issue_id": issue.id, "assignee_id": assignee_id, "title": issue.title} - - -# ============ Work Logs / Time Tracking ============ - -from app.models.worklog import WorkLog - - -class WorkLogCreate(PydanticBaseModel): - issue_id: int - user_id: int - hours: float - description: str | None = None - logged_date: datetime - - -class WorkLogResponse(PydanticBaseModel): - id: int - issue_id: int - user_id: int - hours: float - description: str | None = None - logged_date: datetime - created_at: datetime - class Config: - from_attributes = True - - -@app.post("/worklogs", response_model=WorkLogResponse, status_code=status.HTTP_201_CREATED) -def create_worklog(wl: WorkLogCreate, db: Session = Depends(get_db)): - """Log time spent on an issue.""" - issue = db.query(models.Issue).filter(models.Issue.id == wl.issue_id).first() - if not issue: - raise HTTPException(status_code=404, detail="Issue not found") - user = db.query(models.User).filter(models.User.id == wl.user_id).first() - if not user: - raise HTTPException(status_code=404, detail="User not found") - if wl.hours <= 0: - raise HTTPException(status_code=400, detail="Hours must be positive") - db_wl = WorkLog(**wl.model_dump()) - db.add(db_wl) - db.commit() - db.refresh(db_wl) - return db_wl - - -@app.get("/issues/{issue_id}/worklogs", response_model=List[WorkLogResponse]) -def list_issue_worklogs(issue_id: int, db: Session = Depends(get_db)): - """List all work logs for an issue.""" - return db.query(WorkLog).filter(WorkLog.issue_id == issue_id).order_by(WorkLog.logged_date.desc()).all() - - -@app.get("/issues/{issue_id}/worklogs/summary") -def issue_worklog_summary(issue_id: int, db: Session = Depends(get_db)): - """Get total hours logged on an issue.""" - issue = db.query(models.Issue).filter(models.Issue.id == issue_id).first() - if not issue: - raise HTTPException(status_code=404, detail="Issue not found") - from sqlalchemy import func as sqlfunc - total = db.query(sqlfunc.sum(WorkLog.hours)).filter(WorkLog.issue_id == issue_id).scalar() or 0 - count = db.query(WorkLog).filter(WorkLog.issue_id == issue_id).count() - return {"issue_id": issue_id, "total_hours": round(total, 2), "log_count": count} - - -@app.get("/users/{user_id}/worklogs", response_model=List[WorkLogResponse]) -def list_user_worklogs(user_id: int, limit: int = 50, db: Session = Depends(get_db)): - """List work logs by user.""" - return db.query(WorkLog).filter(WorkLog.user_id == user_id).order_by(WorkLog.logged_date.desc()).limit(limit).all() - - -@app.delete("/worklogs/{worklog_id}", status_code=status.HTTP_204_NO_CONTENT) -def delete_worklog(worklog_id: int, db: Session = Depends(get_db)): - wl = db.query(WorkLog).filter(WorkLog.id == worklog_id).first() - if not wl: - raise HTTPException(status_code=404, detail="Work log not found") - db.delete(wl) - db.commit() - return None - - -@app.get("/projects/{project_id}/worklogs/summary") -def project_worklog_summary(project_id: int, db: Session = Depends(get_db)): - """Get time tracking summary for a project.""" - from sqlalchemy import func as sqlfunc - results = db.query( - models.User.id, - models.User.username, - sqlfunc.sum(WorkLog.hours).label("total_hours"), - sqlfunc.count(WorkLog.id).label("log_count") - ).join(WorkLog, WorkLog.user_id == models.User.id)\ - .join(models.Issue, WorkLog.issue_id == models.Issue.id)\ - .filter(models.Issue.project_id == project_id)\ - .group_by(models.User.id, models.User.username).all() - - total = sum(r.total_hours for r in results) - by_user = [{"user_id": r.id, "username": r.username, "hours": round(r.total_hours, 2), "logs": r.log_count} for r in results] - return {"project_id": project_id, "total_hours": round(total, 2), "by_user": by_user}