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:
2026-06-19 12:25:51 +00:00
parent ff3cbbbfe2
commit 1350e7fc66
16 changed files with 494 additions and 39 deletions
+3 -3
View File
@@ -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",
+4
View File
@@ -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)
+31 -1
View File
@@ -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)
+25 -4
View File
@@ -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}"})
+14 -5
View File
@@ -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,
),
+48 -1
View File
@@ -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
+23 -20
View File
@@ -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"
+76
View File
@@ -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;
}
+49 -1
View File
@@ -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>