diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..8ea3db5 --- /dev/null +++ b/.env.example @@ -0,0 +1,11 @@ +# HarborForge Environment Variables + +# Database +MYSQL_ROOT_PASSWORD=harborforge_root +MYSQL_DATABASE=harborforge +MYSQL_USER=harborforge +MYSQL_PASSWORD=harborforge_pass + +# Application +SECRET_KEY=change-me-use-openssl-rand-hex-32 +LOG_LEVEL=INFO diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..d782d96 --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,23 @@ +FROM python:3.11-slim + +WORKDIR /app + +# Install system dependencies +RUN apt-get update && apt-get install -y \ + build-essential \ + default-libmysqlclient-dev \ + pkg-config \ + && rm -rf /var/lib/apt/lists/* + +# Install Python dependencies +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# Copy application code +COPY . . + +# Expose port +EXPOSE 8000 + +# Run the application +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/api/__init__.py b/backend/app/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/core/__init__.py b/backend/app/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/core/config.py b/backend/app/core/config.py new file mode 100644 index 0000000..09cd622 --- /dev/null +++ b/backend/app/core/config.py @@ -0,0 +1,31 @@ +from sqlalchemy import create_engine +from sqlalchemy.ext.declarative import declarative_base +from sqlalchemy.orm import sessionmaker +from pydantic_settings import BaseSettings +from typing import Optional + + +class Settings(BaseSettings): + DATABASE_URL: str = "mysql+pymysql://harborforge:harborforge_pass@mysql:3306/harborforge" + SECRET_KEY: str = "change-me-in-production" + LOG_LEVEL: str = "INFO" + ALGORITHM: str = "HS256" + ACCESS_TOKEN_EXPIRE_MINUTES: int = 30 + + class Config: + env_file = ".env" + + +settings = Settings() + +engine = create_engine(settings.DATABASE_URL, pool_pre_ping=True) +SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) +Base = declarative_base() + + +def get_db(): + db = SessionLocal() + try: + yield db + finally: + db.close() diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..8b60f07 --- /dev/null +++ b/backend/app/main.py @@ -0,0 +1,190 @@ +from fastapi import FastAPI, Depends, HTTPException, status +from fastapi.middleware.cors import CORSMiddleware +from sqlalchemy.orm import Session +from typing import List + +from app.core.config import get_db, settings +from app.models import models +from app.schemas import schemas + +app = FastAPI( + title="HarborForge API", + description="Agent/人类协同任务管理平台 API", + version="0.1.0" +) + +# CORS +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + + +# Health check +@app.get("/health") +def health_check(): + return {"status": "healthy"} + + +# ============ 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, + 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 status: + query = query.filter(models.Issue.status == 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)): + # Check if username or email exists + 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") + + # Hash password if provided + hashed_password = None + if user.password: + from passlib.context import CryptContext + pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") + hashed_password = pwd_context.hash(user.password) + + 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 + Base.metadata.create_all(bind=engine) diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/models/models.py b/backend/app/models/models.py new file mode 100644 index 0000000..6475e97 --- /dev/null +++ b/backend/app/models/models.py @@ -0,0 +1,122 @@ +from sqlalchemy import Column, Integer, String, Text, DateTime, ForeignKey, Enum, Boolean +from sqlalchemy.orm import relationship +from sqlalchemy.sql import func +from app.core.config import Base +import enum + + +class IssueType(str, enum.Enum): + TASK = "task" + STORY = "story" + TEST = "test" + RESOLUTION = "resolution" # 决议案 - 用于 Agent 僵局提交 + + +class IssueStatus(str, enum.Enum): + OPEN = "open" + IN_PROGRESS = "in_progress" + RESOLVED = "resolved" + CLOSED = "closed" + BLOCKED = "blocked" + + +class IssuePriority(str, enum.Enum): + LOW = "low" + MEDIUM = "medium" + HIGH = "high" + CRITICAL = "critical" + + +class Issue(Base): + __tablename__ = "issues" + + id = Column(Integer, primary_key=True, index=True) + title = Column(String(255), nullable=False) + description = Column(Text, nullable=True) + issue_type = Column(Enum(IssueType), default=IssueType.TASK) + status = Column(Enum(IssueStatus), default=IssueStatus.OPEN) + priority = Column(Enum(IssuePriority), default=IssuePriority.MEDIUM) + + # Relationships + project_id = Column(Integer, ForeignKey("projects.id"), nullable=False) + reporter_id = Column(Integer, ForeignKey("users.id"), nullable=False) + assignee_id = Column(Integer, ForeignKey("users.id"), nullable=True) + + # Resolution specific fields (for RESOLUTION type) + resolution_summary = Column(Text, nullable=True) # 僵局摘要 + positions = Column(Text, nullable=True) # 各方立场 (JSON) + pending_matters = Column(Text, nullable=True) # 待决事项 + + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column(DateTime(timezone=True), onupdate=func.now()) + + # Tags (comma-separated for simplicity) + tags = Column(String(500), nullable=True) + + # Dependencies + depends_on_id = Column(Integer, ForeignKey("issues.id"), nullable=True) + + project = relationship("Project", back_populates="issues") + reporter = relationship("User", foreign_keys=[reporter_id], back_populates="reported_issues") + assignee = relationship("User", foreign_keys=[assignee_id], back_populates="assigned_issues") + comments = relationship("Comment", back_populates="issue", cascade="all, delete-orphan") + + +class Comment(Base): + __tablename__ = "comments" + + id = Column(Integer, primary_key=True, index=True) + content = Column(Text, nullable=False) + issue_id = Column(Integer, ForeignKey("issues.id"), nullable=False) + author_id = Column(Integer, ForeignKey("users.id"), nullable=False) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column(DateTime(timezone=True), onupdate=func.now()) + + issue = relationship("Issue", back_populates="comments") + author = relationship("User", back_populates="comments") + + +class Project(Base): + __tablename__ = "projects" + + id = Column(Integer, primary_key=True, index=True) + name = Column(String(100), unique=True, nullable=False) + description = Column(Text, nullable=True) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + + owner_id = Column(Integer, ForeignKey("users.id"), nullable=False) + + issues = relationship("Issue", back_populates="project", cascade="all, delete-orphan") + members = relationship("ProjectMember", back_populates="project", cascade="all, delete-orphan") + owner = relationship("User", back_populates="owned_projects") + + +class User(Base): + __tablename__ = "users" + + id = Column(Integer, primary_key=True, index=True) + username = Column(String(50), unique=True, nullable=False) + email = Column(String(100), unique=True, nullable=False) + hashed_password = Column(String(255), nullable=True) # Nullable for OAuth users + full_name = Column(String(100), nullable=True) + is_active = Column(Boolean, default=True) + is_admin = Column(Boolean, default=False) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + + owned_projects = relationship("Project", back_populates="owner") + reported_issues = relationship("Issue", foreign_keys=[Issue.reporter_id], back_populates="reporter") + assigned_issues = relationship("Issue", foreign_keys=[Issue.assignee_id], back_populates="assignee") + comments = relationship("Comment", back_populates="author") + project_memberships = relationship("ProjectMember", back_populates="user") + + +class ProjectMember(Base): + __tablename__ = "project_members" + + id = Column(Integer, primary_key=True, index=True) + project_id = Column(Integer, ForeignKey("projects.id"), nullable=False) + user_id = Column(Integer, ForeignKey("users.id"), nullable=False) + role = Column(String(20), default="dev") # admin, dev, mgr, ops + + project = relationship("Project", back_populates="members") + user = relationship("User", back_populates="project_memberships") diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/schemas/schemas.py b/backend/app/schemas/schemas.py new file mode 100644 index 0000000..956a67b --- /dev/null +++ b/backend/app/schemas/schemas.py @@ -0,0 +1,165 @@ +from pydantic import BaseModel +from typing import Optional, List +from datetime import datetime +from enum import Enum + + +class IssueTypeEnum(str, Enum): + TASK = "task" + STORY = "story" + TEST = "test" + RESOLUTION = "resolution" + + +class IssueStatusEnum(str, Enum): + OPEN = "open" + IN_PROGRESS = "in_progress" + RESOLVED = "resolved" + CLOSED = "closed" + BLOCKED = "blocked" + + +class IssuePriorityEnum(str, Enum): + LOW = "low" + MEDIUM = "medium" + HIGH = "high" + CRITICAL = "critical" + + +# Issue schemas +class IssueBase(BaseModel): + title: str + description: Optional[str] = None + issue_type: IssueTypeEnum = IssueTypeEnum.TASK + priority: IssuePriorityEnum = IssuePriorityEnum.MEDIUM + tags: Optional[str] = None + depends_on_id: Optional[int] = None + + +class IssueCreate(IssueBase): + project_id: int + reporter_id: int + assignee_id: Optional[int] = None + # Resolution specific + resolution_summary: Optional[str] = None + positions: Optional[str] = None + pending_matters: Optional[str] = None + + +class IssueUpdate(BaseModel): + title: Optional[str] = None + description: Optional[str] = None + status: Optional[IssueStatusEnum] = None + priority: Optional[IssuePriorityEnum] = None + assignee_id: Optional[int] = None + tags: Optional[str] = None + depends_on_id: Optional[int] = None + # Resolution specific + resolution_summary: Optional[str] = None + positions: Optional[str] = None + pending_matters: Optional[str] = None + + +class IssueResponse(IssueBase): + id: int + status: IssueStatusEnum + project_id: int + reporter_id: int + assignee_id: Optional[int] + resolution_summary: Optional[str] + positions: Optional[str] + pending_matters: Optional[str] + created_at: datetime + updated_at: Optional[datetime] + + class Config: + from_attributes = True + + +# Comment schemas +class CommentBase(BaseModel): + content: str + + +class CommentCreate(CommentBase): + issue_id: int + author_id: int + + +class CommentUpdate(BaseModel): + content: Optional[str] = None + + +class CommentResponse(CommentBase): + id: int + issue_id: int + author_id: int + created_at: datetime + updated_at: Optional[datetime] + + class Config: + from_attributes = True + + +# Project schemas +class ProjectBase(BaseModel): + name: str + description: Optional[str] = None + + +class ProjectCreate(ProjectBase): + owner_id: int + + +class ProjectUpdate(BaseModel): + name: Optional[str] = None + description: Optional[str] = None + + +class ProjectResponse(ProjectBase): + id: int + owner_id: int + created_at: datetime + + class Config: + from_attributes = True + + +# User schemas +class UserBase(BaseModel): + username: str + email: str + full_name: Optional[str] = None + + +class UserCreate(UserBase): + password: Optional[str] = None + is_admin: bool = False + + +class UserResponse(UserBase): + id: int + is_active: bool + is_admin: bool + created_at: datetime + + class Config: + from_attributes = True + + +# Project Member schemas +class ProjectMemberBase(BaseModel): + user_id: int + project_id: int + role: str = "dev" + + +class ProjectMemberCreate(ProjectMemberBase): + pass + + +class ProjectMemberResponse(ProjectMemberBase): + id: int + + class Config: + from_attributes = True diff --git a/backend/app/services/__init__.py b/backend/app/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..9623ca0 --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,11 @@ +fastapi==0.109.0 +uvicorn[standard]==0.27.0 +sqlalchemy==2.0.25 +pymysql==1.1.0 +pydantic==2.5.3 +pydantic-settings==2.1.0 +python-jose[cryptography]==3.3.0 +passlib[bcrypt]==1.7.4 +python-multipart==0.0.6 +alembic==1.13.1 +python-dotenv==1.0.0 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..214f113 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,91 @@ +version: '3.8' + +services: + # MySQL Database + mysql: + image: mysql:8.0 + container_name: harborforge-mysql + restart: unless-stopped + environment: + MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:-harborforge_root} + MYSQL_DATABASE: ${MYSQL_DATABASE:-harborforge} + MYSQL_USER: ${MYSQL_USER:-harborforge} + MYSQL_PASSWORD: ${MYSQL_PASSWORD:-harborforge_pass} + volumes: + - mysql_data:/var/lib/mysql + ports: + - "3306:3306" + networks: + - harborforge + healthcheck: + test: ["CMD", "mysqladmin", "ping", "-h", "localhost"] + interval: 10s + timeout: 5s + retries: 5 + deploy: + resources: + limits: + cpus: '0.5' + memory: 512M + + # FastAPI Backend + backend: + build: + context: ./backend + dockerfile: Dockerfile + container_name: harborforge-backend + restart: unless-stopped + environment: + DATABASE_URL: mysql+pymysql://${MYSQL_USER:-harborforge}:${MYSQL_PASSWORD:-harborforge_pass}@mysql:3306/${MYSQL_DATABASE:-harborforge} + SECRET_KEY: ${SECRET_KEY:-change_me_in_production} + LOG_LEVEL: ${LOG_LEVEL:-INFO} + ports: + - "8000:8000" + depends_on: + mysql: + condition: service_healthy + networks: + - harborforge + volumes: + - ./backend:/app + deploy: + resources: + limits: + cpus: '0.5' + memory: 512M + + # React Frontend (optional, for future) + # frontend: + # build: + # context: ./frontend + # dockerfile: Dockerfile + # container_name: harborforge-frontend + # restart: unless-stopped + # ports: + # - "3000:3000" + # depends_on: + # - backend + # networks: + # - harborforge + # volumes: + # - ./frontend:/app + # - /app/node_modules + + # CLI (run on demand) + # cli: + # build: + # context: ./cli + # dockerfile: Dockerfile + # volumes: + # - ./cli:/app + # - ~/.harborforge:/root/.harborforge + # networks: + # - harborforge + +networks: + harborforge: + driver: bridge + +volumes: + mysql_data: + driver: local