I-1: boards() отдаёт type=null, если тип неизвестен у любого проекта доски. M-2: labels_add ensure_fields нормализуется к метке проекта. M-3: light_list вместо list_projects в _assign_boards. M-4/T5.4: пустой project — 422. M-1, M-5, T10.1-T10.7: README, ARCHITECTURE, CLAUDE.md, design.md (49-51), e2e. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
172 lines
7.6 KiB
Python
172 lines
7.6 KiB
Python
"""Проекты-метки моста: таблица project, проверка метки, базовая JQL из меток, доски, миграция v3 → v4."""
|
||
import logging
|
||
import re
|
||
|
||
from . import db, settings
|
||
|
||
log = logging.getLogger(__name__)
|
||
|
||
LEGACY_LABEL = "AWPCA"
|
||
LEGACY_NAME = "АРМ КА"
|
||
MAX_LEN = 100
|
||
BOARD_TYPES = ("scrum", "kanban")
|
||
_FORBIDDEN = ',"\'()'
|
||
_FIELDS = "label, name, board_id, board_type, created_at"
|
||
|
||
|
||
class ProjectError(ValueError):
|
||
"""Ошибка данных проекта; field — label | name | board_id."""
|
||
|
||
def __init__(self, field: str, reason: str):
|
||
super().__init__(f"{field}: {reason}")
|
||
self.field = field
|
||
|
||
|
||
class ProjectExists(ProjectError):
|
||
def __init__(self, label: str):
|
||
super().__init__("label", f"проект с такой меткой уже есть: {label}")
|
||
|
||
|
||
def _label_error(label) -> str | None:
|
||
if not isinstance(label, str) or not label:
|
||
return "ожидается непустая строка"
|
||
if len(label) > MAX_LEN:
|
||
return f"длиннее {MAX_LEN} символов"
|
||
if any(c.isspace() or c in _FORBIDDEN for c in label):
|
||
return "пробелы и символы , \" ' ( ) недопустимы"
|
||
return None
|
||
|
||
|
||
def validate(label, name, board_id) -> tuple[str, str, int | None]:
|
||
"""Проверить поля проекта; вернуть (метка, название без пробелов по краям, доска)."""
|
||
error = _label_error(label)
|
||
if error:
|
||
raise ProjectError("label", error)
|
||
if not isinstance(name, str) or not name.strip():
|
||
raise ProjectError("name", "ожидается непустая строка")
|
||
if len(name.strip()) > MAX_LEN:
|
||
raise ProjectError("name", f"длиннее {MAX_LEN} символов")
|
||
if board_id is not None and (not isinstance(board_id, int) or isinstance(board_id, bool) or board_id <= 0):
|
||
raise ProjectError("board_id", "ожидается целое > 0 или null")
|
||
return label, name.strip(), board_id
|
||
|
||
|
||
def get(conn, label: str) -> dict | None:
|
||
row = conn.execute(f"SELECT {_FIELDS} FROM project WHERE label = ?", (label,)).fetchone()
|
||
return None if row is None else dict(row)
|
||
|
||
|
||
def create(conn, label: str, name: str, board_id: int | None = None) -> dict:
|
||
label, name, board_id = validate(label, name, board_id)
|
||
with db.transaction(conn):
|
||
if label.casefold() in {x.casefold() for x in labels(conn)}:
|
||
raise ProjectExists(label)
|
||
conn.execute("INSERT INTO project(label, name, board_id, board_type, created_at) VALUES (?, ?, ?, NULL, ?)",
|
||
(label, name, board_id, db.now()))
|
||
return get(conn, label)
|
||
|
||
|
||
def list_projects(conn) -> list[dict]:
|
||
rows = conn.execute(
|
||
f"SELECT {_FIELDS}, (SELECT COUNT(*) FROM issue i WHERE EXISTS "
|
||
"(SELECT 1 FROM json_each(i.labels) WHERE value = project.label)) AS issues_count "
|
||
"FROM project ORDER BY created_at, label").fetchall()
|
||
return [dict(r) for r in rows]
|
||
|
||
|
||
def light_list(conn) -> list[dict]:
|
||
"""Проекты без issues_count (label, board_id, board_type), порядок как у list_projects."""
|
||
return [dict(r) for r in conn.execute("SELECT label, board_id, board_type FROM project ORDER BY created_at, label")]
|
||
|
||
|
||
def labels(conn) -> list[str]:
|
||
return [r["label"] for r in conn.execute("SELECT label FROM project ORDER BY created_at, label")]
|
||
|
||
|
||
def base_jql(conn) -> str | None:
|
||
"""Общая JQL обновления: все метки проектов; без проектов — None."""
|
||
found = labels(conn)
|
||
if not found:
|
||
return None
|
||
key = settings.get(conn, "project_key") or "AURORA"
|
||
return f"project = {key} AND labels in ({', '.join(found)})"
|
||
|
||
|
||
def boards(conn) -> list[dict]:
|
||
"""Доски проектов без повторов (порядок первого проекта). type — None, если тип неизвестен хотя бы у одного
|
||
проекта доски (агент тогда запросит его, record_board_types проставит всем); иначе первый известный."""
|
||
out: dict[int, str | None] = {}
|
||
unknown: set[int] = set()
|
||
for r in conn.execute("SELECT board_id, board_type FROM project WHERE board_id IS NOT NULL ORDER BY created_at, label"):
|
||
out.setdefault(r["board_id"], r["board_type"])
|
||
if r["board_type"] is None:
|
||
unknown.add(r["board_id"])
|
||
return [{"id": i, "type": None if i in unknown else t} for i, t in out.items()]
|
||
|
||
|
||
def record_board_types(conn, found) -> int:
|
||
"""Записать типы досок {"<id>": "scrum" | "kanban"} всем проектам с этой доской; прочее пропустить."""
|
||
if not isinstance(found, dict):
|
||
return 0
|
||
updated = 0
|
||
for raw, kind in found.items():
|
||
if kind not in BOARD_TYPES:
|
||
continue
|
||
try:
|
||
board = int(raw)
|
||
except (TypeError, ValueError):
|
||
continue
|
||
updated += conn.execute("UPDATE project SET board_type = ? WHERE board_id = ?", (kind, board)).rowcount
|
||
return updated
|
||
|
||
|
||
_LEGACY = re.compile(r"\blabels\s*(?:=\s*(?P<one>\S+)|in\s*\((?P<many>[^()]*)\))\s*$", re.IGNORECASE)
|
||
|
||
|
||
def parse_legacy_labels(jql) -> list[str] | None:
|
||
"""Метки из прежней base_jql (`labels = X` / `labels in (X, Y)` в конце); не разобрана — None."""
|
||
m = _LEGACY.search(jql) if isinstance(jql, str) else None
|
||
if not m:
|
||
return None
|
||
out: list[str] = []
|
||
for raw in ([m["one"]] if m["one"] is not None else m["many"].split(",")):
|
||
label = raw.strip().strip('"')
|
||
if _label_error(label):
|
||
return None
|
||
if label.casefold() not in {x.casefold() for x in out}:
|
||
out.append(label)
|
||
return out or None
|
||
|
||
|
||
def ensure_legacy(conn, board_id: int | None) -> bool:
|
||
"""Завести AWPCA «АРМ КА» с доской, если проекта с меткой awpca (без учёта регистра) нет; True — заведён.
|
||
Неверная доска из конфига — проект без доски."""
|
||
if LEGACY_LABEL.casefold() in {x.casefold() for x in labels(conn)}:
|
||
return False
|
||
try:
|
||
validate(LEGACY_LABEL, LEGACY_NAME, board_id)
|
||
except ProjectError:
|
||
board_id = None
|
||
create(conn, LEGACY_LABEL, LEGACY_NAME, board_id)
|
||
return True
|
||
|
||
|
||
def migrate_v3(conn) -> None:
|
||
"""Миграция v3 → v4: проекты из прежней base_jql, доска — единственному проекту, очередь и журнал — на новую JQL."""
|
||
old = settings.get(conn, "base_jql")
|
||
found = parse_legacy_labels(old)
|
||
if found is None:
|
||
log.warning("миграция v4: base_jql не разобрана (%r) — проектов нет, заведите их POST /projects", old)
|
||
return
|
||
board = settings.get(conn, "board_id")
|
||
board = board if isinstance(board, int) and not isinstance(board, bool) and board > 0 else None
|
||
if board is not None and len(found) > 1:
|
||
log.warning("миграция v4: доска %s не отдана ни одному проекту — меток несколько: %s", board, ", ".join(found))
|
||
board = None
|
||
for label in found:
|
||
create(conn, label, LEGACY_NAME if label == LEGACY_LABEL else label, board)
|
||
new = base_jql(conn)
|
||
conn.execute("UPDATE sync_run SET query = ? WHERE query = ?", (new, old))
|
||
conn.execute("UPDATE command SET params = json_set(params, '$.jql', ?) WHERE type = 'refresh' "
|
||
"AND status IN ('queued', 'claimed') AND json_extract(params, '$.jql') = ?", (new, old))
|