Merge pull request 'feat: Webhook system + CLI tool' (#2) from feat/webhook-and-cli into main

Reviewed-on: #2
This commit was merged in pull request #2.
This commit is contained in:
h z
2026-02-24 04:11:53 +00:00
23 changed files with 1809 additions and 373 deletions

181
README.md
View File

@@ -1,68 +1,139 @@
# HarborForge Project Plan # HarborForge Backend
## Overview Agent/人类协同任务管理平台 - FastAPI 后端
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.
## Workstreams & Task Breakdown ## API Endpoints (38)
### 1. Architecture & Platform Foundation ### Auth
- Document system components: API service, React SPA, Python CLI/bot runner, MySQL, webhook dispatcher, optional worker queue. - `POST /auth/token` - 登录获取 JWT token
- Define communication contracts (REST+webhook) and env/secret management. - `GET /auth/me` - 获取当前用户信息
- 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.
### 2. Authentication & Role-Based Access ### Issues
- 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 35 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.
### 3. Core Issue/Work Item Platform > Issues 和 Search 列表接口返回分页格式:`{items, total, page, page_size, total_pages}`
- Design issue model that supports multiple types (task/story/test), state transitions, dependencies, tags, priority, comments. > Issues 支持排序参数:`sort_by` (created_at/priority/title/due_date/status), `sort_order` (asc/desc)
- Support multi-account/role membership per project and enforce permission matrix at API layer. > Issues 支持额外过滤:`assignee_id`, `tag`
- Guarantee every user-facing action can be invoked via API/CLI.
- Catalog automation touchpoints (issue lifecycle updates, script triggers, deployment actions) for future extension.
### 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 > Issues 和 Search 列表接口返回分页格式:
- Build interactive Python CLI mirroring UI flows; commands are rendered differently but invoke same backend APIs. > Issues 支持排序参数: (created_at/priority/title/due_date/status), (asc/desc)
- CLI uses credential files or env tokens; includes runtime prompts for confirmation when credential rotation occurs. > Issues 支持额外过滤:,
- Bot agent runner polls API for pending automations, executes scripts, and emits callbacks via webhook. - `POST /issues` - 创建 issue支持 resolution 决议案类型)
- Document CLI command set (issue CRUD, role management, automation triggers, logs) and error handling. - `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 ### Comments
- Enumerate APIs for all core operations: issues, comments, projects, roles, credentials, automation scripts, audit logs. - `POST /comments` - 创建评论
- Design webhook schema and catalog events to support: issue lifecycle, authentication events, automation/script completion. - `GET /issues/{id}/comments` - 列表
- Ensure webhook delivery is authenticated and configurable; details to be defined during implementation. - `PATCH /comments/{id}` - 更新
- Provide API for credential confirmation flows and for requesting new credentials after deletion. - `DELETE /comments/{id}` - 删除
### 7. Observability & Logging ### Projects
- Log all API invocations to a file on Docker-mounted volume, capped at 10 MB with rotation. - `POST /projects` - 创建
- Keep a webhook audit log (which event, trigger source, response status) for troubleshooting. - `GET /projects` - 列表
- Provide endpoints/UI to browse recent logs (within retention threshold). - `GET /projects/{id}` - 详情
- `PATCH /projects/{id}` - 更新
- `DELETE /projects/{id}` - 删除
### 8. Deployment & Security Posture ### Project Members
- Keep `ufw` locked down on `vps.t1` and rely on SSH tunnels (`ssh -L`) for UI/CLI testing. - `POST /projects/{id}/members` - 添加成员
- Document CLI/agent testing setup (SSH tunnel commands, env vars) so Zhi/Hangman can coordinate. - `GET /projects/{id}/members` - 列表
- Outline backup strategy for MySQL data directory within the Compose stack. - `DELETE /projects/{id}/members/{user_id}` - 移除
- Confirm `AbstractWizard` handles initial admin/project creation; fall back to Zhi if issues arise.
### 9. Collaboration & Delivery Rhythm ### Users
- Maintain the new channel with templates for requirements, story cards, milestones, and progress updates. - `POST /users` - 注册
- Use channel to publish deliverables (architecture doc, API spec, deployment instructions, automation scenarios). - `GET /users` - 列表
- Record testing points for UI/CLI without prescribing steps; let Hangman/Zhi define the execution details. - `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? ### 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=<your-token>
# 命令
python3 cli.py login <username> <password>
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 <issue_id> <new_status>
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 <milestone_id>
python3 cli.py notifications -u <user_id> [--unread]
python3 cli.py overdue [-p project_id]
python3 cli.py log-time <issue_id> <user_id> <hours> [-d "description"]
python3 cli.py worklogs <issue_id>
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 僵局提交)|

78
app/api/deps.py Normal file
View File

@@ -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")

View File

32
app/api/routers/auth.py Normal file
View File

@@ -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

View File

@@ -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

299
app/api/routers/issues.py Normal file
View File

@@ -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}

320
app/api/routers/misc.py Normal file
View File

@@ -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}

114
app/api/routers/projects.py Normal file
View File

@@ -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}

80
app/api/routers/users.py Normal file
View File

@@ -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()

View File

@@ -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}

View File

@@ -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 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( app = FastAPI(
title="HarborForge API", title="HarborForge API",
description="Agent/人类协同任务管理平台 API", description="Agent/人类协同任务管理平台 API",
version="0.1.0" version="0.2.0"
) )
# CORS # CORS
@@ -27,319 +17,35 @@ app.add_middleware(
allow_headers=["*"], allow_headers=["*"],
) )
# Auth # Health & version (kept at top level)
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") @app.get("/health", tags=["System"])
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")
def health_check(): def health_check():
return {"status": "healthy"} return {"status": "healthy"}
@app.get("/version", tags=["System"])
def version():
return {"name": "HarborForge", "version": "0.2.0", "description": "Agent/人类协同任务管理平台"}
# ============ Auth API ============ # Register routers
from app.api.routers.auth import router as auth_router
@app.post("/auth/token", response_model=Token) from app.api.routers.issues import router as issues_router
async def login(form_data: OAuth2PasswordRequestForm = Depends(), db: Session = Depends(get_db)): from app.api.routers.projects import router as projects_router
user = db.query(models.User).filter(models.User.username == form_data.username).first() from app.api.routers.users import router as users_router
if not user or not verify_password(form_data.password, user.hashed_password or ""): from app.api.routers.comments import router as comments_router
raise HTTPException(status_code=401, detail="Incorrect username or password", headers={"WWW-Authenticate": "Bearer"}) from app.api.routers.webhooks import router as webhooks_router
if not user.is_active: from app.api.routers.misc import router as misc_router
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
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 # Run database migration on startup
@app.on_event("startup") @app.on_event("startup")
def startup(): def startup():
from app.core.config import Base, engine from app.core.config import Base, engine
from app.models import webhook, apikey, activity, milestone, notification, worklog
Base.metadata.create_all(bind=engine) 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

15
app/models/activity.py Normal file
View File

@@ -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())

15
app/models/apikey.py Normal file
View File

@@ -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)

25
app/models/milestone.py Normal file
View File

@@ -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")

View File

@@ -56,6 +56,10 @@ class Issue(Base):
# Dependencies # Dependencies
depends_on_id = Column(Integer, ForeignKey("issues.id"), nullable=True) 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") project = relationship("Project", back_populates="issues")
reporter = relationship("User", foreign_keys=[reporter_id], back_populates="reported_issues") reporter = relationship("User", foreign_keys=[reporter_id], back_populates="reported_issues")
assignee = relationship("User", foreign_keys=[assignee_id], back_populates="assigned_issues") assignee = relationship("User", foreign_keys=[assignee_id], back_populates="assigned_issues")

View File

@@ -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")

40
app/models/webhook.py Normal file
View File

@@ -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())

15
app/models/worklog.py Normal file
View File

@@ -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())

View File

@@ -34,6 +34,8 @@ class IssueBase(BaseModel):
priority: IssuePriorityEnum = IssuePriorityEnum.MEDIUM priority: IssuePriorityEnum = IssuePriorityEnum.MEDIUM
tags: Optional[str] = None tags: Optional[str] = None
depends_on_id: Optional[int] = None depends_on_id: Optional[int] = None
due_date: Optional[datetime] = None
milestone_id: Optional[int] = None
class IssueCreate(IssueBase): class IssueCreate(IssueBase):
@@ -54,6 +56,8 @@ class IssueUpdate(BaseModel):
assignee_id: Optional[int] = None assignee_id: Optional[int] = None
tags: Optional[str] = None tags: Optional[str] = None
depends_on_id: Optional[int] = None depends_on_id: Optional[int] = None
due_date: Optional[datetime] = None
milestone_id: Optional[int] = None
# Resolution specific # Resolution specific
resolution_summary: Optional[str] = None resolution_summary: Optional[str] = None
positions: Optional[str] = None positions: Optional[str] = None
@@ -69,6 +73,8 @@ class IssueResponse(IssueBase):
resolution_summary: Optional[str] resolution_summary: Optional[str]
positions: Optional[str] positions: Optional[str]
pending_matters: Optional[str] pending_matters: Optional[str]
due_date: Optional[datetime] = None
milestone_id: Optional[int] = None
created_at: datetime created_at: datetime
updated_at: Optional[datetime] updated_at: Optional[datetime]
@@ -163,3 +169,44 @@ class ProjectMemberResponse(ProjectMemberBase):
class Config: class Config:
from_attributes = True 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

43
app/schemas/webhook.py Normal file
View File

@@ -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

56
app/services/webhook.py Normal file
View File

@@ -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()

331
cli.py Executable file
View File

@@ -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()

View File

@@ -10,3 +10,4 @@ bcrypt==4.0.1
python-multipart==0.0.6 python-multipart==0.0.6
alembic==1.13.1 alembic==1.13.1
python-dotenv==1.0.0 python-dotenv==1.0.0
httpx==0.27.0