Агент: ранг, оценка и все спринты задачи в состоянии refresh (#49)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
79f18abbfa
commit
9de78842b1
44
src/agent.py
44
src/agent.py
@ -1118,7 +1118,8 @@ class Executor:
|
||||
ep = (self.s.get("epic_link") or {}) # режим связи с эпиком — только field (mode=link не поддержан)
|
||||
if ep.get("mode", "field") == "field" and ep.get("field_id"):
|
||||
extra.append(ep["field_id"])
|
||||
return ",".join(self.STATE_FIELDS + extra)
|
||||
extra += [v for v in self.jira_fields().values() if v]
|
||||
return ",".join(dict.fromkeys(self.STATE_FIELDS + extra))
|
||||
|
||||
def search_all(self, jql: str) -> List[dict]:
|
||||
issues, start = [], 0
|
||||
@ -1157,15 +1158,49 @@ class Executor:
|
||||
def name(obj):
|
||||
return (obj or {}).get("name")
|
||||
|
||||
jf = self.jira_fields()
|
||||
ep = self.s.get("epic_link") or {}
|
||||
epic = f.get(ep["field_id"]) if ep.get("mode", "field") == "field" and ep.get("field_id") else None
|
||||
return {"key": raw["key"], "id": raw.get("id"), "issue_type": name(f.get("issuetype")), "summary": f.get("summary"),
|
||||
st = {"key": raw["key"], "id": raw.get("id"), "issue_type": name(f.get("issuetype")), "summary": f.get("summary"),
|
||||
"description": f.get("description"), "status": name(f.get("status")), "priority": name(f.get("priority")),
|
||||
"assignee": name(f.get("assignee")), "reporter": name(f.get("reporter")), "duedate": _date(f.get("duedate")),
|
||||
"planned_start": _date(f.get(self.fid("planned_start"))) if self.has_field("planned_start") else None,
|
||||
"planned_end": _date(f.get(self.fid("planned_end"))) if self.has_field("planned_end") else None,
|
||||
"epic_key": epic, "labels": list(f.get("labels") or []), "sprint": None,
|
||||
"created": f.get("created"), "jira_updated": f.get("updated"), "attachments": self.attachments_of(raw)}
|
||||
if jf["rank"]: # поле не найдено — ключа нет, а не null (R-64)
|
||||
v = f.get(jf["rank"])
|
||||
st["rank"] = v if isinstance(v, str) else None
|
||||
if jf["story_points"]:
|
||||
v = f.get(jf["story_points"])
|
||||
st["story_points"] = v if isinstance(v, (int, float)) and not isinstance(v, bool) else None
|
||||
if jf["sprint"]:
|
||||
st["sprint_ids"] = self.sprint_ids_of(raw["key"], f.get(jf["sprint"]))
|
||||
return st
|
||||
|
||||
@staticmethod
|
||||
def sprint_ids_of(key: str, value: Any) -> List[int]:
|
||||
"""id спринтов из поля спринтов: элементы — словари (id — число или строка цифр) или строки greenhopper
|
||||
(«...[id=72,...]»). Не разобранный элемент — лог и пропуск; повторы убираются."""
|
||||
out: List[int] = []
|
||||
if not isinstance(value, list):
|
||||
return out
|
||||
for it in value:
|
||||
sid = None
|
||||
if isinstance(it, dict):
|
||||
i = it.get("id")
|
||||
if isinstance(i, int) and not isinstance(i, bool):
|
||||
sid = i
|
||||
elif isinstance(i, str) and i.isdigit():
|
||||
sid = int(i)
|
||||
elif isinstance(it, str):
|
||||
m = re.search(r"[\[,]id=(\d+)", it)
|
||||
sid = int(m.group(1)) if m else None
|
||||
if sid is None:
|
||||
log(" ! %s: элемент поля спринта не разобран" % key)
|
||||
elif sid not in out:
|
||||
out.append(sid)
|
||||
return out
|
||||
|
||||
def links_from(self, raw: dict) -> List[dict]:
|
||||
key, out = raw["key"], []
|
||||
@ -1367,6 +1402,11 @@ class Executor:
|
||||
for i in range(0, len(absent), 100):
|
||||
neighbors += self.search_tolerant(absent[i:i + 100], missing, errors)
|
||||
everything = raws + neighbors
|
||||
if everything:
|
||||
jf = self.jira_fields()
|
||||
for k, what, name in (("rank", "ранга", "rank"), ("sprint", "спринта", "sprint_ids"), ("story_points", "оценки", "story_points")):
|
||||
if not jf[k]:
|
||||
log("refresh: поле %s не найдено в Jira — %s у задач не передаётся" % (what, name))
|
||||
types = dict(types or {})
|
||||
names = self.sprint_names([r["key"] for r in everything], scrum, types)
|
||||
issues, links, seen = [], [], set()
|
||||
|
||||
@ -24,7 +24,7 @@ LINK_TYPES = [{"name": "ASUPR_Блокер_Начала", "inward": "Начал
|
||||
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", RANK_FIELD, POINTS_FIELD)
|
||||
|
||||
|
||||
def parse_multipart(raw, content_type):
|
||||
@ -74,6 +74,7 @@ class FakeJira:
|
||||
self.sprints = [{"id": 1, "name": "Спринт 1", "state": "active"}, {"id": 2, "name": "Спринт 2", "state": "future"}]
|
||||
self.boards = {4216: "scrum"} # доска → тип; GET /rest/agile/1.0/board/{id}
|
||||
self.board_sprints = {} # доска → спринты; доски нет — общий self.sprints
|
||||
self.sprint_field_format = "object" # элемент поля спринтов: словарь или строка greenhopper
|
||||
self.sprint_members = {} # id спринта → ключи задач (иначе — по имени спринта задачи)
|
||||
self.seq, self.link_seq, self.comment_seq = 1000, 100, 10
|
||||
self.id_seq = 10000
|
||||
@ -149,7 +150,7 @@ class FakeJira:
|
||||
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}
|
||||
"sprint": None, "sprint_ids": None}
|
||||
self.id_seq += 1
|
||||
base["id"] = str(self.id_seq)
|
||||
base.update({c: None for c in CUSTOM})
|
||||
@ -211,6 +212,7 @@ class FakeJira:
|
||||
"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})
|
||||
out[SPRINT_FIELD] = self.sprint_value(f)
|
||||
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
|
||||
@ -221,6 +223,22 @@ class FakeJira:
|
||||
out["attachment"] = [self.attachment_json(a) for a in self.attachments.get(key, [])]
|
||||
return {"key": key, "id": f["id"], "fields": out}
|
||||
|
||||
def sprint_value(self, f):
|
||||
"""Значение поля спринтов задачи: sprint_raw как есть; иначе спринты по sprint_ids, иначе по имени sprint."""
|
||||
if "sprint_raw" in f:
|
||||
return f["sprint_raw"]
|
||||
allsp = self.all_sprints()
|
||||
if f.get("sprint_ids") is not None:
|
||||
sps = [s for i in f["sprint_ids"] for s in allsp if s["id"] == i]
|
||||
else:
|
||||
sps = [s for s in allsp if s["name"] == f.get("sprint")][:1] if f.get("sprint") else []
|
||||
if not sps:
|
||||
return None
|
||||
if self.sprint_field_format == "string":
|
||||
return ["com.atlassian.greenhopper.service.sprint.Sprint@1f2e3d[id=%d,rapidViewId=4216,state=%s,name=%s,"
|
||||
"sequence=%d]" % (s["id"], s["state"].upper(), s["name"], s["id"]) for s in sps]
|
||||
return [{"id": s["id"], "name": s["name"], "state": s["state"]} for s in sps]
|
||||
|
||||
def links_of(self, key):
|
||||
out = []
|
||||
for l in self.links:
|
||||
|
||||
@ -336,6 +336,7 @@ class RefreshTest(unittest.TestCase):
|
||||
self.assertFalse(out["retryable"])
|
||||
|
||||
def test_jira_error_has_no_peer(self):
|
||||
self.ex.jira_fields()
|
||||
self.fake.fail_next.append((500, {}))
|
||||
out = self.ex.run({"id": 1, "type": "refresh", "params": {"jql": "project = AURORA"}})
|
||||
self.assertTrue(out["retryable"])
|
||||
@ -354,6 +355,46 @@ class RefreshTest(unittest.TestCase):
|
||||
finally:
|
||||
agent.log = orig
|
||||
|
||||
def test_state_rank_points_sprint_ids(self): # сценарий «Доска с закрытыми спринтами», часть задач
|
||||
self.fake.board_sprints[4216] = [{"id": i, "name": "S%d" % i, "state": s}
|
||||
for i, s in ((1, "closed"), (2, "closed"), (3, "active"), (4, "future"))]
|
||||
self.fake.add("AURORA-1", sprint="S3", sprint_ids=[2, 3], customfield_10105="0|i0001:", customfield_10106=3.0)
|
||||
self.fake.add("AURORA-2")
|
||||
self.ex.run({"id": 5, "type": "refresh", "params": {"keys": ["AURORA-1", "AURORA-2"]}})
|
||||
by = {i["key"]: i for i in self.hub.states[0]["issues"]}
|
||||
self.assertEqual((by["AURORA-1"]["rank"], by["AURORA-1"]["story_points"], by["AURORA-1"]["sprint_ids"]),
|
||||
("0|i0001:", 3.0, [2, 3]))
|
||||
self.assertEqual((by["AURORA-2"]["rank"], by["AURORA-2"]["story_points"], by["AURORA-2"]["sprint_ids"]), (None, None, []))
|
||||
fields = [q["fields"].split(",") for q in self.fake.queries if "jql" in q and "fields" in q][0]
|
||||
for f in ("customfield_10104", "customfield_10105", "customfield_10106"):
|
||||
self.assertIn(f, fields)
|
||||
|
||||
def test_sprint_field_server_strings(self):
|
||||
self.fake.add("AURORA-1", sprint_raw=[
|
||||
"com.atlassian.greenhopper.service.sprint.Sprint@1a[id=72,rapidViewId=4216,state=CLOSED,name=A,sequence=72]",
|
||||
"com.atlassian.greenhopper.service.sprint.Sprint@1b[id=73,rapidViewId=4216,state=ACTIVE,name=B,sequence=73]",
|
||||
"мусор", {"id": "74"}, {"name": "без id"}])
|
||||
out, logs = self.logged(lambda: self.refresh(keys=["AURORA-1"]))
|
||||
self.assertEqual(self.hub.states[0]["issues"][0]["sprint_ids"], [72, 73, 74])
|
||||
self.assertEqual(len([l for l in logs if "AURORA-1" in l and "не разобран" in l]), 2, logs)
|
||||
|
||||
def test_story_points_non_number_is_null(self):
|
||||
for key, v in (("AURORA-1", "3"), ("AURORA-2", True), ("AURORA-3", 5)):
|
||||
self.fake.add(key, customfield_10106=v)
|
||||
self.refresh(keys=["AURORA-1", "AURORA-2", "AURORA-3"])
|
||||
self.assertEqual([i["story_points"] for i in self.hub.states[0]["issues"]], [None, None, 5])
|
||||
|
||||
def test_missing_fields_not_sent_logged_once(self): # сценарий «Нет поля оценки», сторона refresh
|
||||
self.fake.fields = []
|
||||
for i in range(1, 121):
|
||||
self.fake.add("AURORA-%d" % i, customfield_10106=2)
|
||||
out, logs = self.logged(lambda: self.refresh(jql=self.JQL))
|
||||
self.assertEqual(out["result"]["pages"], 2)
|
||||
for st in self.hub.states[0]["issues"] + self.hub.states[1]["issues"]:
|
||||
self.assertFalse({"rank", "story_points", "sprint_ids"} & set(st), st)
|
||||
for name in ("rank", "sprint_ids", "story_points"):
|
||||
self.assertEqual(len([l for l in logs if "не найдено" in l and name in l]), 1, logs)
|
||||
|
||||
def test_two_boards_one_new(self): # сценарий «Две доски, одна новая»
|
||||
self.fake.boards[5120] = "kanban"
|
||||
self.fake.add("AURORA-1", sprint="Спринт 2")
|
||||
@ -467,10 +508,6 @@ class RefreshTest(unittest.TestCase):
|
||||
self.assertEqual(out["result"]["boards"], {"5120": "kanban"})
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
|
||||
class JiraFieldsTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.fake = FakeJira()
|
||||
@ -523,3 +560,7 @@ class JiraFieldsTest(unittest.TestCase):
|
||||
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")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user