|
# retoor <retoor@molodetz.nl>
|
|
|
|
import logging
|
|
from pathlib import Path
|
|
from typing import Annotated
|
|
|
|
from fastapi import APIRouter, Form, Request
|
|
from fastapi.responses import FileResponse, JSONResponse
|
|
|
|
from devplacepy.config import PLANNING_REPORTS_DIR
|
|
from devplacepy.models import PlanningForm
|
|
from devplacepy.responses import json_error
|
|
from devplacepy.schemas import PlanningJobOut
|
|
from devplacepy.services.audit import record as audit
|
|
from devplacepy.services.gitea.config import gitea_config
|
|
from devplacepy.services.jobs import queue
|
|
from devplacepy.utils import not_found, require_admin
|
|
|
|
logger = logging.getLogger(__name__)
|
|
router = APIRouter()
|
|
|
|
PLANNING_KIND = "planning"
|
|
RETENTION_EXTEND_SECONDS = 7 * 24 * 60 * 60
|
|
PREFERRED_NAME = "open-tickets-plan"
|
|
|
|
|
|
def _status_payload(job: dict) -> dict:
|
|
result = job.get("result", {})
|
|
done = job.get("status") == queue.DONE
|
|
return {
|
|
"uid": job.get("uid", ""),
|
|
"kind": job.get("kind", ""),
|
|
"status": job.get("status", ""),
|
|
"preferred_name": job.get("preferred_name"),
|
|
"download_url": result.get("download_url") if done else None,
|
|
"markdown": result.get("markdown") if done else None,
|
|
"ai_used": bool(result.get("ai_used")) if done else False,
|
|
"error": job.get("error") or None,
|
|
"issue_count": int(result.get("item_count") or 0),
|
|
"bytes_out": int(job.get("bytes_out") or result.get("bytes_out") or 0),
|
|
"created_at": job.get("created_at"),
|
|
"completed_at": job.get("completed_at") or None,
|
|
}
|
|
|
|
|
|
def _parse_numbers(raw: str) -> list[int]:
|
|
numbers: list[int] = []
|
|
for token in (raw or "").replace(",", " ").split():
|
|
try:
|
|
number = int(token)
|
|
except ValueError:
|
|
continue
|
|
if number > 0 and number not in numbers:
|
|
numbers.append(number)
|
|
return numbers
|
|
|
|
|
|
@router.post("/planning")
|
|
async def planning_generate(
|
|
request: Request, data: Annotated[PlanningForm, Form()]
|
|
):
|
|
admin = require_admin(request)
|
|
if not gitea_config().is_configured:
|
|
return json_error(503, "The issue tracker is not configured")
|
|
numbers = _parse_numbers(data.numbers)
|
|
uid = queue.enqueue(
|
|
"planning", {"numbers": numbers}, "user", admin["uid"], PREFERRED_NAME
|
|
)
|
|
scope = f"{len(numbers)} selected tickets" if numbers else "all open tickets"
|
|
audit.record(
|
|
request,
|
|
"issue.planning.request",
|
|
user=admin,
|
|
target_type="issue",
|
|
summary=f"requested a planning report for {scope}",
|
|
metadata={"selected": len(numbers)},
|
|
links=[audit.job(uid)],
|
|
)
|
|
return JSONResponse({"uid": uid, "status_url": f"/issues/planning/{uid}"})
|
|
|
|
|
|
@router.get("/planning/{uid}")
|
|
async def planning_status(request: Request, uid: str):
|
|
require_admin(request)
|
|
job = queue.get_job(uid)
|
|
if not job or job.get("kind") != PLANNING_KIND:
|
|
raise not_found("Planning job not found")
|
|
return JSONResponse(
|
|
PlanningJobOut.model_validate(_status_payload(job)).model_dump(mode="json")
|
|
)
|
|
|
|
|
|
@router.get("/planning/{uid}/download")
|
|
async def planning_download(request: Request, uid: str):
|
|
require_admin(request)
|
|
job = queue.get_job(uid)
|
|
if (
|
|
not job
|
|
or job.get("kind") != PLANNING_KIND
|
|
or job.get("status") != queue.DONE
|
|
):
|
|
raise not_found("Planning report not available")
|
|
result = job.get("result", {})
|
|
local_path = result.get("local_path", "")
|
|
resolved = Path(local_path).resolve()
|
|
if (
|
|
not local_path
|
|
or not resolved.is_relative_to(PLANNING_REPORTS_DIR.resolve())
|
|
or not resolved.is_file()
|
|
):
|
|
raise not_found("Planning report not available")
|
|
queue.touch_job(uid, RETENTION_EXTEND_SECONDS)
|
|
return FileResponse(
|
|
resolved,
|
|
filename=result.get("final_name", "open-tickets-plan.md"),
|
|
media_type="text/markdown",
|
|
)
|