"""Зеркало задач Jira (issue, issue_link, issue_comment, issue_attachment) и служебные заметки (issue_note). Зеркало пишет только загрузка состояния от агента; заметки обновление зеркала не трогает.""" import json import math from . import attachments, history, projects from ...core 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 = ["work_kind", "done_text", "rest_text", "owner_note"] SPRINT_STATES = ("active", "future", "closed") def check_comments(issues) -> None: """Форма поля comments у задач состояния (агент v10+); поле необязательно. Нарушение — ValueError с ключом и полем.""" for it in issues: if "comments" not in it: continue key, lst = it.get("key"), it["comments"] if not isinstance(lst, list): raise ValueError(f"{key}: comments — список комментариев") for n, c in enumerate(lst): where = f"{key}: comments[{n}]" if not isinstance(c, dict): raise ValueError(f"{where} — объект комментария") if not isinstance(c.get("id"), str) or not c["id"]: raise ValueError(f"{where}.id — непустая строка") if not isinstance(c.get("body"), str) and c.get("body") is not None: raise ValueError(f"{where}.body — строка") for f in ("author", "created", "updated"): if c.get(f) is not None and not isinstance(c[f], str): raise ValueError(f"{where}.{f} — строка или null") def check_attachments(issues) -> None: """Форма поля attachments у задач состояния (агент v14+); поле необязательно. Нарушение — ValueError с ключом и полем.""" for it in issues: if "attachments" not in it: continue key, lst = it.get("key"), it["attachments"] if not isinstance(lst, list): raise ValueError(f"{key}: attachments — список вложений") for n, a in enumerate(lst): where = f"{key}: attachments[{n}]" if not isinstance(a, dict): raise ValueError(f"{where} — объект вложения") if not isinstance(a.get("id"), str) or not a["id"].isdigit(): raise ValueError(f"{where}.id — строка из цифр") if not isinstance(a.get("filename"), str) or not a["filename"]: raise ValueError(f"{where}.filename — непустая строка") size = a.get("size") if size is not None and (not isinstance(size, int) or isinstance(size, bool) or size < 0): raise ValueError(f"{where}.size — неотрицательное целое или null") for f in ("mime_type", "author", "created"): if a.get(f) is not None and not isinstance(a[f], str): raise ValueError(f"{where}.{f} — строка или null") _INT_MAX = 2**63 - 1 # диапазон SQLite INTEGER def _in_range(v) -> bool: return -2**63 <= v <= _INT_MAX def _finite(v) -> bool: """Конечное число; огромные целые (10**400) не падают с OverflowError, а считаются неподходящими.""" try: return math.isfinite(v) except OverflowError: return False def check_agile_fields(issues) -> None: """Форма полей rank, story_points, sprint_ids у задач состояния (агент v15+); поля необязательны (не присланное не проверяется). Нарушение — ValueError с ключом и полем.""" for it in issues: key = it.get("key") if "rank" in it and it["rank"] is not None and not isinstance(it["rank"], str): raise ValueError(f"{key}: rank — строка или null") if "story_points" in it and it["story_points"] is not None: sp = it["story_points"] if isinstance(sp, bool) or not isinstance(sp, (int, float)) or not _finite(sp) or (isinstance(sp, int) and not _in_range(sp)): raise ValueError(f"{key}: story_points — число или null") if "sprint_ids" in it: ids = it["sprint_ids"] if not isinstance(ids, list) or any(isinstance(i, bool) or not isinstance(i, int) or not _in_range(i) for i in ids): raise ValueError(f"{key}: sprint_ids — список целых") def _is_id(v) -> bool: return isinstance(v, int) and not isinstance(v, bool) and 0 < v <= _INT_MAX def check_boards(boards) -> None: """Форма списка boards состояния (агент v15+); None — доски не присланы. Нарушение — ValueError с доской и полем.""" if boards is None: return if not isinstance(boards, list): raise ValueError("boards — список досок") for n, b in enumerate(boards): if not isinstance(b, dict): raise ValueError(f"boards[{n}] — объект доски") if not _is_id(b.get("id")): raise ValueError(f"boards[{n}].id — целое > 0") where = f"доска {b['id']}: " if b.get("type") is not None and b["type"] not in projects.BOARD_TYPES: raise ValueError(where + "type — scrum, kanban или null") cols = b.get("columns") if cols is not None: if not isinstance(cols, list): raise ValueError(where + "columns — список колонок или null") for k, c in enumerate(cols): if not isinstance(c, dict) or not isinstance(c.get("name"), str): raise ValueError(where + f"columns[{k}].name — строка") st = c.get("statuses") if not isinstance(st, list) or any(not isinstance(s, str) for s in st): raise ValueError(where + f"columns[{k}].statuses — список строк") if b.get("kanban_backlog") is not None and not isinstance(b["kanban_backlog"], bool): raise ValueError(where + "kanban_backlog — true, false или null") sprints = b.get("sprints") if sprints is None: continue if not isinstance(sprints, list): raise ValueError(where + "sprints — список спринтов или null") for k, s in enumerate(sprints): w = where + f"sprints[{k}]" if not isinstance(s, dict): raise ValueError(f"{w} — объект спринта") if not _is_id(s.get("id")): raise ValueError(f"{w}.id — целое > 0") if not isinstance(s.get("name"), str) or not s["name"]: raise ValueError(f"{w}.name — непустая строка") if s.get("state") not in SPRINT_STATES: raise ValueError(f"{w}.state — active, future или closed") for f in ("start_date", "end_date", "complete_date", "goal"): if s.get(f) is not None and not isinstance(s[f], str): raise ValueError(f"{w}.{f} — строка или null") _FILTERS = {"sprint": "i.sprint = ?", "assignee": "i.assignee = ?", "status": "i.status = ?", "epic": "i.epic_key = ?", "work_kind": "n.work_kind = ?"} def apply_state(conn, issues, links, neighbor_keys=(), fetched_at=None, authoritative=True, boards=None) -> dict: """Upsert задач; связи с source_key среди присланных задач заменяются присланными. Связи, у которых источник не прислан, не трогаются до загрузки их источника. authoritative=True — загрузка по базовой JQL: флаг neighbor пересчитывается по neighbor_keys (задача, вошедшая в базовую выборку, перестаёт быть соседней). authoritative=False — точечное обновление (по ключам, по произвольной JQL): флаг только поднимается, никогда не снижается. Поля v15 (агент v15+): у задачи rank, story_points — пишутся, только если ключ есть в словаре задачи (null очищает); sprint_ids — список заменяет спринты задачи в issue_sprint. boards — список досок: доска заменяет свою строку jira_board и, если sprints — список, свои спринты (sprints null/нет — спринты не трогаются); тип доски записывается проектам с этой доской. Без поля или без boards прежние данные не меняются (агент v14).""" 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 for f in ("rank", "story_points"): if f in it: row[f] = it[f] 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"]) if isinstance(it.get("sprint_ids"), list): # без поля (агент до v15) спринты задачи не трогаем conn.execute("DELETE FROM issue_sprint WHERE key = ?", (it["key"],)) conn.executemany("INSERT OR IGNORE INTO issue_sprint(key, sprint_id) VALUES (?, ?)", [(it["key"], sid) for sid in it["sprint_ids"]]) if isinstance(it.get("comments"), list): # без поля (агент до v10) комментарии не трогаем conn.execute("DELETE FROM issue_comment WHERE key = ?", (it["key"],)) conn.executemany( "INSERT OR REPLACE INTO issue_comment(key, comment_id, author, created, updated, body, created_at) " "VALUES (?, ?, ?, ?, ?, ?, ?)", [(it["key"], c["id"], c.get("author"), c.get("created"), c.get("updated"), c.get("body") or "", history.jira_utc(c.get("created"))) for c in it["comments"]]) if isinstance(it.get("attachments"), list): # без поля (агент до v14) вложения не трогаем; attachment_file не трогаем никогда conn.execute("DELETE FROM issue_attachment WHERE key = ?", (it["key"],)) conn.executemany( # REPLACE по attachment_id: вложение, переехавшее на другой ключ, перезаписывается "INSERT OR REPLACE INTO issue_attachment(attachment_id, key, filename, size, mime_type, author, created, created_at) " "VALUES (?, ?, ?, ?, ?, ?, ?, ?)", [(a["id"], it["key"], a["filename"], a.get("size"), a.get("mime_type"), a.get("author"), a.get("created"), history.jira_utc(a.get("created"))) for a in it["attachments"]]) 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"])) for b in boards or []: conn.execute( "INSERT OR REPLACE INTO jira_board(board_id, type, columns, kanban_backlog, fetched_at) VALUES (?, ?, ?, ?, ?)", (b["id"], b.get("type"), json.dumps(b["columns"], ensure_ascii=False) if b.get("columns") is not None else None, None if b.get("kanban_backlog") is None else int(b["kanban_backlog"]), fetched_at)) if isinstance(b.get("sprints"), list): # null/нет — спринты доски не меняются (R-14) conn.execute("DELETE FROM jira_sprint WHERE board_id = ?", (b["id"],)) conn.executemany( "INSERT OR REPLACE INTO jira_sprint(sprint_id, board_id, name, state, start_date, end_date, complete_date, goal) " "VALUES (?, ?, ?, ?, ?, ?, ?, ?)", [(s["id"], b["id"], s["name"], s["state"], s.get("start_date"), s.get("end_date"), s.get("complete_date"), s.get("goal")) for s in b["sprints"]]) if b.get("type") in projects.BOARD_TYPES: projects.record_board_types(conn, {str(b["id"]): b["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 # Разрез по проекту: свои задачи (метка проекта — точное совпадение), их соседи по связям в обе стороны и эпики своих. # Единственный параметр — метка, первым в списке аргументов запроса. 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 (задача без статуса не закрыта). 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, ""): 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("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))}))") args += list(closed_statuses) if filters.get("q"): where.append("(i.summary LIKE ? OR i.key LIKE ?)") args += [f"%{filters['q']}%", f"%{filters['q']}%"] if project: sql = (SCOPE_CTE + "SELECT i.*, s.nb AS scope_nb, 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.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 ?") 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, files_dir=None) -> dict | None: row = conn.execute("SELECT * FROM issue WHERE key = ?", (key,)).fetchone() if row is None: return None d = _issue_dict(row) epic = conn.execute("SELECT summary FROM issue WHERE key = ?", (d["epic_key"],)).fetchone() if d.get("epic_key") else None d["epic_summary"] = epic["summary"] if epic else None d["links_out"] = [dict(r) for r in conn.execute( "SELECT l.target_key, l.link_type, i.summary, i.status FROM issue_link l LEFT JOIN issue i ON i.key = l.target_key " "WHERE l.source_key = ? ORDER BY l.link_type, l.target_key", (key,))] d["links_in"] = [dict(r) for r in conn.execute( "SELECT l.source_key, l.link_type, i.summary, i.status FROM issue_link l LEFT JOIN issue i ON i.key = l.source_key " "WHERE l.target_key = ? ORDER BY l.link_type, l.source_key", (key,))] d["comments"] = [dict(r) for r in conn.execute( "SELECT comment_id, author, created, updated, body FROM issue_comment WHERE key = ? " "ORDER BY created DESC, CAST(comment_id AS INTEGER) DESC, comment_id DESC", (key,))] rows = [dict(r) for r in conn.execute( "SELECT attachment_id, filename, size, mime_type, author, created, created_at FROM issue_attachment WHERE key = ? " "ORDER BY created_at DESC, CAST(attachment_id AS INTEGER) DESC, attachment_id DESC", (key,))] d["attachments"] = [] for r in rows: st = attachments.state(conn, files_dir, r["attachment_id"]) d["attachments"].append({**{k: v for k, v in r.items() if k != "created_at"}, "state": st["state"], "error": st["error"]}) d["description_attachment_ids"] = attachments.resolve(attachments.ref_names(d.get("description")), rows) for c in d["comments"]: c["attachment_ids"] = attachments.resolve(attachments.ref_names(c["body"]), rows) d["changes"] = history.issue_changes(conn, 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 ? AND type != 'fetch_attachment' " "ORDER BY id DESC LIMIT 10", (f'%"{key}"%',))] return d def last_full_sync(conn, base_jql=None) -> dict | None: """Последний успешный полный прогон (refresh по базовой JQL, команда done): started_at, finished_at, issues_count. base_jql не задана — берётся текущая базовая JQL проектов; без проектов — None.""" if base_jql is None: from . import projects base_jql = projects.base_jql(conn) 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' " "ORDER BY s.started_at DESC, s.id DESC LIMIT 1", (base_jql,)).fetchone() return dict(run) if run else None BOARD_FIELDS = ["key", "summary", "issue_type", "status", "priority", "assignee", "sprint", "duedate", "planned_start", "planned_end", "epic_key", "labels", "neighbor"] def board(conn, base_jql, project: str | None = None) -> dict: """Снимок зеркала для доски связей (GET /board): все задачи с work_kind из заметок, все связи и последний успешный полный прогон (refresh по базовой JQL, команда done). Данные без преобразований — перевод в модель доски делает интерфейс. С project — свои задачи проекта и соседи (neighbor относительно проекта), связи — те, у которых source или target — своя задача; last_sync — как без проекта. Чтение в одной транзакции: задачи, связи и last_sync — из одного состояния базы.""" own = not conn.in_transaction if own: conn.execute("BEGIN") try: 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.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.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 = last_full_sync(conn, base_jql) finally: if own: conn.execute("COMMIT") return {"generated_at": db.now(), "last_sync": run, "issues": issues, "links": links} 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: """После создания задачи: заметка с временного ключа переезжает на настоящий. Если под настоящим ключом заметка уже есть — слияние (M-3 #4): поле временной заметки побеждает, если оно непустое; updated_at/updated_by — от более поздней; временная строка удаляется.""" if old_key == new_key: return with db.transaction(conn): old = get_note(conn, old_key) if old is None: return cur = get_note(conn, new_key) if cur is None: conn.execute("UPDATE issue_note SET key = ? WHERE key = ?", (new_key, old_key)) return merged = [old[f] if old[f] not in (None, "") else cur[f] for f in NOTE_FIELDS] newer = old if (old["updated_at"] or "") >= (cur["updated_at"] or "") else cur conn.execute(f"UPDATE issue_note SET {', '.join(f'{f} = ?' for f in NOTE_FIELDS)}, updated_at = ?, updated_by = ? " "WHERE key = ?", merged + [newer["updated_at"], newer["updated_by"], new_key]) conn.execute("DELETE FROM issue_note WHERE key = ?", (old_key,))