По финальному ревью: M-1, M-4, M-6. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1956 lines
104 KiB
Python
1956 lines
104 KiB
Python
#!/usr/bin/env python3
|
||
"""jira-agent — клиент моста Jira для виртуалки с доступом к Jira банка.
|
||
|
||
Опрашивает хаб (jira-hub) на Mac mini, выполняет команды «желаемый результат» в Jira по правилу
|
||
«проверь, потом сделай», ведёт журнал на диске и возвращает хабу состояние задач. Только стандартная библиотека,
|
||
Python 3.9. Спека: jira-hub/openspec/specs/agent/spec.md.
|
||
Связь с хабом — HTTP (`hub_url = http://…`) или общая папка (`hub_url = folder:<путь>`), если сеть закрыта VPN.
|
||
|
||
python3 agent.py check # доступ к хабу и Jira, без записи
|
||
python3 agent.py run # основной цикл (по умолчанию)
|
||
python3 agent.py selftest # самопроверка новой версии перед обновлением: версия + check; код 0 или 3
|
||
python3 agent.py prune # почистить journal.jsonl от подтверждённых записей старше 30 дней
|
||
|
||
Самообновление — через запускатель run.py (спека agent, «Самообновление агента»).
|
||
"""
|
||
import argparse
|
||
import base64
|
||
import fcntl
|
||
import hashlib
|
||
import http.client
|
||
import json
|
||
import os
|
||
import re
|
||
import signal
|
||
import ssl
|
||
import subprocess
|
||
import sys
|
||
import time
|
||
import urllib.error
|
||
import urllib.parse
|
||
import urllib.request
|
||
import uuid
|
||
from datetime import datetime, timezone
|
||
from typing import Any, Callable, Dict, List, Optional, Tuple
|
||
|
||
AGENT_VERSION = 14
|
||
EXIT_LOCKED = 73 # «журнал занят другим процессом»: для run.py это не сбой версии (design 8)
|
||
EXIT_UPDATE = 75 # «обновление готово»: run.py подменит agent.py на agent.py.new
|
||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||
API = "/rest/api/2"
|
||
AGILE = "/rest/agile/1.0"
|
||
|
||
|
||
def now_iso() -> str:
|
||
return datetime.now(timezone.utc).replace(microsecond=0).isoformat()
|
||
|
||
|
||
def log(msg: str) -> None:
|
||
print("[%s] %s" % (datetime.now().strftime("%H:%M:%S"), msg), flush=True)
|
||
|
||
|
||
def load_config(path: str) -> dict:
|
||
try:
|
||
with open(path, encoding="utf-8") as f:
|
||
cfg = json.load(f)
|
||
except FileNotFoundError:
|
||
sys.exit("нет файла настроек %s — скопируйте agent.example.json в agent.json и заполните" % path)
|
||
for key in ("hub_url", "hub_token", "jira"):
|
||
if not cfg.get(key):
|
||
sys.exit("в %s нет обязательного поля %s" % (path, key))
|
||
if not cfg["jira"].get("base_url"):
|
||
sys.exit("в %s нет jira.base_url" % path)
|
||
if cfg["hub_url"].startswith("folder:") and not os.path.isabs(cfg["hub_url"][len("folder:"):]):
|
||
sys.exit("hub_url: после folder: нужен абсолютный путь общей папки, например "
|
||
"folder:/Volumes/My Shared Files/RSHB/jira-bridge")
|
||
cfg.setdefault("timeout", 60)
|
||
cfg.setdefault("poll_seconds", 5)
|
||
cfg.setdefault("verify_ssl", True)
|
||
return cfg
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Самообновление: update.json рядом с agent.py (спека agent «Самообновление агента», design 7)
|
||
# ---------------------------------------------------------------------------
|
||
def read_update_state(path: str) -> Optional[dict]:
|
||
"""Состояние обновления; нет файла или мусор — None."""
|
||
try:
|
||
with open(path, encoding="utf-8") as f:
|
||
data = json.load(f)
|
||
except FileNotFoundError:
|
||
return None
|
||
except (OSError, ValueError):
|
||
log("update.json не читается — считаю, что состояния нет")
|
||
return None
|
||
return data if isinstance(data, dict) else None
|
||
|
||
|
||
def write_update_state(path: str, state: str, from_version, to_version, sha256, error=None) -> dict:
|
||
data = {"state": state, "from_version": from_version, "to_version": to_version, "sha256": sha256,
|
||
"error": error, "at": now_iso()}
|
||
tmp = path + ".tmp"
|
||
with open(tmp, "w", encoding="utf-8") as f:
|
||
json.dump(data, f, ensure_ascii=False)
|
||
os.replace(tmp, path)
|
||
return data
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# HTTP
|
||
# ---------------------------------------------------------------------------
|
||
def wire_filename(name: str) -> str:
|
||
"""Имя для filename="…" в multipart: кавычка и переводы строк — %22, %0D, %0A, как у браузеров (HTML5);
|
||
остальное — UTF-8 как есть (Jira Server читает filename в кодировке запроса)."""
|
||
return name.replace('"', "%22").replace("\r", "%0D").replace("\n", "%0A")
|
||
|
||
|
||
def multipart_file(boundary: str, filename: str, data: bytes) -> bytes:
|
||
"""Тело multipart/form-data с одной частью file (спека agent «Тип задачи и вложения»)."""
|
||
disposition = "form-data; name=\"file\"; filename=\"%s\"; filename*=UTF-8''%s" % (
|
||
wire_filename(filename), urllib.parse.quote(filename, safe=""))
|
||
head = "--%s\r\nContent-Disposition: %s\r\nContent-Type: application/octet-stream\r\n\r\n" % (boundary, disposition)
|
||
return head.encode("utf-8") + data + ("\r\n--%s--\r\n" % boundary).encode("ascii")
|
||
|
||
|
||
class HttpError(Exception):
|
||
def __init__(self, code: int, text: str, headers=None):
|
||
super().__init__("HTTP %d: %s" % (code, text[:300]))
|
||
self.code, self.text, self.headers = code, text, headers or {}
|
||
|
||
|
||
class RedirectAway(Exception):
|
||
"""Сервер перенаправил запрос на другой адрес (схема, хост или порт): учётные данные туда не отправляются."""
|
||
def __init__(self, host: str):
|
||
super().__init__("перенаправление на %s" % host)
|
||
self.host = host
|
||
|
||
|
||
class TooBig(Exception):
|
||
"""Ответ длиннее предела: чтение оборвано."""
|
||
|
||
|
||
def _origin(url: str) -> Tuple[str, str, Optional[int]]:
|
||
u = urllib.parse.urlsplit(url)
|
||
scheme = (u.scheme or "").lower()
|
||
return scheme, (u.hostname or "").lower(), u.port or {"http": 80, "https": 443}.get(scheme)
|
||
|
||
|
||
class _SameOriginRedirect(urllib.request.HTTPRedirectHandler):
|
||
"""Перенаправления — только в пределах адреса клиента: urllib переносит Authorization и на чужой хост."""
|
||
def __init__(self, base: str):
|
||
self.origin = _origin(base)
|
||
|
||
def redirect_request(self, req, fp, code, msg, headers, newurl):
|
||
if _origin(newurl) != self.origin:
|
||
fp.close()
|
||
raise RedirectAway(urllib.parse.urlsplit(newurl).hostname or newurl)
|
||
return super().redirect_request(req, fp, code, msg, headers, newurl)
|
||
|
||
|
||
class Http:
|
||
def __init__(self, base: str, headers: Dict[str, str], timeout: float = 60, ctx=None):
|
||
self.base, self.headers, self.timeout, self.ctx = base.rstrip("/"), headers, timeout, ctx
|
||
|
||
def __repr__(self):
|
||
return "<%s %s>" % (self.__class__.__name__, self.base)
|
||
|
||
def call(self, method: str, path: str, body=None, params=None, timeout=None) -> Any:
|
||
data = json.dumps(body, ensure_ascii=False).encode("utf-8") if body is not None else None
|
||
return self.send(method, path, data, "application/json; charset=utf-8" if data is not None else None,
|
||
params, timeout)
|
||
|
||
def send(self, method: str, path: str, data: Optional[bytes], content_type: Optional[str], params=None,
|
||
timeout=None) -> Any:
|
||
"""Запрос с готовым телом (JSON или multipart); ответ — JSON. Ошибки — HttpError и ConnectionError."""
|
||
url = self.base + path
|
||
if params:
|
||
url += "?" + urllib.parse.urlencode({k: v for k, v in params.items() if v is not None})
|
||
req = urllib.request.Request(url, data=data, method=method)
|
||
for k, v in self.headers.items():
|
||
req.add_header(k, v)
|
||
req.add_header("Accept", "application/json")
|
||
if content_type is not None:
|
||
req.add_header("Content-Type", content_type)
|
||
try:
|
||
with self._opener().open(req, timeout=timeout or self.timeout) as resp:
|
||
raw = resp.read()
|
||
status = resp.status
|
||
except RedirectAway as e:
|
||
self._touch()
|
||
raise HttpError(302, "перенаправление на %s отклонено: учётные данные туда не отправляются" % e.host) from None
|
||
except urllib.error.HTTPError as e:
|
||
self._touch()
|
||
try:
|
||
headers = {k.lower(): v for k, v in e.headers.items()}
|
||
text = e.read().decode("utf-8", "replace")
|
||
except (OSError, ConnectionError, TimeoutError, http.client.HTTPException) as err:
|
||
raise ConnectionError("%s: обрыв при чтении ответа: %s" % (self.base, err)) from None
|
||
finally:
|
||
e.close()
|
||
raise HttpError(e.code, text, headers) from None
|
||
except (urllib.error.URLError, ConnectionError, TimeoutError, OSError, http.client.HTTPException) as e:
|
||
raise ConnectionError("%s недоступен: %s" % (self.base, e)) from None
|
||
self._touch()
|
||
try:
|
||
return json.loads(raw.decode("utf-8")) if raw else {}
|
||
except ValueError as e: # включая UnicodeDecodeError и JSONDecodeError
|
||
raise HttpError(status, "ответ не JSON: %s; начало: %s" % (e, raw[:200].decode("utf-8", "replace"))) from None
|
||
|
||
def _opener(self):
|
||
"""Открыватель запросов: перенаправления — только на адрес клиента (urllib переносит Authorization и на
|
||
чужой хост); контекст TLS — из настроек."""
|
||
handlers = [_SameOriginRedirect(self.base)] # type: List[Any]
|
||
if self.ctx is not None:
|
||
handlers.append(urllib.request.HTTPSHandler(context=self.ctx))
|
||
return urllib.request.build_opener(*handlers)
|
||
|
||
def fetch_bytes(self, path: str, limit: int, timeout=None) -> bytes:
|
||
"""GET с ответом-байтами (содержимое вложения): без разбора JSON, читает не больше limit + 1 байт — длиннее
|
||
limit — TooBig. Перенаправление на другой адрес — RedirectAway без перехода. Ошибки — как у send."""
|
||
req = urllib.request.Request(self.base + path, method="GET")
|
||
for k, v in self.headers.items():
|
||
req.add_header(k, v)
|
||
req.add_header("Accept", "*/*")
|
||
try:
|
||
with self._opener().open(req, timeout=timeout or self.timeout) as resp:
|
||
data = resp.read(limit + 1)
|
||
rest = resp.length # остаток по Content-Length: read(n) при обрыве молча отдаёт меньше
|
||
if rest and len(data) <= limit:
|
||
raise ConnectionError("%s: обрыв при чтении ответа: получено %d байт, не дошло %d" % (self.base, len(data), rest))
|
||
except RedirectAway:
|
||
self._touch()
|
||
raise
|
||
except urllib.error.HTTPError as e:
|
||
self._touch()
|
||
try:
|
||
headers = {k.lower(): v for k, v in e.headers.items()}
|
||
text = e.read().decode("utf-8", "replace")
|
||
except (OSError, ConnectionError, TimeoutError, http.client.HTTPException) as err:
|
||
raise ConnectionError("%s: обрыв при чтении ответа: %s" % (self.base, err)) from None
|
||
finally:
|
||
e.close()
|
||
raise HttpError(e.code, text, headers) from None
|
||
except (urllib.error.URLError, ConnectionError, TimeoutError, OSError, http.client.HTTPException) as e:
|
||
raise ConnectionError("%s недоступен: %s" % (self.base, e)) from None
|
||
self._touch()
|
||
if len(data) > limit:
|
||
raise TooBig("ответ длиннее %d байт" % limit)
|
||
return data
|
||
|
||
def _touch(self) -> None:
|
||
"""Вызывается на каждый полученный HTTP-ответ (любой код)."""
|
||
|
||
|
||
class JiraClient(Http):
|
||
"""Все вызовы Jira идут здесь: счётчики вызовов и 429/503, время последнего вызова, повтор по Retry-After."""
|
||
|
||
def __init__(self, cfg: dict):
|
||
j = cfg["jira"]
|
||
auth = j.get("auth") or {}
|
||
kind = auth.get("type") or ("bearer" if auth.get("token") else "basic")
|
||
if kind == "bearer":
|
||
if not auth.get("token"):
|
||
sys.exit("jira.auth.token пуст")
|
||
header = "Bearer " + auth["token"]
|
||
else:
|
||
if not (auth.get("user") and auth.get("password")):
|
||
sys.exit("jira.auth.user / jira.auth.password пусты")
|
||
header = "Basic " + base64.b64encode(("%s:%s" % (auth["user"], auth["password"])).encode()).decode()
|
||
ctx = None
|
||
if not cfg.get("verify_ssl", True):
|
||
ctx = ssl.create_default_context()
|
||
ctx.check_hostname = False
|
||
ctx.verify_mode = ssl.CERT_NONE
|
||
elif cfg.get("ca_bundle"):
|
||
ctx = ssl.create_default_context(cafile=cfg["ca_bundle"])
|
||
super().__init__(j["base_url"], {"Authorization": header, "X-Atlassian-Token": "no-check"}, cfg.get("timeout", 60), ctx)
|
||
self.calls = 0
|
||
self.rate_limited = 0
|
||
self.last_call_at = None # type: Optional[float] (time.monotonic)
|
||
self.last_call_iso = None # type: Optional[str]
|
||
self.sleep = time.sleep
|
||
|
||
def _touch(self) -> None:
|
||
self.calls += 1
|
||
self.last_call_at, self.last_call_iso = time.monotonic(), now_iso()
|
||
|
||
def reset_counters(self) -> None:
|
||
self.calls, self.rate_limited = 0, 0
|
||
|
||
def _retry(self, request: Callable[[], Any]) -> Any:
|
||
"""429/503 — повтор по Retry-After (до 5 запросов, не более 4 ожиданий); прочие ошибки — наружу."""
|
||
for attempt in range(1, 6):
|
||
try:
|
||
return request()
|
||
except HttpError as e:
|
||
if e.code in (429, 503) and attempt < 5:
|
||
wait = 60 * attempt
|
||
try:
|
||
wait = max(0, int(e.headers.get("retry-after")))
|
||
except (TypeError, ValueError):
|
||
pass
|
||
self.rate_limited += 1
|
||
log(" ! HTTP %d, жду %d с (попытка %d/5)" % (e.code, wait, attempt))
|
||
self.sleep(wait)
|
||
continue
|
||
if e.code in (429, 503):
|
||
self.rate_limited += 1
|
||
raise
|
||
|
||
def call(self, method, path, body=None, params=None):
|
||
return self._retry(lambda: Http.call(self, method, path, body, params))
|
||
|
||
def upload(self, key: str, filename: str, data: bytes) -> Any:
|
||
"""Вложение в задачу: multipart, X-Atlassian-Token: no-check (заголовок клиента), таймаут не меньше 120 с.
|
||
Содержимое не логируется."""
|
||
boundary = uuid.uuid4().hex
|
||
body = multipart_file(boundary, filename, data)
|
||
return self._retry(lambda: self.send("POST", API + "/issue/%s/attachments" % key, body,
|
||
"multipart/form-data; boundary=" + boundary,
|
||
timeout=max(self.timeout, 120)))
|
||
|
||
def download(self, path: str, limit: int) -> bytes:
|
||
"""Содержимое вложения (байты), таймаут не меньше 120 с, 429/503 — повтор как у call. Не логируется."""
|
||
return self._retry(lambda: self.fetch_bytes(path, limit, max(self.timeout, 120)))
|
||
|
||
def get(self, path, **params):
|
||
return self.call("GET", path, params=params or None)
|
||
|
||
def post(self, path, body):
|
||
return self.call("POST", path, body)
|
||
|
||
def put(self, path, body):
|
||
return self.call("PUT", path, body)
|
||
|
||
def delete(self, path):
|
||
return self.call("DELETE", path)
|
||
|
||
|
||
class FolderTransport:
|
||
"""Обмен с хабом через общую папку (hub_url = folder:<путь>), когда сеть до хаба закрыта (VPN на виртуалке).
|
||
Запрос — файл req/<id>.json, ответ кладёт реле хаба в resp/<id>.json. Ошибки те же, что у Http.call:
|
||
нет ответа к сроку или нет папки — ConnectionError, код не 2xx — HttpError. Спека: specs/agent, folder-transport."""
|
||
POLL = 0.1
|
||
BAD_JSON_WAIT = 2.0
|
||
|
||
def __init__(self, root: str, authorization: str):
|
||
self.root, self.authorization = root, authorization
|
||
|
||
def __repr__(self):
|
||
return "<FolderTransport %s>" % self.root
|
||
|
||
def call(self, method: str, path: str, body=None, timeout: float = 60) -> Any:
|
||
if not os.path.isdir(self.root):
|
||
raise ConnectionError("нет папки %s" % self.root)
|
||
req_dir, resp_dir = os.path.join(self.root, "req"), os.path.join(self.root, "resp")
|
||
rid = uuid.uuid4().hex
|
||
try:
|
||
os.makedirs(req_dir, exist_ok=True)
|
||
os.makedirs(resp_dir, exist_ok=True)
|
||
deadline = time.time() + timeout
|
||
self._write(req_dir, rid + ".json", {
|
||
"id": rid, "method": method, "path": path, "body": body, "authorization": self.authorization,
|
||
"deadline": datetime.fromtimestamp(deadline, timezone.utc).isoformat()})
|
||
except OSError as e:
|
||
raise ConnectionError("папка %s недоступна: %s" % (self.root, e)) from None
|
||
resp_path = os.path.join(resp_dir, rid + ".json")
|
||
end = time.monotonic() + timeout
|
||
while time.monotonic() < end:
|
||
if os.path.exists(resp_path):
|
||
return self._take(resp_path)
|
||
time.sleep(self.POLL)
|
||
try:
|
||
os.remove(os.path.join(req_dir, rid + ".json"))
|
||
except OSError:
|
||
pass
|
||
raise ConnectionError("не ответил через папку %s за %.0f с" % (self.root, timeout))
|
||
|
||
@staticmethod
|
||
def _write(directory: str, name: str, payload: dict) -> None:
|
||
tmp = os.path.join(directory, "." + name + ".tmp")
|
||
with open(tmp, "w", encoding="utf-8") as f:
|
||
json.dump(payload, f, ensure_ascii=False)
|
||
f.flush()
|
||
os.fsync(f.fileno())
|
||
os.replace(tmp, os.path.join(directory, name))
|
||
|
||
def _take(self, path: str) -> Any:
|
||
end = time.monotonic() + self.BAD_JSON_WAIT
|
||
while True:
|
||
try:
|
||
with open(path, encoding="utf-8") as f:
|
||
resp = json.load(f)
|
||
if not isinstance(resp, dict):
|
||
raise ValueError("ответ не объект")
|
||
status, body = resp.get("status"), resp.get("body")
|
||
if type(status) is not int or not (body is None or isinstance(body, str)):
|
||
raise ValueError("неверные типы status/body") # bool — подкласс int, поэтому type(), не isinstance (M-2 agent#8)
|
||
break
|
||
except (ValueError, OSError):
|
||
if time.monotonic() >= end:
|
||
raise ConnectionError("нечитаемый ответ хаба в %s" % path) from None
|
||
time.sleep(self.POLL)
|
||
try:
|
||
os.remove(path)
|
||
except OSError:
|
||
pass
|
||
text = body or "" # status и body проверены выше (M-5 #18)
|
||
if not 200 <= status < 300:
|
||
raise HttpError(status, text)
|
||
try:
|
||
return json.loads(text) if text else {}
|
||
except ValueError as e:
|
||
raise HttpError(status, "ответ не JSON: %s; начало: %s" % (e, text[:200])) from None
|
||
|
||
|
||
class HubClient(Http):
|
||
def __init__(self, cfg: dict):
|
||
auth = "Bearer " + cfg["hub_token"]
|
||
super().__init__(cfg["hub_url"], {"Authorization": auth}, cfg.get("timeout", 60))
|
||
url = cfg["hub_url"]
|
||
self.folder = FolderTransport(url[len("folder:"):], auth) if url.startswith("folder:") else None
|
||
|
||
def call(self, method: str, path: str, body=None, params=None, timeout=None) -> Any:
|
||
if self.folder is None:
|
||
return super().call(method, path, body, params, timeout)
|
||
if params:
|
||
path += "?" + urllib.parse.urlencode({k: v for k, v in params.items() if v is not None})
|
||
return self.folder.call(method, path, body, timeout or self.timeout)
|
||
|
||
def claim(self, agent_version, settings_version, last_jira_call_at, update_state=None, launcher_version=None) -> dict:
|
||
return self.call("POST", "/agent/claim", {"agent_version": agent_version, "settings_version": settings_version,
|
||
"last_jira_call_at": last_jira_call_at, "update_state": update_state,
|
||
"launcher_version": launcher_version}, timeout=max(self.timeout, 45))
|
||
|
||
def release(self) -> dict:
|
||
return self.call("GET", "/agent/release")
|
||
|
||
def settings(self) -> dict:
|
||
return self.call("GET", "/agent/settings")
|
||
|
||
def command(self, command_id) -> dict:
|
||
return self.call("GET", "/agent/commands/%d" % int(command_id))
|
||
|
||
def result(self, command_id, payload: dict) -> dict:
|
||
return self.call("POST", "/agent/commands/%d/result" % int(command_id), payload)
|
||
|
||
def state(self, payload: dict) -> dict:
|
||
return self.call("POST", "/agent/state", payload)
|
||
|
||
def known_keys(self) -> List[str]:
|
||
return self.call("GET", "/agent/known-keys")["keys"]
|
||
|
||
def history(self, payload: dict) -> dict:
|
||
return self.call("POST", "/agent/history", payload)
|
||
|
||
def file(self, sha256: str) -> dict:
|
||
"""Файл вложения из хранилища хаба: {file, size, content (base64)}; нет файла — HttpError 404 no_file."""
|
||
return self.call("GET", "/agent/files/%s" % sha256)
|
||
|
||
def upload_file(self, data: bytes) -> dict:
|
||
"""Файл вложения, скачанный из Jira, — в хранилище хаба: {file (sha256), size}."""
|
||
return self.call("POST", "/agent/files", {"content": base64.b64encode(data).decode("ascii")})
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Журнал: только дозапись; started → finished → acked по id команды
|
||
# ---------------------------------------------------------------------------
|
||
class Journal:
|
||
def __init__(self, path: str):
|
||
self.path = path
|
||
|
||
def _torn_tail(self) -> bool:
|
||
"""Файл не пуст и не заканчивается переводом строки (оборванная запись)."""
|
||
try:
|
||
if os.path.getsize(self.path) == 0:
|
||
return False
|
||
except FileNotFoundError:
|
||
return False
|
||
with open(self.path, "rb") as f:
|
||
f.seek(-1, os.SEEK_END)
|
||
return f.read(1) != b"\n"
|
||
|
||
def append(self, record: dict) -> None:
|
||
record = dict(record)
|
||
record.setdefault("at", now_iso())
|
||
data = (json.dumps(record, ensure_ascii=False) + "\n").encode("utf-8")
|
||
if self._torn_tail():
|
||
data = b"\n" + data
|
||
fd = os.open(self.path, os.O_WRONLY | os.O_APPEND | os.O_CREAT, 0o600)
|
||
with os.fdopen(fd, "ab") as f:
|
||
f.write(data)
|
||
f.flush()
|
||
os.fsync(f.fileno())
|
||
|
||
def _scan(self):
|
||
"""Возвращает (годные записи, номера битых строк). Годная — dict с command_id."""
|
||
good, bad = [], []
|
||
if not os.path.exists(self.path):
|
||
return good, bad
|
||
with open(self.path, "rb") as f:
|
||
for n, raw in enumerate(f, 1):
|
||
line = raw.strip()
|
||
if not line:
|
||
continue
|
||
try:
|
||
r = json.loads(line.decode("utf-8"))
|
||
except ValueError:
|
||
r = None
|
||
if isinstance(r, dict) and "command_id" in r:
|
||
good.append(r)
|
||
else:
|
||
bad.append(n)
|
||
return good, bad
|
||
|
||
def records(self) -> List[dict]:
|
||
good, bad = self._scan()
|
||
for n in bad:
|
||
log("журнал: строка %d не читается, пропускаю" % n)
|
||
return good
|
||
|
||
def last_at(self) -> Optional[datetime]:
|
||
"""Самое позднее время записи журнала (UTC). Записи без времени или с непонятным временем пропускаются."""
|
||
latest = None
|
||
for r in self.records():
|
||
try:
|
||
t = datetime.fromisoformat(r["at"])
|
||
except (KeyError, TypeError, ValueError):
|
||
continue
|
||
if t.tzinfo is None:
|
||
continue
|
||
if latest is None or t > latest:
|
||
latest = t
|
||
return latest
|
||
|
||
def claimed(self, command_id) -> None:
|
||
"""Команда получена из claim (до паузы): с этого момента хаб считает её выданной агенту."""
|
||
self.append({"kind": "claimed", "command_id": int(command_id)})
|
||
|
||
def started(self, command_id) -> None:
|
||
self.append({"kind": "started", "command_id": int(command_id)})
|
||
|
||
def finished(self, command_id, status, result, error, jira_calls, rate_limited) -> None:
|
||
self.append({"kind": "finished", "command_id": int(command_id), "status": status, "result": result,
|
||
"error": error, "jira_calls": int(jira_calls or 0), "rate_limited": int(rate_limited or 0)})
|
||
|
||
def acked(self, command_id) -> None:
|
||
self.append({"kind": "acked", "command_id": int(command_id)})
|
||
|
||
def unacked_finished(self) -> List[dict]:
|
||
last, acked = {}, set()
|
||
for r in self.records():
|
||
if r.get("kind") == "finished":
|
||
last[r["command_id"]] = r
|
||
acked.discard(r["command_id"])
|
||
elif r.get("kind") == "acked":
|
||
acked.add(r["command_id"])
|
||
return [r for cid, r in last.items() if cid not in acked]
|
||
|
||
def unfinished_started(self) -> List[int]:
|
||
"""Порядковый учёт: claimed/started открывают id, finished/acked закрывает; порядок — первое открытие."""
|
||
opened = {} # command_id -> открыта ли сейчас; dict сохраняет порядок первого открытия
|
||
for r in self.records():
|
||
kind, cid = r.get("kind"), r["command_id"]
|
||
if kind in ("claimed", "started"):
|
||
opened[cid] = True
|
||
elif kind in ("finished", "acked") and cid in opened:
|
||
opened[cid] = False
|
||
return [cid for cid, is_open in opened.items() if is_open]
|
||
|
||
def prune(self, days: int = 30) -> int:
|
||
"""Удалить записи id до его последнего acked, если тот старше days дней (возраст — по acked)
|
||
и после него у id нет started/finished. Возвращает число удалённых годных записей; битые строки
|
||
отбрасываются при перезаписи и учитываются в логе."""
|
||
good, bad = self._scan()
|
||
if bad:
|
||
log("журнал: удаляю битых строк %d" % len(bad))
|
||
cutoff = datetime.now(timezone.utc).timestamp() - days * 86400
|
||
last_ack, last_open = {}, {}
|
||
for i, r in enumerate(good):
|
||
cid = r["command_id"]
|
||
if r.get("kind") == "acked":
|
||
last_ack[cid] = i
|
||
elif r.get("kind") in ("claimed", "started", "finished"):
|
||
last_open[cid] = i
|
||
drop_upto = {} # command_id -> индекс последнего acked: всё до него включительно удаляется
|
||
for cid, i in last_ack.items():
|
||
if last_open.get(cid, -1) > i:
|
||
continue
|
||
try:
|
||
ts = _parse_at(good[i].get("at"))
|
||
except (ValueError, TypeError):
|
||
continue
|
||
if ts < cutoff:
|
||
drop_upto[cid] = i
|
||
keep = [r for i, r in enumerate(good)
|
||
if not (r["command_id"] in drop_upto and i <= drop_upto[r["command_id"]])]
|
||
tmp = self.path + ".tmp"
|
||
try:
|
||
os.remove(tmp)
|
||
except FileNotFoundError:
|
||
pass
|
||
fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
|
||
with os.fdopen(fd, "wb") as f:
|
||
for r in keep:
|
||
f.write((json.dumps(r, ensure_ascii=False) + "\n").encode("utf-8"))
|
||
f.flush()
|
||
os.fsync(f.fileno())
|
||
os.replace(tmp, self.path)
|
||
return len(good) - len(keep)
|
||
|
||
|
||
def _parse_at(value) -> float:
|
||
"""Метка времени журнала в эпоху; наивное время трактуется как UTC."""
|
||
dt = datetime.fromisoformat(value)
|
||
if dt.tzinfo is None:
|
||
dt = dt.replace(tzinfo=timezone.utc)
|
||
return dt.timestamp()
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Исполнитель: команда — желаемый результат; прочитать → сравнить → записать разницу
|
||
# ---------------------------------------------------------------------------
|
||
def _text(value: Any) -> Optional[str]:
|
||
return None if value is None else str(value)
|
||
|
||
|
||
def _jira_time(value: Any) -> Optional[datetime]:
|
||
"""Время Jira (`2026-10-22T13:05:11.000+0300`, `+03:00`, `Z`, с дробной частью или без) → aware datetime; без
|
||
смещения — UTC; иначе None. В 3.9 fromisoformat не разбирает ни `Z`, ни `+0300`."""
|
||
if not isinstance(value, str) or not value:
|
||
return None
|
||
s = value[:-1] + "+0000" if value.endswith("Z") else value
|
||
if len(s) > 6 and s[-6] in "+-" and s[-3] == ":": # ±HH:MM → ±HHMM
|
||
s = s[:-3] + s[-2:]
|
||
for fmt in ("%Y-%m-%dT%H:%M:%S.%f%z", "%Y-%m-%dT%H:%M:%S%z", "%Y-%m-%dT%H:%M:%S.%f", "%Y-%m-%dT%H:%M:%S"):
|
||
try:
|
||
d = datetime.strptime(s, fmt)
|
||
except ValueError:
|
||
continue
|
||
return d if d.tzinfo else d.replace(tzinfo=timezone.utc)
|
||
return None
|
||
|
||
|
||
def _date(value) -> Optional[str]:
|
||
"""Даты Jira (duedate — YYYY-MM-DD, кастомные — с временем) сравниваем по дню."""
|
||
return (value or "")[:10] or None
|
||
|
||
|
||
def _nl(value) -> str:
|
||
r"""Текст для сравнения: Jira хранит правленное в UI с \r\n, команды приходят с \n (T3.9, проверено 13.10.2026)."""
|
||
return (value or "").replace("\r\n", "\n")
|
||
|
||
|
||
class ConfigGap(Exception):
|
||
"""В настройках хаба нет того, что нужно команде (R-11): команда падает с понятным текстом, а не молча теряет поле."""
|
||
|
||
|
||
class HubError(Exception):
|
||
"""Ошибка хаба внутри команды (refresh: known_keys, state): источник — хаб, не Jira (M-6 #18, design 12).
|
||
Повтор — только при недоступности хаба и 5xx; refresh в Jira не пишет, повтор безопасен."""
|
||
|
||
def __init__(self, cause: Exception):
|
||
super().__init__("хаб: %s" % cause)
|
||
self.retryable = isinstance(cause, ConnectionError) or (isinstance(cause, HttpError) and cause.code >= 500)
|
||
|
||
|
||
FILE_GONE = "файл удалён с хаба, загрузите заново"
|
||
MAX_ATTACHMENT = 10 * 1024 * 1024 # предел скачивания вложения, как у хранилища хаба (байт)
|
||
|
||
|
||
def _fmt_size(n: Optional[int]) -> str:
|
||
"""Размер для текстов ошибок: «N Б», «N,N КБ», «N,N МБ» (по 1024, запятая), как у хаба."""
|
||
if n is None:
|
||
return "размер неизвестен"
|
||
if n < 1024:
|
||
return "%d Б" % n
|
||
if n < 1024 * 1024:
|
||
return ("%.1f КБ" % (n / 1024)).replace(".", ",")
|
||
return ("%.1f МБ" % (n / 1024 / 1024)).replace(".", ",")
|
||
|
||
|
||
class Executor:
|
||
def __init__(self, jira: JiraClient, settings: dict, hub: Optional[HubClient] = None):
|
||
self.jira, self.s, self.hub = jira, settings, hub
|
||
self.sleep = time.sleep
|
||
self._symmetric = None
|
||
self._sprints = {} # str(доска) → имя → спринт; на одну команду
|
||
|
||
def run(self, cmd: dict) -> dict:
|
||
self.jira.reset_counters()
|
||
self._sprints = {}
|
||
handler = getattr(self, "do_" + str(cmd.get("type")), None)
|
||
if handler is None:
|
||
return self._out("failed", error="неизвестный тип команды %s" % cmd.get("type"))
|
||
try:
|
||
return handler(cmd.get("params") or {}, cmd)
|
||
except ConfigGap as e:
|
||
return self._out("failed", error=str(e))
|
||
except HubError as e:
|
||
out = self._out("failed", error=str(e))
|
||
out["retryable"] = e.retryable
|
||
out["peer"] = "хабом"
|
||
return out
|
||
except HttpError as e:
|
||
out = self._out("failed", error="HTTP %d %s" % (e.code, e.text[:1500]))
|
||
out["retryable"] = e.code >= 500 # исход неизвестен: Runner повторит через проверку (R-10)
|
||
return out
|
||
except ConnectionError as e:
|
||
out = self._out("failed", error=str(e))
|
||
out["retryable"] = True
|
||
return out
|
||
|
||
def _out(self, status, result=None, error=None) -> dict:
|
||
return {"status": status, "result": result, "error": error,
|
||
"jira_calls": self.jira.calls, "rate_limited": self.jira.rate_limited}
|
||
|
||
def _hub(self, fn, *args):
|
||
"""Вызов хаба из команды: его сбой — HubError, чтобы не выдать его за обрыв связи с Jira."""
|
||
try:
|
||
return fn(*args)
|
||
except (ConnectionError, HttpError) as e:
|
||
raise HubError(e) from None
|
||
|
||
def gap(self, cmd_type: str) -> None:
|
||
"""Зазор между записями внутри одной команды — по политике хаба для этого типа."""
|
||
seconds = int((self.s.get("pacing") or {}).get(cmd_type, 0) or 0)
|
||
if seconds > 0:
|
||
log(" пауза %d с" % seconds)
|
||
self.sleep(seconds)
|
||
|
||
def fid(self, logical: str) -> str:
|
||
return (self.s.get("field_ids") or {}).get(logical, logical)
|
||
|
||
def has_field(self, logical: str) -> bool:
|
||
return logical in ("summary", "description", "priority", "duedate", "labels") or bool((self.s.get("field_ids") or {}).get(logical))
|
||
|
||
def priority_value(self, name: str) -> dict:
|
||
mapped = (self.s.get("priority_map") or {}).get(name, name)
|
||
return {self.s.get("priority_value_key") or "name": mapped}
|
||
|
||
def issue_type_ref(self, kind: str) -> dict:
|
||
"""Тип задачи Jira из issue_types.<kind>: {id}, если id задан, иначе {name}; нет ни того, ни другого —
|
||
ошибка конфигурации, а не {"name": None}, на которое Jira ответит 400 без понятной причины (спека agent v11)."""
|
||
it = (self.s.get("issue_types") or {}).get(kind)
|
||
if isinstance(it, dict) and it.get("id"):
|
||
return {"id": str(it["id"])}
|
||
if isinstance(it, dict) and it.get("name"):
|
||
return {"name": it["name"]}
|
||
raise ConfigGap("нет настройки issue_types.%s" % kind)
|
||
|
||
def epic_field_id(self) -> str:
|
||
ep = self.s.get("epic_link") or {}
|
||
if ep.get("mode", "field") != "field":
|
||
raise ConfigGap("epic_key передан, но epic_link.mode = %r (поддержан только «field»)" % ep.get("mode"))
|
||
if not ep.get("field_id"):
|
||
raise ConfigGap("epic_key передан, но не настроено epic_link.field_id")
|
||
return ep["field_id"]
|
||
|
||
def epic_fields(self, epic_key) -> dict:
|
||
return {self.epic_field_id(): epic_key} if epic_key else {}
|
||
|
||
def need_field(self, logical: str) -> str:
|
||
if not self.has_field(logical):
|
||
raise ConfigGap("поле передано, но не настроено field_ids.%s" % logical)
|
||
return self.fid(logical)
|
||
|
||
# --- ensure_issue ----------------------------------------------------------
|
||
def do_ensure_issue(self, p: dict, cmd: dict) -> dict:
|
||
issuetype = self.issue_type_ref(p["issue_type"]) # до поиска дубля: без настройки — ни одного вызова Jira
|
||
summary = (p["summary"] or "").strip()
|
||
jql = "project = %s AND reporter = currentUser() AND created >= -7d ORDER BY created DESC" % self.s["project_key"]
|
||
start = 0
|
||
while start < 1000:
|
||
found = self.jira.get(API + "/search", jql=jql, fields="summary", maxResults=200, startAt=start)
|
||
issues = found.get("issues", [])
|
||
for it in issues:
|
||
if (it.get("fields", {}).get("summary") or "").strip() == summary:
|
||
return self._out("done", {"key": it["key"], "existed": True})
|
||
start += len(issues)
|
||
if not issues or start >= int(found.get("total") or 0):
|
||
break
|
||
fields = {"project": {"key": self.s["project_key"]}, "issuetype": issuetype, "summary": p["summary"]}
|
||
if p.get("description") is not None:
|
||
fields[self.fid("description")] = p["description"]
|
||
if p.get("priority"):
|
||
fields[self.fid("priority")] = self.priority_value(p["priority"])
|
||
if p.get("duedate"):
|
||
fields[self.fid("duedate")] = p["duedate"]
|
||
for logical in ("planned_start", "planned_end"):
|
||
if p.get(logical):
|
||
fields[self.need_field(logical)] = p[logical]
|
||
if p.get("labels") is not None:
|
||
fields[self.fid("labels")] = list(p["labels"])
|
||
if p.get("assignee"):
|
||
fields["assignee"] = {"name": p["assignee"]}
|
||
fields.update(self.epic_fields(p.get("epic_key")))
|
||
if p["issue_type"] == "epic" and self.s.get("epic_name_field_id"):
|
||
fields[self.s["epic_name_field_id"]] = p["summary"]
|
||
created = self.jira.post(API + "/issue", {"fields": fields})
|
||
return self._out("done", {"key": created["key"], "created": True})
|
||
|
||
# --- ensure_fields ---------------------------------------------------------
|
||
def do_ensure_fields(self, p: dict, cmd: dict) -> dict:
|
||
key = p["key"]
|
||
cur = self.jira.get(API + "/issue/%s" % key)["fields"]
|
||
fields, changed = {}, []
|
||
if "summary" in p and (cur.get("summary") or "") != (p["summary"] or ""):
|
||
fields["summary"] = p["summary"]
|
||
changed.append("summary")
|
||
if "description" in p and _nl(cur.get(self.fid("description"))) != _nl(p["description"]):
|
||
fields[self.fid("description")] = p["description"]
|
||
changed.append("description")
|
||
if "priority" in p:
|
||
want = self.priority_value(p["priority"])
|
||
vkey = list(want)[0]
|
||
have = (cur.get(self.fid("priority")) or {}).get(vkey)
|
||
if (None if have is None else str(have)) != str(want[vkey]):
|
||
fields[self.fid("priority")] = want
|
||
changed.append("priority")
|
||
for logical in ("duedate", "planned_start", "planned_end"):
|
||
if logical not in p:
|
||
continue
|
||
if logical != "duedate" and p[logical] and not self.has_field(logical):
|
||
self.need_field(logical)
|
||
if self.has_field(logical) and _date(cur.get(self.fid(logical))) != _date(p[logical]):
|
||
fields[self.fid(logical)] = p[logical]
|
||
changed.append(logical)
|
||
if "epic_key" in p and (p["epic_key"] or (self.s.get("epic_link") or {}).get("field_id")):
|
||
fid = self.epic_field_id()
|
||
if (cur.get(fid) or None) != (p["epic_key"] or None):
|
||
fields[fid] = p["epic_key"]
|
||
changed.append("epic_key")
|
||
if "assignee" in p and ((cur.get("assignee") or {}).get("name") or None) != (p["assignee"] or None):
|
||
fields["assignee"] = {"name": p["assignee"]} if p["assignee"] else None
|
||
changed.append("assignee")
|
||
if "labels_add" in p or "labels_remove" in p:
|
||
labels = list(cur.get(self.fid("labels")) or [])
|
||
remove = set(p.get("labels_remove") or [])
|
||
new = [l for l in labels if l not in remove] + [l for l in (p.get("labels_add") or []) if l not in labels]
|
||
if new != labels:
|
||
fields[self.fid("labels")] = new
|
||
changed.append("labels")
|
||
if not fields:
|
||
return self._out("done", {"changed": []})
|
||
self.jira.put(API + "/issue/%s" % key, {"fields": fields})
|
||
return self._out("done", {"changed": changed})
|
||
|
||
# --- ensure_comment --------------------------------------------------------
|
||
def do_ensure_comment(self, p: dict, cmd: dict) -> dict:
|
||
key, marker, body = p["key"], p["marker"], p["body"]
|
||
heads = (marker, "*%s.*" % marker) # маркер — только в начале тела (формат агента: «*маркер.* текст»)
|
||
if not body.startswith(heads):
|
||
body = "*%s.* %s" % (marker, body)
|
||
existing, start = None, 0
|
||
while existing is None:
|
||
page = self.jira.get(API + "/issue/%s/comment" % key, startAt=start, maxResults=100)
|
||
comments = page.get("comments", [])
|
||
for c in comments:
|
||
if (c.get("body") or "").startswith(heads):
|
||
existing = c
|
||
break
|
||
start += len(comments)
|
||
if not comments or page.get("total") is None or start >= int(page["total"]):
|
||
break
|
||
if existing and _nl(existing.get("body")) == _nl(body):
|
||
return self._out("done", {"comment_id": existing["id"], "changed": False})
|
||
if existing:
|
||
self.jira.put(API + "/issue/%s/comment/%s" % (key, existing["id"]), {"body": body})
|
||
return self._out("done", {"comment_id": existing["id"], "changed": True})
|
||
c = self.jira.post(API + "/issue/%s/comment" % key, {"body": body})
|
||
return self._out("done", {"comment_id": c.get("id"), "created": True})
|
||
|
||
# --- ensure_status ---------------------------------------------------------
|
||
@staticmethod
|
||
def _pick_transition(trs: list, step: str) -> Optional[dict]:
|
||
"""Переход, ведущий в статус step. При неоднозначности предпочитается он; имя перехода — запасной вариант."""
|
||
for t in trs:
|
||
if (t.get("to") or {}).get("name") == step:
|
||
return t
|
||
for t in trs:
|
||
if t.get("name") == step:
|
||
return t
|
||
return None
|
||
|
||
def do_ensure_status(self, p: dict, cmd: dict) -> dict:
|
||
key, target = p["key"], p["target"]
|
||
if isinstance(target, str):
|
||
path = [target]
|
||
elif isinstance(target, list) and all(isinstance(s, str) for s in target):
|
||
path = list(target)
|
||
else:
|
||
return self._out("failed", error="target должен быть строкой или списком строк")
|
||
if not path or not all(path):
|
||
return self._out("failed", error="пустой путь статусов")
|
||
final = path[-1]
|
||
cur = self.jira.get(API + "/issue/%s" % key, fields="status")["fields"]["status"]["name"]
|
||
start = cur
|
||
if cur == final:
|
||
return self._out("done", {"from": start, "to": final, "steps": 0})
|
||
if cur in path:
|
||
path = path[path.index(cur) + 1:]
|
||
steps = 0
|
||
for step in path:
|
||
if cur == step:
|
||
continue # повтор того же статуса подряд в пути: перехода не нужно
|
||
trs = self.jira.get(API + "/issue/%s/transitions" % key).get("transitions", [])
|
||
hit = self._pick_transition(trs, step)
|
||
if hit is None:
|
||
avail = ", ".join("%s→%s" % (t.get("name"), (t.get("to") or {}).get("name")) for t in trs)
|
||
return self._out("failed", {"from": start, "reached": cur},
|
||
"нет перехода из «%s» в «%s»; доступны: %s" % (cur, step, avail or "—"))
|
||
if steps:
|
||
self.gap("ensure_status")
|
||
self.jira.post(API + "/issue/%s/transitions" % key, {"transition": {"id": hit["id"]}})
|
||
cur = (hit.get("to") or {}).get("name") or step
|
||
steps += 1
|
||
return self._out("done", {"from": start, "to": cur, "steps": steps})
|
||
|
||
# --- ensure_link -----------------------------------------------------------
|
||
def symmetric_types(self) -> set:
|
||
"""Типы, у которых inward == outward («Связанные»): направление не важно.
|
||
Ошибка HTTP пробрасывается наружу и не кэшируется: следующая команда попробует снова."""
|
||
if self._symmetric is None:
|
||
types = self.jira.get(API + "/issueLinkType").get("issueLinkTypes", [])
|
||
self._symmetric = {t["name"] for t in types if t.get("inward") == t.get("outward")}
|
||
return self._symmetric
|
||
|
||
def do_ensure_link(self, p: dict, cmd: dict) -> dict:
|
||
src, dst = p["source"], p["target"]
|
||
types = self.s.get("link_types") or {}
|
||
# намеренный fallback (рулинг R-5): имя, которого нет в link_types, уходит в Jira как есть
|
||
lt = types.get(p["link_type"], p["link_type"])
|
||
managed = set(types.values()) | {lt}
|
||
symmetric = self.symmetric_types()
|
||
raw = self.jira.get(API + "/issue/%s" % src, fields="issuelinks")["fields"].get("issuelinks") or []
|
||
links = [] # (тип, другая задача, направление, id)
|
||
for l in raw:
|
||
t = (l.get("type") or {}).get("name")
|
||
if l.get("outwardIssue"):
|
||
links.append((t, l["outwardIssue"]["key"], "out", l.get("id")))
|
||
if l.get("inwardIssue"):
|
||
links.append((t, l["inwardIssue"]["key"], "in", l.get("id")))
|
||
wanted = [x for x in links if x[1] == dst and x[0] == lt and (x[2] == "out" or lt in symmetric)]
|
||
conflicts = [x for x in links if x[1] == dst and x[0] in managed and x not in wanted]
|
||
if conflicts and not p.get("replace"):
|
||
desc = ", ".join("«%s» (%s)" % (t, "обратное направление" if t == lt else "другой тип") for t, _, _, _ in conflicts)
|
||
return self._out("failed", error="между %s и %s уже есть: %s; нужен replace" % (src, dst, desc))
|
||
removed = []
|
||
for t, other, direction, lid in conflicts:
|
||
if not lid:
|
||
return self._out("failed", {"removed": removed}, "у связи «%s» с %s нет id — снять нельзя" % (t, other))
|
||
if removed:
|
||
self.gap("ensure_link")
|
||
self.jira.delete(API + "/issueLink/%s" % lid)
|
||
removed.append(t)
|
||
if wanted:
|
||
return self._out("done", {"existed": True, "removed": removed})
|
||
if removed:
|
||
self.gap("ensure_link")
|
||
# inwardIssue — источник (получает outward-описание «Блокирует начало…»), outwardIssue — цель
|
||
self.jira.post(API + "/issueLink", {"type": {"name": lt}, "inwardIssue": {"key": src}, "outwardIssue": {"key": dst}})
|
||
return self._out("done", {"created": True, "removed": removed})
|
||
|
||
# --- ensure_sprint ---------------------------------------------------------
|
||
def sprints(self, board: Any) -> Dict[str, dict]:
|
||
"""Имя → спринт (active и future) доски board; без доски — пусто."""
|
||
if str(board) not in self._sprints:
|
||
out, start = {}, 0
|
||
while board:
|
||
page = self.jira.get(AGILE + "/board/%s/sprint" % board, state="active,future", startAt=start, maxResults=50)
|
||
for sp in page.get("values", []):
|
||
out[sp["name"]] = sp
|
||
if page.get("isLast", True) or not page.get("values"):
|
||
break
|
||
start += len(page["values"])
|
||
self._sprints[str(board)] = out
|
||
return self._sprints[str(board)]
|
||
|
||
def do_ensure_sprint(self, p: dict, cmd: dict) -> dict:
|
||
key, want = p["key"], p["sprint"]
|
||
# у задачи без спринта Jira банка отвечает без ключа fields (#13)
|
||
issue = self.jira.get(AGILE + "/issue/%s" % key, fields="sprint")
|
||
cur = (((issue.get("fields") or {}).get("sprint")) or {}).get("name")
|
||
if want == "backlog": # в бэклог доска не нужна
|
||
if cur is None:
|
||
return self._out("done", {"sprint": None, "changed": False})
|
||
self.jira.post(AGILE + "/backlog/issue", {"issues": [key]})
|
||
return self._out("done", {"sprint": None, "from": cur})
|
||
board = p.get("board_id") or self.s.get("board_id") # без доски в команде — хаб до проектов (v11)
|
||
if not board:
|
||
return self._out("failed", error="board_id нет ни в команде, ни в настройках хаба — спринты недоступны")
|
||
if cur == want:
|
||
return self._out("done", {"sprint": want, "changed": False})
|
||
sp = self.sprints(board).get(want)
|
||
if not sp:
|
||
return self._out("failed", error="нет спринта «%s» среди активных и будущих доски %s: %s"
|
||
% (want, board, ", ".join(sorted(self.sprints(board))) or "—"))
|
||
self.jira.post(AGILE + "/sprint/%s/issue" % sp["id"], {"issues": [key]})
|
||
return self._out("done", {"sprint": want, "from": cur})
|
||
|
||
# --- ensure_attachment -----------------------------------------------------
|
||
def hub_file(self, sha: str, size: int) -> Optional[bytes]:
|
||
"""Файл вложения у хаба. None — хаб его не знает (404). Повреждённый или нечитаемый ответ — HubError с повтором,
|
||
как обрыв связи с хабом. Содержимое файла в текст ошибки не попадает."""
|
||
try:
|
||
got = self.hub.file(sha)
|
||
except HttpError as e:
|
||
if e.code == 404:
|
||
return None
|
||
if 200 <= e.code < 300: # ответ не JSON: в тексте Http — начало тела, то есть содержимого
|
||
raise HubError(ConnectionError("ответ на файл %s не JSON" % sha)) from None
|
||
raise HubError(e) from None
|
||
except ConnectionError as e:
|
||
raise HubError(e) from None
|
||
content = got.get("content") if isinstance(got, dict) else None
|
||
data = None
|
||
if isinstance(content, str):
|
||
try:
|
||
data = base64.b64decode(content, validate=True)
|
||
except ValueError: # binascii.Error — подкласс ValueError
|
||
data = None
|
||
if data is None or len(data) != size or hashlib.sha256(data).hexdigest() != sha:
|
||
raise HubError(ConnectionError("файл %s пришёл повреждённым: sha256 или размер не сходятся" % sha))
|
||
return data
|
||
|
||
def do_ensure_attachment(self, p: dict, cmd: dict) -> dict:
|
||
key, sha, filename, size = p["key"], p["file"], p["filename"], p.get("size")
|
||
if size is None: # хаб не нашёл файл при выдаче
|
||
return self._out("failed", error=FILE_GONE)
|
||
names = {filename, wire_filename(filename)} # Jira могла сохранить имя в «браузерной» форме (R-23)
|
||
issue = self.jira.get(API + "/issue/%s" % key, fields="attachment")
|
||
for a in (issue.get("fields") or {}).get("attachment") or []:
|
||
if a.get("filename") in names and a.get("size") == size:
|
||
return self._out("done", {"attachment_id": str(a.get("id")), "existed": True})
|
||
data = self.hub_file(sha, size)
|
||
if data is None:
|
||
return self._out("failed", error=FILE_GONE)
|
||
log(" вложение «%s» (%d байт) → %s" % (filename, size, key))
|
||
created = self.jira.upload(key, filename, data)
|
||
first = created[0] if isinstance(created, list) and created and isinstance(created[0], dict) else {}
|
||
if not first.get("id"):
|
||
return self._out("failed", error="Jira не вернула id вложения в ответе на загрузку")
|
||
return self._out("done", {"attachment_id": str(first["id"]), "created": True})
|
||
|
||
def do_fetch_attachment(self, p: dict, cmd: dict) -> dict:
|
||
"""Файл вложения из Jira — хабу (POST /agent/files). В Jira не пишет; адрес содержимого собирается из base_url,
|
||
перенаправление на другой адрес — отказ (учётные данные туда не уходят). Содержимое не логируется."""
|
||
key, aid = p["key"], str(p["attachment_id"])
|
||
issue = self.jira.get(API + "/issue/%s" % key, fields="attachment")
|
||
found = [a for a in (issue.get("fields") or {}).get("attachment") or [] if str(a.get("id")) == aid]
|
||
if not found:
|
||
return self._out("failed", error="вложение #%s удалено из %s в Jira" % (aid, key))
|
||
filename, size = found[0].get("filename") or "", found[0].get("size")
|
||
size = size if isinstance(size, int) and not isinstance(size, bool) else None
|
||
too_big = "файл больше 10 МБ (%s) — скачайте в Jira"
|
||
if size is not None and size > MAX_ATTACHMENT:
|
||
return self._out("failed", error=too_big % _fmt_size(size))
|
||
try:
|
||
data = self.jira.download("/secure/attachment/%s/%s" % (aid, urllib.parse.quote(filename, safe="")),
|
||
MAX_ATTACHMENT)
|
||
except TooBig:
|
||
return self._out("failed", error=too_big % ("больше " + _fmt_size(MAX_ATTACHMENT)))
|
||
except RedirectAway as e:
|
||
return self._out("failed", error="Jira перенаправила скачивание на %s" % e.host)
|
||
if size is not None and len(data) != size:
|
||
return self._out("failed", error="файл пришёл не целиком (%d из %d)" % (len(data), size))
|
||
log(" вложение #%s «%s» (%s) ← %s" % (aid, filename, _fmt_size(len(data)), key))
|
||
got = self._hub(self.hub.upload_file, data)
|
||
return self._out("done", {"attachment_id": aid, "file": got.get("file"), "size": got.get("size")})
|
||
|
||
# --- refresh ---------------------------------------------------------------
|
||
STATE_FIELDS = ["summary", "description", "status", "priority", "assignee", "reporter", "duedate", "labels",
|
||
"issuetype", "created", "updated", "issuelinks", "comment", "attachment"]
|
||
|
||
def state_fields(self) -> str:
|
||
extra = [self.fid(x) for x in ("planned_start", "planned_end") if self.has_field(x)]
|
||
ep = (self.s.get("epic_link") or {}) # режим связи с эпиком — только field (mode=link не поддержан)
|
||
if ep.get("mode", "field") == "field" and ep.get("field_id"):
|
||
extra.append(ep["field_id"])
|
||
return ",".join(self.STATE_FIELDS + extra)
|
||
|
||
def search_all(self, jql: str) -> List[dict]:
|
||
issues, start = [], 0
|
||
while True:
|
||
page = self.jira.get(API + "/search", jql=jql, fields=self.state_fields(), startAt=start, maxResults=100)
|
||
got = page.get("issues") or []
|
||
issues += got
|
||
start += len(got)
|
||
if not got or start >= int(page.get("total") or 0):
|
||
return issues
|
||
|
||
def search_tolerant(self, keys: List[str], missing: List[str], errors: List[HttpError]) -> List[dict]:
|
||
"""key in (...): Jira Server отвечает 400 на несуществующий/недоступный ключ. Чанк делим пополам до
|
||
одиночного ключа; одиночный с 400 — «не найден» (в missing), не ошибка. Прочие HttpError пробрасываются.
|
||
Первый 400 копится в errors: вызывающий пробрасывает его, если не найден НИ ОДИН ключ (неверная конфигурация,
|
||
а не удалённые задачи).
|
||
Стоимость: до ~2·m·log2(n) вызовов на m отсутствующих ключей из n — принятое ограничение, рулинг R-7."""
|
||
if not keys:
|
||
return []
|
||
try:
|
||
return self.search_all("key in (%s)" % ",".join(keys))
|
||
except HttpError as e:
|
||
if e.code != 400:
|
||
raise
|
||
if not errors:
|
||
errors.append(e)
|
||
if len(keys) == 1:
|
||
missing.append(keys[0])
|
||
return []
|
||
mid = len(keys) // 2
|
||
return self.search_tolerant(keys[:mid], missing, errors) + self.search_tolerant(keys[mid:], missing, errors)
|
||
|
||
def to_state(self, raw: dict) -> dict:
|
||
f = raw.get("fields") or {}
|
||
|
||
def name(obj):
|
||
return (obj or {}).get("name")
|
||
|
||
ep = self.s.get("epic_link") or {}
|
||
epic = f.get(ep["field_id"]) if ep.get("mode", "field") == "field" and ep.get("field_id") else None
|
||
return {"key": raw["key"], "id": raw.get("id"), "issue_type": name(f.get("issuetype")), "summary": f.get("summary"),
|
||
"description": f.get("description"), "status": name(f.get("status")), "priority": name(f.get("priority")),
|
||
"assignee": name(f.get("assignee")), "reporter": name(f.get("reporter")), "duedate": _date(f.get("duedate")),
|
||
"planned_start": _date(f.get(self.fid("planned_start"))) if self.has_field("planned_start") else None,
|
||
"planned_end": _date(f.get(self.fid("planned_end"))) if self.has_field("planned_end") else None,
|
||
"epic_key": epic, "labels": list(f.get("labels") or []), "sprint": None,
|
||
"created": f.get("created"), "jira_updated": f.get("updated"), "attachments": self.attachments_of(raw)}
|
||
|
||
def links_from(self, raw: dict) -> List[dict]:
|
||
key, out = raw["key"], []
|
||
for l in (raw.get("fields") or {}).get("issuelinks") or []:
|
||
t = (l.get("type") or {}).get("name")
|
||
if l.get("outwardIssue"):
|
||
out.append({"source_key": key, "target_key": l["outwardIssue"]["key"], "link_type": t})
|
||
if l.get("inwardIssue"):
|
||
out.append({"source_key": l["inwardIssue"]["key"], "target_key": key, "link_type": t})
|
||
return out
|
||
|
||
def changelog_items(self, key: str, histories: List[dict], since: Optional[datetime],
|
||
field_id_of: Callable[[str], Optional[str]]) -> List[dict]:
|
||
"""Записи журнала задачи → элементы для хаба (по одному на поле записи). Записи без id или времени и элементы
|
||
без поля отбрасываются с записью в лог (хаб отверг бы всю пачку); since — только записи не раньше него.
|
||
field_id: fieldId элемента, у custom без него — по имени поля (field_id_of), у прочих — само поле."""
|
||
out: List[dict] = []
|
||
for h in histories:
|
||
hid, created = h.get("id"), h.get("created")
|
||
if hid in (None, "") or not created:
|
||
log("журнал %s: запись без id или времени отброшена" % key)
|
||
continue
|
||
at = _jira_time(created)
|
||
if since is not None and (at is None or at < since):
|
||
continue
|
||
a = h.get("author") or {}
|
||
author = a.get("displayName") or a.get("name") or None
|
||
for seq, it in enumerate(h.get("items") or []):
|
||
name = it.get("field")
|
||
if not name:
|
||
log("журнал %s: элемент без поля в записи %s отброшен" % (key, hid))
|
||
continue
|
||
fid = it.get("fieldId")
|
||
if not fid:
|
||
fid = field_id_of(name) if it.get("fieldtype") == "custom" else name
|
||
out.append({"history_id": str(hid), "seq": seq, "key": key, "created": created, "author": author,
|
||
"field_id": fid, "field_name": name, "from": _text(it.get("from")),
|
||
"from_string": _text(it.get("fromString")), "to": _text(it.get("to")),
|
||
"to_string": _text(it.get("toString"))})
|
||
return out
|
||
|
||
@staticmethod
|
||
def comment_state(c: dict) -> dict:
|
||
"""Комментарий для хаба: автор — отображаемое имя, иначе логин; текст и время — как отдала Jira."""
|
||
a = c.get("author") or {}
|
||
return {"id": str(c.get("id")), "author": a.get("displayName") or a.get("name") or None,
|
||
"created": c.get("created"), "updated": c.get("updated"), "body": c.get("body") or ""}
|
||
|
||
@staticmethod
|
||
def attachment_state(a: dict) -> dict:
|
||
"""Вложение для хаба — без содержимого: автор как у комментария, размер — целое или None, время Jira как есть."""
|
||
u = a.get("author") or {}
|
||
size = a.get("size")
|
||
return {"id": str(a.get("id")), "filename": a.get("filename") or "",
|
||
"size": size if isinstance(size, int) and not isinstance(size, bool) and size >= 0 else None,
|
||
"mime_type": a.get("mimeType") or None, "author": u.get("displayName") or u.get("name") or None,
|
||
"created": a.get("created")}
|
||
|
||
def attachments_of(self, raw: dict) -> List[dict]:
|
||
"""Вложения задачи из ответа search (поле attachment отдаётся целиком). Нет поля — []; вложение без id или без
|
||
имени отбрасывается с записью в лог: хаб такую пачку не примет."""
|
||
out = []
|
||
for a in (raw.get("fields") or {}).get("attachment") or []:
|
||
if not isinstance(a, dict) or not a.get("id") or not a.get("filename"):
|
||
log(" ! %s: вложение без id или имени пропущено" % raw.get("key"))
|
||
continue
|
||
out.append(self.attachment_state(a))
|
||
return out
|
||
|
||
def comments_of(self, raw: dict) -> Optional[List[dict]]:
|
||
"""Комментарии задачи из ответа search. Если Jira отдала не все (len < total) — дочитать постранично
|
||
через /issue/{key}/comment и взять полный список вместо частичного. Комментарий без id отбрасывается (M-1 #30);
|
||
403/404 при дочитывании — None: задача уходит без comments, хаб оставит прежние (M-2 #30)."""
|
||
key = raw["key"]
|
||
block = (raw.get("fields") or {}).get("comment") or {}
|
||
got = list(block.get("comments") or [])
|
||
total = block.get("total")
|
||
if total is not None and len(got) < int(total):
|
||
got, start = [], 0
|
||
try:
|
||
while True:
|
||
page = self.jira.get(API + "/issue/%s/comment" % key, startAt=start, maxResults=100)
|
||
part = page.get("comments") or []
|
||
got += part
|
||
start += len(part)
|
||
if not part or page.get("total") is None or start >= int(page["total"]):
|
||
break
|
||
except HttpError as e:
|
||
if e.code not in (403, 404):
|
||
raise
|
||
log("refresh: %s — комментарии не дочитаны (HTTP %d), задача уходит без comments" % (key, e.code))
|
||
return None
|
||
out = []
|
||
for c in got:
|
||
if c.get("id") is None or str(c.get("id")) == "":
|
||
log("refresh: %s — комментарий без id пропущен" % key)
|
||
continue
|
||
out.append(self.comment_state(c))
|
||
return out
|
||
|
||
def board_type(self, board: Any) -> Optional[str]:
|
||
"""Тип доски Jira: scrum или kanban; иное и ошибка Jira — None с записью в лог (refresh продолжается)."""
|
||
try:
|
||
t = self.jira.get(AGILE + "/board/%s" % board).get("type")
|
||
except HttpError as e:
|
||
log("refresh: доска %s — тип не определён (HTTP %d)" % (board, e.code))
|
||
return None
|
||
if t in ("scrum", "kanban"):
|
||
return t
|
||
log("refresh: доска %s — тип «%s» не scrum/kanban" % (board, t))
|
||
return None
|
||
|
||
def refresh_boards(self, p: dict) -> Tuple[List[Any], Optional[Dict[str, Optional[str]]]]:
|
||
"""(scrum-доски для имён спринтов, типы запрошенных досок для результата или None).
|
||
Без boards в команде — хаб до проектов: доска из настроек, как v11."""
|
||
if "boards" not in p:
|
||
board = self.s.get("board_id")
|
||
return ([board] if board else []), None
|
||
scrum: List[Any] = []
|
||
types: Dict[str, Optional[str]] = {}
|
||
seen = set()
|
||
for b in p.get("boards") or []:
|
||
if not isinstance(b, dict): # хаб шлёт [{id, type}]; иное не роняет refresh (M-2)
|
||
continue
|
||
bid = b.get("id")
|
||
if bid is None or str(bid) in seen:
|
||
continue
|
||
seen.add(str(bid))
|
||
t = b.get("type")
|
||
if t is None:
|
||
t = types[str(bid)] = self.board_type(bid)
|
||
if t == "scrum":
|
||
scrum.append(bid)
|
||
return scrum, (types or None)
|
||
|
||
def board_sprints(self, board: Any, types: Dict[str, Optional[str]]) -> Dict[str, dict]:
|
||
"""Спринты scrum-доски для refresh. Доска не отдала спринты: стала канбаном — тип в types, доска пропускается
|
||
(R-36); иначе ошибка Jira с номером доски, refresh failed (R-32, I-1)."""
|
||
try:
|
||
return self.sprints(board)
|
||
except HttpError as e:
|
||
if self.board_type(board) == "kanban":
|
||
types[str(board)] = "kanban"
|
||
return {}
|
||
raise HttpError(e.code, "доска %s: %s" % (board, e.text), e.headers) from None
|
||
|
||
def sprint_names(self, keys: List[str], boards: List[Any],
|
||
types: Optional[Dict[str, Optional[str]]] = None) -> Dict[str, str]:
|
||
# Источник спринта — Agile по активным и будущим спринтам досок (R-6): id поля спринта в настройках хаба нет,
|
||
# поэтому у задач закрытых спринтов sprint = None (принятое ограничение). Задача в спринтах двух досок —
|
||
# побеждает последнее найденное имя.
|
||
names = {}
|
||
if not keys:
|
||
return names
|
||
types = {} if types is None else types
|
||
for board in boards:
|
||
for name, sp in self.board_sprints(board, types).items():
|
||
for i in range(0, len(keys), 100):
|
||
chunk = keys[i:i + 100]
|
||
start = 0
|
||
while True:
|
||
page = self.jira.get(AGILE + "/sprint/%s/issue" % sp["id"], jql="key in (%s)" % ",".join(chunk),
|
||
fields="key", startAt=start, maxResults=50)
|
||
got = page.get("issues") or []
|
||
for it in got:
|
||
names[it["key"]] = name
|
||
start += len(got)
|
||
if not got or start >= int(page.get("total") or 0):
|
||
break
|
||
return names
|
||
|
||
def do_refresh(self, p: dict, cmd: dict) -> dict:
|
||
missing: List[str] = []
|
||
errors: List[HttpError] = []
|
||
scrum, types = self.refresh_boards(p) # первым шагом: тип досок нужен хабу и при пустых keys
|
||
if p.get("jql"):
|
||
raws = self.search_all(p["jql"])
|
||
else:
|
||
keys = list(dict.fromkeys(k for k in (p.get("keys") or []) if k))
|
||
if not keys:
|
||
empty: Dict[str, Any] = {"issues": 0, "neighbors": 0, "pages": 0}
|
||
if types is not None:
|
||
empty["boards"] = types
|
||
return self._out("done", empty)
|
||
raws = []
|
||
for i in range(0, len(keys), 100):
|
||
raws += self.search_tolerant(keys[i:i + 100], missing, errors)
|
||
if not raws and errors: # ни один ключ не найден: это конфигурация/запрос, а не удалённые задачи
|
||
raise errors[0]
|
||
fetched = {r["key"] for r in raws}
|
||
known = set(self._hub(self.hub.known_keys)) if self.hub else set()
|
||
linked = set()
|
||
for r in raws:
|
||
for l in self.links_from(r):
|
||
linked.add(l["source_key"])
|
||
linked.add(l["target_key"])
|
||
absent = sorted(k for k in linked if k not in fetched and k not in known)
|
||
neighbors = []
|
||
for i in range(0, len(absent), 100):
|
||
neighbors += self.search_tolerant(absent[i:i + 100], missing, errors)
|
||
everything = raws + neighbors
|
||
types = dict(types or {})
|
||
names = self.sprint_names([r["key"] for r in everything], scrum, types)
|
||
issues, links, seen = [], [], set()
|
||
for r in everything:
|
||
st = self.to_state(r)
|
||
st["sprint"] = names.get(r["key"])
|
||
comments = self.comments_of(r)
|
||
if comments is not None: # None — нет доступа к комментариям: поле не передаём (M-2 #30)
|
||
st["comments"] = comments
|
||
issues.append(st)
|
||
for l in self.links_from(r):
|
||
sig = (l["source_key"], l["target_key"], l["link_type"])
|
||
if sig not in seen:
|
||
seen.add(sig)
|
||
links.append(l)
|
||
neighbor_keys = [r["key"] for r in neighbors]
|
||
pages = 0
|
||
for i in range(0, len(issues), 100):
|
||
chunk = issues[i:i + 100]
|
||
chunk_keys = {x["key"] for x in chunk}
|
||
pages += 1
|
||
self._hub(self.hub.state, {"command_id": cmd.get("id"), "agent_version": AGENT_VERSION, "issues": chunk,
|
||
"links": [l for l in links if l["source_key"] in chunk_keys or l["target_key"] in chunk_keys],
|
||
"neighbor_keys": [k for k in neighbor_keys if k in chunk_keys],
|
||
"page": pages, "last_page": i + 100 >= len(issues)})
|
||
result = {"issues": len(raws), "neighbors": len(neighbors), "pages": pages}
|
||
history = self.refresh_history(p, cmd, everything)
|
||
if history is not None:
|
||
result["history"] = history
|
||
if missing:
|
||
result["missing"] = list(dict.fromkeys(missing))[:50]
|
||
if types:
|
||
result["boards"] = types
|
||
return self._out("done", result)
|
||
|
||
|
||
# --- журнал изменений (v13, spec agent «Журнал изменений») -----------------------------------------------
|
||
HISTORY_BATCH = 500
|
||
|
||
def search_changelog(self, jql: str) -> List[dict]:
|
||
out, start = [], 0
|
||
while True:
|
||
page = self.jira.get(API + "/search", jql=jql, fields="key", expand="changelog", startAt=start, maxResults=100)
|
||
got = page.get("issues") or []
|
||
out += got
|
||
start += len(got)
|
||
if not got or start >= int(page.get("total") or 0):
|
||
return out
|
||
|
||
def full_changelog(self, raw: dict, incomplete: List[str]) -> List[dict]:
|
||
"""Журнал задачи из ответа search; неполный — дочитать GET /issue, всё ещё неполный — что есть, ключ в incomplete."""
|
||
key, cl = raw["key"], raw.get("changelog") or {}
|
||
got = list(cl.get("histories") or [])
|
||
total = cl.get("total")
|
||
if isinstance(total, int) and total > len(got):
|
||
more = (self.jira.get(API + "/issue/%s" % key, fields="key", expand="changelog").get("changelog") or {})
|
||
more_got = list(more.get("histories") or [])
|
||
if len(more_got) > len(got):
|
||
got = more_got
|
||
if total > len(got):
|
||
log("журнал %s: получено %d из %d" % (key, len(got), total))
|
||
incomplete.append(key)
|
||
return got
|
||
|
||
def refresh_history(self, p: dict, cmd: dict, fetched: List[dict]) -> Optional[dict]:
|
||
"""Журнал после состояния: jql — стартовая загрузка (первой, журнал целиком), since — задачи этого refresh с
|
||
updated не раньше since, ещё не прочитанные стартовой.
|
||
Ответ не 2xx Jira или хаба — текст в error (since, отправка) или backfill_error (jql), refresh остаётся done;
|
||
обрыв связи — как во всём refresh."""
|
||
h = p.get("history")
|
||
if not isinstance(h, dict):
|
||
return None
|
||
out: Dict[str, Any] = {"issues": 0, "changes": 0, "incomplete": []}
|
||
names: Dict[str, Any] = {}
|
||
|
||
def field_id_of(name: str) -> Optional[str]:
|
||
if "map" not in names: # один GET /field за refresh, только по нужде
|
||
names["map"] = {f.get("name"): f.get("id") for f in self.jira.get(API + "/field") or []
|
||
if isinstance(f, dict)}
|
||
return names["map"].get(name)
|
||
|
||
items: List[dict] = []
|
||
read = set()
|
||
|
||
def collect(raws: List[dict], since: Optional[datetime]) -> None:
|
||
for raw in raws:
|
||
if raw["key"] in read:
|
||
continue
|
||
items.extend(self.changelog_items(raw["key"], self.full_changelog(raw, out["incomplete"]), since,
|
||
field_id_of))
|
||
read.add(raw["key"]) # только прочитанную: упавшую на ней стартовую часть дочитает since
|
||
|
||
if h.get("jql"): # первой: журнал задач стартовой выборки — целиком (I-1)
|
||
try:
|
||
collect(self.search_changelog(h["jql"]), None)
|
||
except HttpError as e:
|
||
out["backfill_error"] = "HTTP %d %s" % (e.code, e.text[:500])
|
||
log("refresh: стартовая загрузка журнала не прочитана: %s" % out["backfill_error"])
|
||
if h.get("since"):
|
||
since = _jira_time(h["since"])
|
||
try:
|
||
if since is None:
|
||
raise ValueError("history.since — неразбираемое время: %r" % h["since"])
|
||
changed = []
|
||
for r in fetched:
|
||
upd = _jira_time(((r.get("fields") or {}).get("updated")))
|
||
if upd is not None and upd >= since and r["key"] not in read:
|
||
changed.append(r["key"])
|
||
changed = list(dict.fromkeys(changed))
|
||
for i in range(0, len(changed), 100):
|
||
collect(self.search_changelog("key in (%s)" % ",".join(changed[i:i + 100])), since)
|
||
except HttpError as e:
|
||
out["error"] = "HTTP %d %s" % (e.code, e.text[:500])
|
||
log("refresh: журнал не прочитан: %s" % out["error"])
|
||
except ValueError as e:
|
||
out["error"] = str(e)
|
||
log("refresh: %s" % e)
|
||
sent_keys = set()
|
||
for i in range(0, len(items), self.HISTORY_BATCH):
|
||
chunk = items[i:i + self.HISTORY_BATCH]
|
||
try:
|
||
self.hub.history({"command_id": cmd.get("id"), "changes": chunk})
|
||
except HttpError as e:
|
||
out["error"] = "хаб: HTTP %d %s" % (e.code, e.text[:500])
|
||
log("refresh: журнал не принят: %s" % out["error"])
|
||
break
|
||
except ConnectionError as e:
|
||
raise HubError(e) from None
|
||
out["changes"] += len(chunk)
|
||
sent_keys.update(c["key"] for c in chunk)
|
||
out["issues"] = len(sent_keys)
|
||
return out
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Цикл: claim → пауза → журнал → выполнение → отчёт до подтверждения; восстановление при старте
|
||
# ---------------------------------------------------------------------------
|
||
class Updater:
|
||
"""Самообновление агента (спека agent «Самообновление агента», design 7–12). Работает только под run.py."""
|
||
RETRY_SECONDS = 300 # временный сбой подготовки — повтор не раньше (R-8)
|
||
HINT_SECONDS = 3600 # подсказка «запускайте через run.py» — не чаще (R-10)
|
||
SELFTEST_TIMEOUT = 120
|
||
VERSION_RE = re.compile(r"^AGENT_VERSION = (\d+)[ \t]*\r?$", re.M)
|
||
SHA_RE = re.compile(r"[0-9a-f]{64}")
|
||
|
||
def __init__(self, hub, base_dir: str, launcher_version: Optional[int], enabled: bool = True,
|
||
config_path: Optional[str] = None):
|
||
self.hub, self.dir, self.launcher_version, self.enabled = hub, base_dir, launcher_version, enabled
|
||
self.config_path = config_path or os.path.join(base_dir, "agent.json")
|
||
self.state_path = os.path.join(base_dir, "update.json")
|
||
self.new_path = os.path.join(base_dir, "agent.py.new")
|
||
self.agent_path = os.path.join(base_dir, "agent.py")
|
||
self.retry_after = {} # (version, sha256) -> clock(), раньше которого не повторять
|
||
self.hints = {} # вид подсказки -> clock() последней записи в лог (не чаще HINT_SECONDS)
|
||
self.clock = time.monotonic
|
||
self.should_stop = lambda: False
|
||
self.run_selftest = self._run_selftest
|
||
|
||
def state(self) -> Optional[dict]:
|
||
return read_update_state(self.state_path)
|
||
|
||
def confirm(self) -> None:
|
||
"""Первый успешный claim новой версии: trial этой версии → ok (запускатель перестаёт ждать отката)."""
|
||
st = self.state()
|
||
if st and st.get("state") == "trial" and st.get("to_version") == AGENT_VERSION:
|
||
try:
|
||
write_update_state(self.state_path, "ok", st.get("from_version"), AGENT_VERSION, st.get("sha256"))
|
||
except OSError as e: # диск полон — не падать; trial остался, повтор на следующем claim (M-3)
|
||
log("не удалось записать update.json: %s" % e)
|
||
return
|
||
log("обновление до v%d подтверждено" % AGENT_VERSION)
|
||
|
||
def _hint(self, kind: str, msg: str) -> None:
|
||
now = self.clock()
|
||
at = self.hints.get(kind)
|
||
if at is None or now - at >= self.HINT_SECONDS:
|
||
self.hints[kind] = now
|
||
log(msg)
|
||
|
||
def check(self, release) -> bool:
|
||
"""Вызывается после claim без команды. True — agent.py.new проверен, update.json = staged: run выходит с 75."""
|
||
if not self.enabled or not isinstance(release, dict):
|
||
return False
|
||
version, sha = release.get("version"), release.get("sha256")
|
||
if type(version) is not int or not isinstance(sha, str) or not self.SHA_RE.fullmatch(sha):
|
||
self._hint("format", "хаб прислал неполную публикацию: version=%.80r, sha256=%.80r" % (version, sha)) # design 10
|
||
return False
|
||
if version <= AGENT_VERSION:
|
||
return False
|
||
st = self.state()
|
||
if st and st.get("state") in ("rejected", "rolled_back") and st.get("to_version") == version \
|
||
and st.get("sha256") == sha:
|
||
return False
|
||
if self.launcher_version is None:
|
||
self._hint("launcher", "доступна v%d; для самообновления запускайте через run.py" % version)
|
||
return False
|
||
until = self.retry_after.get((version, sha))
|
||
if until is not None and self.clock() < until:
|
||
return False
|
||
return self.prepare(version, sha)
|
||
|
||
def prepare(self, version: int, sha: str) -> bool:
|
||
log("доступна v%d — готовлю обновление" % version)
|
||
try:
|
||
rel = self.hub.release()
|
||
except (ConnectionError, HttpError) as e:
|
||
return self._transient(version, sha, "скачивание: %s" % e)
|
||
content = rel.get("content") if isinstance(rel, dict) else None
|
||
if not isinstance(content, str) or rel.get("version") != version or rel.get("sha256") != sha:
|
||
return self._transient(version, sha, "публикация сменилась между запросами")
|
||
data = content.encode("utf-8")
|
||
if hashlib.sha256(data).hexdigest() != sha:
|
||
return self._reject(version, sha, "sha256 не сходится")
|
||
try:
|
||
compile(content, "agent.py.new", "exec")
|
||
except (SyntaxError, ValueError) as e:
|
||
return self._reject(version, sha, "не компилируется: %s" % e)
|
||
if self.VERSION_RE.findall(content) != [str(version)]:
|
||
return self._reject(version, sha, "AGENT_VERSION в файле не %d" % version)
|
||
tmp = self.new_path + ".tmp"
|
||
try: # диск полон, нет прав — временный сбой, не трейсбек (design 9)
|
||
with open(tmp, "wb") as f:
|
||
f.write(data)
|
||
try:
|
||
os.chmod(tmp, os.stat(self.agent_path).st_mode & 0o777)
|
||
except OSError:
|
||
pass
|
||
os.replace(tmp, self.new_path)
|
||
except OSError as e:
|
||
return self._transient(version, sha, "файлы: %s" % e)
|
||
if self.should_stop():
|
||
self._drop_new()
|
||
return False
|
||
try:
|
||
code, out = self.run_selftest(self.new_path)
|
||
except OSError as e:
|
||
return self._transient(version, sha, "selftest: %s" % e)
|
||
if self.should_stop(): # Ctrl-C уронил и selftest — это не отказ версии
|
||
self._drop_new()
|
||
return False
|
||
if code == 0:
|
||
try:
|
||
write_update_state(self.state_path, "staged", AGENT_VERSION, version, sha)
|
||
except OSError as e:
|
||
return self._transient(version, sha, "update.json: %s" % e)
|
||
log("v%d готова, перезапуск через run.py" % version)
|
||
return True
|
||
if code is None:
|
||
return self._transient(version, sha, "selftest: таймаут %d с" % self.SELFTEST_TIMEOUT)
|
||
if code == 3:
|
||
return self._transient(version, sha, "selftest: хаб или Jira недоступны")
|
||
return self._reject(version, sha, "selftest: код %s: %s" % (code, out[-500:]))
|
||
|
||
def _run_selftest(self, path: str):
|
||
try:
|
||
p = subprocess.run([sys.executable, path, "--config", self.config_path, "selftest"], cwd=self.dir,
|
||
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, timeout=self.SELFTEST_TIMEOUT)
|
||
except subprocess.TimeoutExpired:
|
||
return None, ""
|
||
return p.returncode, p.stdout.decode("utf-8", "replace")
|
||
|
||
def _drop_new(self) -> None:
|
||
for path in (self.new_path, self.new_path + ".tmp"):
|
||
try:
|
||
os.remove(path)
|
||
except OSError:
|
||
pass
|
||
|
||
def _transient(self, version, sha, reason) -> bool:
|
||
self._drop_new()
|
||
self.retry_after[(version, sha)] = self.clock() + self.RETRY_SECONDS
|
||
log("обновление до v%d отложено на %d мин: %s" % (version, self.RETRY_SECONDS // 60, reason))
|
||
return False
|
||
|
||
def _reject(self, version, sha, reason) -> bool:
|
||
self._drop_new()
|
||
try:
|
||
write_update_state(self.state_path, "rejected", AGENT_VERSION, version, sha, reason)
|
||
except OSError as e:
|
||
log("не удалось записать update.json: %s" % e)
|
||
log("v%d отвергнута: %s" % (version, reason))
|
||
return False
|
||
|
||
|
||
class Runner:
|
||
RETRY_ATTEMPTS = 3 # повторов команды после обрыва связи с Jira / 5xx (R-10)
|
||
RETRY_MIN_WAIT = 30 # секунд до повтора
|
||
|
||
def __init__(self, cfg: dict, hub: HubClient, jira: JiraClient, journal: Journal):
|
||
self.cfg, self.hub, self.jira, self.journal = cfg, hub, jira, journal
|
||
self.settings = None # type: Optional[dict]
|
||
self.executor = None # type: Optional[Executor]
|
||
self.stop = False
|
||
self.sleep = time.sleep
|
||
self.idle_limit = None # type: Optional[int]
|
||
self.updater = None # type: Optional[Updater]
|
||
self.exit_code = 0 # EXIT_UPDATE — выход для подмены версии
|
||
|
||
@property
|
||
def poll(self) -> float:
|
||
return max(float(self.cfg.get("poll_seconds", 5) or 0), 0)
|
||
|
||
def hub_wait(self, e: Exception, base: float) -> float:
|
||
"""Залогировать сбой хаба и вернуть, сколько ждать. 401/403 — токен неверен: повторять часто бессмысленно."""
|
||
if isinstance(e, HttpError) and e.code in (401, 403):
|
||
log("хаб: HTTP %d — неверный hub_token в agent.json, повтор через 60 с" % e.code)
|
||
return 60
|
||
log("хаб: %s, повтор через %d с" % (e, base))
|
||
return base
|
||
|
||
def load_settings(self) -> None:
|
||
self.settings = self.hub.settings()
|
||
self.executor = Executor(self.jira, self.settings, self.hub)
|
||
self.executor.sleep = self.sleep
|
||
log("настройки хаба v%s (проект %s)" % (self.settings.get("settings_version"), self.settings.get("project_key")))
|
||
|
||
def restore_pace(self) -> None:
|
||
"""После перезапуска время последнего вызова Jira неизвестно (живёт в памяти). Берём самое позднее время записи
|
||
журнала: finished пишется сразу после последнего вызова команды, оценка не раньше настоящего вызова (M-9).
|
||
Время в будущем (часы ушли назад) — считаем «сейчас»: пауза полная (M-6 #12). Оценка служит только паузе:
|
||
last_call_iso (уходит хабу в claim) ставит лишь настоящий вызов Jira (M-7 #12)."""
|
||
if self.jira.last_call_at is not None:
|
||
return
|
||
at = self.journal.last_at()
|
||
if at is None:
|
||
return
|
||
ago = (datetime.now(timezone.utc) - at).total_seconds()
|
||
if ago < 0:
|
||
log("журнал: время последней записи в будущем (%s) — пауза перед первой командой полная" % at.isoformat())
|
||
self.jira.last_call_at = time.monotonic()
|
||
return # вторая строка «не позже <будущее>» противоречила бы первой (M-2 #18)
|
||
self.jira.last_call_at = time.monotonic() - ago
|
||
log("темп: последний вызов Jira не позже %s (по журналу)"
|
||
% at.astimezone(timezone.utc).replace(microsecond=0).isoformat())
|
||
|
||
def send_result(self, rec: dict) -> bool:
|
||
"""Отчёт хабу; повторяется каждые 10 с, пока хаб не подтвердит. 404/409/422 — хаб отверг (команда отменена
|
||
или удалена): считаем закрытой, чтобы не зациклиться. При остановке по сигналу делается одна попытка."""
|
||
cid = rec["command_id"]
|
||
payload = {"status": rec["status"], "result": rec.get("result"), "error": rec.get("error"),
|
||
"jira_calls": rec.get("jira_calls", 0), "rate_limited": rec.get("rate_limited", 0),
|
||
"agent_version": AGENT_VERSION}
|
||
while True:
|
||
try:
|
||
self.hub.result(cid, payload)
|
||
self.journal.acked(cid)
|
||
return True
|
||
except HttpError as e:
|
||
if e.code in (404, 409, 422):
|
||
log("хаб отверг отчёт по #%s: %s — считаю закрытым" % (cid, e))
|
||
self.journal.acked(cid)
|
||
return True
|
||
wait = self.hub_wait(e, 10)
|
||
except ConnectionError as e:
|
||
wait = self.hub_wait(e, 10)
|
||
if self.stop:
|
||
log("остановка: отчёт по #%s не подтверждён, будет дослан при следующем старте" % cid)
|
||
return False
|
||
self.sleep(wait)
|
||
|
||
def wait_seconds(self, seconds) -> bool:
|
||
"""Спать заданное время шагами по секунде; False — остановлены во время ожидания."""
|
||
remaining = float(seconds or 0)
|
||
if remaining > 0:
|
||
log(" пауза %.0f с" % remaining)
|
||
while remaining > 0 and not self.stop:
|
||
step = min(remaining, 1.0)
|
||
self.sleep(step)
|
||
remaining -= step
|
||
return not self.stop
|
||
|
||
def wait_pause(self, pause_seconds) -> bool:
|
||
"""Досыпать до зазора от последнего вызова Jira; False — остановлены во время паузы.
|
||
Если вызовов Jira ещё не было — пауза не нужна."""
|
||
pause = float(pause_seconds or 0)
|
||
if pause > 0 and self.jira.last_call_at is not None:
|
||
return self.wait_seconds(pause - (time.monotonic() - self.jira.last_call_at))
|
||
return not self.stop
|
||
|
||
def run_once(self, cmd: dict) -> dict:
|
||
try:
|
||
return self.executor.run(cmd)
|
||
except Exception as e: # баг в обработчике не должен ронять цикл и зацикливать восстановление
|
||
log(" ! необработанная ошибка: %r" % e)
|
||
return {"status": "failed", "result": None,
|
||
"error": "внутренняя ошибка агента: %s: %s" % (type(e).__name__, str(e)[:300]),
|
||
"jira_calls": self.jira.calls, "rate_limited": self.jira.rate_limited}
|
||
|
||
def execute(self, cmd: dict, journal_claimed: bool = True) -> None:
|
||
cid = cmd["id"]
|
||
log("#%s %s %s" % (cid, cmd["type"], json.dumps(cmd.get("params"), ensure_ascii=False)[:200]))
|
||
if journal_claimed:
|
||
self.journal.claimed(cid) # сразу после claim, до паузы (R-9): остановка не теряет команду
|
||
if not self.wait_pause(cmd.get("pause_seconds")):
|
||
return
|
||
self.journal.started(cid) # started — только после паузы
|
||
calls = limited = attempt = 0
|
||
while True:
|
||
out = self.run_once(cmd)
|
||
calls += out["jira_calls"]
|
||
limited += out["rate_limited"]
|
||
peer = out.pop("peer", "Jira") # с кем оборвалась связь: Jira или хаб (refresh)
|
||
if not out.pop("retryable", False):
|
||
break
|
||
if attempt >= self.RETRY_ATTEMPTS:
|
||
out = {"status": "failed", "result": None,
|
||
"error": "результат неизвестен (обрыв связи с %s), повтор безопасен: %s" % (peer, out.get("error"))}
|
||
break
|
||
attempt += 1
|
||
log(" ! обрыв связи с %s, повтор %d/%d через проверку: %s" % (peer, attempt, self.RETRY_ATTEMPTS, out.get("error")))
|
||
# F-1: обрыв не обновляет last_call_at, поэтому ждём фиксированно от момента отказа, а не от последнего вызова
|
||
if not self.wait_seconds(max(float(cmd.get("pause_seconds") or 0), self.RETRY_MIN_WAIT)):
|
||
return # остановка: finished не пишем, команду подхватит recover
|
||
out["jira_calls"], out["rate_limited"] = calls, limited
|
||
self.journal.finished(cid, out["status"], out.get("result"), out.get("error"), calls, limited)
|
||
log(" → %s %s" % (out["status"], out.get("error") or json.dumps(out.get("result"), ensure_ascii=False)))
|
||
rec = dict(out)
|
||
rec["command_id"] = cid
|
||
self.send_result(rec)
|
||
|
||
def fetch_command(self, cid):
|
||
"""Команда из хаба по id; None — хаб такой не знает; при недоступности хаба ждём и повторяем."""
|
||
while True:
|
||
try:
|
||
return self.hub.command(cid)
|
||
except HttpError as e:
|
||
if e.code in (404, 409, 422):
|
||
log("#%s: хаб ответил %s" % (cid, e))
|
||
return None
|
||
wait = self.hub_wait(e, 10)
|
||
except ConnectionError as e:
|
||
wait = self.hub_wait(e, 10)
|
||
if self.stop:
|
||
raise InterruptedError(cid)
|
||
self.sleep(wait)
|
||
|
||
def recover(self) -> None:
|
||
for rec in self.journal.unacked_finished():
|
||
log("досылаю отчёт по #%s" % rec["command_id"])
|
||
self.send_result(rec)
|
||
for cid in self.journal.unfinished_started():
|
||
if self.stop:
|
||
return
|
||
try:
|
||
cmd = self.fetch_command(cid)
|
||
except InterruptedError:
|
||
return
|
||
if cmd is None:
|
||
self.journal.acked(cid)
|
||
continue
|
||
if cmd.get("status") != "claimed":
|
||
log("#%s уже %s в хабе — пропускаю" % (cid, cmd.get("status")))
|
||
self.journal.acked(cid)
|
||
continue
|
||
log("#%s выдана до перезапуска — выполняю через проверку" % cid)
|
||
self.execute(cmd, journal_claimed=False) # claimed/started для неё уже в журнале (FM-4); пауза — из хаба (M-9)
|
||
|
||
def reload_settings_before(self, cmd) -> bool:
|
||
"""Настройки сменились: перечитать ДО выполнения. False — остановка, команда не должна выполняться
|
||
(если она уже выдана хабом, фиксируем claimed — подхватит recover)."""
|
||
while True:
|
||
try:
|
||
self.load_settings()
|
||
return True
|
||
except (ConnectionError, HttpError) as e:
|
||
wait = self.hub_wait(e, max(self.poll, 1))
|
||
if self.stop:
|
||
if cmd:
|
||
self.journal.claimed(cmd["id"])
|
||
return False
|
||
if not cmd:
|
||
self.sleep(wait)
|
||
return False
|
||
self.sleep(wait)
|
||
|
||
def loop(self) -> None:
|
||
self.restore_pace()
|
||
idle, recovered = 0, False
|
||
while not self.stop:
|
||
if self.settings is None:
|
||
try:
|
||
self.load_settings()
|
||
except (ConnectionError, HttpError) as e:
|
||
self.sleep(self.hub_wait(e, max(self.poll, 1)))
|
||
continue
|
||
if not recovered:
|
||
self.recover()
|
||
recovered = not self.stop
|
||
continue
|
||
try:
|
||
upd = self.updater
|
||
resp = self.hub.claim(AGENT_VERSION, self.settings.get("settings_version"), self.jira.last_call_iso,
|
||
upd.state() if upd else None, upd.launcher_version if upd else None)
|
||
except (ConnectionError, HttpError) as e:
|
||
self.sleep(self.hub_wait(e, max(self.poll, 1)))
|
||
continue
|
||
if self.updater:
|
||
self.updater.confirm()
|
||
cmd = resp.get("command")
|
||
if resp.get("settings_version") != self.settings.get("settings_version"):
|
||
if not self.reload_settings_before(cmd):
|
||
continue
|
||
if cmd:
|
||
idle = 0
|
||
self.execute(cmd)
|
||
continue
|
||
if self.updater and not self.stop and self.updater.check(resp.get("agent_release")):
|
||
if not self.stop: # Ctrl-C во время подготовки — обычная остановка; staged доделает run.py
|
||
self.exit_code = EXIT_UPDATE
|
||
return
|
||
idle += 1
|
||
if self.idle_limit is not None and idle >= self.idle_limit:
|
||
return
|
||
self.sleep(self.poll)
|
||
|
||
|
||
def acquire_lock(journal_path: str):
|
||
"""Исключительная блокировка <журнал>.lock: один процесс агента на журнал. Хаб довыдаёт команду без отчёта при
|
||
следующем claim, поэтому второй процесс выполнял бы ту же команду одновременно с первым. None — уже занято."""
|
||
f = open(journal_path + ".lock", "a")
|
||
try:
|
||
fcntl.flock(f.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||
except OSError:
|
||
f.close()
|
||
return None
|
||
return f
|
||
|
||
|
||
def cmd_run(cfg: dict, journal_path: str, config_path: Optional[str] = None) -> int:
|
||
lock = acquire_lock(journal_path)
|
||
if lock is None:
|
||
log("агент уже запущен с журналом %s — второй процесс не стартует" % journal_path)
|
||
return EXIT_LOCKED
|
||
try:
|
||
return _run(cfg, journal_path, config_path)
|
||
finally:
|
||
lock.close()
|
||
|
||
|
||
def cmd_prune(journal_path: str) -> int:
|
||
"""Удалить подтверждённые записи старше 30 дней. Под той же блокировкой, что run: prune перезаписывает файл, и запись,
|
||
добавленная работающим агентом (finished — неотправленный отчёт), пропала бы (T2.3)."""
|
||
lock = acquire_lock(journal_path)
|
||
if lock is None:
|
||
log("агент запущен с журналом %s — prune невозможен, сначала остановите агента" % journal_path)
|
||
return 1
|
||
try:
|
||
removed = Journal(journal_path).prune(30)
|
||
log("журнал: удалено записей %d" % removed)
|
||
return 0
|
||
finally:
|
||
lock.close()
|
||
|
||
|
||
def launcher_version() -> Optional[int]:
|
||
"""Версия запускателя из окружения (run.py ставит JIRA_AGENT_LAUNCHER); без запускателя — None."""
|
||
try:
|
||
return int(os.environ["JIRA_AGENT_LAUNCHER"])
|
||
except (KeyError, ValueError):
|
||
return None
|
||
|
||
|
||
def _run(cfg: dict, journal_path: str, config_path: Optional[str] = None) -> int:
|
||
runner = Runner(cfg, HubClient(cfg), JiraClient(cfg), Journal(journal_path))
|
||
runner.updater = Updater(runner.hub, HERE, launcher_version(), cfg.get("auto_update", True) is not False, config_path)
|
||
runner.updater.should_stop = lambda: runner.stop
|
||
|
||
def on_signal(signum, frame):
|
||
try:
|
||
if runner.stop:
|
||
log("повторный сигнал — выхожу немедленно")
|
||
else:
|
||
log("сигнал %s: доделываю текущую команду и выхожу" % signum)
|
||
except RuntimeError: # print внутри обработчика, прервавшего другой print (reentrant call)
|
||
pass
|
||
if runner.stop:
|
||
sys.exit(130)
|
||
runner.stop = True
|
||
|
||
signal.signal(signal.SIGINT, on_signal)
|
||
signal.signal(signal.SIGTERM, on_signal)
|
||
log("jira-agent v%d: хаб %s, Jira %s, журнал %s" % (AGENT_VERSION, cfg["hub_url"], cfg["jira"]["base_url"], journal_path))
|
||
runner.loop()
|
||
log("выход для обновления (код %d)" % EXIT_UPDATE if runner.exit_code == EXIT_UPDATE else "остановлен")
|
||
return runner.exit_code
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# CLI
|
||
# ---------------------------------------------------------------------------
|
||
def cmd_check(cfg: dict) -> int:
|
||
ok = True
|
||
hub = HubClient(cfg)
|
||
via = ""
|
||
if hub.folder is not None:
|
||
hub.timeout = 10 # реле молчит дольше 10 с — хаб недоступен
|
||
via = " через папку %s" % hub.folder.root
|
||
try:
|
||
s = hub.settings()
|
||
log("хаб: доступен%s, настройки v%s, проект %s" % (via, s.get("settings_version"), s.get("project_key")))
|
||
except (ConnectionError, HttpError) as e:
|
||
log("хаб: НЕДОСТУПЕН — %s" % e)
|
||
ok = False
|
||
try:
|
||
me = JiraClient(cfg).get(API + "/myself")
|
||
log("Jira: доступен, пользователь %s" % me.get("name"))
|
||
except (ConnectionError, HttpError) as e:
|
||
log("Jira: НЕДОСТУПЕН — %s" % e)
|
||
ok = False
|
||
return 0 if ok else 1
|
||
|
||
|
||
def cmd_selftest(cfg: dict) -> int:
|
||
"""Самопроверка версии перед обновлением: её запускает предыдущая версия. Контракт кодов MUST сохраняться:
|
||
0 — хаб и Jira доступны, 3 — что-то недоступно (временно); любой другой код — файл негоден (design 10)."""
|
||
log("jira-agent v%d" % AGENT_VERSION)
|
||
return 0 if cmd_check(cfg) == 0 else 3
|
||
|
||
|
||
def main(argv=None) -> int:
|
||
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
||
ap.add_argument("--config", default=os.path.join(HERE, "agent.json"))
|
||
ap.add_argument("--journal", default=os.path.join(HERE, "journal.jsonl"))
|
||
ap.add_argument("cmd", nargs="?", default="run", choices=["run", "check", "selftest", "prune"])
|
||
args = ap.parse_args(argv)
|
||
cfg = load_config(args.config)
|
||
if args.cmd == "check":
|
||
return cmd_check(cfg)
|
||
if args.cmd == "selftest":
|
||
return cmd_selftest(cfg)
|
||
if args.cmd == "prune":
|
||
return cmd_prune(args.journal)
|
||
return cmd_run(cfg, args.journal, args.config)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
sys.exit(main())
|