feat: enforce hard test-tier requirement across all DevPlace workflow agents and feature-builder docs
DevPlace CI / test (push) Failing after 21m53s
DevPlace CI / test (push) Failing after 21m53s
Update the feature-builder agent prompt, test-maintainer agent, and all four workflow JS files (devii-tool, endpoint, feature, job-service) to codify the DevPlace test standard as a non-optional project requirement: one test file per endpoint, directory tree mirroring the URL/source path, split into three tiers (unit, api, e2e). Add explicit Test phases to devii-tool, endpoint, feature, and job-service workflows, and embed tier-specific test instructions (path mapping, fixture choice, coverage scope) directly in each workflow's meta description and TESTS constant.
This commit is contained in:
@@ -25,6 +25,7 @@ ZIPS_DIR = DATA_DIR / "zips"
|
||||
ZIP_STAGING_DIR = DATA_DIR / "zip_staging"
|
||||
FORK_STAGING_DIR = DATA_DIR / "fork_staging"
|
||||
SEO_REPORTS_DIR = DATA_DIR / "seo_reports"
|
||||
PLANNING_REPORTS_DIR = DATA_DIR / "planning_reports"
|
||||
DBAPI_DIR = DATA_DIR / "dbapi"
|
||||
DEEPSEARCH_DIR = DATA_DIR / "deepsearch"
|
||||
DEEPSEARCH_CHROMA_DIR = DEEPSEARCH_DIR / "chroma"
|
||||
@@ -103,6 +104,7 @@ DATA_PATHS: dict[str, Path] = {
|
||||
"zip_staging": ZIP_STAGING_DIR,
|
||||
"fork_staging": FORK_STAGING_DIR,
|
||||
"seo_reports": SEO_REPORTS_DIR,
|
||||
"planning_reports": PLANNING_REPORTS_DIR,
|
||||
"dbapi": DBAPI_DIR,
|
||||
"deepsearch": DEEPSEARCH_DIR,
|
||||
"deepsearch_chroma": DEEPSEARCH_CHROMA_DIR,
|
||||
|
||||
@@ -308,6 +308,29 @@ def create_comment_record(
|
||||
return comment_uid, comment_url
|
||||
|
||||
|
||||
def edit_comment_record(request, user: dict, comment: dict, content: str) -> str:
|
||||
target_type = comment.get("target_type", "post")
|
||||
target_uid = comment.get("target_uid") or comment.get("post_uid", "")
|
||||
updated_at = datetime.now(timezone.utc).isoformat()
|
||||
get_table("comments").update(
|
||||
{"uid": comment["uid"], "content": content, "updated_at": updated_at}, ["uid"]
|
||||
)
|
||||
logger.info(f"Comment {comment['uid']} edited by {user['username']}")
|
||||
audit.record(
|
||||
request,
|
||||
"comment.edit",
|
||||
user=user,
|
||||
target_type="comment",
|
||||
target_uid=comment["uid"],
|
||||
summary=f"{user['username']} edited a comment under {target_type} {target_uid}",
|
||||
links=[
|
||||
audit.target("comment", comment["uid"]),
|
||||
audit.parent(target_type, target_uid),
|
||||
],
|
||||
)
|
||||
return updated_at
|
||||
|
||||
|
||||
def delete_comment_record(request, user: dict, comment: dict) -> tuple[str, str]:
|
||||
target_type = comment.get("target_type", "post")
|
||||
target_uid = comment.get("target_uid") or comment.get("post_uid", "")
|
||||
|
||||
+27
-7
@@ -1190,10 +1190,11 @@ def load_comments(target_type, target_uid, user=None):
|
||||
top.append(item)
|
||||
return top
|
||||
|
||||
def get_recent_comments_by_post_uids(post_uids, limit=3, user=None):
|
||||
if not post_uids or "comments" not in db.tables:
|
||||
def get_recent_comments_by_target_uids(target_type, target_uids, limit=3, user=None):
|
||||
if not target_uids or "comments" not in db.tables:
|
||||
return {}
|
||||
placeholders, params = _in_clause(post_uids)
|
||||
placeholders, params = _in_clause(target_uids)
|
||||
params["tt"] = target_type
|
||||
params["lim"] = limit
|
||||
raw = list(
|
||||
db.query(
|
||||
@@ -1201,7 +1202,7 @@ def get_recent_comments_by_post_uids(post_uids, limit=3, user=None):
|
||||
f" SELECT *, ROW_NUMBER() OVER ("
|
||||
f" PARTITION BY target_uid ORDER BY created_at DESC, id DESC"
|
||||
f" ) AS rn FROM comments"
|
||||
f" WHERE target_type='post' AND target_uid IN ({placeholders}) AND deleted_at IS NULL"
|
||||
f" WHERE target_type=:tt AND target_uid IN ({placeholders}) AND deleted_at IS NULL"
|
||||
f") WHERE rn <= :lim ORDER BY target_uid, created_at ASC",
|
||||
**params,
|
||||
)
|
||||
@@ -1209,10 +1210,29 @@ def get_recent_comments_by_post_uids(post_uids, limit=3, user=None):
|
||||
if not raw:
|
||||
return {}
|
||||
items = _build_comment_items(raw, user)
|
||||
result = defaultdict(list)
|
||||
by_target = defaultdict(list)
|
||||
for c in raw:
|
||||
result[c["target_uid"]].append(items[c["uid"]])
|
||||
return dict(result)
|
||||
by_target[c["target_uid"]].append(c)
|
||||
result = {}
|
||||
for target_uid, group in by_target.items():
|
||||
in_group = {c["uid"] for c in group}
|
||||
top = []
|
||||
for c in group:
|
||||
item = items[c["uid"]]
|
||||
item["children"] = []
|
||||
for c in group:
|
||||
item = items[c["uid"]]
|
||||
parent = c.get("parent_uid")
|
||||
if parent and parent in in_group:
|
||||
items[parent]["children"].append(item)
|
||||
else:
|
||||
top.append(item)
|
||||
result[target_uid] = top
|
||||
return result
|
||||
|
||||
|
||||
def get_recent_comments_by_post_uids(post_uids, limit=3, user=None):
|
||||
return get_recent_comments_by_target_uids("post", post_uids, limit, user)
|
||||
|
||||
|
||||
def load_comments_by_target_uids(target_type, target_uids, user=None):
|
||||
|
||||
+86
-1
@@ -935,6 +935,39 @@ four ways to sign requests.
|
||||
],
|
||||
notes=["Either `target_uid` or `post_uid` is required."],
|
||||
),
|
||||
endpoint(
|
||||
id="comments-edit",
|
||||
method="POST",
|
||||
path="/comments/edit/{comment_uid}",
|
||||
title="Edit a comment",
|
||||
summary="Edit the body of a comment you own. Returns the updated comment.",
|
||||
auth="user",
|
||||
encoding="form",
|
||||
params=[
|
||||
field(
|
||||
"comment_uid",
|
||||
"path",
|
||||
"string",
|
||||
True,
|
||||
"COMMENT_UID",
|
||||
"UID of the comment.",
|
||||
),
|
||||
field(
|
||||
"content",
|
||||
"form",
|
||||
"textarea",
|
||||
True,
|
||||
"Edited body.",
|
||||
"New body, 3-1000 characters.",
|
||||
),
|
||||
],
|
||||
sample_response={
|
||||
"uid": "COMMENT_UID",
|
||||
"content": "Edited body.",
|
||||
"url": "/posts/POST_SLUG#comment-COMMENT_UID",
|
||||
"updated_at": "2026-06-15T12:00:00+00:00",
|
||||
},
|
||||
),
|
||||
endpoint(
|
||||
id="comments-delete",
|
||||
method="POST",
|
||||
@@ -3711,6 +3744,55 @@ four ways to sign requests.
|
||||
"completed_at": "2026-06-12T09:00:03+00:00",
|
||||
},
|
||||
),
|
||||
endpoint(
|
||||
id="issues-planning-queue",
|
||||
method="POST",
|
||||
path="/issues/planning",
|
||||
title="Generate a tickets planning report",
|
||||
summary="Enqueue a grouped, ordered markdown planning report of all open tickets. 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."],
|
||||
sample_response={
|
||||
"uid": "PLANNING_JOB_UID",
|
||||
"status_url": "/issues/planning/PLANNING_JOB_UID",
|
||||
},
|
||||
),
|
||||
endpoint(
|
||||
id="issues-planning-status",
|
||||
method="GET",
|
||||
path="/issues/planning/{uid}",
|
||||
title="Planning report job status",
|
||||
summary="Poll the planning job; the result carries the rendered markdown and the download URL. Admin only.",
|
||||
auth="admin",
|
||||
ajax=True,
|
||||
params=[field("uid", "path", "string", True, "PLANNING_JOB_UID", "Planning job uid.")],
|
||||
sample_response={
|
||||
"uid": "PLANNING_JOB_UID",
|
||||
"kind": "planning",
|
||||
"status": "done",
|
||||
"download_url": "/issues/planning/PLANNING_JOB_UID/download",
|
||||
"markdown": "# Open Tickets Planning\n\n...",
|
||||
"ai_used": True,
|
||||
"issue_count": 12,
|
||||
"bytes_out": 4096,
|
||||
"error": None,
|
||||
"created_at": "2026-06-15T09:00:00+00:00",
|
||||
"completed_at": "2026-06-15T09:00:05+00:00",
|
||||
},
|
||||
),
|
||||
endpoint(
|
||||
id="issues-planning-download",
|
||||
method="GET",
|
||||
path="/issues/planning/{uid}/download",
|
||||
title="Download the planning report",
|
||||
summary="Download the generated planning report as a markdown file. Admin only.",
|
||||
auth="admin",
|
||||
interactive=True,
|
||||
params=[field("uid", "path", "string", True, "PLANNING_JOB_UID", "Planning job uid.")],
|
||||
),
|
||||
endpoint(
|
||||
id="issues-detail",
|
||||
method="GET",
|
||||
@@ -4535,7 +4617,10 @@ _ACTION_RESPONSES = {
|
||||
"/posts/POST_SLUG#comment-COMMENT_UID",
|
||||
{"uid": "COMMENT_UID", "url": "/posts/POST_SLUG#comment-COMMENT_UID"},
|
||||
),
|
||||
"comments-delete": ("/posts/POST_SLUG", None),
|
||||
"comments-delete": (
|
||||
"/posts/POST_SLUG",
|
||||
{"deleted_uid": "COMMENT_UID", "target_type": "post", "target_uid": "TARGET_UID"},
|
||||
),
|
||||
"projects-create": (
|
||||
"/projects/PROJECT_SLUG",
|
||||
{"uid": "PROJECT_UID", "slug": "PROJECT_SLUG", "url": "/projects/PROJECT_SLUG"},
|
||||
|
||||
@@ -85,6 +85,7 @@ from devplacepy.services.devii import DeviiService
|
||||
from devplacepy.services.jobs.zip_service import ZipService
|
||||
from devplacepy.services.jobs.fork_service import ForkService
|
||||
from devplacepy.services.jobs.issue_create_service import IssueCreateService
|
||||
from devplacepy.services.jobs.planning_service import PlanningReportService
|
||||
from devplacepy.services.jobs.seo.service import SeoService
|
||||
from devplacepy.services.dbapi.service import DbApiJobService
|
||||
from devplacepy.services.pubsub import PubSubService
|
||||
@@ -196,6 +197,7 @@ async def lifespan(app: FastAPI):
|
||||
service_manager.register(PubSubService())
|
||||
service_manager.register(DeepsearchService())
|
||||
service_manager.register(IssueCreateService())
|
||||
service_manager.register(PlanningReportService())
|
||||
service_manager.register(IssueTrackerService())
|
||||
service_manager.register(ContainerService())
|
||||
service_manager.register(XmlrpcService())
|
||||
|
||||
@@ -168,6 +168,10 @@ class CommentForm(BaseModel):
|
||||
return self
|
||||
|
||||
|
||||
class CommentEditForm(BaseModel):
|
||||
content: str = Field(min_length=3, max_length=1000)
|
||||
|
||||
|
||||
class ProjectForm(BaseModel):
|
||||
title: str = Field(min_length=1, max_length=200)
|
||||
description: str = Field(min_length=1, max_length=5000)
|
||||
|
||||
@@ -625,6 +625,12 @@ def list_files(project_uid: str) -> list:
|
||||
return [node_to_dict(row) for row in rows]
|
||||
|
||||
|
||||
def count_files(project_uid: str) -> int:
|
||||
if "project_files" not in db.tables:
|
||||
return 0
|
||||
return _table().count(project_uid=project_uid, type="file", deleted_at=None)
|
||||
|
||||
|
||||
def read_file(project_uid: str, raw_path: str) -> dict:
|
||||
path = normalize_path(raw_path)
|
||||
node = get_node(project_uid, path)
|
||||
|
||||
@@ -6,6 +6,7 @@ from devplacepy.routers.admin import (
|
||||
auditlog,
|
||||
bots,
|
||||
containers,
|
||||
issues,
|
||||
media,
|
||||
news,
|
||||
notifications,
|
||||
@@ -24,6 +25,7 @@ router.include_router(trash.router)
|
||||
router.include_router(settings.router)
|
||||
router.include_router(notifications.router)
|
||||
router.include_router(news.router)
|
||||
router.include_router(issues.router)
|
||||
router.include_router(auditlog.router)
|
||||
router.include_router(bots.router)
|
||||
router.include_router(services.router, prefix="/services")
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
|
||||
from devplacepy.seo import base_seo_context
|
||||
from devplacepy.services.gitea.config import gitea_config
|
||||
from devplacepy.templating import templates
|
||||
from devplacepy.utils import require_admin
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/issues/planning", response_class=HTMLResponse)
|
||||
async def admin_issues_planning(request: Request):
|
||||
admin = require_admin(request)
|
||||
seo_ctx = base_seo_context(
|
||||
request,
|
||||
title="Ticket Planning - Admin",
|
||||
description="Generate a grouped, ordered planning report of all open tickets.",
|
||||
robots="noindex,nofollow",
|
||||
breadcrumbs=[
|
||||
{"name": "Home", "url": "/feed"},
|
||||
{"name": "Admin", "url": "/admin"},
|
||||
{"name": "Issues", "url": "/issues"},
|
||||
],
|
||||
)
|
||||
context = {
|
||||
**seo_ctx,
|
||||
"request": request,
|
||||
"user": admin,
|
||||
"admin_section": "issues",
|
||||
"configured": gitea_config().is_configured,
|
||||
}
|
||||
return templates.TemplateResponse(request, "admin_issues_planning.html", context)
|
||||
@@ -4,11 +4,18 @@ import logging
|
||||
from typing import Annotated
|
||||
from fastapi import APIRouter, Request, Form
|
||||
from fastapi.responses import RedirectResponse
|
||||
from fastapi.responses import JSONResponse
|
||||
from devplacepy.database import get_table, resolve_object_url
|
||||
from devplacepy.content import is_owner, create_comment_record, delete_comment_record
|
||||
from devplacepy.content import (
|
||||
is_owner,
|
||||
create_comment_record,
|
||||
delete_comment_record,
|
||||
edit_comment_record,
|
||||
)
|
||||
from devplacepy.utils import require_user, is_admin
|
||||
from devplacepy.models import CommentForm
|
||||
from devplacepy.models import CommentForm, CommentEditForm
|
||||
from devplacepy.responses import action_result, wants_json, json_error
|
||||
from devplacepy.schemas import CommentEditOut
|
||||
from devplacepy.services.audit import record as audit
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -33,6 +40,49 @@ async def create_comment(request: Request, data: Annotated[CommentForm, Form()])
|
||||
)
|
||||
|
||||
|
||||
@router.post("/edit/{comment_uid}")
|
||||
async def edit_comment(
|
||||
request: Request, comment_uid: str, data: Annotated[CommentEditForm, Form()]
|
||||
):
|
||||
user = require_user(request)
|
||||
comments = get_table("comments")
|
||||
comment = comments.find_one(uid=comment_uid, deleted_at=None)
|
||||
if not comment or not is_owner(comment, user):
|
||||
target_type = comment.get("target_type", "post") if comment else "post"
|
||||
target_uid = (
|
||||
(comment.get("target_uid") or comment.get("post_uid", "")) if comment else ""
|
||||
)
|
||||
audit.record(
|
||||
request,
|
||||
"comment.edit",
|
||||
user=user,
|
||||
result="denied",
|
||||
target_type="comment",
|
||||
target_uid=comment_uid,
|
||||
summary=f"{user['username']} denied editing comment {comment_uid}",
|
||||
)
|
||||
if wants_json(request):
|
||||
return json_error(403, "Not allowed")
|
||||
return RedirectResponse(
|
||||
url=resolve_object_url(target_type, target_uid), status_code=302
|
||||
)
|
||||
content = data.content.strip()
|
||||
updated_at = edit_comment_record(request, user, comment, content)
|
||||
target_type = comment.get("target_type", "post")
|
||||
target_uid = comment.get("target_uid") or comment.get("post_uid", "")
|
||||
comment_url = f"{resolve_object_url(target_type, target_uid)}#comment-{comment_uid}"
|
||||
if wants_json(request):
|
||||
return JSONResponse(
|
||||
CommentEditOut(
|
||||
uid=comment_uid,
|
||||
content=content,
|
||||
url=comment_url,
|
||||
updated_at=updated_at,
|
||||
).model_dump(mode="json")
|
||||
)
|
||||
return RedirectResponse(url=comment_url, status_code=302)
|
||||
|
||||
|
||||
@router.post("/delete/{comment_uid}")
|
||||
async def delete_comment(request: Request, comment_uid: str):
|
||||
user = require_user(request)
|
||||
@@ -53,4 +103,12 @@ async def delete_comment(request: Request, comment_uid: str):
|
||||
return RedirectResponse(url="/feed", status_code=302)
|
||||
target_type, target_uid = delete_comment_record(request, user, comment)
|
||||
redirect_url = resolve_object_url(target_type, target_uid)
|
||||
return action_result(request, redirect_url)
|
||||
return action_result(
|
||||
request,
|
||||
redirect_url,
|
||||
data={
|
||||
"deleted_uid": comment_uid,
|
||||
"target_type": target_type,
|
||||
"target_uid": target_uid,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -9,6 +9,7 @@ from devplacepy.database import (
|
||||
get_table,
|
||||
get_users_by_uids,
|
||||
get_gist_languages,
|
||||
get_recent_comments_by_target_uids,
|
||||
paginate,
|
||||
text_search_clause,
|
||||
)
|
||||
@@ -86,7 +87,13 @@ def get_gists_list(user_uid=None, language=None, search="", before=None, viewer=
|
||||
return [], next_cursor, total
|
||||
|
||||
users_map = get_users_by_uids([g["user_uid"] for g in gists])
|
||||
return enrich_items(gists, "gist", users_map, user=viewer), next_cursor, total
|
||||
enriched = enrich_items(gists, "gist", users_map, user=viewer)
|
||||
recent_comments = get_recent_comments_by_target_uids(
|
||||
"gist", [g["uid"] for g in gists], 3, viewer
|
||||
)
|
||||
for item in enriched:
|
||||
item["recent_comments"] = recent_comments.get(item["gist"]["uid"], [])
|
||||
return enriched, next_cursor, total
|
||||
|
||||
|
||||
@router.get("", response_class=HTMLResponse)
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from devplacepy.routers.issues import comment, create, status
|
||||
from devplacepy.routers.issues import comment, create, planning, status
|
||||
from devplacepy.routers.issues.index import router
|
||||
|
||||
router.include_router(planning.router)
|
||||
router.include_router(create.router)
|
||||
router.include_router(comment.router)
|
||||
router.include_router(status.router)
|
||||
|
||||
@@ -45,6 +45,7 @@ async def issues_page(request: Request, state: str = STATE_OPEN, page: int = 1):
|
||||
"pagination": None,
|
||||
"configured": True,
|
||||
"error_message": None,
|
||||
"viewer_is_admin": is_admin(user),
|
||||
}
|
||||
|
||||
config = gitea_config()
|
||||
@@ -73,7 +74,7 @@ async def issues_page(request: Request, state: str = STATE_OPEN, page: int = 1):
|
||||
return respond(request, "issues.html", base_ctx, model=IssuesOut)
|
||||
|
||||
|
||||
@router.get("/{number}", response_class=HTMLResponse)
|
||||
@router.get("/{number:int}", response_class=HTMLResponse)
|
||||
async def issue_detail(request: Request, number: int):
|
||||
user = get_current_user(request)
|
||||
if not gitea_config().is_configured:
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi.responses import FileResponse, JSONResponse
|
||||
|
||||
from devplacepy.config import PLANNING_REPORTS_DIR
|
||||
from devplacepy.responses import json_error
|
||||
from devplacepy.schemas import PlanningJobOut
|
||||
from devplacepy.services.audit import record as audit
|
||||
from devplacepy.services.gitea.config import gitea_config
|
||||
from devplacepy.services.jobs import queue
|
||||
from devplacepy.utils import not_found, require_admin
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
PLANNING_KIND = "planning"
|
||||
RETENTION_EXTEND_SECONDS = 7 * 24 * 60 * 60
|
||||
PREFERRED_NAME = "open-tickets-plan"
|
||||
|
||||
|
||||
def _status_payload(job: dict) -> dict:
|
||||
result = job.get("result", {})
|
||||
done = job.get("status") == queue.DONE
|
||||
return {
|
||||
"uid": job.get("uid", ""),
|
||||
"kind": job.get("kind", ""),
|
||||
"status": job.get("status", ""),
|
||||
"preferred_name": job.get("preferred_name"),
|
||||
"download_url": result.get("download_url") if done else None,
|
||||
"markdown": result.get("markdown") if done else None,
|
||||
"ai_used": bool(result.get("ai_used")) if done else False,
|
||||
"error": job.get("error") or None,
|
||||
"issue_count": int(result.get("item_count") or 0),
|
||||
"bytes_out": int(job.get("bytes_out") or result.get("bytes_out") or 0),
|
||||
"created_at": job.get("created_at"),
|
||||
"completed_at": job.get("completed_at") or None,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/planning")
|
||||
async def planning_generate(request: Request):
|
||||
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)
|
||||
audit.record(
|
||||
request,
|
||||
"issue.planning.request",
|
||||
user=admin,
|
||||
target_type="issue",
|
||||
summary="requested an open-tickets planning report",
|
||||
links=[audit.job(uid)],
|
||||
)
|
||||
return JSONResponse({"uid": uid, "status_url": f"/issues/planning/{uid}"})
|
||||
|
||||
|
||||
@router.get("/planning/{uid}")
|
||||
async def planning_status(request: Request, uid: str):
|
||||
require_admin(request)
|
||||
job = queue.get_job(uid)
|
||||
if not job or job.get("kind") != PLANNING_KIND:
|
||||
raise not_found("Planning job not found")
|
||||
return JSONResponse(
|
||||
PlanningJobOut.model_validate(_status_payload(job)).model_dump(mode="json")
|
||||
)
|
||||
|
||||
|
||||
@router.get("/planning/{uid}/download")
|
||||
async def planning_download(request: Request, uid: str):
|
||||
require_admin(request)
|
||||
job = queue.get_job(uid)
|
||||
if (
|
||||
not job
|
||||
or job.get("kind") != PLANNING_KIND
|
||||
or job.get("status") != queue.DONE
|
||||
):
|
||||
raise not_found("Planning report not available")
|
||||
result = job.get("result", {})
|
||||
local_path = result.get("local_path", "")
|
||||
resolved = Path(local_path).resolve()
|
||||
if (
|
||||
not local_path
|
||||
or not resolved.is_relative_to(PLANNING_REPORTS_DIR.resolve())
|
||||
or not resolved.is_file()
|
||||
):
|
||||
raise not_found("Planning report not available")
|
||||
queue.touch_job(uid, RETENTION_EXTEND_SECONDS)
|
||||
return FileResponse(
|
||||
resolved,
|
||||
filename=result.get("final_name", "open-tickets-plan.md"),
|
||||
media_type="text/markdown",
|
||||
)
|
||||
@@ -10,6 +10,7 @@ from devplacepy.database import (
|
||||
load_comments,
|
||||
resolve_by_slug,
|
||||
get_news_images_by_uids,
|
||||
get_recent_comments_by_target_uids,
|
||||
get_user_bookmarks,
|
||||
paginate,
|
||||
)
|
||||
@@ -51,6 +52,9 @@ async def news_page(request: Request, before: str = None):
|
||||
|
||||
article_uids = [a["uid"] for a in articles]
|
||||
images_by_news = get_news_images_by_uids(article_uids)
|
||||
recent_comments = get_recent_comments_by_target_uids(
|
||||
"news", article_uids, 3, user
|
||||
)
|
||||
|
||||
enriched = []
|
||||
for a in articles:
|
||||
@@ -60,6 +64,7 @@ async def news_page(request: Request, before: str = None):
|
||||
"time_ago": time_ago(a["synced_at"]),
|
||||
"image_url": images_by_news.get(a["uid"]),
|
||||
"grade": a.get("grade", 0),
|
||||
"recent_comments": recent_comments.get(a["uid"], []),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ from devplacepy.database import (
|
||||
get_users_by_uids,
|
||||
get_site_stats,
|
||||
get_user_votes,
|
||||
get_recent_comments_by_target_uids,
|
||||
paginate,
|
||||
text_search_clause,
|
||||
resolve_by_slug,
|
||||
@@ -18,6 +19,7 @@ from devplacepy.database import (
|
||||
count_forks,
|
||||
get_top_authors,
|
||||
)
|
||||
from devplacepy.project_files import count_files
|
||||
from devplacepy.services.jobs import queue
|
||||
from devplacepy.content import (
|
||||
load_detail,
|
||||
@@ -95,10 +97,14 @@ def get_projects_list(
|
||||
user_votes = (
|
||||
get_user_votes(viewer["uid"], [p["uid"] for p in page]) if viewer else {}
|
||||
)
|
||||
recent_comments = get_recent_comments_by_target_uids(
|
||||
"project", [p["uid"] for p in page], 3, viewer
|
||||
)
|
||||
for p in page:
|
||||
author = users_map.get(p["user_uid"])
|
||||
p["author_name"] = author["username"] if author else "Unknown"
|
||||
p["my_vote"] = user_votes.get(p["uid"], 0)
|
||||
p["recent_comments"] = recent_comments.get(p["uid"], [])
|
||||
|
||||
return page, next_cursor, total
|
||||
|
||||
@@ -205,6 +211,7 @@ async def project_detail(request: Request, project_slug: str):
|
||||
"read_only": bool(project.get("read_only")),
|
||||
"forked_from": forked_from,
|
||||
"fork_count": count_forks(project["uid"]),
|
||||
"file_count": count_files(project["uid"]),
|
||||
},
|
||||
),
|
||||
model=ProjectDetailOut,
|
||||
|
||||
@@ -85,6 +85,21 @@ class ZipJobOut(_Out):
|
||||
completed_at: Optional[str] = None
|
||||
|
||||
|
||||
class PlanningJobOut(_Out):
|
||||
uid: str = ""
|
||||
kind: str = ""
|
||||
status: str = ""
|
||||
preferred_name: Optional[str] = None
|
||||
download_url: Optional[str] = None
|
||||
markdown: Optional[str] = None
|
||||
ai_used: bool = False
|
||||
error: Optional[str] = None
|
||||
issue_count: int = 0
|
||||
bytes_out: int = 0
|
||||
created_at: Optional[str] = None
|
||||
completed_at: Optional[str] = None
|
||||
|
||||
|
||||
class ForkJobOut(_Out):
|
||||
uid: str = ""
|
||||
kind: str = ""
|
||||
@@ -333,6 +348,13 @@ class CommentOut(_Out):
|
||||
created_at: Optional[str] = None
|
||||
|
||||
|
||||
class CommentEditOut(_Out):
|
||||
uid: str = ""
|
||||
content: str = ""
|
||||
url: str = ""
|
||||
updated_at: Optional[str] = None
|
||||
|
||||
|
||||
class CommentItemOut(_Out):
|
||||
comment: CommentOut
|
||||
author: Optional[UserOut] = None
|
||||
@@ -386,11 +408,13 @@ class GistItemOut(_Out):
|
||||
time_ago: Optional[str] = None
|
||||
my_vote: int = 0
|
||||
comment_count: int = 0
|
||||
recent_comments: list[CommentItemOut] = []
|
||||
|
||||
|
||||
class ProjectListItemOut(ProjectOut):
|
||||
author_name: Optional[str] = None
|
||||
my_vote: int = 0
|
||||
recent_comments: list[CommentItemOut] = []
|
||||
|
||||
|
||||
class NewsListItemOut(_Out):
|
||||
@@ -398,6 +422,7 @@ class NewsListItemOut(_Out):
|
||||
time_ago: Optional[str] = None
|
||||
image_url: Optional[str] = None
|
||||
grade: Optional[int] = None
|
||||
recent_comments: list[CommentItemOut] = []
|
||||
|
||||
|
||||
class ConversationOut(_Out):
|
||||
@@ -564,6 +589,7 @@ class ProjectDetailOut(_Out):
|
||||
read_only: bool = False
|
||||
forked_from: Optional[dict] = None
|
||||
fork_count: int = 0
|
||||
file_count: int = 0
|
||||
|
||||
|
||||
class GistsOut(_Out):
|
||||
|
||||
@@ -6,7 +6,8 @@ import hashlib
|
||||
import logging
|
||||
import math
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from devplacepy import stealth
|
||||
from devplacepy.config import INTERNAL_EMBED_MODEL, INTERNAL_EMBED_URL
|
||||
@@ -22,6 +23,23 @@ TOKEN_PATTERN = re.compile(r"[a-z0-9]+")
|
||||
class EmbedResult:
|
||||
vectors: list[list[float]]
|
||||
backend: str
|
||||
latency_ms: int = 0
|
||||
cache_hits: int = 0
|
||||
|
||||
|
||||
@dataclass
|
||||
class EmbeddingCache:
|
||||
store: dict[str, list[float]] = field(default_factory=dict)
|
||||
|
||||
def key(self, text: str) -> str:
|
||||
return hashlib.sha1((text or "").encode("utf-8")).hexdigest()
|
||||
|
||||
def get(self, text: str) -> list[float] | None:
|
||||
return self.store.get(self.key(text))
|
||||
|
||||
def put(self, text: str, vector: list[float]) -> None:
|
||||
if vector:
|
||||
self.store[self.key(text)] = vector
|
||||
|
||||
|
||||
def _local_vector(text: str) -> list[float]:
|
||||
@@ -41,19 +59,51 @@ def _local_vector(text: str) -> list[float]:
|
||||
|
||||
|
||||
def local_embed(texts: list[str]) -> EmbedResult:
|
||||
return EmbedResult(vectors=[_local_vector(text) for text in texts], backend="local")
|
||||
start = time.monotonic()
|
||||
vectors = [_local_vector(text) for text in texts]
|
||||
latency_ms = int((time.monotonic() - start) * 1000)
|
||||
return EmbedResult(vectors=vectors, backend="local", latency_ms=latency_ms)
|
||||
|
||||
|
||||
async def embed_texts(
|
||||
texts: list[str], api_key: str, *, gateway_url: str = INTERNAL_EMBED_URL
|
||||
texts: list[str],
|
||||
api_key: str,
|
||||
*,
|
||||
gateway_url: str = INTERNAL_EMBED_URL,
|
||||
cache: EmbeddingCache | None = None,
|
||||
) -> EmbedResult:
|
||||
if not texts:
|
||||
return EmbedResult(vectors=[], backend="empty")
|
||||
cache_hits = 0
|
||||
pending_index: list[int] = []
|
||||
pending_text: list[str] = []
|
||||
resolved: list[list[float] | None] = [None] * len(texts)
|
||||
if cache is not None:
|
||||
for index, text in enumerate(texts):
|
||||
cached_vector = cache.get(text)
|
||||
if cached_vector is not None:
|
||||
resolved[index] = cached_vector
|
||||
cache_hits += 1
|
||||
else:
|
||||
pending_index.append(index)
|
||||
pending_text.append(text)
|
||||
else:
|
||||
pending_index = list(range(len(texts)))
|
||||
pending_text = list(texts)
|
||||
if not pending_text:
|
||||
return EmbedResult(
|
||||
vectors=[vector or [] for vector in resolved],
|
||||
backend="cache",
|
||||
latency_ms=0,
|
||||
cache_hits=cache_hits,
|
||||
)
|
||||
headers = {
|
||||
"Authorization": f"Bearer {api_key}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
payload = {"model": INTERNAL_EMBED_MODEL, "input": texts}
|
||||
payload = {"model": INTERNAL_EMBED_MODEL, "input": pending_text}
|
||||
start = time.monotonic()
|
||||
backend = "gateway"
|
||||
try:
|
||||
async with stealth.stealth_async_client(timeout=EMBED_TIMEOUT_SECONDS) as client:
|
||||
response = await client.post(gateway_url, json=payload, headers=headers)
|
||||
@@ -61,10 +111,22 @@ async def embed_texts(
|
||||
raise RuntimeError(f"embed gateway returned {response.status_code}")
|
||||
data = response.json()
|
||||
rows = data.get("data") or []
|
||||
vectors = [row.get("embedding") or [] for row in rows]
|
||||
if len(vectors) != len(texts) or any(not vector for vector in vectors):
|
||||
fresh = [row.get("embedding") or [] for row in rows]
|
||||
if len(fresh) != len(pending_text) or any(not vector for vector in fresh):
|
||||
raise RuntimeError("embed gateway returned an incomplete response")
|
||||
return EmbedResult(vectors=vectors, backend="gateway")
|
||||
except Exception as exc:
|
||||
logger.warning("deepsearch embedding gateway failed, using local: %s", exc)
|
||||
return local_embed(texts)
|
||||
fresh = [_local_vector(text) for text in pending_text]
|
||||
backend = "local"
|
||||
latency_ms = int((time.monotonic() - start) * 1000)
|
||||
for offset, index in enumerate(pending_index):
|
||||
vector = fresh[offset]
|
||||
resolved[index] = vector
|
||||
if cache is not None:
|
||||
cache.put(pending_text[offset], vector)
|
||||
return EmbedResult(
|
||||
vectors=[vector or [] for vector in resolved],
|
||||
backend=backend,
|
||||
latency_ms=latency_ms,
|
||||
cache_hits=cache_hits,
|
||||
)
|
||||
|
||||
@@ -15,8 +15,7 @@ logger = logging.getLogger(__name__)
|
||||
TOKEN_PATTERN = re.compile(r"[a-z0-9]+")
|
||||
BM25_K1 = 1.5
|
||||
BM25_B = 0.75
|
||||
HYBRID_VECTOR_WEIGHT = 0.6
|
||||
HYBRID_KEYWORD_WEIGHT = 0.4
|
||||
RRF_K = 60.0
|
||||
DEFAULT_TOP_K = 8
|
||||
CANDIDATE_MULTIPLIER = 4
|
||||
|
||||
@@ -182,18 +181,39 @@ class VectorStore:
|
||||
)
|
||||
if not candidates:
|
||||
return []
|
||||
vector_rank = sorted(
|
||||
candidates, key=lambda chunk: chunk.score, reverse=True
|
||||
)
|
||||
keyword = self.keyword_scores(query, candidates)
|
||||
vec_max = max((chunk.score for chunk in candidates), default=0.0) or 1.0
|
||||
kw_max = max(keyword.values(), default=0.0) or 1.0
|
||||
keyword_rank = sorted(
|
||||
candidates, key=lambda chunk: keyword.get(chunk.uid, 0.0), reverse=True
|
||||
)
|
||||
fused: dict[str, float] = {}
|
||||
for rank, chunk in enumerate(vector_rank, start=1):
|
||||
fused[chunk.uid] = fused.get(chunk.uid, 0.0) + 1.0 / (RRF_K + rank)
|
||||
for rank, chunk in enumerate(keyword_rank, start=1):
|
||||
fused[chunk.uid] = fused.get(chunk.uid, 0.0) + 1.0 / (RRF_K + rank)
|
||||
for chunk in candidates:
|
||||
vec_norm = max(0.0, chunk.score) / vec_max
|
||||
kw_norm = keyword.get(chunk.uid, 0.0) / kw_max
|
||||
chunk.score = (
|
||||
HYBRID_VECTOR_WEIGHT * vec_norm + HYBRID_KEYWORD_WEIGHT * kw_norm
|
||||
)
|
||||
chunk.score = fused.get(chunk.uid, 0.0)
|
||||
candidates.sort(key=lambda chunk: chunk.score, reverse=True)
|
||||
return candidates[:top_k]
|
||||
|
||||
def coverage_analytics(self) -> dict:
|
||||
chunks = self.all_chunks()
|
||||
if not chunks:
|
||||
return {"chunks": 0, "domains": 0, "sources": 0, "avg_chunk_chars": 0}
|
||||
domains = {chunk.metadata.get("url", "") for chunk in chunks}
|
||||
domains.discard("")
|
||||
sources = {chunk.source for chunk in chunks}
|
||||
sources.discard("")
|
||||
avg_chars = int(sum(len(chunk.text) for chunk in chunks) / len(chunks))
|
||||
return {
|
||||
"chunks": len(chunks),
|
||||
"domains": len(domains),
|
||||
"sources": len(sources),
|
||||
"avg_chunk_chars": avg_chars,
|
||||
}
|
||||
|
||||
def drop(self) -> None:
|
||||
try:
|
||||
import chromadb
|
||||
|
||||
@@ -197,6 +197,16 @@ ACTIONS: tuple[Action, ...] = (
|
||||
body("attachment_uids", ATTACHMENTS),
|
||||
),
|
||||
),
|
||||
Action(
|
||||
name="edit_comment",
|
||||
method="POST",
|
||||
path="/comments/edit/{comment_uid}",
|
||||
summary="Edit the body of one of your own comments",
|
||||
params=(
|
||||
path("comment_uid", "Uid of the comment."),
|
||||
body("content", "New comment body, 3-1000 characters.", required=True),
|
||||
),
|
||||
),
|
||||
Action(
|
||||
name="delete_comment",
|
||||
method="POST",
|
||||
@@ -910,6 +920,20 @@ ACTIONS: tuple[Action, ...] = (
|
||||
body("description", "Issue description.", required=True),
|
||||
),
|
||||
),
|
||||
Action(
|
||||
name="planning_report_generate",
|
||||
method="POST",
|
||||
path="/issues/planning",
|
||||
summary="Queue an admin planning report of all 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."
|
||||
),
|
||||
params=(),
|
||||
requires_auth=True,
|
||||
requires_admin=True,
|
||||
),
|
||||
Action(
|
||||
name="issue_job_status",
|
||||
method="GET",
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import logging
|
||||
|
||||
import httpx
|
||||
|
||||
from devplacepy import stealth
|
||||
from devplacepy.config import INTERNAL_GATEWAY_URL
|
||||
from devplacepy.services.gitea.config import HTTP_TIMEOUT_SECONDS, GiteaConfig
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
MAX_TOKENS = 3000
|
||||
MAX_ISSUES = 50
|
||||
BODY_EXCERPT_MAX = 600
|
||||
PLAN_MAX = 80000
|
||||
UNGROUPED_LABEL = "General"
|
||||
|
||||
SYSTEM_PROMPT = (
|
||||
"You are a senior engineering lead for DevPlace, a social network for software "
|
||||
"developers. You are given the full list of OPEN issue tickets from a Gitea tracker. "
|
||||
"Produce a single, clear delivery plan in GitHub-flavored markdown that GROUPS the "
|
||||
"tickets into logical workstreams and ORDERS them so dependencies and high-impact work "
|
||||
"come first. Never invent tickets, numbers, or facts that are not in the input.\n\n"
|
||||
"Return ONLY markdown, using EXACTLY this structure:\n"
|
||||
"# Open Tickets Planning\n"
|
||||
"A short paragraph summarising the overall plan and how many tickets it covers.\n\n"
|
||||
"Then one level-2 heading per group (a workstream or theme). Under each group, an "
|
||||
"ordered (numbered) list of its tickets. Each list item MUST start with the ticket "
|
||||
"number as '#N', then the title, then a single sentence explaining the chosen order "
|
||||
"or the work to do, for example:\n"
|
||||
"1. #12 Fix the login redirect - blocks every authenticated flow, do first.\n\n"
|
||||
"End with a level-2 heading '## Suggested Order' giving a flat numbered list of every "
|
||||
"ticket number in the recommended execution order."
|
||||
)
|
||||
|
||||
|
||||
def _excerpt(text: str) -> str:
|
||||
text = (text or "").strip().replace("\r\n", "\n")
|
||||
if len(text) > BODY_EXCERPT_MAX:
|
||||
return text[:BODY_EXCERPT_MAX].rstrip() + "..."
|
||||
return text
|
||||
|
||||
|
||||
def _labels(issue: dict) -> list[str]:
|
||||
labels = issue.get("labels") or []
|
||||
names: list[str] = []
|
||||
for label in labels:
|
||||
if isinstance(label, dict):
|
||||
name = str(label.get("name", "")).strip()
|
||||
else:
|
||||
name = str(label).strip()
|
||||
if name:
|
||||
names.append(name)
|
||||
return names
|
||||
|
||||
|
||||
def _group_key(issue: dict) -> str:
|
||||
labels = _labels(issue)
|
||||
return labels[0] if labels else UNGROUPED_LABEL
|
||||
|
||||
|
||||
def _fallback(issues: list[dict]) -> str:
|
||||
groups: dict[str, list[dict]] = {}
|
||||
for issue in issues:
|
||||
groups.setdefault(_group_key(issue), []).append(issue)
|
||||
|
||||
ordered_group_names = sorted(
|
||||
groups,
|
||||
key=lambda name: (name == UNGROUPED_LABEL, name.lower()),
|
||||
)
|
||||
|
||||
lines: list[str] = ["# Open Tickets Planning", ""]
|
||||
lines.append(
|
||||
f"Deterministic plan covering {len(issues)} open ticket"
|
||||
f"{'' if len(issues) == 1 else 's'}, grouped by primary label and ordered by "
|
||||
"ticket number within each group."
|
||||
)
|
||||
lines.append("")
|
||||
|
||||
flat_order: list[int] = []
|
||||
for name in ordered_group_names:
|
||||
members = sorted(groups[name], key=lambda i: int(i.get("number", 0)))
|
||||
lines.append(f"## {name}")
|
||||
for position, issue in enumerate(members, start=1):
|
||||
number = int(issue.get("number", 0))
|
||||
title = str(issue.get("title", "")).strip() or "Untitled"
|
||||
excerpt = _excerpt(issue.get("body", ""))
|
||||
rationale = excerpt or "No description provided."
|
||||
lines.append(f"{position}. #{number} {title} - {rationale}")
|
||||
flat_order.append(number)
|
||||
lines.append("")
|
||||
|
||||
lines.append("## Suggested Order")
|
||||
for position, number in enumerate(flat_order, start=1):
|
||||
lines.append(f"{position}. #{number}")
|
||||
lines.append("")
|
||||
|
||||
return "\n".join(lines)[:PLAN_MAX]
|
||||
|
||||
|
||||
def _user_message(issues: list[dict]) -> str:
|
||||
parts: list[str] = ["Open tickets to plan:", ""]
|
||||
for issue in issues:
|
||||
number = int(issue.get("number", 0))
|
||||
title = str(issue.get("title", "")).strip() or "Untitled"
|
||||
labels = ", ".join(_labels(issue)) or "none"
|
||||
excerpt = _excerpt(issue.get("body", ""))
|
||||
parts.append(f"#{number} {title}")
|
||||
parts.append(f"labels: {labels}")
|
||||
parts.append(f"description: {excerpt or 'none'}")
|
||||
parts.append("")
|
||||
return "\n".join(parts)
|
||||
|
||||
|
||||
async def generate_plan(
|
||||
issues: list[dict], config: GiteaConfig
|
||||
) -> tuple[str, bool]:
|
||||
issues = list(issues)[:MAX_ISSUES]
|
||||
if not issues:
|
||||
return "# Open Tickets Planning\n\nThere are no open tickets to plan.\n", False
|
||||
if not config.ai_enhance:
|
||||
return _fallback(issues), False
|
||||
|
||||
payload = {
|
||||
"model": config.ai_model,
|
||||
"messages": [
|
||||
{"role": "system", "content": SYSTEM_PROMPT},
|
||||
{"role": "user", "content": _user_message(issues)},
|
||||
],
|
||||
"max_tokens": MAX_TOKENS,
|
||||
"temperature": 0.2,
|
||||
}
|
||||
headers = {"Content-Type": "application/json"}
|
||||
if config.ai_key:
|
||||
headers["Authorization"] = f"Bearer {config.ai_key}"
|
||||
|
||||
try:
|
||||
async with stealth.stealth_async_client(timeout=HTTP_TIMEOUT_SECONDS) as client:
|
||||
response = await client.post(
|
||||
INTERNAL_GATEWAY_URL, json=payload, headers=headers
|
||||
)
|
||||
response.raise_for_status()
|
||||
content = (
|
||||
response.json()
|
||||
.get("choices", [{}])[0]
|
||||
.get("message", {})
|
||||
.get("content", "")
|
||||
)
|
||||
except (httpx.HTTPError, ValueError, KeyError, IndexError) as exc:
|
||||
logger.warning("Planning generation failed, using fallback: %s", exc)
|
||||
return _fallback(issues), False
|
||||
|
||||
markdown = (content or "").strip()
|
||||
if not markdown:
|
||||
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
|
||||
@@ -2,9 +2,11 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import logging
|
||||
import re
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Awaitable, Callable
|
||||
|
||||
@@ -14,6 +16,7 @@ from devplacepy import stealth
|
||||
from devplacepy.net_guard import BlockedAddressError, guard_public_url, guarded_async_client
|
||||
|
||||
from .pdf import MAX_PDF_BYTES, extract_pdf_text, is_pdf
|
||||
from .phases import PHASE_CRAWLING
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -22,6 +25,7 @@ RSEARCH_TIMEOUT_SECONDS = 45.0
|
||||
FETCH_TIMEOUT_SECONDS = 20.0
|
||||
MAX_FETCH_BYTES = 2_500_000
|
||||
RESULTS_PER_QUERY = 8
|
||||
CRAWL_CONCURRENCY = 4
|
||||
USER_AGENT = (
|
||||
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) "
|
||||
"Chrome/131.0.0.0 Safari/537.36 DevPlaceDeepSearchBot/1.0"
|
||||
|
||||
@@ -5,10 +5,13 @@ from __future__ import annotations
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from typing import Callable
|
||||
|
||||
from devplacepy import stealth
|
||||
from devplacepy.config import INTERNAL_GATEWAY_URL, INTERNAL_MODEL
|
||||
|
||||
from .phases import PHASE_PLANNING
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
ENHANCE_TIMEOUT_SECONDS = 90.0
|
||||
@@ -52,7 +55,20 @@ def _parse(text: str) -> list[str]:
|
||||
return cleaned[:MAX_SUBQUERIES]
|
||||
|
||||
|
||||
async def plan_queries(query: str, api_key: str) -> list[str]:
|
||||
def _noop(frame: dict) -> None:
|
||||
return None
|
||||
|
||||
|
||||
async def plan_queries(
|
||||
query: str, api_key: str, emit: Callable[[dict], None] = _noop
|
||||
) -> list[str]:
|
||||
emit(
|
||||
{
|
||||
"type": "substep",
|
||||
"phase": PHASE_PLANNING,
|
||||
"message": "Drafting diverse search angles",
|
||||
}
|
||||
)
|
||||
payload = {
|
||||
"model": INTERNAL_MODEL,
|
||||
"messages": [
|
||||
@@ -79,7 +95,22 @@ async def plan_queries(query: str, api_key: str) -> list[str]:
|
||||
)
|
||||
parsed = _parse(content)
|
||||
if parsed:
|
||||
emit(
|
||||
{
|
||||
"type": "substep",
|
||||
"phase": PHASE_PLANNING,
|
||||
"message": f"Planned {len(parsed)} search angles",
|
||||
}
|
||||
)
|
||||
return parsed
|
||||
except Exception as exc:
|
||||
logger.warning("deepsearch query planner failed, using fallback: %s", exc)
|
||||
return _fallback(query)
|
||||
fallback = _fallback(query)
|
||||
emit(
|
||||
{
|
||||
"type": "substep",
|
||||
"phase": PHASE_PLANNING,
|
||||
"message": f"Planner unavailable, using {len(fallback)} heuristic angles",
|
||||
}
|
||||
)
|
||||
return fallback
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
PHASE_PLANNING = "planning"
|
||||
PHASE_SEARCHING = "searching"
|
||||
PHASE_CRAWLING = "crawling"
|
||||
PHASE_INDEXING = "indexing"
|
||||
PHASE_ANALYSIS = "analysis"
|
||||
PHASE_SYNTHESIS = "synthesis"
|
||||
|
||||
PHASE_ORDER: list[str] = [
|
||||
PHASE_PLANNING,
|
||||
PHASE_SEARCHING,
|
||||
PHASE_CRAWLING,
|
||||
PHASE_INDEXING,
|
||||
PHASE_ANALYSIS,
|
||||
PHASE_SYNTHESIS,
|
||||
]
|
||||
|
||||
PHASE_LABELS: dict[str, str] = {
|
||||
PHASE_PLANNING: "Planning",
|
||||
PHASE_SEARCHING: "Searching",
|
||||
PHASE_CRAWLING: "Crawling",
|
||||
PHASE_INDEXING: "Indexing",
|
||||
PHASE_ANALYSIS: "Analysis",
|
||||
PHASE_SYNTHESIS: "Synthesis",
|
||||
}
|
||||
|
||||
TOTAL_PHASES = len(PHASE_ORDER)
|
||||
HEARTBEAT_SECONDS = 2.0
|
||||
|
||||
|
||||
def phase_index(phase: str) -> int:
|
||||
try:
|
||||
return PHASE_ORDER.index(phase)
|
||||
except ValueError:
|
||||
return 0
|
||||
@@ -0,0 +1,119 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import logging
|
||||
import zlib
|
||||
from pathlib import Path
|
||||
|
||||
from devplacepy.attachments import _directory_for
|
||||
from devplacepy.config import PLANNING_REPORTS_DIR
|
||||
from devplacepy.services.gitea import runtime
|
||||
from devplacepy.services.gitea.client import STATE_OPEN, GiteaError
|
||||
from devplacepy.services.gitea.config import gitea_config
|
||||
from devplacepy.services.gitea.planning import MAX_ISSUES, generate_plan
|
||||
from devplacepy.services.jobs.base import JobService
|
||||
from devplacepy.utils import slugify
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
PAGE_LIMIT = 50
|
||||
|
||||
|
||||
class PlanningReportService(JobService):
|
||||
kind = "planning"
|
||||
title = "Ticket planning"
|
||||
description = (
|
||||
"Builds a grouped, ordered markdown planning report of all open Gitea tickets off "
|
||||
"the request path using the internal AI service (with a deterministic fallback), "
|
||||
"writes the markdown artifact, and prunes it once unused for the retention window."
|
||||
)
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__(name="planning", interval_seconds=2)
|
||||
|
||||
async def process(self, job: dict) -> dict:
|
||||
from devplacepy.services.audit import record as audit
|
||||
|
||||
uid: str = job["uid"]
|
||||
owner_kind: str = job.get("owner_kind") or "system"
|
||||
owner_id: str = job.get("owner_id") or ""
|
||||
try:
|
||||
config = gitea_config()
|
||||
if not config.is_configured:
|
||||
raise GiteaError("Gitea integration is not configured", status=503)
|
||||
|
||||
issues = await self._collect_open(config)
|
||||
markdown, ai_used = await generate_plan(issues, config)
|
||||
issue_count = len(issues)
|
||||
|
||||
final_name = self._final_name(job.get("preferred_name", ""), markdown)
|
||||
target_dir = PLANNING_REPORTS_DIR / _directory_for(uid)
|
||||
target_dir.mkdir(parents=True, exist_ok=True)
|
||||
final_path = target_dir / final_name
|
||||
final_path.write_text(markdown, encoding="utf-8")
|
||||
bytes_out = len(markdown.encode("utf-8"))
|
||||
except Exception:
|
||||
audit.record_system(
|
||||
"issue.planning.generate",
|
||||
actor_kind="user" if owner_kind == "user" else owner_kind,
|
||||
actor_uid=owner_id if owner_kind == "user" else None,
|
||||
result="failure",
|
||||
target_type="issue",
|
||||
metadata={"job": uid},
|
||||
summary="planning report generation failed",
|
||||
links=[audit.job(uid)],
|
||||
)
|
||||
raise
|
||||
|
||||
audit.record_system(
|
||||
"issue.planning.generate",
|
||||
actor_kind="user" if owner_kind == "user" else owner_kind,
|
||||
actor_uid=owner_id if owner_kind == "user" else None,
|
||||
target_type="issue",
|
||||
metadata={
|
||||
"issue_count": issue_count,
|
||||
"ai_used": ai_used,
|
||||
"bytes_out": bytes_out,
|
||||
},
|
||||
summary=f"planning report for {issue_count} open tickets generated",
|
||||
links=[audit.job(uid)],
|
||||
)
|
||||
self.log(f"Planning job {uid} covered {issue_count} open tickets")
|
||||
return {
|
||||
"download_url": f"/issues/planning/{uid}/download",
|
||||
"local_path": str(final_path),
|
||||
"final_name": final_name,
|
||||
"markdown": markdown,
|
||||
"ai_used": ai_used,
|
||||
"item_count": issue_count,
|
||||
"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 _final_name(self, preferred_name: str, markdown: str) -> str:
|
||||
name = preferred_name or "open-tickets-plan"
|
||||
if name.lower().endswith(".md"):
|
||||
name = name[:-3]
|
||||
slug = slugify(name) or "open-tickets-plan"
|
||||
crc32 = zlib.crc32(markdown.encode("utf-8")) & 0xFFFFFFFF
|
||||
return f"{crc32:08x}.{slug}.md"
|
||||
|
||||
def cleanup(self, job: dict) -> None:
|
||||
result = job.get("result", {})
|
||||
local_path = result.get("local_path")
|
||||
if local_path:
|
||||
Path(local_path).unlink(missing_ok=True)
|
||||
@@ -900,7 +900,7 @@ body:has(.page-messages) {
|
||||
}
|
||||
|
||||
.page:has(.page-messages) {
|
||||
padding-top: 0;
|
||||
padding-top: var(--nav-height);
|
||||
padding-bottom: 0;
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
|
||||
@@ -143,6 +143,14 @@ devii-terminal.devii-state-fullscreen .devii-window {
|
||||
resize: none;
|
||||
}
|
||||
|
||||
/* On-screen keyboard inset: devii-terminal.js sets inline top/height from
|
||||
visualViewport so the window ends exactly at the keyboard top and the
|
||||
input row stays reachable. Drop the fixed bottom/height so the inline wins. */
|
||||
devii-terminal.devii-keyboard-visible.devii-state-fullscreen .devii-window {
|
||||
bottom: auto;
|
||||
transition: none;
|
||||
}
|
||||
|
||||
/* Title bar */
|
||||
devii-terminal .devii-titlebar {
|
||||
display: flex;
|
||||
@@ -493,6 +501,13 @@ devii-terminal.devii-mobile .devii-winctl button[data-win="fullscreen"] {
|
||||
height: 30px;
|
||||
font-size: 16px;
|
||||
}
|
||||
devii-terminal .devii-inputrow {
|
||||
min-height: 44px;
|
||||
}
|
||||
devii-terminal .devii-input {
|
||||
min-height: 44px;
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Devii client-side tutorial overlays (attached to document.body) */
|
||||
|
||||
@@ -496,6 +496,10 @@ textarea.param-input {
|
||||
.code-copy-btn {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.content-copy-btn {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---- Unified code blocks: highlight + line numbers + copy (widget tabs, response, prose) ---- */
|
||||
|
||||
@@ -95,6 +95,15 @@
|
||||
resize: none;
|
||||
}
|
||||
|
||||
/* On-screen keyboard inset: visualViewport sets inline top/height from
|
||||
FloatingWindow.js so the window shrinks above the keyboard instead of
|
||||
being pushed off-screen. Drop the fixed bottom/height so the inline wins. */
|
||||
.fw-host.fw-state-fullscreen.fw-keyboard-visible .fw-window,
|
||||
.fw-host.fw-state-maximized.fw-keyboard-visible .fw-window {
|
||||
bottom: auto;
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.fw-host .fw-titlebar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -14,6 +14,12 @@
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
.issues-header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.issues-filters {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
|
||||
@@ -399,20 +399,20 @@
|
||||
}
|
||||
}
|
||||
|
||||
.landing-bots-intro {
|
||||
.landing-help-intro {
|
||||
max-width: 760px;
|
||||
margin: 0 0 1.5rem;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.landing-bots-grid {
|
||||
.landing-help-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.landing-bot-card {
|
||||
.landing-help-card {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-xl);
|
||||
@@ -422,34 +422,87 @@
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.landing-bot-card:hover {
|
||||
.landing-help-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
.landing-bot-card-lead {
|
||||
.landing-help-card-lead {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.landing-bot-icon {
|
||||
.landing-help-icon {
|
||||
font-size: 1.75rem;
|
||||
}
|
||||
|
||||
.landing-bot-card h3 {
|
||||
.landing-help-card h3 {
|
||||
margin: 0;
|
||||
color: var(--text-primary);
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
|
||||
.landing-bot-card p {
|
||||
.landing-help-card p {
|
||||
margin: 0;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.landing-help-link {
|
||||
margin-top: auto;
|
||||
color: var(--accent);
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.landing-help-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.landing-help-links {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.landing-help-links a {
|
||||
color: var(--accent);
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.landing-help-links a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.landing-help-cta {
|
||||
margin-top: auto;
|
||||
align-self: flex-start;
|
||||
background: var(--accent);
|
||||
color: var(--white);
|
||||
border: none;
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 0.6rem 1.1rem;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: filter 0.15s ease;
|
||||
}
|
||||
|
||||
.landing-help-cta:hover {
|
||||
filter: brightness(1.08);
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.landing-bots-grid {
|
||||
.landing-help-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.landing-help-cta {
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -120,6 +120,36 @@
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
dp-content.rendered-content {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.content-copy-btn {
|
||||
position: absolute;
|
||||
top: 0.25rem;
|
||||
right: 0.25rem;
|
||||
z-index: 2;
|
||||
padding: 0.2rem 0.6rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary);
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-light);
|
||||
border-radius: var(--radius);
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
|
||||
dp-content:hover .content-copy-btn,
|
||||
.content-copy-btn:focus {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.content-copy-btn:hover {
|
||||
color: var(--white);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.embed-youtube {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
width: 100%;
|
||||
--kb-inset: 0px;
|
||||
}
|
||||
|
||||
.messages-layout {
|
||||
@@ -292,11 +293,16 @@
|
||||
|
||||
.messages-input-area {
|
||||
padding: 0.75rem 1rem;
|
||||
padding-bottom: calc(0.75rem + var(--kb-inset) + env(safe-area-inset-bottom));
|
||||
padding-left: calc(1rem + env(safe-area-inset-left));
|
||||
padding-right: calc(1rem + env(safe-area-inset-right));
|
||||
border-top: 1px solid var(--border);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex-shrink: 0;
|
||||
min-height: 56px;
|
||||
background: var(--bg-card);
|
||||
}
|
||||
|
||||
.messages-input-area input[type="text"] {
|
||||
@@ -403,13 +409,23 @@
|
||||
.messages-layout {
|
||||
grid-template-columns: 1fr;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
border-radius: 0;
|
||||
border-left: none;
|
||||
border-right: none;
|
||||
}
|
||||
.messages-list {
|
||||
display: flex;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
}
|
||||
.messages-list.hide {
|
||||
display: none;
|
||||
}
|
||||
.messages-main {
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
}
|
||||
.messages-main.hide {
|
||||
display: none;
|
||||
}
|
||||
@@ -418,6 +434,9 @@
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.messages-input-area input[type="text"] {
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 360px) {
|
||||
@@ -427,6 +446,9 @@
|
||||
|
||||
.messages-input-area {
|
||||
padding: 0.5rem 0.75rem;
|
||||
padding-bottom: calc(0.5rem + var(--kb-inset) + env(safe-area-inset-bottom));
|
||||
padding-left: calc(0.75rem + env(safe-area-inset-left));
|
||||
padding-right: calc(0.75rem + env(safe-area-inset-right));
|
||||
}
|
||||
|
||||
.message-bubble {
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
/* retoor <retoor@molodetz.nl> */
|
||||
|
||||
.planning-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.planning-subtitle {
|
||||
margin: 0.25rem 0 0;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.planning-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 1rem 1.25rem;
|
||||
margin: 1.5rem 0;
|
||||
color: var(--text-secondary);
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
}
|
||||
|
||||
.planning-spinner {
|
||||
width: 1.1rem;
|
||||
height: 1.1rem;
|
||||
border: 2px solid var(--border-light);
|
||||
border-top-color: var(--accent);
|
||||
border-radius: 50%;
|
||||
animation: planning-spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes planning-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.planning-result {
|
||||
margin: 1.5rem 0;
|
||||
}
|
||||
|
||||
.planning-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.planning-hint {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.planning-report {
|
||||
display: block;
|
||||
padding: 1.5rem;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
}
|
||||
|
||||
.planning-empty {
|
||||
margin: 1.5rem 0;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.planning-header {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.planning-report {
|
||||
padding: 1rem;
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,7 @@ import { DeviiTerminal } from "./DeviiTerminal.js";
|
||||
import { ZipDownloader } from "./ZipDownloader.js";
|
||||
import { ProjectForker } from "./ProjectForker.js";
|
||||
import { IssueReporter } from "./IssueReporter.js";
|
||||
import { PlanningGenerator } from "./PlanningGenerator.js";
|
||||
import { MediaGallery } from "./MediaGallery.js";
|
||||
import WindowManager from "./components/WindowManager.js";
|
||||
import { ContainerTerminalManager } from "./ContainerTerminalManager.js";
|
||||
@@ -67,6 +68,7 @@ class Application {
|
||||
this.zipDownloader = new ZipDownloader();
|
||||
this.projectForker = new ProjectForker();
|
||||
this.issueReporter = new IssueReporter();
|
||||
this.planningGenerator = new PlanningGenerator();
|
||||
this.mediaGallery = new MediaGallery();
|
||||
this.pubsub = new PubSubClient();
|
||||
}
|
||||
|
||||
@@ -1,10 +1,45 @@
|
||||
// retoor <retoor@molodetz.nl>
|
||||
|
||||
import { Http } from "./Http.js";
|
||||
import { contentRenderer } from "./ContentRenderer.js";
|
||||
|
||||
export class CommentManager {
|
||||
constructor() {
|
||||
this.initCommentReply();
|
||||
this.initCommentEdit();
|
||||
this.initCommentDelete();
|
||||
}
|
||||
|
||||
initCommentDelete() {
|
||||
document.addEventListener("submit", (e) => {
|
||||
const form = e.target.closest(".comment-delete-form");
|
||||
if (!form) return;
|
||||
e.preventDefault();
|
||||
this.submitDelete(form);
|
||||
});
|
||||
}
|
||||
|
||||
async submitDelete(form) {
|
||||
const wrapper = form.closest(".comment");
|
||||
if (!wrapper) return;
|
||||
const button = form.querySelector("button[type='submit']");
|
||||
if (button) button.disabled = true;
|
||||
|
||||
const anchor = wrapper.previousElementSibling
|
||||
&& wrapper.previousElementSibling.classList.contains("comment")
|
||||
? wrapper.previousElementSibling
|
||||
: wrapper.parentElement.closest(".comment, .comments-section, .post-card");
|
||||
const anchorTop = anchor
|
||||
? anchor.getBoundingClientRect().top + window.scrollY
|
||||
: wrapper.getBoundingClientRect().top + window.scrollY;
|
||||
|
||||
try {
|
||||
await Http.send(form.action);
|
||||
wrapper.remove();
|
||||
window.scrollTo({ top: Math.max(anchorTop, 0) });
|
||||
} catch (err) {
|
||||
if (button) button.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
initCommentReply() {
|
||||
@@ -16,6 +51,90 @@ export class CommentManager {
|
||||
});
|
||||
}
|
||||
|
||||
initCommentEdit() {
|
||||
document.addEventListener("click", (e) => {
|
||||
const btn = e.target.closest("[data-action='edit']");
|
||||
if (!btn) return;
|
||||
e.preventDefault();
|
||||
this.toggleEditForm(btn);
|
||||
});
|
||||
}
|
||||
|
||||
toggleEditForm(btn) {
|
||||
const body = btn.closest(".comment-body");
|
||||
if (!body) return;
|
||||
const text = body.querySelector(":scope > .comment-text");
|
||||
if (!text) return;
|
||||
|
||||
const existing = body.querySelector(":scope > .comment-edit-form");
|
||||
if (existing) {
|
||||
existing.remove();
|
||||
text.style.display = "";
|
||||
return;
|
||||
}
|
||||
|
||||
const form = document.createElement("form");
|
||||
form.className = "comment-form comment-edit-form";
|
||||
form.method = "POST";
|
||||
form.action = btn.dataset.editUrl;
|
||||
|
||||
const textarea = document.createElement("textarea");
|
||||
textarea.name = "content";
|
||||
textarea.className = "emoji-picker-target";
|
||||
textarea.value = text.dataset.raw || text.textContent;
|
||||
textarea.rows = 3;
|
||||
form.appendChild(textarea);
|
||||
|
||||
const actions = document.createElement("div");
|
||||
actions.className = "comment-form-actions";
|
||||
const save = document.createElement("button");
|
||||
save.type = "submit";
|
||||
save.className = "btn btn-primary btn-sm";
|
||||
save.textContent = "Save";
|
||||
const cancel = document.createElement("button");
|
||||
cancel.type = "button";
|
||||
cancel.className = "comment-action-btn";
|
||||
cancel.textContent = "Cancel";
|
||||
cancel.addEventListener("click", () => {
|
||||
form.remove();
|
||||
text.style.display = "";
|
||||
});
|
||||
actions.appendChild(save);
|
||||
actions.appendChild(cancel);
|
||||
form.appendChild(actions);
|
||||
|
||||
form.addEventListener("submit", (e) => {
|
||||
e.preventDefault();
|
||||
this.submitEdit(form, text);
|
||||
});
|
||||
|
||||
text.style.display = "none";
|
||||
text.insertAdjacentElement("afterend", form);
|
||||
|
||||
const enhancer = window.app && window.app.content;
|
||||
if (enhancer) enhancer.initEmojiPickers();
|
||||
textarea.focus();
|
||||
}
|
||||
|
||||
async submitEdit(form, text) {
|
||||
const save = form.querySelector("button[type='submit']");
|
||||
if (save) save.disabled = true;
|
||||
const content = form.querySelector("textarea").value;
|
||||
try {
|
||||
const result = await Http.send(form.action, { content });
|
||||
text.dataset.raw = result.content;
|
||||
text.textContent = result.content;
|
||||
contentRenderer.applyTo(text);
|
||||
text.querySelectorAll("img:not(.avatar-img)").forEach((img) => {
|
||||
img.dataset.lightbox = "";
|
||||
});
|
||||
form.remove();
|
||||
text.style.display = "";
|
||||
} catch (err) {
|
||||
if (save) save.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
toggleReplyForm(btn) {
|
||||
const comment = btn.closest(".comment");
|
||||
if (!comment) return;
|
||||
|
||||
@@ -43,8 +43,13 @@ export class ContentRenderer {
|
||||
});
|
||||
}
|
||||
|
||||
normalizeDashes(text) {
|
||||
return text.replace(/\u2014/g, "-");
|
||||
}
|
||||
|
||||
render(text) {
|
||||
if (!text) return "";
|
||||
text = this.normalizeDashes(text);
|
||||
text = this.replaceShortcodes(text);
|
||||
|
||||
let html;
|
||||
|
||||
@@ -38,9 +38,33 @@ export class MessagesLayout {
|
||||
this.connect();
|
||||
this.bindForm();
|
||||
this.bindTyping();
|
||||
this.bindViewport();
|
||||
this.markRead();
|
||||
}
|
||||
|
||||
bindViewport() {
|
||||
const viewport = window.visualViewport;
|
||||
if (!viewport || !this.input) {
|
||||
return;
|
||||
}
|
||||
const applyInset = () => {
|
||||
const inset = Math.max(0, window.innerHeight - viewport.height - viewport.offsetTop);
|
||||
this.layout.style.setProperty("--kb-inset", `${Math.round(inset)}px`);
|
||||
this.scrollThreadToEnd();
|
||||
};
|
||||
viewport.addEventListener("resize", applyInset);
|
||||
viewport.addEventListener("scroll", applyInset);
|
||||
this.input.addEventListener("focus", () => {
|
||||
window.setTimeout(() => {
|
||||
this.scrollThreadToEnd();
|
||||
this.input.scrollIntoView({ block: "end" });
|
||||
}, 250);
|
||||
});
|
||||
this.input.addEventListener("blur", () => {
|
||||
this.layout.style.setProperty("--kb-inset", "0px");
|
||||
});
|
||||
}
|
||||
|
||||
connect() {
|
||||
this.socket = new MessagesSocket({
|
||||
onReady: () => this.onReady(),
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
// retoor <retoor@molodetz.nl>
|
||||
|
||||
import { Http } from "./Http.js";
|
||||
import { JobPoller } from "./JobPoller.js";
|
||||
|
||||
export class PlanningGenerator {
|
||||
constructor() {
|
||||
this.active = false;
|
||||
document.addEventListener("click", (event) => {
|
||||
const button = event.target.closest("[data-planning-generate]");
|
||||
if (!button) return;
|
||||
event.preventDefault();
|
||||
this.generate(button);
|
||||
});
|
||||
}
|
||||
|
||||
elements() {
|
||||
return {
|
||||
status: document.querySelector("[data-planning-status]"),
|
||||
result: document.querySelector("[data-planning-result]"),
|
||||
empty: document.querySelector("[data-planning-empty]"),
|
||||
report: document.querySelector("[data-planning-report]"),
|
||||
download: document.querySelector("[data-planning-download]"),
|
||||
};
|
||||
}
|
||||
|
||||
async generate(button) {
|
||||
if (this.active) return;
|
||||
this.active = true;
|
||||
button.disabled = true;
|
||||
button.classList.add("is-loading");
|
||||
const ui = this.elements();
|
||||
if (ui.empty) ui.empty.hidden = true;
|
||||
if (ui.result) ui.result.hidden = true;
|
||||
if (ui.status) ui.status.hidden = false;
|
||||
try {
|
||||
const action = button.dataset.action || "/issues/planning";
|
||||
const job = await Http.sendForm(action, {});
|
||||
await this.poll(job.status_url, ui);
|
||||
} catch (error) {
|
||||
window.app.toast.show("Could not start the planning report", { type: "error" });
|
||||
if (ui.status) ui.status.hidden = true;
|
||||
if (ui.empty) ui.empty.hidden = false;
|
||||
} finally {
|
||||
this.active = false;
|
||||
button.disabled = false;
|
||||
button.classList.remove("is-loading");
|
||||
}
|
||||
}
|
||||
|
||||
poll(statusUrl, ui) {
|
||||
return JobPoller.run(statusUrl, {
|
||||
onDone: (status) => {
|
||||
if (ui.status) ui.status.hidden = true;
|
||||
this.renderReport(status, ui);
|
||||
if (ui.result) ui.result.hidden = false;
|
||||
window.app.toast.show("Planning report ready", { type: "success" });
|
||||
},
|
||||
onFailed: (status) => {
|
||||
if (ui.status) ui.status.hidden = true;
|
||||
if (ui.empty) ui.empty.hidden = false;
|
||||
window.app.toast.show(
|
||||
"Planning failed: " + (status.error || "unknown error"),
|
||||
{ type: "error" },
|
||||
);
|
||||
},
|
||||
onTimeout: () => {
|
||||
if (ui.status) {
|
||||
ui.status.querySelector(".planning-status-text").textContent =
|
||||
"Still generating, this is taking longer than expected...";
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
renderReport(status, ui) {
|
||||
if (ui.download && status.download_url) {
|
||||
ui.download.href = status.download_url;
|
||||
}
|
||||
if (!ui.report) return;
|
||||
const fresh = document.createElement("dp-content");
|
||||
fresh.className = ui.report.className;
|
||||
fresh.setAttribute("data-planning-report", "");
|
||||
fresh.textContent = status.markdown || "";
|
||||
ui.report.replaceWith(fresh);
|
||||
}
|
||||
}
|
||||
@@ -13,11 +13,38 @@ export class AppContent extends Component {
|
||||
return;
|
||||
}
|
||||
this._rendered = true;
|
||||
this._source = this.textContent;
|
||||
this.classList.add("rendered-content");
|
||||
contentRenderer.applyTo(this);
|
||||
if (typeof hljs !== "undefined") {
|
||||
this.querySelectorAll("pre code").forEach((block) => hljs.highlightElement(block));
|
||||
}
|
||||
if (!this.hasAttribute("no-copy")) {
|
||||
this.addCopyButton();
|
||||
}
|
||||
}
|
||||
|
||||
addCopyButton() {
|
||||
if (!this._source || !this._source.trim()) {
|
||||
return;
|
||||
}
|
||||
if (this.querySelector(":scope > .content-copy-btn")) {
|
||||
return;
|
||||
}
|
||||
const btn = document.createElement("button");
|
||||
btn.type = "button";
|
||||
btn.className = "content-copy-btn";
|
||||
btn.textContent = "Copy";
|
||||
btn.addEventListener("click", async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(this._source);
|
||||
btn.textContent = "Copied";
|
||||
} catch {
|
||||
btn.textContent = "Failed";
|
||||
}
|
||||
setTimeout(() => { btn.textContent = "Copy"; }, 1500);
|
||||
});
|
||||
this.appendChild(btn);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -26,7 +26,13 @@ export class AppContextMenu extends Component {
|
||||
if (!this.menu.contains(e.target)) this.close();
|
||||
});
|
||||
document.addEventListener("scroll", () => this.close(), true);
|
||||
window.addEventListener("resize", () => this.close());
|
||||
this._lastWidth = window.innerWidth;
|
||||
window.addEventListener("resize", () => {
|
||||
if (window.innerWidth !== this._lastWidth) {
|
||||
this._lastWidth = window.innerWidth;
|
||||
this.close();
|
||||
}
|
||||
});
|
||||
document.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Escape") this.close();
|
||||
});
|
||||
@@ -67,12 +73,17 @@ export class AppContextMenu extends Component {
|
||||
this.render(items);
|
||||
this.menu.classList.add("visible");
|
||||
const rect = this.menu.getBoundingClientRect();
|
||||
const vv = window.visualViewport;
|
||||
const safeW = vv ? vv.width : window.innerWidth;
|
||||
const safeH = vv ? vv.height : window.innerHeight;
|
||||
const offX = vv ? vv.offsetLeft : 0;
|
||||
const offY = vv ? vv.offsetTop : 0;
|
||||
let left = x;
|
||||
let top = y;
|
||||
if (left + rect.width > window.innerWidth) left = window.innerWidth - rect.width - 8;
|
||||
if (top + rect.height > window.innerHeight) top = window.innerHeight - rect.height - 8;
|
||||
this.menu.style.left = Math.max(8, left) + "px";
|
||||
this.menu.style.top = Math.max(8, top) + "px";
|
||||
if (left + rect.width > offX + safeW) left = offX + safeW - rect.width - 8;
|
||||
if (top + rect.height > offY + safeH) top = offY + safeH - rect.height - 8;
|
||||
this.menu.style.left = Math.max(offX + 8, left) + "px";
|
||||
this.menu.style.top = Math.max(offY + 8, top) + "px";
|
||||
}
|
||||
|
||||
close() {
|
||||
|
||||
@@ -239,6 +239,7 @@ export default class ContainerTerminalElement extends FloatingWindow {
|
||||
return;
|
||||
}
|
||||
this._sendResize();
|
||||
if (this.term.scrollToBottom) this.term.scrollToBottom();
|
||||
}
|
||||
|
||||
_onShow() {
|
||||
|
||||
@@ -5,6 +5,7 @@ import { assetUrl } from "../assetVersion.js";
|
||||
|
||||
const FW_CSS_ID = "floating-window-css";
|
||||
const MOBILE_QUERY = "(max-width: 640px)";
|
||||
const KEYBOARD_INSET_THRESHOLD = 120;
|
||||
|
||||
export default class FloatingWindow extends Component {
|
||||
constructor() {
|
||||
@@ -14,6 +15,18 @@ export default class FloatingWindow extends Component {
|
||||
this._drag = null;
|
||||
this._rafResize = null;
|
||||
this._mobile = window.matchMedia(MOBILE_QUERY);
|
||||
this._vv = window.visualViewport || null;
|
||||
}
|
||||
|
||||
get _safeViewport() {
|
||||
return {
|
||||
width: window.innerWidth,
|
||||
height: this._vv ? this._vv.height : window.innerHeight,
|
||||
};
|
||||
}
|
||||
|
||||
get _keyboardVisible() {
|
||||
return !!(this._vv && window.innerHeight - this._vv.height > KEYBOARD_INSET_THRESHOLD);
|
||||
}
|
||||
|
||||
get windowKey() {
|
||||
@@ -52,6 +65,11 @@ export default class FloatingWindow extends Component {
|
||||
this._scheduleResize();
|
||||
};
|
||||
window.addEventListener("resize", this._winResize);
|
||||
this._vvHandler = () => this._onVisualViewport();
|
||||
if (this._vv) {
|
||||
this._vv.addEventListener("resize", this._vvHandler);
|
||||
this._vv.addEventListener("scroll", this._vvHandler);
|
||||
}
|
||||
const saved = this._loadGeometry();
|
||||
if (saved) {
|
||||
this.geometry = saved;
|
||||
@@ -66,6 +84,10 @@ export default class FloatingWindow extends Component {
|
||||
disconnectedCallback() {
|
||||
window.removeEventListener("resize", this._winResize);
|
||||
this._mobile.removeEventListener("change", this._viewportHandler);
|
||||
if (this._vv && this._vvHandler) {
|
||||
this._vv.removeEventListener("resize", this._vvHandler);
|
||||
this._vv.removeEventListener("scroll", this._vvHandler);
|
||||
}
|
||||
if (this._ro) this._ro.disconnect();
|
||||
if (globalThis.app && globalThis.app.windows) globalThis.app.windows.unregister(this);
|
||||
this._onHide();
|
||||
@@ -155,7 +177,7 @@ export default class FloatingWindow extends Component {
|
||||
_atSize([width, height]) {
|
||||
if (!this.geometry) return false;
|
||||
const w = Math.max(320, Math.min(width, window.innerWidth - 16));
|
||||
const h = Math.max(220, Math.min(height, window.innerHeight - 16));
|
||||
const h = Math.max(220, Math.min(height, this._safeViewport.height - 16));
|
||||
return Math.abs(this.geometry.width - w) <= 4 && Math.abs(this.geometry.height - h) <= 4;
|
||||
}
|
||||
|
||||
@@ -204,13 +226,14 @@ export default class FloatingWindow extends Component {
|
||||
}
|
||||
|
||||
_presetGeometry(width, height) {
|
||||
const safeHeight = this._safeViewport.height;
|
||||
const w = Math.max(320, Math.min(width, window.innerWidth - 16));
|
||||
const h = Math.max(220, Math.min(height, window.innerHeight - 16));
|
||||
const h = Math.max(220, Math.min(height, safeHeight - 16));
|
||||
this.geometry = {
|
||||
width: w,
|
||||
height: h,
|
||||
left: Math.max(8, Math.round((window.innerWidth - w) / 2)),
|
||||
top: Math.max(8, Math.round((window.innerHeight - h) / 2)),
|
||||
top: Math.max(8, Math.round((safeHeight - h) / 2)),
|
||||
};
|
||||
this._setState("normal");
|
||||
this._persistGeometry();
|
||||
@@ -246,13 +269,14 @@ export default class FloatingWindow extends Component {
|
||||
}
|
||||
|
||||
_defaultGeometry() {
|
||||
const safeHeight = this._safeViewport.height;
|
||||
const width = Math.min(820, Math.round(window.innerWidth * 0.94));
|
||||
const height = Math.min(560, Math.round(window.innerHeight * 0.82));
|
||||
const height = Math.min(560, Math.round(safeHeight * 0.82));
|
||||
return {
|
||||
width,
|
||||
height,
|
||||
left: Math.max(12, Math.round((window.innerWidth - width) / 2)),
|
||||
top: Math.max(12, Math.round((window.innerHeight - height) / 2)),
|
||||
top: Math.max(12, Math.round((safeHeight - height) / 2)),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -268,10 +292,11 @@ export default class FloatingWindow extends Component {
|
||||
_clampGeometry() {
|
||||
if (!this.geometry) return;
|
||||
const g = this.geometry;
|
||||
const safeHeight = this._safeViewport.height;
|
||||
g.width = Math.min(g.width, window.innerWidth - 16);
|
||||
g.height = Math.min(g.height, window.innerHeight - 16);
|
||||
g.height = Math.min(g.height, safeHeight - 16);
|
||||
g.left = Math.max(8, Math.min(g.left, window.innerWidth - g.width - 8));
|
||||
g.top = Math.max(8, Math.min(g.top, window.innerHeight - g.height - 8));
|
||||
g.top = Math.max(8, Math.min(g.top, safeHeight - g.height - 8));
|
||||
}
|
||||
|
||||
_clampWindow() {
|
||||
@@ -287,6 +312,16 @@ export default class FloatingWindow extends Component {
|
||||
this._setState("normal");
|
||||
}
|
||||
|
||||
_onVisualViewport() {
|
||||
if (!this._vv) return;
|
||||
this.classList.toggle("fw-keyboard-visible", this._keyboardVisible);
|
||||
if (this.win && (this.state === "fullscreen" || this.state === "maximized")) {
|
||||
this.win.style.height = `${this._vv.height}px`;
|
||||
this.win.style.top = `${this._vv.offsetTop}px`;
|
||||
}
|
||||
this._scheduleResize();
|
||||
}
|
||||
|
||||
_startDrag(event) {
|
||||
if (this.state !== "normal") return;
|
||||
if (event.target.tagName === "BUTTON") return;
|
||||
@@ -308,8 +343,9 @@ export default class FloatingWindow extends Component {
|
||||
if (event.cancelable) event.preventDefault();
|
||||
const point = event.touches ? event.touches[0] : event;
|
||||
const g = this.geometry;
|
||||
const safeHeight = this._safeViewport.height;
|
||||
g.left = Math.max(8, Math.min(point.clientX - this._drag.dx, window.innerWidth - g.width - 8));
|
||||
g.top = Math.max(8, Math.min(point.clientY - this._drag.dy, window.innerHeight - g.height - 8));
|
||||
g.top = Math.max(8, Math.min(point.clientY - this._drag.dy, safeHeight - g.height - 8));
|
||||
this.win.style.left = `${g.left}px`;
|
||||
this.win.style.top = `${g.top}px`;
|
||||
}
|
||||
@@ -345,8 +381,9 @@ export default class FloatingWindow extends Component {
|
||||
if (event.cancelable) event.preventDefault();
|
||||
const point = event.touches ? event.touches[0] : event;
|
||||
const g = this.geometry;
|
||||
const safeHeight = this._safeViewport.height;
|
||||
g.width = Math.max(320, Math.min(this._resize.w + (point.clientX - this._resize.x), window.innerWidth - g.left - 8));
|
||||
g.height = Math.max(220, Math.min(this._resize.h + (point.clientY - this._resize.y), window.innerHeight - g.top - 8));
|
||||
g.height = Math.max(220, Math.min(this._resize.h + (point.clientY - this._resize.y), safeHeight - g.top - 8));
|
||||
this.win.style.width = `${g.width}px`;
|
||||
this.win.style.height = `${g.height}px`;
|
||||
this._scheduleResize();
|
||||
|
||||
@@ -84,6 +84,12 @@ export default class DeviiTerminalElement extends FloatingWindow {
|
||||
this._applyFont(this._readFont());
|
||||
this._mobile.addEventListener("change", () => this._onViewportChange());
|
||||
window.addEventListener("resize", () => this._clampWindow());
|
||||
this._vv = window.visualViewport || null;
|
||||
this._vvHandler = () => this._onVisualViewport2();
|
||||
if (this._vv) {
|
||||
this._vv.addEventListener("resize", this._vvHandler);
|
||||
this._vv.addEventListener("scroll", this._vvHandler);
|
||||
}
|
||||
window.addEventListener("pagehide", () => this._captureFocusForUnload());
|
||||
window.addEventListener("beforeunload", () => this._captureFocusForUnload());
|
||||
this._identity = null;
|
||||
@@ -416,18 +422,25 @@ export default class DeviiTerminalElement extends FloatingWindow {
|
||||
this.win.style.width = "";
|
||||
this.win.style.height = "";
|
||||
}
|
||||
const keyboardVisible = this._keyboardVisible;
|
||||
this.classList.toggle("devii-keyboard-visible", keyboardVisible);
|
||||
if (resolved === "fullscreen" && keyboardVisible && this._vv) {
|
||||
this.win.style.height = `${this._vv.height}px`;
|
||||
this.win.style.top = `${this._vv.offsetTop}px`;
|
||||
}
|
||||
this._syncControls();
|
||||
this._persist();
|
||||
}
|
||||
|
||||
_defaultGeometry() {
|
||||
const safeHeight = this._safeViewport.height;
|
||||
const width = Math.min(760, Math.round(window.innerWidth * 0.94));
|
||||
const height = Math.min(600, Math.round(window.innerHeight * 0.82));
|
||||
const height = Math.min(600, Math.round(safeHeight * 0.82));
|
||||
return {
|
||||
width,
|
||||
height,
|
||||
left: Math.max(12, window.innerWidth - width - 24),
|
||||
top: Math.max(12, window.innerHeight - height - 24),
|
||||
top: Math.max(12, safeHeight - height - 24),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -436,6 +449,25 @@ export default class DeviiTerminalElement extends FloatingWindow {
|
||||
this._setState("open");
|
||||
}
|
||||
|
||||
_onVisualViewport2() {
|
||||
if (this.state === "closed" || !this._vv) return;
|
||||
this.classList.toggle("devii-keyboard-visible", this._keyboardVisible);
|
||||
if (this.state === "fullscreen") {
|
||||
this.win.style.height = `${this._vv.height}px`;
|
||||
this.win.style.top = `${this._vv.offsetTop}px`;
|
||||
}
|
||||
this._ensureInputVisible();
|
||||
}
|
||||
|
||||
_ensureInputVisible() {
|
||||
window.requestAnimationFrame(() => {
|
||||
if (document.activeElement === this.input && this.input.scrollIntoView) {
|
||||
this.input.scrollIntoView({ block: "end" });
|
||||
}
|
||||
if (this.output) this.output.scrollTop = this.output.scrollHeight;
|
||||
});
|
||||
}
|
||||
|
||||
_onKey(event) {
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
{% set _user = item.author %}{% set _class = "comment-author" %}{% include "_user_link.html" %}
|
||||
<span class="comment-time">{{ item.time_ago }}</span>
|
||||
</div>
|
||||
<div class="comment-text rendered-content" data-render>{{ item.comment['content'] }}</div>
|
||||
<div class="comment-text rendered-content" data-render data-raw="{{ item.comment['content'] }}">{{ item.comment['content'] }}</div>
|
||||
{% set attachments = item.get('attachments', []) %}
|
||||
{% if attachments %}
|
||||
{% include "_attachment_display.html" %}
|
||||
@@ -26,7 +26,8 @@
|
||||
<div class="comment-actions">
|
||||
<button type="button" class="comment-action-btn" data-action="reply"{{ guest_disabled(user) }}><span class="icon">💬</span> Reply</button>
|
||||
{% if owns(item.comment, user) %}
|
||||
<form method="POST" action="/comments/delete/{{ item.comment['uid'] }}" class="inline-form">
|
||||
<button type="button" class="comment-action-btn" data-action="edit" data-edit-url="/comments/edit/{{ item.comment['uid'] }}"><span class="icon">✏️</span> Edit</button>
|
||||
<form method="POST" action="/comments/delete/{{ item.comment['uid'] }}" class="inline-form comment-delete-form" data-comment-uid="{{ item.comment['uid'] }}">
|
||||
<button type="submit" class="comment-action-btn" data-confirm="Delete this comment?"><span class="icon">🗑️</span> Delete</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
{% extends "admin_base.html" %}
|
||||
{% block extra_head %}
|
||||
<link rel="stylesheet" href="{{ static_url('/static/css/admin.css') }}">
|
||||
<link rel="stylesheet" href="{{ static_url('/static/css/sidebar.css') }}">
|
||||
<link rel="stylesheet" href="{{ static_url('/static/css/planning.css') }}">
|
||||
{% endblock %}
|
||||
{% block admin_content %}
|
||||
<div class="admin-header planning-header">
|
||||
<div>
|
||||
<h1>Ticket Planning</h1>
|
||||
<p class="planning-subtitle">Generate a grouped, ordered planning report of every open ticket.</p>
|
||||
</div>
|
||||
{% if configured %}
|
||||
<button type="button" class="btn btn-primary" data-planning-generate data-action="/issues/planning">
|
||||
<span class="btn-spinner" aria-hidden="true"></span> Generate planning
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if not configured %}
|
||||
<div class="empty-state">The issue tracker is not configured yet. Configure Gitea in Services first.</div>
|
||||
{% else %}
|
||||
<div class="planning-status" data-planning-status hidden>
|
||||
<span class="planning-spinner" aria-hidden="true"></span>
|
||||
<span class="planning-status-text">Generating the planning report...</span>
|
||||
</div>
|
||||
|
||||
<div class="planning-result" data-planning-result hidden>
|
||||
<div class="planning-actions">
|
||||
<a class="btn btn-secondary" data-planning-download href="#" download>Download markdown</a>
|
||||
<span class="planning-hint">Hover the report and use the Copy button to copy the markdown source.</span>
|
||||
</div>
|
||||
<dp-content class="planning-report" data-planning-report></dp-content>
|
||||
</div>
|
||||
|
||||
<div class="planning-empty empty-state" data-planning-empty>
|
||||
No planning generated yet. Click "Generate planning" to build one from the open tickets.
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -2,7 +2,7 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
|
||||
<meta name="theme-color" content="#0f0a1a">
|
||||
<meta name="asset-version" content="{{ static_version }}">
|
||||
<title>{% if page_title %}{{ page_title }}{% else %}DevPlace - The Developer Social Network{% endif %}</title>
|
||||
|
||||
@@ -16,6 +16,8 @@ Source: `static/js/components/AppContent.js`.
|
||||
emitting unsanitised HTML.
|
||||
- Equivalent to `data-render` on a server-rendered element, but as a self-contained element you
|
||||
drop in directly.
|
||||
- A copy button appears in the top-right corner on hover (and on focus) that copies the element's
|
||||
original markdown source to the clipboard, mirroring the code-block copy button.
|
||||
|
||||
## Usage
|
||||
|
||||
|
||||
@@ -49,6 +49,24 @@
|
||||
</a>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-section">
|
||||
<div class="sidebar-heading">Resources</div>
|
||||
<div class="sidebar-nav">
|
||||
<a href="/docs" class="sidebar-link">
|
||||
<span class="icon">📚</span>
|
||||
Docs
|
||||
</a>
|
||||
<a href="/issues" class="sidebar-link">
|
||||
<span class="icon">🐛</span>
|
||||
Issues
|
||||
</a>
|
||||
<a href="/devii/" class="sidebar-link" data-devii-open>
|
||||
<span class="icon">🤖</span>
|
||||
Devii
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div class="feed-main">
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
{% from "_macros.html" import modal %}
|
||||
{% block extra_head %}
|
||||
<link rel="stylesheet" href="{{ static_url('/static/css/feed.css') }}">
|
||||
<link rel="stylesheet" href="{{ static_url('/static/css/post.css') }}">
|
||||
<link rel="stylesheet" href="{{ static_url('/static/css/gists.css') }}">
|
||||
<link rel="stylesheet" href="{{ static_url('/static/css/sidebar.css') }}">
|
||||
<link rel="stylesheet" href="{{ static_url('/static/vendor/codemirror/codemirror.min.css') }}">
|
||||
@@ -69,6 +70,15 @@
|
||||
<div class="gist-card-desc">{{ item.gist['description'][:200] }}{% if item.gist['description']|length > 200 %}...{% endif %}</div>
|
||||
{% endif %}
|
||||
|
||||
{% if item.recent_comments %}
|
||||
{% from "_comment.html" import render_comment with context %}
|
||||
<div class="post-card-comments">
|
||||
{% for c in item.recent_comments %}
|
||||
{{ render_comment(c, 0) }}
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="gist-card-footer">
|
||||
<div class="gist-author">
|
||||
{% set _size = 20 %}{% set _size_class = "sm" %}{% set _user = item.author %}{% include "_avatar_link.html" %}
|
||||
|
||||
@@ -65,7 +65,7 @@
|
||||
|
||||
{% if can_comment %}
|
||||
<form method="POST" action="/issues/{{ issue.number }}/comment" class="comment-form issue-comment-form">
|
||||
<textarea name="body" required maxlength="5000" placeholder="Add a comment (posted to the tracker)..." class="min-h-120"></textarea>
|
||||
<textarea name="body" required maxlength="5000" placeholder="Add a comment (posted to the tracker)..." class="min-h-120" data-mention></textarea>
|
||||
<div class="issue-comment-form-footer">
|
||||
<button type="submit" class="btn btn-primary btn-sm"><span class="btn-spinner" aria-hidden="true"></span>Comment</button>
|
||||
</div>
|
||||
|
||||
@@ -7,11 +7,16 @@
|
||||
<div class="issues-layout">
|
||||
<div class="issues-header">
|
||||
<h1>Issue Reports</h1>
|
||||
{% if user %}
|
||||
<button type="button" class="btn btn-primary btn-sm" data-modal="create-issue-modal"><span class="icon">🐛</span> Report Issue</button>
|
||||
{% else %}
|
||||
<a href="/auth/login" class="btn btn-primary btn-sm login-required" title="Log in to participate"><span class="icon">🐛</span> Report Issue</a>
|
||||
{% endif %}
|
||||
<div class="issues-header-actions">
|
||||
{% if viewer_is_admin %}
|
||||
<a href="/admin/issues/planning" class="btn btn-secondary btn-sm"><span class="icon">🗂️</span> Generate planning</a>
|
||||
{% endif %}
|
||||
{% if user %}
|
||||
<button type="button" class="btn btn-primary btn-sm" data-modal="create-issue-modal"><span class="icon">🐛</span> Report Issue</button>
|
||||
{% else %}
|
||||
<a href="/auth/login" class="btn btn-primary btn-sm login-required" title="Log in to participate"><span class="icon">🐛</span> Report Issue</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="issues-filters">
|
||||
@@ -65,7 +70,7 @@
|
||||
</div>
|
||||
<div class="auth-field auth-field-gap">
|
||||
<label for="issue-description">Description</label>
|
||||
<textarea id="issue-description" name="description" required maxlength="5000" placeholder="Steps to reproduce, expected vs actual, environment..." class="min-h-120"></textarea>
|
||||
<textarea id="issue-description" name="description" required maxlength="5000" placeholder="Steps to reproduce, expected vs actual, environment..." class="min-h-120" data-mention></textarea>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="modal-close btn btn-secondary">Cancel</button>
|
||||
|
||||
@@ -149,37 +149,40 @@
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
<section class="landing-section landing-bots">
|
||||
<section class="landing-section landing-help">
|
||||
<div class="landing-section-header">
|
||||
<h2>Self-Maintaining by Design</h2>
|
||||
<a href="/docs/claude.html" class="landing-section-link">How it works →</a>
|
||||
<h2>Build With Us</h2>
|
||||
<a href="/docs/index.html" class="landing-section-link">Browse the docs →</a>
|
||||
</div>
|
||||
<p class="landing-bots-intro">DevPlace keeps its own house in order. A set of single-purpose Claude Code subagents reviews the codebase for security, audit coverage, documentation accuracy, and code quality, then fixes what it finds and proves the build still works.</p>
|
||||
<div class="landing-bots-grid">
|
||||
<div class="landing-bot-card">
|
||||
<div class="landing-bot-icon">🛡</div>
|
||||
<h3>Security</h3>
|
||||
<p>Checks every route guard, ownership rule, and input limit across the app.</p>
|
||||
<p class="landing-help-intro">DevPlace is built in the open and made to be extended. Read the docs, explore the API, file an issue, or let Devii - our in-platform AI developer - do the work with you. Everything you need to start contributing is one click away.</p>
|
||||
<div class="landing-help-grid">
|
||||
<div class="landing-help-card">
|
||||
<div class="landing-help-icon">📚</div>
|
||||
<h3>Documentation</h3>
|
||||
<p>Guides, the design system, component references, and deep internals - tiered for newcomers and operators alike.</p>
|
||||
<a href="/docs/index.html" class="landing-help-link">Read the docs →</a>
|
||||
</div>
|
||||
<div class="landing-bot-card">
|
||||
<div class="landing-bot-icon">📜</div>
|
||||
<h3>Audit</h3>
|
||||
<p>Confirms every action that changes data leaves an audit trail.</p>
|
||||
<div class="landing-help-card">
|
||||
<div class="landing-help-icon">🧩</div>
|
||||
<h3>API Reference</h3>
|
||||
<p>A complete REST API with live OpenAPI schema. Build integrations, bots, and tools on top of every endpoint.</p>
|
||||
<div class="landing-help-links">
|
||||
<a href="/swagger">Swagger UI</a>
|
||||
<a href="/openapi.json">OpenAPI JSON</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="landing-bot-card">
|
||||
<div class="landing-bot-icon">📚</div>
|
||||
<h3>Docs</h3>
|
||||
<p>Keeps the documentation in step with the code, for the right audience.</p>
|
||||
<div class="landing-help-card">
|
||||
<div class="landing-help-icon">🐛</div>
|
||||
<h3>Contribute & Report</h3>
|
||||
<p>Found a bug or have an idea? Open an issue and follow it through to resolution in the integrated tracker.</p>
|
||||
<a href="/issues" class="landing-help-link">Open the issue tracker →</a>
|
||||
</div>
|
||||
<div class="landing-bot-card">
|
||||
<div class="landing-bot-icon">♻</div>
|
||||
<h3>Quality</h3>
|
||||
<p>Removes duplication and enforces the project's naming and style rules.</p>
|
||||
</div>
|
||||
<div class="landing-bot-card">
|
||||
<div class="landing-bot-icon">🧪</div>
|
||||
<h3>Tests</h3>
|
||||
<p>Spots routes with no integration test and writes the missing coverage.</p>
|
||||
<div class="landing-help-card landing-help-card-lead">
|
||||
<div class="landing-help-icon">🤖</div>
|
||||
<h3>Meet Devii</h3>
|
||||
<p>Devii is your AI developer built into the platform. Ask it to write posts, manage projects, run containers, edit files, and guide you live on the page.</p>
|
||||
<button type="button" class="landing-help-cta" data-devii-open>Launch Devii</button>
|
||||
<a href="/devii/" class="landing-help-link">Open the full terminal →</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
{% extends "base.html" %}
|
||||
{% block extra_head %}
|
||||
<link rel="stylesheet" href="{{ static_url('/static/css/feed.css') }}">
|
||||
<link rel="stylesheet" href="{{ static_url('/static/css/post.css') }}">
|
||||
<link rel="stylesheet" href="{{ static_url('/static/css/news.css') }}">
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
@@ -36,6 +38,14 @@
|
||||
{% if item.article.get('description') %}
|
||||
<p class="news-card-desc">{{ item.article['description'][:250] }}{% if item.article['description']|length > 250 %}...{% endif %}</p>
|
||||
{% endif %}
|
||||
{% if item.recent_comments %}
|
||||
{% from "_comment.html" import render_comment with context %}
|
||||
<div class="post-card-comments">
|
||||
{% for c in item.recent_comments %}
|
||||
{{ render_comment(c, 0) }}
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
<a href="{{ item.article['url'] }}" target="_blank" rel="noopener" class="news-read-link">
|
||||
Read on {{ item.article['source_name'] }} ↗
|
||||
</a>
|
||||
|
||||
@@ -60,7 +60,7 @@
|
||||
{% set _topics = topics %}{% set _selected = post['topic'] %}{% include "_topic_selector.html" %}
|
||||
<div class="auth-field auth-field-gap">
|
||||
<label for="edit-title">Title</label>
|
||||
<input type="text" id="edit-title" name="title" maxlength="500" value="{{ post.get('title', '') }}">
|
||||
<input type="text" id="edit-title" name="title" maxlength="500" value="{{ post.get('title') or '' }}">
|
||||
</div>
|
||||
<div class="auth-field auth-field-gap">
|
||||
<label for="edit-content">Content</label>
|
||||
|
||||
@@ -67,7 +67,7 @@
|
||||
{% endif %}
|
||||
|
||||
<div class="project-detail-actions">
|
||||
<a href="/projects/{{ project['slug'] or project['uid'] }}/files" class="project-star-btn"><span class="icon">📁</span> Files</a>
|
||||
<a href="/projects/{{ project['slug'] or project['uid'] }}/files" class="project-star-btn"><span class="icon">📁</span> Files ({{ file_count }} files)</a>
|
||||
<button type="button" class="project-star-btn" data-share="/projects/{{ project['slug'] or project['uid'] }}"><span class="icon">🔗</span> Share</button>
|
||||
{% if user %}
|
||||
{% set _type = "project" %}{% set _uid = project['uid'] %}{% set _my_vote = my_vote %}{% set _count = star_count %}{% set _btn_class = "project-star-btn" %}{% include "_star_vote.html" %}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
{% from "_macros.html" import modal %}
|
||||
{% block extra_head %}
|
||||
<link rel="stylesheet" href="{{ static_url('/static/css/feed.css') }}">
|
||||
<link rel="stylesheet" href="{{ static_url('/static/css/post.css') }}">
|
||||
<link rel="stylesheet" href="{{ static_url('/static/css/projects.css') }}">
|
||||
<link rel="stylesheet" href="{{ static_url('/static/css/sidebar.css') }}">
|
||||
{% endblock %}
|
||||
@@ -80,6 +81,15 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if project.recent_comments %}
|
||||
{% from "_comment.html" import render_comment with context %}
|
||||
<div class="post-card-comments">
|
||||
{% for c in project.recent_comments %}
|
||||
{{ render_comment(c, 0) }}
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="empty-state empty-state-full">No projects found. Create one!</div>
|
||||
|
||||
Reference in New Issue
Block a user