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:
@@ -0,0 +1,85 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
import requests
|
||||
|
||||
from devplacepy.database import get_table, refresh_snapshot, set_setting
|
||||
from tests.conftest import BASE_URL
|
||||
|
||||
|
||||
@pytest.fixture(scope="module", autouse=True)
|
||||
def _planning_settings(app_server):
|
||||
set_setting("rate_limit_per_minute", "1000000")
|
||||
set_setting("gitea_base_url", "https://gitea.test")
|
||||
set_setting("gitea_owner", "retoor")
|
||||
set_setting("gitea_repo", "pydevplace")
|
||||
set_setting("gitea_token", "planning-test-token")
|
||||
set_setting("issue_ai_enhance", "0")
|
||||
yield
|
||||
set_setting("gitea_base_url", "")
|
||||
set_setting("gitea_token", "")
|
||||
|
||||
|
||||
def _admin(seeded_db):
|
||||
refresh_snapshot()
|
||||
key = get_table("users").find_one(username="alice_test")["api_key"]
|
||||
session = requests.Session()
|
||||
session.headers.update({"X-API-KEY": key})
|
||||
return session
|
||||
|
||||
|
||||
def _member(seeded_db):
|
||||
refresh_snapshot()
|
||||
key = get_table("users").find_one(username="bob_test")["api_key"]
|
||||
session = requests.Session()
|
||||
session.headers.update({"X-API-KEY": key})
|
||||
return session
|
||||
|
||||
|
||||
def test_planning_requires_admin(seeded_db):
|
||||
member = _member(seeded_db)
|
||||
response = member.post(
|
||||
f"{BASE_URL}/issues/planning", data={"numbers": "1"}, allow_redirects=False
|
||||
)
|
||||
assert response.status_code in (302, 303)
|
||||
|
||||
|
||||
def test_planning_status_unknown_uid_is_404(seeded_db):
|
||||
admin = _admin(seeded_db)
|
||||
response = admin.get(f"{BASE_URL}/issues/planning/does-not-exist")
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
def test_planning_enqueue_stores_selected_numbers(seeded_db):
|
||||
admin = _admin(seeded_db)
|
||||
response = admin.post(
|
||||
f"{BASE_URL}/issues/planning", data={"numbers": "4, 7,7, x"}
|
||||
)
|
||||
assert response.status_code == 200, response.text[:300]
|
||||
body = response.json()
|
||||
uid = body["uid"]
|
||||
assert body["status_url"] == f"/issues/planning/{uid}"
|
||||
|
||||
refresh_snapshot()
|
||||
job = get_table("jobs").find_one(uid=uid)
|
||||
assert job is not None
|
||||
assert json.loads(job["payload"])["numbers"] == [4, 7]
|
||||
|
||||
status = admin.get(f"{BASE_URL}/issues/planning/{uid}")
|
||||
assert status.status_code == 200
|
||||
payload = status.json()
|
||||
assert payload["uid"] == uid
|
||||
assert payload["kind"] == "planning"
|
||||
assert payload["status"] in ("pending", "running", "done", "failed")
|
||||
|
||||
|
||||
def test_planning_enqueue_without_numbers_is_all_open(seeded_db):
|
||||
admin = _admin(seeded_db)
|
||||
response = admin.post(f"{BASE_URL}/issues/planning", data={})
|
||||
assert response.status_code == 200, response.text[:300]
|
||||
uid = response.json()["uid"]
|
||||
refresh_snapshot()
|
||||
job = get_table("jobs").find_one(uid=uid)
|
||||
assert json.loads(job["payload"])["numbers"] == []
|
||||
@@ -0,0 +1 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
@@ -0,0 +1,22 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from tests.conftest import BASE_URL
|
||||
|
||||
|
||||
def test_planning_page_renders_for_admin(alice):
|
||||
page, _ = alice
|
||||
page.goto(
|
||||
f"{BASE_URL}/admin/issues/planning", wait_until="domcontentloaded"
|
||||
)
|
||||
assert page.url.endswith("/admin/issues/planning")
|
||||
page.locator("h1:has-text('Ticket Planning')").wait_for(state="visible")
|
||||
assert page.is_visible("text=phased implementation document")
|
||||
|
||||
|
||||
def test_planning_page_blocks_members(bob):
|
||||
page, _ = bob
|
||||
page.goto(
|
||||
f"{BASE_URL}/admin/issues/planning", wait_until="domcontentloaded"
|
||||
)
|
||||
assert "/admin/issues/planning" not in page.url
|
||||
assert not page.is_visible("h1:has-text('Ticket Planning')")
|
||||
@@ -14,7 +14,12 @@ from devplacepy.services.gitea import runtime, store
|
||||
from devplacepy.services.gitea.config import gitea_config
|
||||
from devplacepy.services.gitea.enhance import enhance_ticket
|
||||
from devplacepy.services.gitea.fake import FakeGiteaClient
|
||||
from devplacepy.services.gitea.planning import generate_plan
|
||||
from devplacepy.services.gitea.planning import (
|
||||
collect_open_issues,
|
||||
generate_plan,
|
||||
verbatim_tickets,
|
||||
)
|
||||
from devplacepy.services.jobs.planning_service import PlanningReportService
|
||||
from devplacepy.services.gitea.service import IssueTrackerService
|
||||
from devplacepy.services.jobs import queue
|
||||
from devplacepy.services.jobs.issue_create_service import IssueCreateService
|
||||
@@ -324,11 +329,87 @@ def test_planning_meters_usage_via_gateway(gitea_env, monkeypatch):
|
||||
issues = [{"number": 1, "title": "t", "labels": [], "body": "b"}]
|
||||
markdown, ai_used = run_async(generate_plan(issues, gitea_config(), totals))
|
||||
assert ai_used is True
|
||||
assert markdown == "# Open Tickets Implementation Plan\n\nDone."
|
||||
assert markdown.startswith("# Open Tickets Implementation Plan\n\nDone.")
|
||||
assert "# Appendix: Source Tickets (verbatim)" in markdown
|
||||
assert totals["calls"] == 1
|
||||
assert totals["total_tokens"] == 600
|
||||
|
||||
|
||||
def test_planning_ai_output_always_carries_verbatim_appendix(gitea_env, monkeypatch):
|
||||
set_setting("issue_ai_enhance", "1")
|
||||
refresh_snapshot()
|
||||
monkeypatch.setattr(
|
||||
"devplacepy.stealth.stealth_async_client",
|
||||
_fake_gateway("# Open Tickets Implementation Plan\n\nSummarised plan only."),
|
||||
)
|
||||
issues = [
|
||||
{
|
||||
"number": 12,
|
||||
"title": "Add export",
|
||||
"labels": [{"name": "feature"}],
|
||||
"html_url": "https://gitea.test/retoor/pydevplace/issues/12",
|
||||
"body": "Full body line A.\nFull body line B.",
|
||||
}
|
||||
]
|
||||
markdown, ai_used = run_async(generate_plan(issues, gitea_config()))
|
||||
assert ai_used is True
|
||||
assert "Summarised plan only." in markdown
|
||||
assert "# Appendix: Source Tickets (verbatim)" in markdown
|
||||
assert "## #12 Add export" in markdown
|
||||
assert "https://gitea.test/retoor/pydevplace/issues/12" in markdown
|
||||
assert "> Full body line A." in markdown
|
||||
assert "> Full body line B." in markdown
|
||||
|
||||
|
||||
def test_verbatim_tickets_reproduces_full_body_and_labels():
|
||||
issues = [
|
||||
{
|
||||
"number": 5,
|
||||
"title": "Fix crash",
|
||||
"labels": [{"name": "bug"}, {"name": "urgent"}],
|
||||
"html_url": "https://gitea.test/x/5",
|
||||
"body": "Steps:\n1. open\n2. boom",
|
||||
}
|
||||
]
|
||||
markdown = verbatim_tickets(issues)
|
||||
assert markdown.startswith("# Appendix: Source Tickets (verbatim)")
|
||||
assert "## #5 Fix crash" in markdown
|
||||
assert "**Labels**: bug, urgent" in markdown
|
||||
assert "**Source**: https://gitea.test/x/5" in markdown
|
||||
assert "> Steps:" in markdown
|
||||
assert "> 1. open" in markdown
|
||||
assert "> 2. boom" in markdown
|
||||
|
||||
|
||||
def test_collect_open_issues_pages_through_fake(gitea_env):
|
||||
for index in range(3):
|
||||
run_async(gitea_env.create_issue(f"Ticket {index}", f"Body {index}"))
|
||||
issues = run_async(collect_open_issues(gitea_env))
|
||||
assert {issue["number"] for issue in issues} == {1, 2, 3}
|
||||
|
||||
|
||||
def test_planning_service_filters_to_selected_numbers(gitea_env):
|
||||
for index in range(3):
|
||||
run_async(gitea_env.create_issue(f"Ticket {index}", f"Body {index}"))
|
||||
service = PlanningReportService()
|
||||
|
||||
selected = run_async(service._collect([3, 1]))
|
||||
assert [issue["number"] for issue in selected] == [3, 1]
|
||||
|
||||
missing = run_async(service._collect([99]))
|
||||
assert missing == []
|
||||
|
||||
everything = run_async(service._collect([]))
|
||||
assert {issue["number"] for issue in everything} == {1, 2, 3}
|
||||
|
||||
|
||||
def test_planning_service_parses_payload_numbers():
|
||||
service = PlanningReportService()
|
||||
job = {"payload": {"numbers": ["4", 7, "x", 7, None]}}
|
||||
assert service._selected_numbers(job) == [4, 7]
|
||||
assert service._selected_numbers({"payload": {}}) == []
|
||||
|
||||
|
||||
def test_enhance_meters_usage_via_gateway(gitea_env, monkeypatch):
|
||||
set_setting("issue_ai_enhance", "1")
|
||||
refresh_snapshot()
|
||||
|
||||
Reference in New Issue
Block a user