test(agent): фейки — project и created в поиске дубля, долгий claim хаба (M-10 #4)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
1bf2ff9179
commit
fa936ba716
@ -2,6 +2,7 @@
|
||||
import json
|
||||
import re
|
||||
import threading
|
||||
import time
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
|
||||
|
||||
@ -22,6 +23,7 @@ class FakeHub:
|
||||
self.in_flight = None
|
||||
self.release = None # {"version", "sha256", "content"} — опубликованная версия агента
|
||||
self.release_requests = 0
|
||||
self.claim_wait = 0 # с: при пустой очереди держать claim, как хаб
|
||||
self.lock = threading.Lock()
|
||||
hub = self
|
||||
|
||||
@ -61,8 +63,16 @@ class FakeHub:
|
||||
body = json.loads(h.rfile.read(length).decode("utf-8")) if length else {}
|
||||
if h.headers.get("Authorization") != "Bearer " + self.token:
|
||||
return self._send(h, 401, {"error": "unauthorized"})
|
||||
path = h.path.split("?")[0]
|
||||
if path == "/agent/claim" and self.claim_wait:
|
||||
end = time.monotonic() + self.claim_wait
|
||||
while time.monotonic() < end: # ждать без блокировки: другие запросы (enqueue) проходят
|
||||
with self.lock:
|
||||
if self.queue:
|
||||
break
|
||||
time.sleep(0.05)
|
||||
with self.lock:
|
||||
code, payload = self.route(method, h.path.split("?")[0], body)
|
||||
code, payload = self.route(method, path, body)
|
||||
self._send(h, code, payload)
|
||||
|
||||
def _send(self, h, code, payload):
|
||||
|
||||
@ -4,9 +4,12 @@ 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 = { # статус → [(имя перехода, целевой статус)]
|
||||
"Ожидание разработки": [("Взять в разработку", "Разработка"), ("Ожидает анализа", "Ожидает анализа"), ("Снято", "Снято")],
|
||||
"Разработка": [("Малое исправление", "Готово к тестированию"), ("Готово", "Готово"), ("Снято", "Снято")],
|
||||
@ -24,6 +27,7 @@ CUSTOM = ("customfield_10801", "customfield_10802", "customfield_15302", "custom
|
||||
class FakeJira:
|
||||
def __init__(self, project="AURORA"):
|
||||
self.project = project
|
||||
self.now = datetime.now(MSK) # часы фейка: окно created >= -Nd считается от них
|
||||
self.issues = {}
|
||||
self.comments = {}
|
||||
self.links = []
|
||||
@ -64,10 +68,14 @@ class FakeJira:
|
||||
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": "2026-10-01T10:00:00.000+0300", "updated": "2026-10-01T10:00:00.000+0300",
|
||||
"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)
|
||||
@ -108,6 +116,14 @@ class FakeJira:
|
||||
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"]]
|
||||
|
||||
@ -165,7 +181,7 @@ class FakeJira:
|
||||
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",
|
||||
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":
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import unittest
|
||||
from datetime import timedelta
|
||||
|
||||
from _common import SETTINGS, agent, jira_cfg, no_sleep
|
||||
from fake_jira import FakeJira
|
||||
@ -146,6 +147,20 @@ class ExecutorFieldsTest(unittest.TestCase):
|
||||
out = self.run_cmd("ensure_issue", temp_key="n:2", issue_type="task", summary="Точная тема")
|
||||
self.assertEqual(out["result"], {"key": "AURORA-501", "existed": True})
|
||||
|
||||
def test_ensure_issue_old_or_foreign_is_not_duplicate(self):
|
||||
self.fake.add("AURORA-500", reporter="me", summary="Нужная задача",
|
||||
created=self.fake.ts(self.fake.now - timedelta(days=8))) # за окном created >= -7d
|
||||
self.fake.add("OTHER-1", reporter="me", summary="Нужная задача") # чужой проект
|
||||
out = self.run_cmd("ensure_issue", temp_key="n:1", issue_type="task", summary="Нужная задача")
|
||||
self.assertTrue(out["result"]["created"])
|
||||
self.assertNotIn(out["result"]["key"], ("AURORA-500", "OTHER-1"))
|
||||
|
||||
def test_ensure_issue_within_window_is_duplicate(self):
|
||||
self.fake.add("AURORA-500", reporter="me", summary="Нужная задача",
|
||||
created=self.fake.ts(self.fake.now - timedelta(days=6)))
|
||||
out = self.run_cmd("ensure_issue", temp_key="n:1", issue_type="task", summary="Нужная задача")
|
||||
self.assertEqual(out["result"], {"key": "AURORA-500", "existed": True})
|
||||
|
||||
def test_http_error_becomes_failed(self):
|
||||
self.fake.fail_next.append((400, {}))
|
||||
out = self.run_cmd("ensure_issue", temp_key="n:1", issue_type="task", summary="x")
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import os
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
import unittest
|
||||
from datetime import datetime, timedelta, timezone
|
||||
@ -224,6 +225,28 @@ class RunnerTest(unittest.TestCase):
|
||||
self.assertEqual(self.hub.results[2]["status"], "done")
|
||||
self.assertEqual(self.kinds()[:3], [("started", 1), ("finished", 1), ("acked", 1)])
|
||||
|
||||
def test_stop_during_long_claim(self):
|
||||
self.hub.claim_wait = 1.0 # хаб держит claim при пустой очереди (как claim_wait = 30)
|
||||
self.runner.idle_limit = None
|
||||
threading.Timer(0.3, lambda: setattr(self.runner, "stop", True)).start()
|
||||
start = time.monotonic()
|
||||
self.runner.loop()
|
||||
self.assertGreaterEqual(time.monotonic() - start, 0.9) # дождался ответа claim
|
||||
self.assertEqual(self.hub.claims, 1)
|
||||
self.assertEqual(self.journal.records(), [])
|
||||
|
||||
def test_command_arrives_during_long_claim(self):
|
||||
self.hub.claim_wait = 5.0
|
||||
|
||||
def arrive():
|
||||
self.hub.enqueue("ensure_fields", {"key": "AURORA-1", "duedate": "2026-10-23"})
|
||||
self.hub.claim_wait = 0 # следующий claim (после отчёта) — без ожидания
|
||||
threading.Timer(0.3, arrive).start()
|
||||
start = time.monotonic()
|
||||
self.runner.loop()
|
||||
self.assertLess(time.monotonic() - start, 4) # ответ пришёл с командой, а не по таймауту
|
||||
self.assertEqual(self.hub.results[1]["status"], "done")
|
||||
|
||||
def test_stop_during_pause(self):
|
||||
self.runner.jira.last_call_at = time.monotonic()
|
||||
self.hub.enqueue("ensure_fields", {"key": "AURORA-1", "duedate": "2026-10-23"}, pause=5)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user