diff --git a/README.md b/README.md index 165ded7..70831a8 100644 --- a/README.md +++ b/README.md @@ -75,6 +75,14 @@ Agent/人类协同任务管理平台 - FastAPI 后端 ### 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 @@ -99,6 +107,8 @@ python3 cli.py milestones [-p project_id] python3 cli.py milestone-progress python3 cli.py notifications -u [--unread] python3 cli.py overdue [-p project_id] +python3 cli.py log-time [-d "description"] +python3 cli.py worklogs python3 cli.py health python3 cli.py version ``` diff --git a/app/main.py b/app/main.py index 2dae198..3f0464e 100644 --- a/app/main.py +++ b/app/main.py @@ -269,6 +269,7 @@ def startup(): from app.models import activity from app.models import milestone from app.models import notification + from app.models import worklog Base.metadata.create_all(bind=engine) @@ -1014,3 +1015,99 @@ def assign_issue(issue_id: int, assignee_id: int, db: Session = Depends(get_db)) "issue", issue.id) return {"issue_id": issue.id, "assignee_id": assignee_id, "title": issue.title} + + +# ============ Work Logs / Time Tracking ============ + +from app.models.worklog import WorkLog + + +class WorkLogCreate(PydanticBaseModel): + issue_id: int + user_id: int + hours: float + description: str | None = None + logged_date: datetime + + +class WorkLogResponse(PydanticBaseModel): + 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 + + +@app.post("/worklogs", response_model=WorkLogResponse, status_code=status.HTTP_201_CREATED) +def create_worklog(wl: WorkLogCreate, db: Session = Depends(get_db)): + """Log time spent on an issue.""" + 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 + + +@app.get("/issues/{issue_id}/worklogs", response_model=List[WorkLogResponse]) +def list_issue_worklogs(issue_id: int, db: Session = Depends(get_db)): + """List all work logs for an issue.""" + return db.query(WorkLog).filter(WorkLog.issue_id == issue_id).order_by(WorkLog.logged_date.desc()).all() + + +@app.get("/issues/{issue_id}/worklogs/summary") +def issue_worklog_summary(issue_id: int, db: Session = Depends(get_db)): + """Get total hours logged on an issue.""" + issue = db.query(models.Issue).filter(models.Issue.id == issue_id).first() + if not issue: + raise HTTPException(status_code=404, detail="Issue not found") + from sqlalchemy import func as sqlfunc + 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} + + +@app.get("/users/{user_id}/worklogs", response_model=List[WorkLogResponse]) +def list_user_worklogs(user_id: int, limit: int = 50, db: Session = Depends(get_db)): + """List work logs by user.""" + return db.query(WorkLog).filter(WorkLog.user_id == user_id).order_by(WorkLog.logged_date.desc()).limit(limit).all() + + +@app.delete("/worklogs/{worklog_id}", status_code=status.HTTP_204_NO_CONTENT) +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 + + +@app.get("/projects/{project_id}/worklogs/summary") +def project_worklog_summary(project_id: int, db: Session = Depends(get_db)): + """Get time tracking summary for a project.""" + from sqlalchemy import func as sqlfunc + results = db.query( + models.User.id, + models.User.username, + sqlfunc.sum(WorkLog.hours).label("total_hours"), + sqlfunc.count(WorkLog.id).label("log_count") + ).join(WorkLog, WorkLog.user_id == models.User.id)\ + .join(models.Issue, WorkLog.issue_id == models.Issue.id)\ + .filter(models.Issue.project_id == project_id)\ + .group_by(models.User.id, models.User.username).all() + + total = sum(r.total_hours for r in results) + by_user = [{"user_id": r.id, "username": r.username, "hours": round(r.total_hours, 2), "logs": r.log_count} for r in results] + return {"project_id": project_id, "total_hours": round(total, 2), "by_user": by_user} diff --git a/app/models/worklog.py b/app/models/worklog.py new file mode 100644 index 0000000..53decf8 --- /dev/null +++ b/app/models/worklog.py @@ -0,0 +1,15 @@ +from sqlalchemy import Column, Integer, Text, DateTime, ForeignKey, Float +from sqlalchemy.sql import func +from app.core.config import Base + + +class WorkLog(Base): + __tablename__ = "work_logs" + + id = Column(Integer, primary_key=True, index=True) + issue_id = Column(Integer, ForeignKey("issues.id"), nullable=False) + user_id = Column(Integer, ForeignKey("users.id"), nullable=False) + hours = Column(Float, nullable=False) # Hours spent + description = Column(Text, nullable=True) + logged_date = Column(DateTime(timezone=True), nullable=False) # When the work was done + created_at = Column(DateTime(timezone=True), server_default=func.now()) diff --git a/cli.py b/cli.py index 91dcd79..e4f134c 100755 --- a/cli.py +++ b/cli.py @@ -193,6 +193,30 @@ def cmd_overdue(args): 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") @@ -267,6 +291,15 @@ def main(): 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() @@ -287,6 +320,8 @@ def main(): "milestone-progress": cmd_milestone_progress, "notifications": cmd_notifications, "overdue": cmd_overdue, + "log-time": cmd_log_time, + "worklogs": cmd_worklogs, } cmds[args.command](args)