2026-10-24 20:51:20 +04:00

406 lines
25 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Зеркало задач 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,))