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:
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user