feat: time tracking / work logs (create, list, summary, project summary, CLI commands)
This commit is contained in:
10
README.md
10
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 <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
|
||||
```
|
||||
|
||||
97
app/main.py
97
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}
|
||||
|
||||
15
app/models/worklog.py
Normal file
15
app/models/worklog.py
Normal 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())
|
||||
35
cli.py
35
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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user