diff --git a/README.md b/README.md index d85a0a0..e1ea154 100644 --- a/README.md +++ b/README.md @@ -1,68 +1,139 @@ -# 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 (38) -### 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 -### 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. +> 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` -### 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. -### 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. +> Issues 和 Search 列表接口返回分页格式: +> Issues 支持排序参数: (created_at/priority/title/due_date/status), (asc/desc) +> Issues 支持额外过滤:, +- `POST /issues` - 创建 issue(支持 resolution 决议案类型) +- `GET /issues` - 列表(分页、排序、按 assignee/tag 过滤)(支持按 project/status/type 过滤) +- `GET /issues/{id}` - 详情 +- `PATCH /issues/{id}` - 更新 +- `DELETE /issues/{id}` - 删除 +- `POST /issues/{id}/transition` - 状态变更(触发 webhook) +- `GET /search/issues?q=keyword` - 搜索 -### 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. +### Comments +- `POST /comments` - 创建评论 +- `GET /issues/{id}/comments` - 列表 +- `PATCH /comments/{id}` - 更新 +- `DELETE /comments/{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). +### Projects +- `POST /projects` - 创建 +- `GET /projects` - 列表 +- `GET /projects/{id}` - 详情 +- `PATCH /projects/{id}` - 更新 +- `DELETE /projects/{id}` - 删除 -### 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. +### Project Members +- `POST /projects/{id}/members` - 添加成员 +- `GET /projects/{id}/members` - 列表 +- `DELETE /projects/{id}/members/{user_id}` - 移除 -### 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. +### Users +- `POST /users` - 注册 +- `GET /users` - 列表 +- `GET /users/{id}` - 详情 +- `PATCH /users/{id}` - 更新 ---- +### Webhooks +- `POST /webhooks` - 创建 +- `GET /webhooks` - 列表 +- `GET /webhooks/{id}` - 详情 +- `PATCH /webhooks/{id}` - 更新 +- `DELETE /webhooks/{id}` - 删除 +- `GET /webhooks/{id}/logs` - 投递日志 -Need any section expanded into user stories or backlog cards next? \ No newline at end of file +### System +- `GET /health` - 健康检查 +- `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` - 里程碑完成进度 + +### 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 投递 + +### 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 + +## CLI + +```bash +# 环境变量 +export HARBORFORGE_URL=http://localhost:8000 +export HARBORFORGE_TOKEN= + +# 命令 +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 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 +``` + +## 技术栈 + +- Python 3.11 + FastAPI +- SQLAlchemy + MySQL +- JWT (python-jose) +- Docker + +## Issue Types + +| Type | 用途 | +|------|------| +| task | 普通任务 | +| story | 用户故事 | +| test | 测试用例 | +| resolution | 决议案(Agent 僵局提交)| 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 ead4bf1..fb15eb6 100644 --- a/app/main.py +++ b/app/main.py @@ -1,21 +1,11 @@ -from fastapi import FastAPI, Depends, HTTPException, status +"""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 app = FastAPI( title="HarborForge API", description="Agent/人类协同任务管理平台 API", - version="0.1.0" + version="0.2.0" ) # CORS @@ -27,319 +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, db: Session = Depends(get_db)): - db_issue = models.Issue(**issue.model_dump()) - db.add(db_issue) - db.commit() - db.refresh(db_issue) - return db_issue - - -@app.get("/issues", response_model=List[schemas.IssueResponse]) -def list_issues( - project_id: int = None, - issue_status: str = None, - issue_type: str = None, - skip: int = 0, - limit: int = 100, - db: Session = Depends(get_db) -): - 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 - - -@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, 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 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()) 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) 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/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/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()) 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/app/schemas/schemas.py b/app/schemas/schemas.py index 956a67b..77e2619 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,44 @@ 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 + + +# 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 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 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/cli.py b/cli.py new file mode 100755 index 0000000..e4f134c --- /dev/null +++ b/cli.py @@ -0,0 +1,331 @@ +#!/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 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 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 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") + + # 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") + + + # 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) + + + # 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) + + 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() + 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, + "search": cmd_search, + "transition": cmd_transition, + "stats": cmd_stats, + "milestones": cmd_milestones, + "milestone-progress": cmd_milestone_progress, + "notifications": cmd_notifications, + "overdue": cmd_overdue, + "log-time": cmd_log_time, + "worklogs": cmd_worklogs, + } + cmds[args.command](args) + + +if __name__ == "__main__": + import urllib.parse + main() 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