#!/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 = 12 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 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 urllib.request.urlopen(req, timeout=timeout or self.timeout, context=self.ctx) as resp: raw = resp.read() status = resp.status 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 _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 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/.json, ответ кладёт реле хаба в resp/.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 "" % 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 file(self, sha256: str) -> dict: """Файл вложения из хранилища хаба: {file, size, content (base64)}; нет файла — HttpError 404 no_file.""" return self.call("GET", "/agent/files/%s" % sha256) # --------------------------------------------------------------------------- # Журнал: только дозапись; 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 _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 = "файл удалён с хаба, загрузите заново" 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.: {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}) # --- refresh --------------------------------------------------------------- STATE_FIELDS = ["summary", "description", "status", "priority", "assignee", "reporter", "duedate", "labels", "issuetype", "created", "updated", "issuelinks", "comment"] 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")} 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 @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 ""} 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} if missing: result["missing"] = list(dict.fromkeys(missing))[:50] if types: result["boards"] = types return self._out("done", result) # --------------------------------------------------------------------------- # Цикл: 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())