Хаб: разрез по проекту — свои, соседи по связям и эпики своих в /board и /issues (#61)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
f60aecd889
commit
63972572fd
@ -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):
|
||||
|
||||
@ -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' "
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user