forked from retoor/devplacepy
feat: add optional ticket selection to planning report generation
Add a `numbers` form field to the planning report endpoint, allowing admins to generate a report for a specific subset of open tickets instead of always planning all open tickets. The selected numbers are parsed from a comma-separated string, deduplicated, and passed to the background job. The job's scope is recorded in the audit log and the Devii tool description is updated to document the new parameter.
This commit is contained in:
@@ -3976,12 +3976,12 @@ four ways to sign requests.
|
||||
method="POST",
|
||||
path="/issues/planning",
|
||||
title="Generate a tickets planning report",
|
||||
summary="Enqueue a complete, phased markdown implementation document covering every open ticket, with each ticket's full description reproduced verbatim plus implementation steps and acceptance criteria. Admin only.",
|
||||
summary="Enqueue a phased markdown implementation document for open tickets, with each ticket's full description reproduced verbatim (inline plus a Source Tickets appendix) alongside implementation steps and acceptance criteria. Admin only.",
|
||||
auth="admin",
|
||||
encoding="form",
|
||||
ajax=True,
|
||||
params=[],
|
||||
notes=["Returns a job uid and status_url. Poll the status_url until status is done to read the markdown and download it."],
|
||||
params=[field("numbers", "form", "string", False, "1,4,7", "Comma-separated issue numbers to include. Omit to plan every open ticket.")],
|
||||
notes=["Returns a job uid and status_url. Poll the status_url until status is done to read the markdown and download it.", "Provide 'numbers' to plan only a chosen subset of open tickets; omitting it plans all open tickets."],
|
||||
sample_response={
|
||||
"uid": "PLANNING_JOB_UID",
|
||||
"status_url": "/issues/planning/PLANNING_JOB_UID",
|
||||
|
||||
@@ -268,6 +268,10 @@ class ForkForm(BaseModel):
|
||||
title: str = Field(min_length=1, max_length=200)
|
||||
|
||||
|
||||
class PlanningForm(BaseModel):
|
||||
numbers: str = Field(default="", max_length=4096)
|
||||
|
||||
|
||||
class UploadUrlForm(BaseModel):
|
||||
url: str = Field(min_length=1, max_length=2048)
|
||||
filename: Optional[str] = Field(default=None, max_length=255)
|
||||
|
||||
@@ -6,7 +6,9 @@ from fastapi import APIRouter, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
|
||||
from devplacepy.seo import base_seo_context
|
||||
from devplacepy.services.gitea import runtime
|
||||
from devplacepy.services.gitea.config import gitea_config
|
||||
from devplacepy.services.gitea.planning import collect_open_issues
|
||||
from devplacepy.templating import templates
|
||||
from devplacepy.utils import require_admin
|
||||
|
||||
@@ -14,6 +16,27 @@ logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
async def _open_tickets() -> tuple[list[dict], bool]:
|
||||
try:
|
||||
issues = await collect_open_issues(runtime.get_client())
|
||||
except Exception as exc:
|
||||
logger.warning("Could not load open tickets for planning: %s", exc)
|
||||
return [], True
|
||||
tickets = [
|
||||
{
|
||||
"number": int(issue.get("number", 0)),
|
||||
"title": str(issue.get("title", "")).strip() or "Untitled",
|
||||
"labels": [
|
||||
str(label.get("name", "")).strip()
|
||||
for label in (issue.get("labels") or [])
|
||||
if isinstance(label, dict) and label.get("name")
|
||||
],
|
||||
}
|
||||
for issue in issues
|
||||
]
|
||||
return tickets, False
|
||||
|
||||
|
||||
@router.get("/issues/planning", response_class=HTMLResponse)
|
||||
async def admin_issues_planning(request: Request):
|
||||
admin = require_admin(request)
|
||||
@@ -28,11 +51,18 @@ async def admin_issues_planning(request: Request):
|
||||
{"name": "Issues", "url": "/issues"},
|
||||
],
|
||||
)
|
||||
configured = gitea_config().is_configured
|
||||
tickets: list[dict] = []
|
||||
tickets_error = False
|
||||
if configured:
|
||||
tickets, tickets_error = await _open_tickets()
|
||||
context = {
|
||||
**seo_ctx,
|
||||
"request": request,
|
||||
"user": admin,
|
||||
"admin_section": "issues",
|
||||
"configured": gitea_config().is_configured,
|
||||
"configured": configured,
|
||||
"tickets": tickets,
|
||||
"tickets_error": tickets_error,
|
||||
}
|
||||
return templates.TemplateResponse(request, "admin_issues_planning.html", context)
|
||||
|
||||
@@ -2,11 +2,13 @@
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Request
|
||||
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
|
||||
@@ -41,18 +43,37 @@ def _status_payload(job: dict) -> dict:
|
||||
}
|
||||
|
||||
|
||||
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):
|
||||
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")
|
||||
uid = queue.enqueue("planning", {}, "user", admin["uid"], PREFERRED_NAME)
|
||||
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="requested an open-tickets planning report",
|
||||
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}"})
|
||||
|
||||
@@ -965,13 +965,22 @@ ACTIONS: tuple[Action, ...] = (
|
||||
name="planning_report_generate",
|
||||
method="POST",
|
||||
path="/issues/planning",
|
||||
summary="Queue an admin planning report of all open tickets.",
|
||||
summary="Queue an admin planning report of open tickets.",
|
||||
description=(
|
||||
"Queues a background job that builds a grouped, ordered markdown planning report "
|
||||
"of every open ticket and returns {uid, status_url}. Poll the status_url until "
|
||||
"status is 'done', then show the markdown and the download_url. Admin only."
|
||||
"Queues a background job that builds a phased, ordered markdown implementation "
|
||||
"document for open tickets and returns {uid, status_url}. Pass 'numbers' as a "
|
||||
"comma-separated list of issue numbers to plan only those tickets; omit it to plan "
|
||||
"every open ticket. Each ticket's full description is reproduced verbatim, both "
|
||||
"inline and in a 'Source Tickets (verbatim)' appendix, so the document is "
|
||||
"self-contained. Poll the status_url until status is 'done', then show the markdown "
|
||||
"and the download_url. Admin only."
|
||||
),
|
||||
params=(
|
||||
body(
|
||||
"numbers",
|
||||
"Comma-separated issue numbers to include (e.g. '1,4,7'). Omit for all open tickets.",
|
||||
),
|
||||
),
|
||||
params=(),
|
||||
requires_auth=True,
|
||||
requires_admin=True,
|
||||
),
|
||||
|
||||
@@ -6,6 +6,7 @@ import httpx
|
||||
|
||||
from devplacepy import stealth
|
||||
from devplacepy.config import INTERNAL_GATEWAY_URL
|
||||
from devplacepy.services.gitea.client import STATE_OPEN
|
||||
from devplacepy.services.gitea.config import GiteaConfig
|
||||
from devplacepy.services.openai_gateway.usage import accumulate_usage
|
||||
|
||||
@@ -13,6 +14,7 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
MAX_TOKENS = 32000
|
||||
MAX_ISSUES = 50
|
||||
PAGE_LIMIT = 50
|
||||
BODY_MAX = 40000
|
||||
PLAN_MAX = 600000
|
||||
PLANNING_TIMEOUT_SECONDS = 600.0
|
||||
@@ -90,6 +92,50 @@ def _group_key(issue: dict) -> str:
|
||||
return labels[0] if labels else UNGROUPED_LABEL
|
||||
|
||||
|
||||
async def collect_open_issues(client, limit: int = MAX_ISSUES) -> list[dict]:
|
||||
collected: list[dict] = []
|
||||
page = 1
|
||||
while len(collected) < limit:
|
||||
issues, total = await client.list_issues(
|
||||
state=STATE_OPEN, page=page, limit=PAGE_LIMIT
|
||||
)
|
||||
if not issues:
|
||||
break
|
||||
collected.extend(issues)
|
||||
if len(issues) < PAGE_LIMIT or len(collected) >= total:
|
||||
break
|
||||
page += 1
|
||||
return collected[:limit]
|
||||
|
||||
|
||||
def verbatim_tickets(issues: list[dict]) -> str:
|
||||
lines: list[str] = ["# Appendix: Source Tickets (verbatim)", ""]
|
||||
lines.append(
|
||||
"The complete, unedited description of every ticket covered by this plan, reproduced "
|
||||
"directly from the tracker so the document is self-contained."
|
||||
)
|
||||
lines.append("")
|
||||
for issue in issues:
|
||||
number = int(issue.get("number", 0))
|
||||
title = str(issue.get("title", "")).strip() or "Untitled"
|
||||
labels = ", ".join(_labels(issue)) or "none"
|
||||
url = str(issue.get("html_url", "")).strip()
|
||||
body = _body(issue.get("body", "")) or "No description provided."
|
||||
lines.append(f"## #{number} {title}")
|
||||
lines.append("")
|
||||
lines.append(f"**Labels**: {labels}")
|
||||
if url:
|
||||
lines.append("")
|
||||
lines.append(f"**Source**: {url}")
|
||||
lines.append("")
|
||||
lines.append("**Original ticket**:")
|
||||
lines.append("")
|
||||
for body_line in body.split("\n"):
|
||||
lines.append(f"> {body_line}")
|
||||
lines.append("")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _fallback(issues: list[dict]) -> str:
|
||||
groups: dict[str, list[dict]] = {}
|
||||
for issue in issues:
|
||||
@@ -206,4 +252,5 @@ async def generate_plan(
|
||||
logger.warning("Planning generation returned empty content, using fallback")
|
||||
return _fallback(issues), False
|
||||
logger.info("Generated AI planning for %d open tickets", len(issues))
|
||||
return markdown[:PLAN_MAX], True
|
||||
document = f"{markdown}\n\n---\n\n{verbatim_tickets(issues)}"
|
||||
return document[:PLAN_MAX], True
|
||||
|
||||
@@ -8,17 +8,15 @@ from devplacepy.attachments import _directory_for
|
||||
from devplacepy.config import PLANNING_REPORTS_DIR
|
||||
from devplacepy.database import add_issue_usage
|
||||
from devplacepy.services.gitea import runtime
|
||||
from devplacepy.services.gitea.client import STATE_OPEN, GiteaError
|
||||
from devplacepy.services.gitea.client import GiteaError
|
||||
from devplacepy.services.gitea.config import gitea_config
|
||||
from devplacepy.services.gitea.planning import MAX_ISSUES, generate_plan
|
||||
from devplacepy.services.gitea.planning import collect_open_issues, generate_plan
|
||||
from devplacepy.services.jobs.base import JobService
|
||||
from devplacepy.services.openai_gateway.usage import new_usage_totals
|
||||
from devplacepy.utils import slugify
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
PAGE_LIMIT = 50
|
||||
|
||||
|
||||
class PlanningReportService(JobService):
|
||||
kind = "planning"
|
||||
@@ -44,7 +42,8 @@ class PlanningReportService(JobService):
|
||||
if not config.is_configured:
|
||||
raise GiteaError("Gitea integration is not configured", status=503)
|
||||
|
||||
issues = await self._collect_open(config)
|
||||
numbers = self._selected_numbers(job)
|
||||
issues = await self._collect(numbers)
|
||||
usage_totals = new_usage_totals()
|
||||
markdown, ai_used = await generate_plan(issues, config, usage_totals)
|
||||
issue_count = len(issues)
|
||||
@@ -77,6 +76,7 @@ class PlanningReportService(JobService):
|
||||
target_type="issue",
|
||||
metadata={
|
||||
"issue_count": issue_count,
|
||||
"selected": len(numbers),
|
||||
"ai_used": ai_used,
|
||||
"bytes_out": bytes_out,
|
||||
},
|
||||
@@ -94,21 +94,24 @@ class PlanningReportService(JobService):
|
||||
"bytes_out": bytes_out,
|
||||
}
|
||||
|
||||
async def _collect_open(self, config) -> list[dict]:
|
||||
client = runtime.get_client()
|
||||
collected: list[dict] = []
|
||||
page = 1
|
||||
while len(collected) < MAX_ISSUES:
|
||||
issues, total = await client.list_issues(
|
||||
state=STATE_OPEN, page=page, limit=PAGE_LIMIT
|
||||
)
|
||||
if not issues:
|
||||
break
|
||||
collected.extend(issues)
|
||||
if len(issues) < PAGE_LIMIT or len(collected) >= total:
|
||||
break
|
||||
page += 1
|
||||
return collected[:MAX_ISSUES]
|
||||
def _selected_numbers(self, job: dict) -> list[int]:
|
||||
raw = job.get("payload", {}).get("numbers") or []
|
||||
numbers: list[int] = []
|
||||
for value in raw:
|
||||
try:
|
||||
number = int(value)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if number not in numbers:
|
||||
numbers.append(number)
|
||||
return numbers
|
||||
|
||||
async def _collect(self, numbers: list[int]) -> list[dict]:
|
||||
open_issues = await collect_open_issues(runtime.get_client())
|
||||
if not numbers:
|
||||
return open_issues
|
||||
by_number = {int(issue.get("number", 0)): issue for issue in open_issues}
|
||||
return [by_number[number] for number in numbers if number in by_number]
|
||||
|
||||
def _final_name(self, preferred_name: str, markdown: str) -> str:
|
||||
name = preferred_name or "open-tickets-plan"
|
||||
|
||||
@@ -41,6 +41,82 @@
|
||||
}
|
||||
}
|
||||
|
||||
.planning-select {
|
||||
margin: 1.5rem 0;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.planning-select-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding: 0.85rem 1.25rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.planning-select-all {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.planning-select-count {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.planning-ticket-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
max-height: 22rem;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.planning-ticket {
|
||||
border-bottom: 1px solid var(--border-light);
|
||||
}
|
||||
|
||||
.planning-ticket:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.planning-ticket label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
padding: 0.6rem 1.25rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.planning-ticket-number {
|
||||
color: var(--text-secondary);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.planning-ticket-title {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.planning-ticket-label {
|
||||
padding: 0.1rem 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary);
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
}
|
||||
|
||||
.planning-result {
|
||||
margin: 1.5rem 0;
|
||||
}
|
||||
|
||||
@@ -12,6 +12,48 @@ export class PlanningGenerator {
|
||||
event.preventDefault();
|
||||
this.generate(button);
|
||||
});
|
||||
document.addEventListener("change", (event) => {
|
||||
if (event.target.closest("[data-planning-select-all]")) {
|
||||
this.toggleAll(event.target.checked);
|
||||
this.syncSelection();
|
||||
} else if (event.target.closest("[data-planning-ticket]")) {
|
||||
this.syncSelection();
|
||||
}
|
||||
});
|
||||
this.syncSelection();
|
||||
}
|
||||
|
||||
tickets() {
|
||||
return Array.from(document.querySelectorAll("[data-planning-ticket]"));
|
||||
}
|
||||
|
||||
selectedNumbers() {
|
||||
return this.tickets()
|
||||
.filter((box) => box.checked)
|
||||
.map((box) => box.value);
|
||||
}
|
||||
|
||||
toggleAll(checked) {
|
||||
this.tickets().forEach((box) => {
|
||||
box.checked = checked;
|
||||
});
|
||||
}
|
||||
|
||||
syncSelection() {
|
||||
const tickets = this.tickets();
|
||||
if (!tickets.length) return;
|
||||
const selected = tickets.filter((box) => box.checked).length;
|
||||
const count = document.querySelector("[data-planning-count]");
|
||||
if (count) {
|
||||
count.textContent = `${selected} of ${tickets.length} selected`;
|
||||
}
|
||||
const master = document.querySelector("[data-planning-select-all]");
|
||||
if (master) {
|
||||
master.checked = selected === tickets.length;
|
||||
master.indeterminate = selected > 0 && selected < tickets.length;
|
||||
}
|
||||
const button = document.querySelector("[data-planning-generate]");
|
||||
if (button) button.disabled = selected === 0;
|
||||
}
|
||||
|
||||
elements() {
|
||||
@@ -26,6 +68,11 @@ export class PlanningGenerator {
|
||||
|
||||
async generate(button) {
|
||||
if (this.active) return;
|
||||
const numbers = this.selectedNumbers();
|
||||
if (this.tickets().length && !numbers.length) {
|
||||
window.app.toast.show("Select at least one ticket", { type: "error" });
|
||||
return;
|
||||
}
|
||||
this.active = true;
|
||||
button.disabled = true;
|
||||
button.classList.add("is-loading");
|
||||
@@ -35,7 +82,7 @@ export class PlanningGenerator {
|
||||
if (ui.status) ui.status.hidden = false;
|
||||
try {
|
||||
const action = button.dataset.action || "/issues/planning";
|
||||
const job = await Http.sendForm(action, {});
|
||||
const job = await Http.sendForm(action, { numbers: numbers.join(",") });
|
||||
await this.poll(job.status_url, ui);
|
||||
} catch (error) {
|
||||
window.app.toast.show("Could not start the planning report", { type: "error" });
|
||||
@@ -45,6 +92,7 @@ export class PlanningGenerator {
|
||||
this.active = false;
|
||||
button.disabled = false;
|
||||
button.classList.remove("is-loading");
|
||||
this.syncSelection();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -20,6 +20,34 @@
|
||||
{% if not configured %}
|
||||
<div class="empty-state">The issue tracker is not configured yet. Configure Gitea in Services first.</div>
|
||||
{% else %}
|
||||
{% if tickets_error %}
|
||||
<div class="empty-state">Could not load the open tickets from the tracker. Try again shortly.</div>
|
||||
{% elif not tickets %}
|
||||
<div class="empty-state">There are no open tickets to plan.</div>
|
||||
{% else %}
|
||||
<div class="planning-select" data-planning-select>
|
||||
<div class="planning-select-head">
|
||||
<label class="planning-select-all">
|
||||
<input type="checkbox" data-planning-select-all checked>
|
||||
<span>Select all</span>
|
||||
</label>
|
||||
<span class="planning-select-count" data-planning-count>{{ tickets|length }} of {{ tickets|length }} selected</span>
|
||||
</div>
|
||||
<ul class="planning-ticket-list">
|
||||
{% for ticket in tickets %}
|
||||
<li class="planning-ticket">
|
||||
<label>
|
||||
<input type="checkbox" data-planning-ticket value="{{ ticket.number }}" checked>
|
||||
<span class="planning-ticket-number">#{{ ticket.number }}</span>
|
||||
<span class="planning-ticket-title">{{ ticket.title }}</span>
|
||||
{% for label in ticket.labels %}<span class="planning-ticket-label">{{ label }}</span>{% endfor %}
|
||||
</label>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="planning-status" data-planning-status hidden role="status" aria-live="polite">
|
||||
<span class="planning-spinner" aria-hidden="true"></span>
|
||||
<span class="planning-status-text">Generating the planning report...</span>
|
||||
|
||||
Reference in New Issue
Block a user