from fastapi import FastAPI, Depends, HTTPException, status, BackgroundTasks from fastapi.middleware.cors import CORSMiddleware from sqlalchemy.orm import Session from typing import List from datetime import datetime, timedelta from jose import JWTError, jwt from passlib.context import CryptContext from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm from pydantic import BaseModel from app.core.config import get_db, settings from app.models import models from app.schemas import schemas from app.services.webhook import fire_webhooks_sync app = FastAPI( title="HarborForge API", description="Agent/人类协同任务管理平台 API", version="0.1.0" ) # CORS app.add_middleware( CORSMiddleware, allow_origins=["*"], allow_credentials=True, allow_methods=["*"], 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") def health_check(): return {"status": "healthy"} # ============ Auth API ============ @app.post("/auth/token", response_model=Token) async def login(form_data: OAuth2PasswordRequestForm = Depends(), db: Session = Depends(get_db)): user = db.query(models.User).filter(models.User.username == form_data.username).first() if not user or not verify_password(form_data.password, user.hashed_password or ""): raise HTTPException(status_code=401, detail="Incorrect username or password", headers={"WWW-Authenticate": "Bearer"}) if not user.is_active: raise HTTPException(status_code=400, detail="Inactive user") access_token = create_access_token(data={"sub": str(user.id)}, expires_delta=timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)) return {"access_token": access_token, "token_type": "bearer"} @app.get("/auth/me", response_model=schemas.UserResponse) async def get_me(current_user: models.User = Depends(get_current_user)): return current_user # ============ Issues API ============ @app.post("/issues", response_model=schemas.IssueResponse, status_code=status.HTTP_201_CREATED) def create_issue(issue: schemas.IssueCreate, bg: BackgroundTasks, db: Session = Depends(get_db)): db_issue = models.Issue(**issue.model_dump()) db.add(db_issue) db.commit() db.refresh(db_issue) event = "resolution.created" if db_issue.issue_type == "resolution" else "issue.created" bg.add_task(fire_webhooks_sync, event, {"issue_id": db_issue.id, "title": db_issue.title, "type": db_issue.issue_type, "status": db_issue.status}, db_issue.project_id, db) return db_issue @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 # Run database migration on startup @app.on_event("startup") def startup(): from app.core.config import Base, engine from app.models import webhook Base.metadata.create_all(bind=engine) # ============ Project Members API ============ @app.post("/projects/{project_id}/members", response_model=schemas.ProjectMemberResponse, status_code=status.HTTP_201_CREATED) def add_project_member(project_id: int, member: schemas.ProjectMemberCreate, db: Session = Depends(get_db)): project = db.query(models.Project).filter(models.Project.id == project_id).first() if not project: raise HTTPException(status_code=404, detail="Project not found") user = db.query(models.User).filter(models.User.id == member.user_id).first() if not user: raise HTTPException(status_code=404, detail="User not found") existing = db.query(models.ProjectMember).filter( models.ProjectMember.project_id == project_id, models.ProjectMember.user_id == member.user_id ).first() if existing: raise HTTPException(status_code=400, detail="User already a member") db_member = models.ProjectMember(project_id=project_id, user_id=member.user_id, role=member.role) db.add(db_member) db.commit() db.refresh(db_member) return db_member @app.get("/projects/{project_id}/members", response_model=List[schemas.ProjectMemberResponse]) def list_project_members(project_id: int, db: Session = Depends(get_db)): members = db.query(models.ProjectMember).filter(models.ProjectMember.project_id == project_id).all() return members @app.delete("/projects/{project_id}/members/{user_id}", status_code=status.HTTP_204_NO_CONTENT) def remove_project_member(project_id: int, user_id: int, db: Session = Depends(get_db)): member = db.query(models.ProjectMember).filter( models.ProjectMember.project_id == project_id, models.ProjectMember.user_id == user_id ).first() if not member: raise HTTPException(status_code=404, detail="Member not found") db.delete(member) db.commit() return None # ============ System API ============ @app.get("/version") def version(): return { "name": "HarborForge", "version": "0.1.0", "description": "Agent/人类协同任务管理平台" } # ============ Projects (update/delete) ============ @app.patch("/projects/{project_id}", response_model=schemas.ProjectResponse) def update_project(project_id: int, project_update: schemas.ProjectUpdate, db: Session = Depends(get_db)): project = db.query(models.Project).filter(models.Project.id == project_id).first() if not project: raise HTTPException(status_code=404, detail="Project not found") update_data = project_update.model_dump(exclude_unset=True) for field, value in update_data.items(): setattr(project, field, value) db.commit() db.refresh(project) return project @app.delete("/projects/{project_id}", status_code=status.HTTP_204_NO_CONTENT) def delete_project(project_id: int, db: Session = Depends(get_db)): project = db.query(models.Project).filter(models.Project.id == project_id).first() if not project: raise HTTPException(status_code=404, detail="Project not found") db.delete(project) db.commit() return None # ============ Users (update/delete) ============ @app.patch("/users/{user_id}", response_model=schemas.UserResponse) def update_user(user_id: int, db: Session = Depends(get_db), full_name: str = None, email: str = None): user = db.query(models.User).filter(models.User.id == user_id).first() if not user: raise HTTPException(status_code=404, detail="User not found") if full_name is not None: user.full_name = full_name if email is not None: user.email = email db.commit() db.refresh(user) return user # ============ Webhooks API ============ from app.models.webhook import Webhook, WebhookLog from app.schemas.webhook import WebhookCreate, WebhookUpdate, WebhookResponse, WebhookLogResponse @app.post("/webhooks", response_model=WebhookResponse, status_code=status.HTTP_201_CREATED) def create_webhook(wh: WebhookCreate, db: Session = Depends(get_db)): db_wh = Webhook(**wh.model_dump()) db.add(db_wh) db.commit() db.refresh(db_wh) return db_wh @app.get("/webhooks", response_model=List[WebhookResponse]) def list_webhooks(project_id: int = None, db: Session = Depends(get_db)): query = db.query(Webhook) if project_id is not None: query = query.filter(Webhook.project_id == project_id) return query.all() @app.get("/webhooks/{webhook_id}", response_model=WebhookResponse) def get_webhook(webhook_id: int, db: Session = Depends(get_db)): wh = db.query(Webhook).filter(Webhook.id == webhook_id).first() if not wh: raise HTTPException(status_code=404, detail="Webhook not found") return wh @app.patch("/webhooks/{webhook_id}", response_model=WebhookResponse) def update_webhook(webhook_id: int, wh_update: WebhookUpdate, db: Session = Depends(get_db)): wh = db.query(Webhook).filter(Webhook.id == webhook_id).first() if not wh: raise HTTPException(status_code=404, detail="Webhook not found") for field, value in wh_update.model_dump(exclude_unset=True).items(): setattr(wh, field, value) db.commit() db.refresh(wh) return wh @app.delete("/webhooks/{webhook_id}", status_code=status.HTTP_204_NO_CONTENT) def delete_webhook(webhook_id: int, db: Session = Depends(get_db)): wh = db.query(Webhook).filter(Webhook.id == webhook_id).first() if not wh: raise HTTPException(status_code=404, detail="Webhook not found") db.delete(wh) db.commit() return None @app.get("/webhooks/{webhook_id}/logs", response_model=List[WebhookLogResponse]) def list_webhook_logs(webhook_id: int, limit: int = 50, db: Session = Depends(get_db)): logs = db.query(WebhookLog).filter( WebhookLog.webhook_id == webhook_id ).order_by(WebhookLog.created_at.desc()).limit(limit).all() return logs # ============ Issue Status Transition ============ @app.post("/issues/{issue_id}/transition", response_model=schemas.IssueResponse) def transition_issue(issue_id: int, new_status: str, bg: BackgroundTasks, db: Session = Depends(get_db)): """Transition issue status with validation and webhook.""" valid_statuses = ["open", "in_progress", "resolved", "closed", "blocked"] if new_status not in valid_statuses: raise HTTPException(status_code=400, detail=f"Invalid status. Must be one of: {valid_statuses}") issue = db.query(models.Issue).filter(models.Issue.id == issue_id).first() if not issue: raise HTTPException(status_code=404, detail="Issue not found") old_status = issue.status issue.status = new_status db.commit() db.refresh(issue) event = "issue.closed" if new_status == "closed" else "issue.updated" bg.add_task(fire_webhooks_sync, event, { "issue_id": issue.id, "title": issue.title, "old_status": old_status, "new_status": new_status, }, issue.project_id, db) return issue # ============ Search API ============ @app.get("/search/issues", response_model=List[schemas.IssueResponse]) def search_issues( q: str, project_id: int = None, skip: int = 0, limit: int = 50, db: Session = Depends(get_db) ): """Search issues by title or description keyword.""" query = db.query(models.Issue).filter( (models.Issue.title.contains(q)) | (models.Issue.description.contains(q)) ) if project_id: query = query.filter(models.Issue.project_id == project_id) return query.offset(skip).limit(limit).all()