"""Фейковый 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) 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")), "Задача")