feat: initial HarborForge project structure

- Docker Compose with MySQL + FastAPI backend
- Issue model with RESOLUTION type (for agent deadlock resolution)
- Project, User, Comment models
- Basic CRUD API endpoints
- .env.example for configuration
This commit is contained in:
root
2026-02-21 08:02:32 +00:00
parent 054eed50e9
commit a557ab6f4a
14 changed files with 644 additions and 0 deletions

23
backend/Dockerfile Normal file
View File

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

0
backend/app/__init__.py Normal file
View File

View File

View File

View File

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

190
backend/app/main.py Normal file
View File

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

View File

View File

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

View File

View File

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

View File

11
backend/requirements.txt Normal file
View File

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