feat(agent): скелет, клиенты Jira и хаба, фейковый Jira, команда check

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014Typ5sXWNMFirr2va2uL8o
This commit is contained in:
Andrey Epifancev 2026-10-11 23:28:04 +04:00
parent ad62822b73
commit e290b4e4a6
6 changed files with 591 additions and 0 deletions

12
src/agent.example.json Normal file
View File

@ -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
}

216
src/agent.py Normal file
View File

@ -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())

7
tests/__init__.py Normal file
View File

@ -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)

46
tests/_common.py Normal file
View File

@ -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

232
tests/fake_jira.py Normal file
View File

@ -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")), "Задача")

78
tests/test_clients.py Normal file
View File

@ -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()