Хаб: разрез по проекту — свои, соседи по связям и эпики своих в /board и /issues (#61)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-19 20:39:10 +04:00
parent f60aecd889
commit 63972572fd
4 changed files with 146 additions and 14 deletions

View File

@ -214,11 +214,23 @@ def h_known_keys(hub, conn, m, q, body):
return 200, {"keys": mirror.known_keys(conn)}
ISSUE_FILTERS = ("sprint", "assignee", "work_kind", "fact_status", "status", "label", "epic", "neighbor", "q", "hide_closed")
ISSUE_FILTERS = ("sprint", "assignee", "work_kind", "fact_status", "status", "label", "epic", "neighbor", "q", "hide_closed", "project")
def _project_param(conn, q) -> str | None:
"""q["project"] — метка существующего проекта; без параметра — None, неизвестная метка — 404."""
label = q.get("project")
if label in (None, ""):
return None
if projects.get(conn, label) is None:
raise ApiError(404, "not_found", f"project {label}")
return label
def h_issues(hub, conn, m, q, body):
project = _project_param(conn, q)
filters = {k: q.get(k) for k in ISSUE_FILTERS}
filters["project"] = project
closed = settings.get(conn, "closed_statuses")
closed = [s for s in closed if isinstance(s, str)] if isinstance(closed, list) else []
return 200, {"issues": mirror.list_issues(conn, filters, _int_param(q, "limit", 50), _int_param(q, "offset", 0),
@ -230,7 +242,7 @@ def h_issue(hub, conn, m, q, body):
def h_board(hub, conn, m, q, body):
return 200, mirror.board(conn, projects.base_jql(conn))
return 200, mirror.board(conn, projects.base_jql(conn), _project_param(conn, q))
def h_app(hub, conn, m, q, body):

View File

@ -80,9 +80,31 @@ def _issue_dict(row) -> dict:
return d
# Разрез по проекту: свои задачи (метка проекта — точное совпадение), их соседи по связям в обе стороны и эпики своих.
# Единственный параметр — метка, первым в списке аргументов запроса.
SCOPE_CTE = """WITH own(key) AS (
SELECT i.key FROM issue i WHERE EXISTS (SELECT 1 FROM json_each(i.labels) WHERE value = ?)),
near(key) AS (
SELECT target_key FROM issue_link WHERE source_key IN (SELECT key FROM own)
UNION SELECT source_key FROM issue_link WHERE target_key IN (SELECT key FROM own)
UNION SELECT epic_key FROM issue WHERE key IN (SELECT key FROM own) AND epic_key IS NOT NULL),
scope(key, nb) AS (
SELECT key, 0 FROM own
UNION ALL SELECT n.key, 1 FROM near n JOIN issue i ON i.key = n.key WHERE n.key NOT IN (SELECT key FROM own))
"""
def labels_of(conn, key: str) -> list[str] | None:
"""Метки задачи зеркала; None — задачи в зеркале нет."""
row = conn.execute("SELECT labels FROM issue WHERE key = ?", (key,)).fetchone()
return None if row is None else json.loads(row["labels"] or "[]")
def list_issues(conn, filters=None, limit=50, offset=0, closed_statuses=()) -> list[dict]:
"""hide_closed — без задач, чей status из closed_statuses (задача без статуса не закрыта)."""
"""hide_closed — без задач, чей status из closed_statuses (задача без статуса не закрыта).
filters["project"] — разрез по проекту (SCOPE_CTE): neighbor — относительно проекта."""
filters = filters or {}
project = filters.get("project") or None
where, args = [], []
for name, sql in _FILTERS.items():
if filters.get(name) not in (None, ""):
@ -92,7 +114,7 @@ def list_issues(conn, filters=None, limit=50, offset=0, closed_statuses=()) -> l
where.append("EXISTS (SELECT 1 FROM json_each(i.labels) WHERE value = ?)")
args.append(filters["label"])
if filters.get("neighbor") not in (None, ""):
where.append("i.neighbor = ?")
where.append("s.nb = ?" if project else "i.neighbor = ?")
args.append(1 if filters["neighbor"] in (True, 1, "1", "true") else 0)
if filters.get("hide_closed") in (True, 1, "1", "true") and closed_statuses:
where.append(f"(i.status IS NULL OR i.status NOT IN ({', '.join('?' * len(closed_statuses))}))")
@ -100,10 +122,20 @@ def list_issues(conn, filters=None, limit=50, offset=0, closed_statuses=()) -> l
if filters.get("q"):
where.append("(i.summary LIKE ? OR i.key LIKE ?)")
args += [f"%{filters['q']}%", f"%{filters['q']}%"]
sql = ("SELECT i.*, n.fact_status, n.work_kind FROM issue i LEFT JOIN issue_note n ON n.key = i.key"
if project:
sql = (SCOPE_CTE + "SELECT i.*, s.nb AS scope_nb, n.fact_status, n.work_kind FROM issue i "
"JOIN scope s ON s.key = i.key LEFT JOIN issue_note n ON n.key = i.key")
args = [project] + args
else:
sql = "SELECT i.*, n.fact_status, n.work_kind FROM issue i LEFT JOIN issue_note n ON n.key = i.key"
sql = (sql
+ (" WHERE " + " AND ".join(where) if where else "")
+ " ORDER BY i.duedate IS NULL, i.duedate, i.key LIMIT ? OFFSET ?")
return [_issue_dict(r) for r in conn.execute(sql, args + [int(limit), int(offset)])]
out = [_issue_dict(r) for r in conn.execute(sql, args + [int(limit), int(offset)])]
if project: # i.* уже несёт neighbor зеркала — в разрезе он относительно проекта
for d in out:
d["neighbor"] = bool(d.pop("scope_nb"))
return out
def get_issue(conn, key) -> dict | None:
@ -133,20 +165,31 @@ BOARD_FIELDS = ["key", "summary", "issue_type", "status", "priority", "assignee"
"planned_end", "epic_key", "labels", "neighbor"]
def board(conn, base_jql) -> dict:
def board(conn, base_jql, project: str | None = None) -> dict:
"""Снимок зеркала для доски связей (GET /board): все задачи с заметками fact_status/work_kind, все связи и последний
успешный полный прогон (refresh по базовой JQL, команда done). Данные без преобразований — перевод в модель доски
делает интерфейс. Чтение в одной транзакции: задачи, связи и last_sync — из одного состояния базы."""
делает интерфейс. С project — свои задачи проекта и соседи (neighbor относительно проекта), связи — те, у которых
source или target — своя задача; last_sync — как без проекта. Чтение в одной транзакции: задачи, связи и last_sync — из одного состояния базы."""
own = not conn.in_transaction
if own:
conn.execute("BEGIN")
try:
issues = [_issue_dict(r) for r in conn.execute(
f"SELECT {', '.join('i.' + c for c in BOARD_FIELDS)}, n.fact_status, n.work_kind "
"FROM issue i LEFT JOIN issue_note n ON n.key = i.key ORDER BY i.key")]
links = [dict(r) for r in conn.execute(
"SELECT source_key AS source, target_key AS target, link_type FROM issue_link "
"ORDER BY source_key, target_key, link_type")]
if project:
cols = ", ".join("i." + c for c in BOARD_FIELDS if c != "neighbor")
issues = [_issue_dict(r) for r in conn.execute(
SCOPE_CTE + f"SELECT {cols}, s.nb AS neighbor, n.fact_status, n.work_kind FROM issue i "
"JOIN scope s ON s.key = i.key LEFT JOIN issue_note n ON n.key = i.key ORDER BY i.key", (project,))]
links = [dict(r) for r in conn.execute(
SCOPE_CTE + "SELECT source_key AS source, target_key AS target, link_type FROM issue_link "
"WHERE source_key IN (SELECT key FROM own) OR target_key IN (SELECT key FROM own) "
"ORDER BY source_key, target_key, link_type", (project,))]
else:
issues = [_issue_dict(r) for r in conn.execute(
f"SELECT {', '.join('i.' + c for c in BOARD_FIELDS)}, n.fact_status, n.work_kind "
"FROM issue i LEFT JOIN issue_note n ON n.key = i.key ORDER BY i.key")]
links = [dict(r) for r in conn.execute(
"SELECT source_key AS source, target_key AS target, link_type FROM issue_link "
"ORDER BY source_key, target_key, link_type")]
run = conn.execute(
"SELECT s.started_at, s.finished_at, s.issues_count FROM sync_run s JOIN command c ON c.id = s.command_id "
"WHERE s.kind = 'jql' AND s.query = ? AND s.finished_at IS NOT NULL AND c.status = 'done' "

View File

@ -830,3 +830,24 @@ def test_projects_need_client_token(hub):
with pytest.raises(HubError) as e:
hub["agent"].post("/projects", {"label": "STOCKS_DRRK", "name": "x"})
assert e.value.status == 401
def test_board_and_issues_by_project(hub):
c, a = hub["client"], hub["agent"]
c.post("/projects", {"label": "AWPCA", "name": "АРМ КА"})
c.post("/projects", {"label": "STOCKS_DRRK", "name": "Запасы ДРРК"})
a.post("/agent/state", {"issues": [
{"key": "A-1", "summary": "Т1", "labels": ["STOCKS_DRRK"]},
{"key": "A-2", "summary": "Т2", "labels": ["AWPCA"]},
{"key": "A-3", "summary": "Т3", "labels": ["AWPCA"]}],
"links": [{"source_key": "A-1", "target_key": "A-2", "link_type": "L"}]})
snap = c.get("/board?project=STOCKS_DRRK")
assert [(i["key"], i["neighbor"]) for i in snap["issues"]] == [("A-1", False), ("A-2", True)]
assert [(l["source"], l["target"]) for l in snap["links"]] == [("A-1", "A-2")]
assert snap["last_sync"] == c.get("/board")["last_sync"]
assert len(c.get("/board")["issues"]) == 3
assert [i["key"] for i in c.get("/issues?project=STOCKS_DRRK&neighbor=1")["issues"]] == ["A-2"]
for path in ("/issues?project=NOPE", "/board?project=NOPE"):
with pytest.raises(HubError) as e:
c.get(path)
assert e.value.status == 404 and "project NOPE" in e.value.detail

View File

@ -255,3 +255,59 @@ def test_list_issues_hide_closed(conn):
assert keys({"hide_closed": "0"}) == ["A-1", "A-2", "A-3", "A-4"]
assert keys({"hide_closed": "1"}, []) == ["A-1", "A-2", "A-3", "A-4"] # пустой набор — ничего не скрыто
assert keys({"hide_closed": "1", "status": "Готово"}) == [] # отменяет скрытие приложение, не сервер
PROJ = "STOCKS_DRRK"
def _scene(conn): # сценарий data-model «Свои и соседи» + hub-api «Снимок проекта»
mirror.apply_state(conn, [
issue("AURORA-1", labels=["STOCKS_DRRK"], epic_key="AURORA-9"),
issue("AURORA-2", labels=["AWPCA"], epic_key=None),
issue("AURORA-3", labels=["AWPCA"], epic_key=None),
issue("AURORA-4", labels=["STOCKS_DRRK", "kind-dev"], epic_key="AURORA-404"), # эпика нет в зеркале
issue("AURORA-5", labels=["AWPCA"], epic_key=None),
issue("AURORA-9", labels=["AWPCA"], issue_type="Фича", epic_key=None)],
[{"source_key": "AURORA-1", "target_key": "AURORA-2", "link_type": "L"},
{"source_key": "AURORA-2", "target_key": "AURORA-3", "link_type": "L"},
{"source_key": "AURORA-5", "target_key": "AURORA-4", "link_type": "L"}, # входящая — тоже сосед
{"source_key": "AURORA-4", "target_key": "AURORA-777", "link_type": "L"}], # конца нет в зеркале
neighbor_keys=["AURORA-1"]) # признак зеркала не важен в разрезе
def test_board_project_scope(conn):
_scene(conn)
snap = mirror.board(conn, BASE, project=PROJ)
assert [(i["key"], i["neighbor"]) for i in snap["issues"]] == [
("AURORA-1", False), ("AURORA-2", True), ("AURORA-4", False), ("AURORA-5", True), ("AURORA-9", True)]
assert {(l["source"], l["target"]) for l in snap["links"]} == {
("AURORA-1", "AURORA-2"), ("AURORA-5", "AURORA-4"), ("AURORA-4", "AURORA-777")}
assert len(mirror.board(conn, BASE)["issues"]) == 6 # без проекта — всё зеркало
def test_board_project_last_sync_same(conn):
_scene(conn)
assert mirror.board(conn, BASE, project=PROJ)["last_sync"] == mirror.board(conn, BASE)["last_sync"]
def test_list_issues_project(conn): # сценарий «Список проекта»
_scene(conn)
keys = lambda **f: [i["key"] for i in mirror.list_issues(conn, {"project": PROJ, **f})]
assert sorted(keys()) == ["AURORA-1", "AURORA-2", "AURORA-4", "AURORA-5", "AURORA-9"]
assert sorted(keys(neighbor="1")) == ["AURORA-2", "AURORA-5", "AURORA-9"]
assert sorted(keys(neighbor="0")) == ["AURORA-1", "AURORA-4"]
assert keys(label="kind-dev") == ["AURORA-4"]
assert {i["key"]: i["neighbor"] for i in mirror.list_issues(conn, {"project": PROJ})}["AURORA-1"] is False
def test_list_issues_project_paging_and_filters(conn):
_scene(conn)
page = mirror.list_issues(conn, {"project": PROJ}, limit=2, offset=1)
assert [i["key"] for i in page] == ["AURORA-2", "AURORA-4"]
assert [i["key"] for i in mirror.list_issues(conn, {"project": PROJ, "q": "AURORA-5"})] == ["AURORA-5"]
def test_labels_of(conn):
_scene(conn)
assert mirror.labels_of(conn, "AURORA-4") == ["STOCKS_DRRK", "kind-dev"]
assert mirror.labels_of(conn, "AURORA-404") is None