jira-agent/tests/fake_jira.py
2026-10-16 01:41:24 +04:00

300 lines
16 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 datetime import datetime, timedelta, timezone
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import parse_qs, urlparse
MSK = timezone(timedelta(hours=3))
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.now = datetime.now(MSK) # часы фейка: окно created >= -Nd считается от них
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.comment_denied = {} # ключ → код ответа на GET /issue/{key}/comment (403 — нет прав, 404 — нет задачи)
self.search_comment_cap = None # сколько комментариев задачи отдаёт search (None — все), как обрезка Jira
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()
@staticmethod
def ts(dt):
return dt.astimezone(MSK).strftime("%Y-%m-%dT%H:%M:%S.000+0300")
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": self.ts(self.now - timedelta(hours=1)), "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 add_comment(self, key, body, name="ivanov", display="Иванов Иван", created="2026-10-14T12:00:00.000+0300",
updated=None):
author = {k: v for k, v in (("name", name), ("displayName", display)) if v is not None} if name or display else None
self.comment_seq += 1
c = {"id": str(self.comment_seq), "body": body, "author": author, "created": created, "updated": updated or created}
self.comments.setdefault(key, []).append(c)
return c
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, fields=None):
"""fields — список запрошенных полей (как параметр fields у search); None — все поля, как GET /issue/{key}."""
f = self.issues[key]
out = {"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)}
out.update({c: f.get(c) for c in CUSTOM})
if fields is None or "comment" in fields:
lst = self.comments.get(key, [])
cap = self.search_comment_cap if fields is not None else None
shown = lst if cap is None else lst[:cap]
out["comment"] = {"startAt": 0, "maxResults": len(shown) if cap is None else cap, "total": len(lst),
"comments": shown}
return {"key": key, "id": f["id"], "fields": out}
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"]
m = re.search(r"project = (\w+)", jql)
if m: # как Jira: только задачи проекта
keys = [k for k in keys if k.startswith(m.group(1) + "-")]
m = re.search(r"created >= -(\d+)d", jql)
if m: # как Jira: только созданные за последние N дней
since = self.now - timedelta(days=int(m.group(1)))
keys = [k for k in keys
if datetime.strptime(self.issues[k]["created"], "%Y-%m-%dT%H:%M:%S.%f%z") >= since]
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]
fields = (q.get("fields") or "").split(",") if q.get("fields") else []
return 200, {"startAt": start, "maxResults": maxr, "total": len(keys),
"issues": [self.issue_json(k, fields) 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=self.ts(self.now),
**{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)
if method == "GET" and key in self.comment_denied:
return self.comment_denied[key], {"errorMessages": ["Нет прав на просмотр комментариев"]}
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
ts = self.ts(self.now)
lst.append({"id": str(self.comment_seq), "body": body["body"], "author": self._user("me"),
"created": ts, "updated": ts})
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")), "Задача")