feat: Webhook system + CLI tool #2

Merged
hzhang merged 22 commits from feat/webhook-and-cli into main 2026-02-24 04:11:53 +00:00
3 changed files with 204 additions and 0 deletions
Showing only changes of commit 0a8b18729b - Show all commits

View File

@@ -268,6 +268,7 @@ def startup():
from app.models import apikey
from app.models import activity
from app.models import milestone
from app.models import notification
Base.metadata.create_all(bind=engine)
@@ -870,6 +871,7 @@ def milestone_progress(milestone_id: int, db: Session = Depends(get_db)):
# ============ Export API ============
import csv
import json
import io
from fastapi.responses import StreamingResponse
@@ -900,3 +902,115 @@ def export_issues_csv(project_id: int = None, db: Session = Depends(get_db)):
headers={"Content-Disposition": "attachment; filename=issues.csv"}
)
# ============ Notifications API ============
from app.models.notification import Notification as NotificationModel
class NotificationResponse(PydanticBaseModel):
id: int
user_id: int
type: str
title: str
message: str | None = None
entity_type: str | None = None
entity_id: int | None = None
is_read: bool
created_at: datetime
class Config:
from_attributes = True
def notify_user(db: Session, user_id: int, ntype: str, title: str, message: str = None, entity_type: str = None, entity_id: int = None):
"""Helper to create a notification."""
n = NotificationModel(user_id=user_id, type=ntype, title=title, message=message, entity_type=entity_type, entity_id=entity_id)
db.add(n)
db.commit()
return n
@app.get("/notifications", response_model=List[NotificationResponse])
def list_notifications(user_id: int, unread_only: bool = False, limit: int = 50, db: Session = Depends(get_db)):
"""List notifications for a user."""
query = db.query(NotificationModel).filter(NotificationModel.user_id == user_id)
if unread_only:
query = query.filter(NotificationModel.is_read == False)
return query.order_by(NotificationModel.created_at.desc()).limit(limit).all()
@app.get("/notifications/count")
def notification_count(user_id: int, db: Session = Depends(get_db)):
"""Get unread notification count."""
count = db.query(NotificationModel).filter(
NotificationModel.user_id == user_id,
NotificationModel.is_read == False
).count()
return {"user_id": user_id, "unread": count}
@app.post("/notifications/{notification_id}/read")
def mark_read(notification_id: int, db: Session = Depends(get_db)):
n = db.query(NotificationModel).filter(NotificationModel.id == notification_id).first()
if not n:
raise HTTPException(status_code=404, detail="Notification not found")
n.is_read = True
db.commit()
return {"status": "read"}
@app.post("/notifications/read-all")
def mark_all_read(user_id: int, db: Session = Depends(get_db)):
"""Mark all notifications as read for a user."""
db.query(NotificationModel).filter(
NotificationModel.user_id == user_id,
NotificationModel.is_read == False
).update({"is_read": True})
db.commit()
return {"status": "all_read"}
# ============ Webhook Retry ============
@app.post("/webhooks/{webhook_id}/retry/{log_id}")
def retry_webhook(webhook_id: int, log_id: int, bg: BackgroundTasks, db: Session = Depends(get_db)):
"""Retry a failed webhook delivery."""
log_entry = db.query(WebhookLog).filter(
WebhookLog.id == log_id,
WebhookLog.webhook_id == webhook_id
).first()
if not log_entry:
raise HTTPException(status_code=404, detail="Webhook log not found")
wh = db.query(Webhook).filter(Webhook.id == webhook_id).first()
if not wh:
raise HTTPException(status_code=404, detail="Webhook not found")
bg.add_task(fire_webhooks_sync, log_entry.event, json.loads(log_entry.payload), wh.project_id, db)
return {"status": "retry_queued", "log_id": log_id}
# ============ Issue Assignment with Notification ============
@app.post("/issues/{issue_id}/assign")
def assign_issue(issue_id: int, assignee_id: int, db: Session = Depends(get_db)):
"""Assign issue to user and send notification."""
issue = db.query(models.Issue).filter(models.Issue.id == issue_id).first()
if not issue:
raise HTTPException(status_code=404, detail="Issue not found")
user = db.query(models.User).filter(models.User.id == assignee_id).first()
if not user:
raise HTTPException(status_code=404, detail="User not found")
old_assignee = issue.assignee_id
issue.assignee_id = assignee_id
db.commit()
db.refresh(issue)
# Notify assignee
notify_user(db, assignee_id, "issue.assigned",
f"Issue #{issue.id} assigned to you",
f"'{issue.title}' has been assigned to you.",
"issue", issue.id)
return {"issue_id": issue.id, "assignee_id": assignee_id, "title": issue.title}

View File

@@ -0,0 +1,20 @@
from sqlalchemy import Column, Integer, String, Text, DateTime, Boolean, ForeignKey
from sqlalchemy.sql import func
from sqlalchemy.orm import relationship
from app.core.config import Base
class Notification(Base):
__tablename__ = "notifications"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
type = Column(String(50), nullable=False) # issue.assigned, issue.mentioned, comment.added, milestone.due
title = Column(String(255), nullable=False)
message = Column(Text, nullable=True)
entity_type = Column(String(50), nullable=True) # issue, comment, milestone
entity_id = Column(Integer, nullable=True)
is_read = Column(Boolean, default=False)
created_at = Column(DateTime(timezone=True), server_default=func.now())
user = relationship("User")

70
cli.py
View File

@@ -145,6 +145,54 @@ def cmd_stats(args):
print(f" {t}: {c}")
def cmd_milestones(args):
params = f"?project_id={args.project}" if args.project else ""
milestones = _request("GET", f"/milestones{params}")
if not milestones:
print(" No milestones found.")
return
for m in milestones:
status_icon = "🟢" if m["status"] == "open" else ""
due = f" (due: {m['due_date'][:10]})" if m.get("due_date") else ""
print(f" {status_icon} #{m['id']} {m['title']}{due}")
def cmd_milestone_progress(args):
result = _request("GET", f"/milestones/{args.milestone_id}/progress")
bar_len = 20
filled = int(bar_len * result["progress_pct"] / 100)
bar = "" * filled + "" * (bar_len - filled)
print(f" {result['title']}")
print(f" [{bar}] {result['progress_pct']}% ({result['completed']}/{result['total_issues']})")
def cmd_notifications(args):
params = [f"user_id={args.user}"]
if args.unread:
params.append("unread_only=true")
qs = "&".join(params)
notifs = _request("GET", f"/notifications?{qs}")
if not notifs:
print(" No notifications.")
return
for n in notifs:
icon = "🔴" if not n["is_read"] else ""
print(f" {icon} [{n['type']}] {n['title']}")
def cmd_overdue(args):
params = f"?project_id={args.project}" if args.project else ""
issues = _request("GET", f"/issues/overdue{params}")
if not issues:
print(" No overdue issues! 🎉")
return
for i in issues:
due = i.get("due_date", "?")[:10] if i.get("due_date") else "?"
print(f" ⏰ #{i['id']} [{i['priority']}] {i['title']} (due: {due})")
def main():
parser = argparse.ArgumentParser(description="HarborForge CLI")
sub = parser.add_subparsers(dest="command")
@@ -201,6 +249,24 @@ def main():
p_stats = sub.add_parser("stats", help="Dashboard stats")
p_stats.add_argument("--project", "-p", type=int)
# milestones
p_ms = sub.add_parser("milestones", help="List milestones")
p_ms.add_argument("--project", "-p", type=int)
# milestone progress
p_msp = sub.add_parser("milestone-progress", help="Show milestone progress")
p_msp.add_argument("milestone_id", type=int)
# notifications
p_notif = sub.add_parser("notifications", help="List notifications")
p_notif.add_argument("--user", "-u", type=int, required=True)
p_notif.add_argument("--unread", action="store_true")
# overdue
p_overdue = sub.add_parser("overdue", help="List overdue issues")
p_overdue.add_argument("--project", "-p", type=int)
args = parser.parse_args()
if not args.command:
parser.print_help()
@@ -217,6 +283,10 @@ def main():
"search": cmd_search,
"transition": cmd_transition,
"stats": cmd_stats,
"milestones": cmd_milestones,
"milestone-progress": cmd_milestone_progress,
"notifications": cmd_notifications,
"overdue": cmd_overdue,
}
cmds[args.command](args)