Агент: поиск полей ранга, спринта и оценки с кэшем и перекрытием field_ids (#49)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-24 18:30:55 +04:00
parent 9fbe885ba8
commit 21374bef08
3 changed files with 117 additions and 4 deletions

View File

@ -669,16 +669,26 @@ def _fmt_size(n: Optional[int]) -> str:
return ("%.1f МБ" % (n / 1024 / 1024)).replace(".", ",") return ("%.1f МБ" % (n / 1024 / 1024)).replace(".", ",")
RANK_SCHEMA = "com.pyxis.greenhopper.jira:gh-lexo-rank" # схема поля «Rank» (ранг Lexorank)
SPRINT_SCHEMA = "com.pyxis.greenhopper.jira:gh-sprint" # схема поля «Sprint»
class Executor: class Executor:
def __init__(self, jira: JiraClient, settings: dict, hub: Optional[HubClient] = None): def __init__(self, jira: JiraClient, settings: dict, hub: Optional[HubClient] = None):
self.jira, self.s, self.hub = jira, settings, hub self.jira, self.s, self.hub = jira, settings, hub
self.sleep = time.sleep self.sleep = time.sleep
self._symmetric = None self._symmetric = None
self._sprints = {} # str(доска) → имя → спринт; на одну команду self._sprints = {} # str(доска) → имя → спринт; на одну команду
self._field_list = None # GET /field: список полей на одну команду
self._jira_fields = None # ранг, спринт, оценка → id поля; см. jira_fields
self._jira_fields_ok = False # поля прочитаны без ошибки: кэш живёт, пока жив Executor
def run(self, cmd: dict) -> dict: def run(self, cmd: dict) -> dict:
self.jira.reset_counters() self.jira.reset_counters()
self._sprints = {} self._sprints = {}
self._field_list = None
if not self._jira_fields_ok: # неудачный поиск полей — повтор в следующей команде (R-67)
self._jira_fields = None
handler = getattr(self, "do_" + str(cmd.get("type")), None) handler = getattr(self, "do_" + str(cmd.get("type")), None)
if handler is None: if handler is None:
return self._out("failed", error="неизвестный тип команды %s" % cmd.get("type")) return self._out("failed", error="неизвестный тип команды %s" % cmd.get("type"))
@ -724,6 +734,47 @@ class Executor:
def has_field(self, logical: str) -> bool: def has_field(self, logical: str) -> bool:
return logical in ("summary", "description", "priority", "duedate", "labels") or bool((self.s.get("field_ids") or {}).get(logical)) return logical in ("summary", "description", "priority", "duedate", "labels") or bool((self.s.get("field_ids") or {}).get(logical))
def field_list(self) -> List[dict]:
"""GET /rest/api/2/field — один раз на команду; только элементы-словари. Ошибки Jira и связи — наружу."""
if self._field_list is None:
self._field_list = [f for f in self.jira.get(API + "/field") or [] if isinstance(f, dict)]
return self._field_list
def jira_fields(self) -> Dict[str, Optional[str]]:
"""Поля ранга, спринта и оценки: {"rank", "sprint", "story_points"} → id или None. Настроенный в field_ids id
берётся как есть; остальные ищутся в field_list: ранг и спринт по схеме, оценка по имени «story points» (регистр
не важен), при нескольких — наименьший (len(id), id). Ошибка HTTP — лог, None, поиск повторится в следующей
команде; связь — наружу."""
if self._jira_fields is not None:
return self._jira_fields
ids = self.s.get("field_ids") or {}
over = {k: ids.get(k) or None for k in ("rank", "sprint", "story_points")}
if all(over.values()):
self._jira_fields, self._jira_fields_ok = dict(over), True
return self._jira_fields
try:
fields = self.field_list()
except HttpError as e:
log("поля Jira не прочитаны (HTTP %d) — ранг, спринт и оценка неизвестны" % e.code)
self._jira_fields, self._jira_fields_ok = over, False
return self._jira_fields
def find(match) -> Optional[str]:
cands = sorted((f for f in fields if match(f)), key=lambda f: (len(str(f.get("id"))), str(f.get("id"))))
return cands[0].get("id") if cands else None
def by_schema(schema: str):
return lambda f: isinstance(f.get("schema"), dict) and f["schema"].get("custom") == schema
def by_name(name: str):
return lambda f: str(f.get("name") or "").strip().casefold() == name
found = {"rank": find(by_schema(RANK_SCHEMA)), "sprint": find(by_schema(SPRINT_SCHEMA)),
"story_points": find(by_name("story points"))}
self._jira_fields = {k: over[k] or found[k] for k in found}
self._jira_fields_ok = True
return self._jira_fields
def priority_value(self, name: str) -> dict: def priority_value(self, name: str) -> dict:
mapped = (self.s.get("priority_map") or {}).get(name, name) mapped = (self.s.get("priority_map") or {}).get(name, name)
return {self.s.get("priority_value_key") or "name": mapped} return {self.s.get("priority_value_key") or "name": mapped}
@ -1392,9 +1443,8 @@ class Executor:
names: Dict[str, Any] = {} names: Dict[str, Any] = {}
def field_id_of(name: str) -> Optional[str]: def field_id_of(name: str) -> Optional[str]:
if "map" not in names: # один GET /field за refresh, только по нужде if "map" not in names: # по списку полей команды — GET /field один раз (field_list)
names["map"] = {f.get("name"): f.get("id") for f in self.jira.get(API + "/field") or [] names["map"] = {f.get("name"): f.get("id") for f in self.field_list()}
if isinstance(f, dict)}
return names["map"].get(name) return names["map"].get(name)
items: List[dict] = [] items: List[dict] = []

View File

@ -21,6 +21,9 @@ WORKFLOW = { # статус → [(имя перехода, целевой ст
} }
LINK_TYPES = [{"name": "ASUPR_Блокер_Начала", "inward": "Начало заблокировано", "outward": "Блокирует начало"}, LINK_TYPES = [{"name": "ASUPR_Блокер_Начала", "inward": "Начало заблокировано", "outward": "Блокирует начало"},
{"name": "ASUPR_Связанные", "inward": "Связанные", "outward": "Связанные"}] {"name": "ASUPR_Связанные", "inward": "Связанные", "outward": "Связанные"}]
SPRINT_FIELD = "customfield_10104"
RANK_FIELD = "customfield_10105"
POINTS_FIELD = "customfield_10106"
CUSTOM = ("customfield_10801", "customfield_10802", "customfield_15302", "customfield_15304") CUSTOM = ("customfield_10801", "customfield_10802", "customfield_15302", "customfield_15304")
@ -100,7 +103,13 @@ class FakeJira:
self.issue_changelog_cap = None # то же только для GET /issue/{key}?expand=changelog (None — как changelog_cap) self.issue_changelog_cap = None # то же только для GET /issue/{key}?expand=changelog (None — как changelog_cap)
self.jql_results = {} # JQL → ключи (проверяется в search_keys первым) self.jql_results = {} # JQL → ключи (проверяется в search_keys первым)
self.fields = [{"id": "customfield_10201", "name": "Плановое окончание", "custom": True}, self.fields = [{"id": "customfield_10201", "name": "Плановое окончание", "custom": True},
{"id": "customfield_10104", "name": "Sprint", "custom": True}, {"id": SPRINT_FIELD, "name": "Sprint", "custom": True,
"schema": {"type": "array", "items": "string",
"custom": "com.pyxis.greenhopper.jira:gh-sprint"}},
{"id": RANK_FIELD, "name": "Rank", "custom": True,
"schema": {"type": "any", "custom": "com.pyxis.greenhopper.jira:gh-lexo-rank"}},
{"id": POINTS_FIELD, "name": "Story Points", "custom": True,
"schema": {"type": "number", "custom": "com.atlassian.jira.plugin.system.customfieldtypes:float"}},
{"id": "duedate", "name": "Срок исполнения", "custom": False}] # GET /rest/api/2/field {"id": "duedate", "name": "Срок исполнения", "custom": False}] # GET /rest/api/2/field
self.lock = threading.Lock() self.lock = threading.Lock()
fake = self fake = self

View File

@ -469,3 +469,57 @@ class RefreshTest(unittest.TestCase):
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()
class JiraFieldsTest(unittest.TestCase):
def setUp(self):
self.fake = FakeJira()
self.hub = FakeHub(SETTINGS)
cfg = jira_cfg(self.fake, hub_url=self.hub.base_url, hub_token=self.hub.token)
self.jira = agent.JiraClient(cfg)
self.jira.sleep = no_sleep
self.ex = agent.Executor(self.jira, dict(SETTINGS), agent.HubClient(cfg))
self.ex.sleep = no_sleep
def tearDown(self):
self.fake.stop()
self.hub.stop()
def logged(self, fn):
logs, orig = [], agent.log
agent.log = logs.append
try:
return fn(), logs
finally:
agent.log = orig
def test_fields_found_by_schema_and_name(self):
want = {"rank": "customfield_10105", "sprint": "customfield_10104", "story_points": "customfield_10106"}
self.assertEqual(self.ex.jira_fields(), want)
self.ex.run({"id": 2, "type": "nope", "params": {}}) # новая команда — кэш процесса жив
self.assertEqual(self.ex.jira_fields(), want)
self.assertEqual(self.fake.requests.count(("GET", "/rest/api/2/field")), 1)
def test_fields_override_skips_lookup(self):
self.ex.s["field_ids"] = dict(SETTINGS["field_ids"], rank="cf_r", sprint="cf_s", story_points="cf_p")
self.assertEqual(self.ex.jira_fields(), {"rank": "cf_r", "sprint": "cf_s", "story_points": "cf_p"})
self.assertEqual(self.fake.requests, [])
def test_fields_partial_override(self):
self.ex.s["field_ids"] = dict(SETTINGS["field_ids"], story_points="customfield_20000")
self.assertEqual(self.ex.jira_fields()["story_points"], "customfield_20000")
self.assertEqual(self.ex.jira_fields()["rank"], "customfield_10105")
def test_story_points_first_by_numeric_id_any_case(self):
self.fake.fields += [{"id": "customfield_9999", "name": " story points ", "custom": True},
{"id": "customfield_10050", "name": "Story Points", "custom": True}]
self.assertEqual(self.ex.jira_fields()["story_points"], "customfield_9999")
def test_field_lookup_error_retried_next_command(self):
self.fake.fail_next.append((403, {}))
out, logs = self.logged(lambda: (self.ex.jira_fields(), self.ex.jira_fields())) # logged — как в RefreshTest
self.assertEqual(out[0], {"rank": None, "sprint": None, "story_points": None})
self.assertTrue(any("HTTP 403" in l for l in logs), logs)
self.assertEqual(self.fake.requests.count(("GET", "/rest/api/2/field")), 1) # в той же команде — не повторяем
self.ex.run({"id": 2, "type": "nope", "params": {}})
self.assertEqual(self.ex.jira_fields()["rank"], "customfield_10105")