jira-agent/tests/fake_jira.py
Andrey Epifancev 552bead64b fix(agent): ensure_sprint на задаче без спринта — ответ Agile без fields (#13)
Jira банка на GET /rest/agile/1.0/issue/{key}?fields=sprint у задачи без спринта отвечает без ключа fields;
агент падал с KeyError. Фейк Jira теперь отвечает так же.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 23:45:47 +04:00

261 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Фейковый Jira Server для тестов агента: состояние в памяти, http.server в потоке,
только маршруты, которые использует агент. Семантика связей — как у настоящего Jira Server."""
import json
import re
import socket
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.id_seq = 10000
self.fail_next = [] # [(код, {заголовки})] — следующие ответы с ошибкой
self.requests = [] # [(метод, путь)]
self.queries = [] # параметры запросов (q) — для проверки JQL
self.drop_next_post_issue = False # выполнить создание задачи и оборвать соединение без ответа
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()
self.server.server_close()
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}
self.id_seq += 1
base["id"] = str(self.id_seq)
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"], "id": int(f["priority"]) if str(f["priority"]).isdigit() else 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": f["id"], "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 ""):
keys = [k for k, f in self.issues.items() if f["reporter"] == "me"]
return keys[::-1] if "ORDER BY created DESC" in jql else keys
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))
self.queries.append(q)
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]}
except Exception as e: # некорректный запрос к фейку
code, payload = 400, {"errorMessages": ["фейк: %s: %s" % (type(e).__name__, e)]}
if self.drop_next_post_issue and method == "POST" and url.path == "/rest/api/2/issue":
self.drop_next_post_issue = False
h.close_connection = True
h.connection.shutdown(socket.SHUT_RDWR) # задача создана, ответа клиент не получит
return
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":
m = re.search(r"key in \(([^)]*)\)", q.get("jql") or "")
if m and any(k.strip() not in self.issues for k in m.group(1).split(",")): # как Jira Server
return 400, {"errorMessages": ["Задача не существует или у вас нет прав на её просмотр."]}
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":
if not any(str(l["id"]) == m.group(1) for l in self.links):
return 404, {"errorMessages": ["Issue link does not exist: %s" % m.group(1)]}
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":
start, maxr = int(q.get("startAt", 0)), int(q.get("maxResults", 1000))
return 200, {"startAt": start, "maxResults": maxr, "total": len(lst), "comments": lst[start:start + maxr]}
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(",")
vals = [s for s in self.sprints if s["state"] in states]
start, maxr = int(q.get("startAt", 0)), int(q.get("maxResults", 50))
return 200, {"values": vals[start:start + maxr], "isLast": start + maxr >= len(vals)}
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"]]
start, maxr = int(q.get("startAt", 0)), int(q.get("maxResults", 50))
return 200, {"startAt": start, "maxResults": maxr, "total": len(keys),
"issues": [{"key": k} for k in keys[start:start + maxr]]}
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)
if sp is None: # как Jira банка (13.10.2026): без спринта ключа fields в ответе нет
return 200, {"key": m.group(1)}
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")), "Задача")