"""Зеркало задач Jira (issue, issue_link) и служебные заметки (issue_note). Зеркало пишет только загрузка состояния от агента; заметки обновление зеркала не трогает.""" import json from . import db ISSUE_COLUMNS = ["key", "id", "issue_type", "summary", "description", "status", "priority", "assignee", "reporter", "duedate", "planned_start", "planned_end", "epic_key", "labels", "sprint", "created", "jira_updated"] NOTE_FIELDS = ["fact_status", "work_kind", "done_text", "rest_text", "owner_note"] _FILTERS = {"sprint": "i.sprint = ?", "assignee": "i.assignee = ?", "status": "i.status = ?", "epic": "i.epic_key = ?", "work_kind": "n.work_kind = ?", "fact_status": "n.fact_status = ?"} def apply_state(conn, issues, links, neighbor_keys=(), fetched_at=None, authoritative=True) -> dict: """Upsert задач; связи с source_key среди присланных задач заменяются присланными. Связи, у которых источник не прислан, не трогаются до загрузки их источника. authoritative=True — загрузка по базовой JQL: флаг neighbor пересчитывается по neighbor_keys (задача, вошедшая в базовую выборку, перестаёт быть соседней). authoritative=False — точечное обновление (по ключам, по произвольной JQL): флаг только поднимается, никогда не снижается.""" fetched_at = fetched_at or db.now() neighbors = set(neighbor_keys) keys = [] with db.transaction(conn): for it in issues: row = {c: it.get(c) for c in ISSUE_COLUMNS} row["labels"] = json.dumps(row.get("labels") or [], ensure_ascii=False) row["fetched_at"] = fetched_at row["neighbor"] = 1 if it["key"] in neighbors else 0 cols = list(row) sets = [f"{c} = excluded.{c}" for c in cols if c not in ("key", "neighbor")] sets.append("neighbor = excluded.neighbor" if authoritative else "neighbor = MAX(issue.neighbor, excluded.neighbor)") conn.execute( f"INSERT INTO issue({', '.join(cols)}) VALUES ({', '.join('?' * len(cols))}) " "ON CONFLICT(key) DO UPDATE SET " + ", ".join(sets), [row[c] for c in cols]) keys.append(it["key"]) conn.executemany("DELETE FROM issue_link WHERE source_key = ?", [(k,) for k in keys]) for link in links: conn.execute("INSERT OR IGNORE INTO issue_link(source_key, target_key, link_type) VALUES (?, ?, ?)", (link["source_key"], link["target_key"], link["link_type"])) return {"issues": len(keys), "links": len(links)} def known_keys(conn) -> list[str]: return [r["key"] for r in conn.execute("SELECT key FROM issue ORDER BY key")] def _issue_dict(row) -> dict: d = dict(row) d["labels"] = json.loads(d.get("labels") or "[]") d["neighbor"] = bool(d.get("neighbor")) return d def list_issues(conn, filters=None, limit=50, offset=0) -> list[dict]: filters = filters or {} where, args = [], [] for name, sql in _FILTERS.items(): if filters.get(name) not in (None, ""): where.append(sql) args.append(filters[name]) if filters.get("label"): 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 = ?") args.append(1 if filters["neighbor"] in (True, 1, "1", "true") else 0) 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" + (" 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)])] def get_issue(conn, key) -> dict | None: row = conn.execute("SELECT * FROM issue WHERE key = ?", (key,)).fetchone() if row is None: return None d = _issue_dict(row) d["links_out"] = [dict(r) for r in conn.execute( "SELECT target_key, link_type FROM issue_link WHERE source_key = ? ORDER BY link_type, target_key", (key,))] d["links_in"] = [dict(r) for r in conn.execute( "SELECT source_key, link_type FROM issue_link WHERE target_key = ? ORDER BY link_type, source_key", (key,))] d["note"] = get_note(conn, key) d["commands"] = [dict(r) for r in conn.execute( "SELECT id, batch_id, type, status, finished_at, error FROM command WHERE params LIKE ? ORDER BY id DESC LIMIT 10", (f'%"{key}"%',))] return d def get_note(conn, key) -> dict | None: row = conn.execute("SELECT * FROM issue_note WHERE key = ?", (key,)).fetchone() return dict(row) if row else None def set_note(conn, key, fields: dict, updated_by: str) -> dict: unknown = set(fields) - set(NOTE_FIELDS) if unknown: raise ValueError(f"неизвестные поля заметки: {', '.join(sorted(unknown))}") with db.transaction(conn): # чтение, слияние и запись — атомарно (каждый запрос и поток открывает своё соединение, R-2) cur = get_note(conn, key) or {"key": key} cur.update(fields) cur["updated_at"], cur["updated_by"] = db.now(), updated_by cols = ["key"] + NOTE_FIELDS + ["updated_at", "updated_by"] conn.execute(f"INSERT INTO issue_note({', '.join(cols)}) VALUES ({', '.join('?' * len(cols))}) " "ON CONFLICT(key) DO UPDATE SET " + ", ".join(f"{c} = excluded.{c}" for c in cols[1:]), [cur.get(c) for c in cols]) return get_note(conn, key) def rename_note(conn, old_key, new_key) -> None: """После создания задачи: заметка с временного ключа переезжает на настоящий (старая запись под новым ключом, если вдруг была, уступает место).""" if old_key == new_key: return with db.transaction(conn): if get_note(conn, old_key) is None: return conn.execute("DELETE FROM issue_note WHERE key = ?", (new_key,)) conn.execute("UPDATE issue_note SET key = ? WHERE key = ?", (new_key, old_key))