diff --git a/src/agent.example.json b/src/agent.example.json new file mode 100644 index 0000000..15d7765 --- /dev/null +++ b/src/agent.example.json @@ -0,0 +1,12 @@ +{ + "hub_url": "http://192.168.0.106:8790", + "hub_token": "агентский токен из `jira-hub tokens`", + "jira": { + "base_url": "https://sdlc.go.rshbank.ru/jira", + "auth": {"type": "basic", "user": "логин", "password": "пароль", "token": ""} + }, + "verify_ssl": true, + "ca_bundle": null, + "timeout": 60, + "poll_seconds": 5 +} diff --git a/src/agent.py b/src/agent.py new file mode 100644 index 0000000..76a29e1 --- /dev/null +++ b/src/agent.py @@ -0,0 +1,216 @@ +#!/usr/bin/env python3 +"""jira-agent — клиент моста Jira для виртуалки с доступом к Jira банка. + +Опрашивает хаб (jira-hub) на Mac mini, выполняет команды «желаемый результат» в Jira по правилу +«проверь, потом сделай», ведёт журнал на диске и возвращает хабу состояние задач. Только стандартная библиотека, +Python 3.9. Спека: jira-hub/openspec/changes/bridge-core/specs/agent/spec.md. + + python3 agent.py check # доступ к хабу и Jira, без записи + python3 agent.py run # основной цикл (по умолчанию) + python3 agent.py prune # почистить journal.jsonl от подтверждённых записей старше 30 дней +""" +import argparse +import base64 +import json +import os +import signal +import ssl +import sys +import time +import urllib.error +import urllib.parse +import urllib.request +from datetime import datetime, timezone +from typing import Any, Dict, List, Optional + +AGENT_VERSION = 1 +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) + cfg.setdefault("timeout", 60) + cfg.setdefault("poll_seconds", 5) + cfg.setdefault("verify_ssl", True) + return cfg + + +# --------------------------------------------------------------------------- +# HTTP +# --------------------------------------------------------------------------- +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) -> Any: + url = self.base + path + if params: + url += "?" + urllib.parse.urlencode({k: v for k, v in params.items() if v is not None}) + data = json.dumps(body, ensure_ascii=False).encode("utf-8") if body is not None else 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 data is not None: + req.add_header("Content-Type", "application/json; charset=utf-8") + try: + with urllib.request.urlopen(req, timeout=self.timeout, context=self.ctx) as resp: + raw = resp.read() + return json.loads(raw.decode("utf-8")) if raw else {} + except urllib.error.HTTPError as e: + raise HttpError(e.code, e.read().decode("utf-8", "replace"), dict(e.headers)) from None + except (urllib.error.URLError, ConnectionError, TimeoutError, OSError) as e: + raise ConnectionError("%s недоступен: %s" % (self.base, e)) from None + + +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 reset_counters(self) -> None: + self.calls, self.rate_limited = 0, 0 + + def call(self, method, path, body=None, params=None): + for attempt in range(1, 6): + try: + out = super().call(method, path, body, params) + self.calls += 1 + self.last_call_at, self.last_call_iso = time.monotonic(), now_iso() + return out + except HttpError as e: + if e.code in (429, 503) and attempt < 5: + retry = e.headers.get("Retry-After") + wait = int(retry) if retry and retry.isdigit() else 60 * attempt + 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 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 HubClient(Http): + def __init__(self, cfg: dict): + super().__init__(cfg["hub_url"], {"Authorization": "Bearer " + cfg["hub_token"]}, cfg.get("timeout", 60)) + + def claim(self, agent_version, settings_version, last_jira_call_at) -> dict: + return self.call("POST", "/agent/claim", {"agent_version": agent_version, "settings_version": settings_version, + "last_jira_call_at": last_jira_call_at}) + + 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"] + + +# --------------------------------------------------------------------------- +# CLI +# --------------------------------------------------------------------------- +def cmd_check(cfg: dict) -> int: + ok = True + try: + s = HubClient(cfg).settings() + log("хаб: доступен, настройки v%s, проект %s" % (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 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", "prune"]) + args = ap.parse_args(argv) + cfg = load_config(args.config) + if args.cmd == "check": + return cmd_check(cfg) + log("команда %s ещё не реализована" % args.cmd) + return 2 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..b7a4fdc --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1,7 @@ +# Тесты импортируют соседние модули (_common, fake_jira) по простому имени — добавляем каталог в путь. +import os +import sys + +_HERE = os.path.dirname(os.path.abspath(__file__)) +if _HERE not in sys.path: + sys.path.insert(0, _HERE) diff --git a/tests/_common.py b/tests/_common.py new file mode 100644 index 0000000..41fc089 --- /dev/null +++ b/tests/_common.py @@ -0,0 +1,46 @@ +"""Общее для тестов: путь к src, настройки хаба как их отдаёт /agent/settings, конфиг агента под фейковый Jira.""" +import os +import sys + +SRC = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "src") +if SRC not in sys.path: + sys.path.insert(0, SRC) + +import agent # noqa: E402 + +ZERO_PACING = {"ensure_issue": 0, "ensure_fields": 0, "ensure_comment": 0, "ensure_status": 0, "ensure_link": 0, + "ensure_sprint": 0, "refresh": 0} + +SETTINGS = { + "base_jql": "project = AURORA AND labels = AWPCA", + "project_key": "AURORA", + "issue_types": {"task": {"name": "Задача", "id": "10100"}, "epic": {"name": "Фича", "id": "12300"}}, + "epic_name_field_id": "customfield_15304", + "field_ids": {"description": "description", "priority": "priority", "duedate": "duedate", "labels": "labels", + "planned_start": "customfield_10801", "planned_end": "customfield_10802"}, + "epic_link": {"mode": "field", "field_id": "customfield_15302"}, + "priority_map": {"Наивысший": "Критичный", "Высокий": "Высокий", "Средний": "Средний", "Низкий": "Низкий"}, + "priority_value_key": "name", + "labels": ["AWPCA"], + "status_labels": {}, + "kind_labels": {}, + "comment_marker": "Актуализация 10.10.2026", + "transitions_map": {}, + "link_types": {"блокирует начало": "ASUPR_Блокер_Начала", "связанные": "ASUPR_Связанные"}, + "board_id": 4216, + "pacing": dict(ZERO_PACING), + "min_agent_version": 1, + "settings_version": 1, +} + + +def jira_cfg(fake, **over): + cfg = {"hub_url": "http://127.0.0.1:1", "hub_token": "agent-token", + "jira": {"base_url": fake.base_url, "auth": {"type": "basic", "user": "me", "password": "secret"}}, + "verify_ssl": True, "ca_bundle": None, "timeout": 5, "poll_seconds": 0} + cfg.update(over) + return cfg + + +def no_sleep(_seconds): + pass diff --git a/tests/fake_jira.py b/tests/fake_jira.py new file mode 100644 index 0000000..3075a42 --- /dev/null +++ b/tests/fake_jira.py @@ -0,0 +1,232 @@ +"""Фейковый Jira Server для тестов агента: состояние в памяти, http.server в потоке, +только маршруты, которые использует агент. Семантика связей — как у настоящего Jira Server.""" +import json +import re +import threading +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from urllib.parse import parse_qs, urlparse + +WORKFLOW = { # статус → [(имя перехода, целевой статус)] + "Ожидание разработки": [("Взять в разработку", "Разработка"), ("Ожидает анализа", "Ожидает анализа"), ("Снято", "Снято")], + "Разработка": [("Малое исправление", "Готово к тестированию"), ("Готово", "Готово"), ("Снято", "Снято")], + "Ожидает анализа": [("Анализ", "Анализ"), ("Снято", "Снято")], + "Анализ": [("Готово", "Готово")], + "Готово к тестированию": [("Тестирование", "Тестирование")], + "Готово": [], + "Снято": [], +} +LINK_TYPES = [{"name": "ASUPR_Блокер_Начала", "inward": "Начало заблокировано", "outward": "Блокирует начало"}, + {"name": "ASUPR_Связанные", "inward": "Связанные", "outward": "Связанные"}] +CUSTOM = ("customfield_10801", "customfield_10802", "customfield_15302", "customfield_15304") + + +class FakeJira: + def __init__(self, project="AURORA"): + self.project = project + self.issues = {} + self.comments = {} + self.links = [] + self.sprints = [{"id": 1, "name": "Спринт 1", "state": "active"}, {"id": 2, "name": "Спринт 2", "state": "future"}] + self.seq, self.link_seq, self.comment_seq = 1000, 100, 10 + self.fail_next = [] # [(код, {заголовки})] — следующие ответы с ошибкой + self.requests = [] # [(метод, путь)] + self.lock = threading.Lock() + fake = self + + class Handler(BaseHTTPRequestHandler): + def log_message(self, *a): + pass + + def do_GET(self): + fake.handle(self, "GET") + + def do_POST(self): + fake.handle(self, "POST") + + def do_PUT(self): + fake.handle(self, "PUT") + + def do_DELETE(self): + fake.handle(self, "DELETE") + + self.server = ThreadingHTTPServer(("127.0.0.1", 0), Handler) + threading.Thread(target=self.server.serve_forever, daemon=True).start() + + @property + def base_url(self): + return "http://127.0.0.1:%d" % self.server.server_port + + def stop(self): + self.server.shutdown() + + def add(self, key, in_base=True, **fields): + base = {"summary": "Тема " + key, "description": None, "status": "Ожидание разработки", "priority": "Средний", + "assignee": None, "reporter": "someone", "duedate": None, "labels": ["AWPCA"] if in_base else [], + "issuetype": "Задача", "created": "2026-10-01T10:00:00.000+0300", "updated": "2026-10-01T10:00:00.000+0300", + "sprint": None} + base.update({c: None for c in CUSTOM}) + base.update(fields) + self.issues[key] = base + return base + + def writes(self): + return [r for r in self.requests if r[0] in ("POST", "PUT", "DELETE")] + + # --- представление --------------------------------------------------------- + def _user(self, name): + return {"name": name, "displayName": name} if name else None + + def issue_json(self, key): + f = self.issues[key] + fields = {"summary": f["summary"], "description": f["description"], "status": {"name": f["status"]}, + "priority": {"name": f["priority"]}, "assignee": self._user(f["assignee"]), "reporter": self._user(f["reporter"]), + "duedate": f["duedate"], "labels": list(f["labels"]), "issuetype": {"name": f["issuetype"]}, + "created": f["created"], "updated": f["updated"], "issuelinks": self.links_of(key)} + fields.update({c: f.get(c) for c in CUSTOM}) + return {"key": key, "id": str(10000 + sorted(self.issues).index(key)), "fields": fields} + + def links_of(self, key): + out = [] + for l in self.links: + if l["source"] == key: + out.append({"id": str(l["id"]), "type": {"name": l["type"]}, "outwardIssue": {"key": l["target"]}}) + elif l["target"] == key: + out.append({"id": str(l["id"]), "type": {"name": l["type"]}, "inwardIssue": {"key": l["source"]}}) + return out + + def search_keys(self, jql): + m = re.search(r"key in \(([^)]*)\)", jql or "") + if m: + keys = [k.strip() for k in m.group(1).split(",")] + return [k for k in keys if k in self.issues] + if "reporter = currentUser()" in (jql or ""): + return [k for k, f in self.issues.items() if f["reporter"] == "me"] + return [k for k, f in self.issues.items() if "AWPCA" in f["labels"]] + + # --- маршрутизация ---------------------------------------------------------- + def handle(self, h, method): + url = urlparse(h.path) + q = {k: v[0] for k, v in parse_qs(url.query).items()} + length = int(h.headers.get("Content-Length") or 0) + body = json.loads(h.rfile.read(length).decode("utf-8")) if length else {} + with self.lock: + self.requests.append((method, url.path)) + if self.fail_next: + code, headers = self.fail_next.pop(0) + return self._send(h, code, {"errorMessages": ["имитация ошибки"]}, headers) + try: + code, payload = self.route(method, url.path, q, body) + except KeyError as e: + code, payload = 404, {"errorMessages": ["Issue Does Not Exist: %s" % e]} + self._send(h, code, payload) + + def _send(self, h, code, payload, headers=None): + data = json.dumps(payload, ensure_ascii=False).encode("utf-8") if payload is not None else b"" + h.send_response(code) + for k, v in (headers or {}).items(): + h.send_header(k, v) + h.send_header("Content-Type", "application/json") + h.send_header("Content-Length", str(len(data))) + h.end_headers() + h.wfile.write(data) + + def route(self, method, path, q, body): + p = path + if p == "/rest/api/2/myself": + return 200, {"name": "me", "displayName": "Me"} + if p == "/rest/api/2/search": + keys = self.search_keys(q.get("jql")) + start, maxr = int(q.get("startAt", 0)), int(q.get("maxResults", 50)) + page = keys[start:start + maxr] + return 200, {"startAt": start, "maxResults": maxr, "total": len(keys), "issues": [self.issue_json(k) for k in page]} + if p == "/rest/api/2/issue" and method == "POST": + self.seq += 1 + key = "%s-%d" % (self.project, self.seq) + f = body["fields"] + self.add(key, summary=f.get("summary"), description=f.get("description"), + priority=(f.get("priority") or {}).get("name", "Средний"), duedate=f.get("duedate"), + labels=list(f.get("labels") or []), issuetype=self._type_name(f.get("issuetype")), + reporter="me", created="2026-10-11T12:00:00.000+0300", + **{c: f.get(c) for c in CUSTOM}) + return 201, {"id": "1", "key": key} + if p == "/rest/api/2/issueLinkType": + return 200, {"issueLinkTypes": LINK_TYPES} + if p == "/rest/api/2/issueLink" and method == "POST": + self.link_seq += 1 + self.links.append({"id": self.link_seq, "type": body["type"]["name"], + "source": body["inwardIssue"]["key"], "target": body["outwardIssue"]["key"]}) + return 201, {} + m = re.match(r"^/rest/api/2/issueLink/(\d+)$", p) + if m and method == "DELETE": + self.links = [l for l in self.links if str(l["id"]) != m.group(1)] + return 204, None + m = re.match(r"^/rest/api/2/issue/([^/]+)$", p) + if m: + key = m.group(1) + if method == "GET": + return 200, self.issue_json(key) + if method == "PUT": + f = self.issues[key] + for k, v in body.get("fields", {}).items(): + if k in ("priority", "assignee"): + f[k] = (v or {}).get("name") + elif k == "labels": + f[k] = list(v) + else: + f[k] = v + f["updated"] = "2026-10-11T13:00:00.000+0300" + return 204, None + m = re.match(r"^/rest/api/2/issue/([^/]+)/comment$", p) + if m: + key = m.group(1) + self.issues[key] + lst = self.comments.setdefault(key, []) + if method == "GET": + return 200, {"comments": lst} + self.comment_seq += 1 + lst.append({"id": str(self.comment_seq), "body": body["body"]}) + return 201, lst[-1] + m = re.match(r"^/rest/api/2/issue/([^/]+)/comment/(\d+)$", p) + if m and method == "PUT": + for c in self.comments.get(m.group(1), []): + if c["id"] == m.group(2): + c["body"] = body["body"] + return 200, {} + m = re.match(r"^/rest/api/2/issue/([^/]+)/transitions$", p) + if m: + key = m.group(1) + trs = WORKFLOW.get(self.issues[key]["status"], []) + if method == "GET": + return 200, {"transitions": [{"id": str(i + 1), "name": n, "to": {"name": to}} for i, (n, to) in enumerate(trs)]} + tid = int(body["transition"]["id"]) - 1 + self.issues[key]["status"] = trs[tid][1] + return 204, None + m = re.match(r"^/rest/agile/1\.0/board/(\d+)/sprint$", p) + if m: + states = (q.get("state") or "active,future").split(",") + return 200, {"values": [s for s in self.sprints if s["state"] in states], "isLast": True} + m = re.match(r"^/rest/agile/1\.0/sprint/(\d+)/issue$", p) + if m: + sp = next(s for s in self.sprints if s["id"] == int(m.group(1))) + if method == "GET": + keys = [k for k in self.search_keys(q.get("jql") or "key in ()") if self.issues[k]["sprint"] == sp["name"]] + return 200, {"issues": [{"key": k} for k in keys]} + for k in body["issues"]: + self.issues[k]["sprint"] = sp["name"] + return 204, None + if p == "/rest/agile/1.0/backlog/issue" and method == "POST": + for k in body["issues"]: + self.issues[k]["sprint"] = None + return 204, None + m = re.match(r"^/rest/agile/1\.0/issue/([^/]+)$", p) + if m: + name = self.issues[m.group(1)]["sprint"] + sp = next((s for s in self.sprints if s["name"] == name), None) + return 200, {"key": m.group(1), "fields": {"sprint": sp}} + return 404, {"errorMessages": ["нет маршрута %s %s" % (method, p)]} + + def _type_name(self, ref): + ref = ref or {} + if ref.get("name"): + return ref["name"] + return {"10100": "Задача", "12300": "Фича"}.get(str(ref.get("id")), "Задача") diff --git a/tests/test_clients.py b/tests/test_clients.py new file mode 100644 index 0000000..f315163 --- /dev/null +++ b/tests/test_clients.py @@ -0,0 +1,78 @@ +import unittest + +from _common import agent, jira_cfg, no_sleep +from fake_jira import FakeJira + + +class ClientsTest(unittest.TestCase): + def setUp(self): + self.fake = FakeJira() + self.jira = agent.JiraClient(jira_cfg(self.fake)) + self.jira.sleep = no_sleep + + def tearDown(self): + self.fake.stop() + + def test_get_and_counters(self): + self.fake.add("AURORA-1") + data = self.jira.get("/rest/api/2/issue/AURORA-1", fields="summary") + self.assertEqual(data["fields"]["summary"], "Тема AURORA-1") + self.assertEqual(self.jira.calls, 1) + self.assertIsNotNone(self.jira.last_call_at) + self.assertEqual(self.fake.requests[-1], ("GET", "/rest/api/2/issue/AURORA-1")) + + def test_retry_on_429_with_retry_after(self): + self.fake.add("AURORA-1") + self.fake.fail_next.append((429, {"Retry-After": "0"})) + self.fake.fail_next.append((503, {})) + data = self.jira.get("/rest/api/2/issue/AURORA-1") + self.assertEqual(data["key"], "AURORA-1") + self.assertEqual(self.jira.rate_limited, 2) + self.assertEqual(self.jira.calls, 1) + + def test_http_error_not_retried(self): + with self.assertRaises(agent.HttpError) as cm: + self.jira.get("/rest/api/2/issue/NOPE-1") + self.assertEqual(cm.exception.code, 404) + self.assertIn("Does Not Exist", cm.exception.text) + + def test_gives_up_after_five(self): + self.fake.add("AURORA-1") + self.fake.fail_next.extend([(429, {"Retry-After": "0"})] * 5) + with self.assertRaises(agent.HttpError): + self.jira.get("/rest/api/2/issue/AURORA-1") + self.assertEqual(self.jira.rate_limited, 5) + + def test_basic_auth_header_and_no_secret_in_repr(self): + self.assertTrue(self.jira.headers["Authorization"].startswith("Basic ")) + self.assertNotIn("secret", repr(self.jira)) + + def test_load_config_requires_fields(self): + import json + import os + import tempfile + with tempfile.TemporaryDirectory() as d: + path = os.path.join(d, "agent.json") + with open(path, "w", encoding="utf-8") as f: + json.dump({"hub_url": "http://x", "hub_token": "t"}, f) + with self.assertRaises(SystemExit): + agent.load_config(path) + with open(path, "w", encoding="utf-8") as f: + json.dump(jira_cfg(self.fake), f) + cfg = agent.load_config(path) + self.assertEqual(cfg["poll_seconds"], 0) + + def test_check_command(self): + import json + import os + import tempfile + with tempfile.TemporaryDirectory() as d: + path = os.path.join(d, "agent.json") + with open(path, "w", encoding="utf-8") as f: + json.dump(jira_cfg(self.fake), f) + # хаб недоступен (порт 1), Jira доступен — check печатает оба результата и возвращает 1 + self.assertEqual(agent.main(["--config", path, "check"]), 1) + + +if __name__ == "__main__": + unittest.main()