387 lines
22 KiB
Python
387 lines
22 KiB
Python
"""Фейковый 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, unquote, 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")
|
||
|
||
|
||
def parse_multipart(raw, content_type):
|
||
"""Строгий разбор multipart/form-data, как commons-fileupload Jira Server с кодировкой заголовков UTF-8:
|
||
[{"headers": {имя в нижнем регистре: значение}, "data": bytes}]. Нарушение формата — AssertionError (фейк ответит 400)."""
|
||
m = re.search(r"boundary=([^;\s]+)", content_type)
|
||
assert m, "нет boundary в Content-Type"
|
||
delim = b"--" + m.group(1).encode("ascii")
|
||
head, tail = delim + b"\r\n", b"\r\n" + delim + b"--\r\n"
|
||
assert raw.startswith(head), "тело не начинается с границы"
|
||
assert raw.endswith(tail), "нет закрывающей границы"
|
||
parts = []
|
||
for chunk in raw[len(head):len(raw) - len(tail)].split(b"\r\n" + delim + b"\r\n"):
|
||
hdr, sep, data = chunk.partition(b"\r\n\r\n")
|
||
assert sep, "у части нет пустой строки после заголовков"
|
||
headers = {}
|
||
for line in hdr.decode("utf-8").split("\r\n"):
|
||
name, _, value = line.partition(":")
|
||
headers[name.strip().lower()] = value.strip()
|
||
parts.append({"headers": headers, "data": data})
|
||
return parts
|
||
|
||
|
||
def disposition_params(value):
|
||
"""Параметры Content-Disposition части: name; filename — как в кавычках, без percent-декодирования (так читает
|
||
Jira Server); filename* — после UTF-8'' с percent-декодированием."""
|
||
out = {}
|
||
m = re.search(r'(?:^|;)\s*name="([^"]*)"', value)
|
||
if m:
|
||
out["name"] = m.group(1)
|
||
m = re.search(r'(?:^|;)\s*filename="([^"]*)"', value)
|
||
if m:
|
||
out["filename"] = m.group(1)
|
||
m = re.search(r"(?:^|;)\s*filename\*=UTF-8''([^;\s]+)", value)
|
||
if m:
|
||
out["filename*"] = unquote(m.group(1), encoding="utf-8", errors="strict")
|
||
return out
|
||
|
||
|
||
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.attachments = {} # ключ → [{id, filename, size, mimeType, data}]
|
||
self.attachment_seq = 20000
|
||
self.uploads = [] # разобранные POST /attachments: {key, token, content_type, headers, disposition}
|
||
self.drop_next_post_attachment = False # сохранить вложение и оборвать соединение без ответа
|
||
self.attachment_limit = None # байт: больше — 413, как предел вложений Jira
|
||
self.bad_attachment_response = False # ответить на загрузку пустым списком
|
||
self.last_headers = {} # заголовки текущего запроса (имена в нижнем регистре)
|
||
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 add_attachment(self, key, filename, data, mime="application/octet-stream"):
|
||
self.attachment_seq += 1
|
||
a = {"id": str(self.attachment_seq), "filename": filename, "size": len(data), "mimeType": mime, "data": data}
|
||
self.attachments.setdefault(key, []).append(a)
|
||
return a
|
||
|
||
def attachment_json(self, a):
|
||
return {"self": "%s/rest/api/2/attachment/%s" % (self.base_url, a["id"]), "id": a["id"],
|
||
"filename": a["filename"], "size": a["size"], "mimeType": a["mimeType"]}
|
||
|
||
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}
|
||
if fields is None or "attachment" in fields:
|
||
out["attachment"] = [self.attachment_json(a) for a in self.attachments.get(key, [])]
|
||
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)
|
||
raw = h.rfile.read(length) if length else b""
|
||
ctype = h.headers.get("Content-Type") or ""
|
||
if ctype.startswith("multipart/form-data"):
|
||
body = {"_raw": raw, "_content_type": ctype} # разбирает маршрут: ошибка формата — 400
|
||
else:
|
||
body = json.loads(raw.decode("utf-8")) if raw else {}
|
||
with self.lock:
|
||
self.last_headers = {k.lower(): v for k, v in h.headers.items()}
|
||
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
|
||
if self.drop_next_post_attachment and method == "POST" and url.path.endswith("/attachments"):
|
||
self.drop_next_post_attachment = 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/([^/]+)/attachments$", p)
|
||
if m and method == "POST":
|
||
key = m.group(1)
|
||
self.issues[key]
|
||
if self.last_headers.get("x-atlassian-token") != "no-check":
|
||
return 403, {"errorMessages": ["XSRF check failed"]} # как Jira Server без заголовка
|
||
parts = parse_multipart(body["_raw"], body["_content_type"])
|
||
assert len(parts) == 1, "ожидалась одна часть, пришло %d" % len(parts)
|
||
part = parts[0]
|
||
disp = disposition_params(part["headers"].get("content-disposition", ""))
|
||
assert disp.get("name") == "file", "часть называется не file: %r" % disp
|
||
self.uploads.append({"key": key, "token": self.last_headers.get("x-atlassian-token"),
|
||
"content_type": body["_content_type"], "headers": part["headers"], "disposition": disp})
|
||
if self.attachment_limit is not None and len(part["data"]) > self.attachment_limit:
|
||
return 413, {"errorMessages": ["Размер вложения превышает предел %d байт" % self.attachment_limit]}
|
||
a = self.add_attachment(key, disp["filename"], part["data"],
|
||
part["headers"].get("content-type") or "application/octet-stream")
|
||
if self.bad_attachment_response:
|
||
return 200, []
|
||
return 200, [self.attachment_json(a)]
|
||
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")), "Задача")
|