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
+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"