Агент: поиск полей ранга, спринта и оценки с кэшем и перекрытием field_ids (#49)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
9fbe885ba8
commit
21374bef08
56
src/agent.py
56
src/agent.py
@ -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] = []
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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")
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user