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:
parent
ad62822b73
commit
e290b4e4a6
12
src/agent.example.json
Normal file
12
src/agent.example.json
Normal 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
216
src/agent.py
Normal 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
7
tests/__init__.py
Normal 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
46
tests/_common.py
Normal 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
232
tests/fake_jira.py
Normal 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
78
tests/test_clients.py
Normal 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()
|
||||
Loading…
x
Reference in New Issue
Block a user