2223 lines
107 KiB
Markdown
2223 lines
107 KiB
Markdown
# Мост Jira — агент (jira-agent) — план реализации
|
||
|
||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||
|
||
**Goal:** Один файл `agent.py` для виртуалки с доступом к Jira: опрашивает хаб, выполняет команды «проверь, потом сделай», ведёт журнал на диске, возвращает хабу состояние задач.
|
||
|
||
**Architecture:** `src/agent.py` (Python 3.9, только stdlib): `JiraClient` (REST v2 + Agile, 429/503 по Retry-After), `HubClient`, `Journal` (jsonl, только дозапись), `Executor` (по методу на тип команды, каждая — чтение → сравнение → запись разницы), `Runner` (цикл claim → пауза → журнал → выполнение → отчёт до подтверждения; восстановление по журналу при старте). Политику темпа агент не знает: пауза приходит с командой, а зазор внутри многошаговых команд — из настроек хаба.
|
||
|
||
**Tech Stack:** Python 3.9.6 (`/usr/bin/python3` и здесь, и на виртуалке), stdlib `urllib`, `json`, `http.server` (только в тестах), `unittest`.
|
||
|
||
**Spec:** `../jira-hub/openspec/changes/bridge-core/` — `specs/agent/spec.md` (агент), `specs/commands/spec.md` (контракт команд), `specs/hub-api/spec.md` (маршруты `/agent/*`), `design.md`. План хаба — `../jira-hub/docs/superpowers/plans/2026-10-11-bridge-core-hub.md`.
|
||
|
||
## Global Constraints
|
||
|
||
- Python `>= 3.9`, только стандартная библиотека; никаких `match`, `X | Y` в аннотациях, `str.removeprefix` допустим (3.9). Тесты запускать `/usr/bin/python3 -m unittest discover -s tests -t .`.
|
||
- Учётные данные Jira — только в `agent.json`; в хаб, журнал и логи не попадают.
|
||
- Одна команда в полёте; `started`/`finished`/`acked` в `journal.jsonl`; отчёт повторяется до подтверждения хаба.
|
||
- Все вызовы Jira идут через `JiraClient.call`: счётчики `calls`, `rate_limited`, `last_call_at`; 429/503 — `Retry-After` или 60 с × попытка, до 5 раз.
|
||
- Семантика связей: `source` блокирует `target`; `POST /issueLink` — `inwardIssue = source`, `outwardIssue = target`; в GET у X `outwardIssue = Y` значит «X → Y».
|
||
- Язык кода и комментариев — русский, идентификаторы — английские; коммиты — русские, с `Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>`.
|
||
- Файл `agent.py` лежит в `src/` (хук require-plan защищает `src/`); на виртуалку копируется один файл плюс `agent.json`.
|
||
|
||
## Review Focus
|
||
|
||
1. Описание в `ensure_fields` с переводами строк и wiki-разметкой должно сравниваться точно, без «нормализации» (иначе каждая команда будет перезаписывать описание). Тест — Task 3 (`test_fields_description_exact_compare`).
|
||
2. `ensure_status`, когда текущий статус уже внутри пути (например «Разработка» при пути Разработка → Готово): выполняется только остаток пути. Тест — Task 4 (`test_status_resumes_from_middle`).
|
||
3. `refresh` при пустом списке `keys` (все временные ключи отвалились) — `done` с нулём задач, без вызовов Jira. Тест — Task 5 (`test_refresh_empty_keys`).
|
||
4. Отчёт хабу, когда хаб вернул 404/422 (команда отменена с `force` или удалена): агент помечает `acked` и идёт дальше, не зацикливается. Тест — Task 6 (`test_result_rejected_is_acked`).
|
||
5. Ctrl-C во время паузы перед командой: команда не начинается, журнал без `started`, процесс выходит. Тест — Task 6 (`test_stop_during_pause`).
|
||
|
||
## Структура файлов
|
||
|
||
| Файл | Ответственность |
|
||
|---|---|
|
||
| `src/agent.py` | весь агент: конфиг, HTTP-клиенты, журнал, исполнители, чтение состояния, цикл, CLI (`run`, `check`, `prune`) |
|
||
| `src/agent.example.json` | шаблон настроек |
|
||
| `tests/_common.py` | путь к `src`, общие настройки хаба для тестов, фабрики |
|
||
| `tests/fake_jira.py` | фейковый Jira Server в памяти (http.server) |
|
||
| `tests/fake_hub.py` | фейковый хаб (маршруты `/agent/*`) |
|
||
| `tests/test_*.py` | unittest |
|
||
| `README.md`, `CLAUDE.md`, `ARCHITECTURE.md` | доставка, запуск, launchd, prune |
|
||
|
||
---
|
||
|
||
### Task 1: Скелет агента, HTTP-клиенты, фейковый Jira, `check`
|
||
|
||
**Files:**
|
||
- Create: `src/agent.py`, `src/agent.example.json`, `tests/__init__.py` (пустой), `tests/_common.py`, `tests/fake_jira.py`
|
||
- Test: `tests/test_clients.py`
|
||
|
||
**Interfaces:**
|
||
- Produces (в `agent.py`): `AGENT_VERSION = 1`, `now_iso() -> str`, `log(msg)`, `load_config(path) -> dict`,
|
||
`HttpError(code, text, headers)`, `Http(base, headers, timeout, ctx).call(method, path, body=None, params=None)`,
|
||
`JiraClient(cfg).get/post/put/delete`, счётчики `calls`, `rate_limited`, `last_call_at`, `reset_counters()`, `sleep` (подменяемый),
|
||
`HubClient(cfg).claim(agent_version, settings_version, last_jira_call_at)`, `.settings()`, `.command(id)`, `.result(id, payload)`,
|
||
`.state(payload)`, `.known_keys()`, `main(argv) -> int` с подкомандами `run | check | prune` (run и prune — заглушки до Task 6/2).
|
||
- Produces (тесты): `fake_jira.FakeJira()` (`.base_url`, `.add(key, in_base=True, **fields)`, `.issues`, `.links`, `.comments`,
|
||
`.sprints`, `.requests`, `.writes()`, `.fail_next`, `.stop()`), `_common.SETTINGS`, `_common.jira_cfg(fake) -> dict`, `_common.agent` (модуль).
|
||
|
||
- [ ] **Step 1: Общие тестовые файлы**
|
||
|
||
`tests/_common.py`:
|
||
```python
|
||
"""Общее для тестов: путь к src, настройки хаба как их отдаёт /agent/settings, конфиг агента под фейковый Jira."""
|
||
import os
|
||
import sys
|
||
|
||
SRC = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "src")
|
||
if SRC not in sys.path:
|
||
sys.path.insert(0, SRC)
|
||
|
||
import agent # noqa: E402
|
||
|
||
ZERO_PACING = {"ensure_issue": 0, "ensure_fields": 0, "ensure_comment": 0, "ensure_status": 0, "ensure_link": 0,
|
||
"ensure_sprint": 0, "refresh": 0}
|
||
|
||
SETTINGS = {
|
||
"base_jql": "project = AURORA AND labels = AWPCA",
|
||
"project_key": "AURORA",
|
||
"issue_types": {"task": {"name": "Задача", "id": "10100"}, "epic": {"name": "Фича", "id": "12300"}},
|
||
"epic_name_field_id": "customfield_15304",
|
||
"field_ids": {"description": "description", "priority": "priority", "duedate": "duedate", "labels": "labels",
|
||
"planned_start": "customfield_10801", "planned_end": "customfield_10802"},
|
||
"epic_link": {"mode": "field", "field_id": "customfield_15302"},
|
||
"priority_map": {"Наивысший": "Критичный", "Высокий": "Высокий", "Средний": "Средний", "Низкий": "Низкий"},
|
||
"priority_value_key": "name",
|
||
"labels": ["AWPCA"],
|
||
"status_labels": {},
|
||
"kind_labels": {},
|
||
"comment_marker": "Актуализация 10.10.2026",
|
||
"transitions_map": {},
|
||
"link_types": {"блокирует начало": "ASUPR_Блокер_Начала", "связанные": "ASUPR_Связанные"},
|
||
"board_id": 4216,
|
||
"pacing": dict(ZERO_PACING),
|
||
"min_agent_version": 1,
|
||
"settings_version": 1,
|
||
}
|
||
|
||
|
||
def jira_cfg(fake, **over):
|
||
cfg = {"hub_url": "http://127.0.0.1:1", "hub_token": "agent-token",
|
||
"jira": {"base_url": fake.base_url, "auth": {"type": "basic", "user": "me", "password": "secret"}},
|
||
"verify_ssl": True, "ca_bundle": None, "timeout": 5, "poll_seconds": 0}
|
||
cfg.update(over)
|
||
return cfg
|
||
|
||
|
||
def no_sleep(_seconds):
|
||
pass
|
||
```
|
||
|
||
`tests/fake_jira.py`:
|
||
```python
|
||
"""Фейковый Jira Server для тестов агента: состояние в памяти, http.server в потоке,
|
||
только маршруты, которые использует агент. Семантика связей — как у настоящего Jira Server."""
|
||
import json
|
||
import re
|
||
import threading
|
||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||
from urllib.parse import parse_qs, urlparse
|
||
|
||
WORKFLOW = { # статус → [(имя перехода, целевой статус)]
|
||
"Ожидание разработки": [("Взять в разработку", "Разработка"), ("Ожидает анализа", "Ожидает анализа"), ("Снято", "Снято")],
|
||
"Разработка": [("Малое исправление", "Готово к тестированию"), ("Готово", "Готово"), ("Снято", "Снято")],
|
||
"Ожидает анализа": [("Анализ", "Анализ"), ("Снято", "Снято")],
|
||
"Анализ": [("Готово", "Готово")],
|
||
"Готово к тестированию": [("Тестирование", "Тестирование")],
|
||
"Готово": [],
|
||
"Снято": [],
|
||
}
|
||
LINK_TYPES = [{"name": "ASUPR_Блокер_Начала", "inward": "Начало заблокировано", "outward": "Блокирует начало"},
|
||
{"name": "ASUPR_Связанные", "inward": "Связанные", "outward": "Связанные"}]
|
||
CUSTOM = ("customfield_10801", "customfield_10802", "customfield_15302", "customfield_15304")
|
||
|
||
|
||
class FakeJira:
|
||
def __init__(self, project="AURORA"):
|
||
self.project = project
|
||
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.fail_next = [] # [(код, {заголовки})] — следующие ответы с ошибкой
|
||
self.requests = [] # [(метод, путь)]
|
||
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()
|
||
|
||
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",
|
||
"sprint": None}
|
||
base.update({c: None for c in CUSTOM})
|
||
base.update(fields)
|
||
self.issues[key] = base
|
||
return base
|
||
|
||
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):
|
||
f = self.issues[key]
|
||
fields = {"summary": f["summary"], "description": f["description"], "status": {"name": f["status"]},
|
||
"priority": {"name": 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)}
|
||
fields.update({c: f.get(c) for c in CUSTOM})
|
||
return {"key": key, "id": str(10000 + sorted(self.issues).index(key)), "fields": fields}
|
||
|
||
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 ""):
|
||
return [k for k, f in self.issues.items() if f["reporter"] == "me"]
|
||
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)
|
||
body = json.loads(h.rfile.read(length).decode("utf-8")) if length else {}
|
||
with self.lock:
|
||
self.requests.append((method, url.path))
|
||
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]}
|
||
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":
|
||
keys = self.search_keys(q.get("jql"))
|
||
start, maxr = int(q.get("startAt", 0)), int(q.get("maxResults", 50))
|
||
page = keys[start:start + maxr]
|
||
return 200, {"startAt": start, "maxResults": maxr, "total": len(keys), "issues": [self.issue_json(k) 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="2026-10-11T12:00:00.000+0300",
|
||
**{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":
|
||
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)
|
||
self.issues[key]
|
||
lst = self.comments.setdefault(key, [])
|
||
if method == "GET":
|
||
return 200, {"comments": lst}
|
||
self.comment_seq += 1
|
||
lst.append({"id": str(self.comment_seq), "body": body["body"]})
|
||
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/([^/]+)/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(",")
|
||
return 200, {"values": [s for s in self.sprints if s["state"] in states], "isLast": True}
|
||
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"]]
|
||
return 200, {"issues": [{"key": k} for k in keys]}
|
||
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)
|
||
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")), "Задача")
|
||
```
|
||
|
||
- [ ] **Step 2: Тесты клиентов**
|
||
|
||
`tests/test_clients.py`:
|
||
```python
|
||
import unittest
|
||
|
||
from _common import agent, jira_cfg, no_sleep
|
||
from fake_jira import FakeJira
|
||
|
||
|
||
class ClientsTest(unittest.TestCase):
|
||
def setUp(self):
|
||
self.fake = FakeJira()
|
||
self.jira = agent.JiraClient(jira_cfg(self.fake))
|
||
self.jira.sleep = no_sleep
|
||
|
||
def tearDown(self):
|
||
self.fake.stop()
|
||
|
||
def test_get_and_counters(self):
|
||
self.fake.add("AURORA-1")
|
||
data = self.jira.get("/rest/api/2/issue/AURORA-1", fields="summary")
|
||
self.assertEqual(data["fields"]["summary"], "Тема AURORA-1")
|
||
self.assertEqual(self.jira.calls, 1)
|
||
self.assertIsNotNone(self.jira.last_call_at)
|
||
self.assertEqual(self.fake.requests[-1], ("GET", "/rest/api/2/issue/AURORA-1"))
|
||
|
||
def test_retry_on_429_with_retry_after(self):
|
||
self.fake.add("AURORA-1")
|
||
self.fake.fail_next.append((429, {"Retry-After": "0"}))
|
||
self.fake.fail_next.append((503, {}))
|
||
data = self.jira.get("/rest/api/2/issue/AURORA-1")
|
||
self.assertEqual(data["key"], "AURORA-1")
|
||
self.assertEqual(self.jira.rate_limited, 2)
|
||
self.assertEqual(self.jira.calls, 1)
|
||
|
||
def test_http_error_not_retried(self):
|
||
with self.assertRaises(agent.HttpError) as cm:
|
||
self.jira.get("/rest/api/2/issue/NOPE-1")
|
||
self.assertEqual(cm.exception.code, 404)
|
||
self.assertIn("Does Not Exist", cm.exception.text)
|
||
|
||
def test_gives_up_after_five(self):
|
||
self.fake.add("AURORA-1")
|
||
self.fake.fail_next.extend([(429, {"Retry-After": "0"})] * 5)
|
||
with self.assertRaises(agent.HttpError):
|
||
self.jira.get("/rest/api/2/issue/AURORA-1")
|
||
self.assertEqual(self.jira.rate_limited, 5)
|
||
|
||
def test_basic_auth_header_and_no_secret_in_repr(self):
|
||
self.assertTrue(self.jira.headers["Authorization"].startswith("Basic "))
|
||
self.assertNotIn("secret", repr(self.jira))
|
||
|
||
def test_load_config_requires_fields(self):
|
||
import json
|
||
import os
|
||
import tempfile
|
||
with tempfile.TemporaryDirectory() as d:
|
||
path = os.path.join(d, "agent.json")
|
||
with open(path, "w", encoding="utf-8") as f:
|
||
json.dump({"hub_url": "http://x", "hub_token": "t"}, f)
|
||
with self.assertRaises(SystemExit):
|
||
agent.load_config(path)
|
||
with open(path, "w", encoding="utf-8") as f:
|
||
json.dump(jira_cfg(self.fake), f)
|
||
cfg = agent.load_config(path)
|
||
self.assertEqual(cfg["poll_seconds"], 0)
|
||
|
||
def test_check_command(self):
|
||
import json
|
||
import os
|
||
import tempfile
|
||
with tempfile.TemporaryDirectory() as d:
|
||
path = os.path.join(d, "agent.json")
|
||
with open(path, "w", encoding="utf-8") as f:
|
||
json.dump(jira_cfg(self.fake), f)
|
||
# хаб недоступен (порт 1), Jira доступен — check печатает оба результата и возвращает 1
|
||
self.assertEqual(agent.main(["--config", path, "check"]), 1)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
unittest.main()
|
||
```
|
||
|
||
- [ ] **Step 3: Запустить — падает**
|
||
|
||
Run: `/usr/bin/python3 -m unittest tests.test_clients -v`
|
||
Expected: FAIL — `ModuleNotFoundError: No module named 'agent'` (или AttributeError)
|
||
|
||
- [ ] **Step 4: agent.py (скелет)**
|
||
|
||
`src/agent.example.json`:
|
||
```json
|
||
{
|
||
"hub_url": "http://192.168.0.106:8790",
|
||
"hub_token": "агентский токен из `jira-hub tokens`",
|
||
"jira": {
|
||
"base_url": "https://sdlc.go.rshbank.ru/jira",
|
||
"auth": {"type": "basic", "user": "логин", "password": "пароль", "token": ""}
|
||
},
|
||
"verify_ssl": true,
|
||
"ca_bundle": null,
|
||
"timeout": 60,
|
||
"poll_seconds": 5
|
||
}
|
||
```
|
||
|
||
`src/agent.py`:
|
||
```python
|
||
#!/usr/bin/env python3
|
||
"""jira-agent — клиент моста Jira для виртуалки с доступом к Jira банка.
|
||
|
||
Опрашивает хаб (jira-hub) на Mac mini, выполняет команды «желаемый результат» в Jira по правилу
|
||
«проверь, потом сделай», ведёт журнал на диске и возвращает хабу состояние задач. Только стандартная библиотека,
|
||
Python 3.9. Спека: jira-hub/openspec/changes/bridge-core/specs/agent/spec.md.
|
||
|
||
python3 agent.py check # доступ к хабу и Jira, без записи
|
||
python3 agent.py run # основной цикл (по умолчанию)
|
||
python3 agent.py prune # почистить journal.jsonl от подтверждённых записей старше 30 дней
|
||
"""
|
||
import argparse
|
||
import base64
|
||
import json
|
||
import os
|
||
import signal
|
||
import ssl
|
||
import sys
|
||
import time
|
||
import urllib.error
|
||
import urllib.parse
|
||
import urllib.request
|
||
from datetime import datetime, timezone
|
||
from typing import Any, Dict, List, Optional
|
||
|
||
AGENT_VERSION = 1
|
||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||
API = "/rest/api/2"
|
||
AGILE = "/rest/agile/1.0"
|
||
|
||
|
||
def now_iso() -> str:
|
||
return datetime.now(timezone.utc).replace(microsecond=0).isoformat()
|
||
|
||
|
||
def log(msg: str) -> None:
|
||
print("[%s] %s" % (datetime.now().strftime("%H:%M:%S"), msg), flush=True)
|
||
|
||
|
||
def load_config(path: str) -> dict:
|
||
try:
|
||
with open(path, encoding="utf-8") as f:
|
||
cfg = json.load(f)
|
||
except FileNotFoundError:
|
||
sys.exit("нет файла настроек %s — скопируйте agent.example.json в agent.json и заполните" % path)
|
||
for key in ("hub_url", "hub_token", "jira"):
|
||
if not cfg.get(key):
|
||
sys.exit("в %s нет обязательного поля %s" % (path, key))
|
||
if not cfg["jira"].get("base_url"):
|
||
sys.exit("в %s нет jira.base_url" % path)
|
||
cfg.setdefault("timeout", 60)
|
||
cfg.setdefault("poll_seconds", 5)
|
||
cfg.setdefault("verify_ssl", True)
|
||
return cfg
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# HTTP
|
||
# ---------------------------------------------------------------------------
|
||
class HttpError(Exception):
|
||
def __init__(self, code: int, text: str, headers=None):
|
||
super().__init__("HTTP %d: %s" % (code, text[:300]))
|
||
self.code, self.text, self.headers = code, text, headers or {}
|
||
|
||
|
||
class Http:
|
||
def __init__(self, base: str, headers: Dict[str, str], timeout: float = 60, ctx=None):
|
||
self.base, self.headers, self.timeout, self.ctx = base.rstrip("/"), headers, timeout, ctx
|
||
|
||
def __repr__(self):
|
||
return "<%s %s>" % (self.__class__.__name__, self.base)
|
||
|
||
def call(self, method: str, path: str, body=None, params=None) -> Any:
|
||
url = self.base + path
|
||
if params:
|
||
url += "?" + urllib.parse.urlencode({k: v for k, v in params.items() if v is not None})
|
||
data = json.dumps(body, ensure_ascii=False).encode("utf-8") if body is not None else None
|
||
req = urllib.request.Request(url, data=data, method=method)
|
||
for k, v in self.headers.items():
|
||
req.add_header(k, v)
|
||
req.add_header("Accept", "application/json")
|
||
if data is not None:
|
||
req.add_header("Content-Type", "application/json; charset=utf-8")
|
||
try:
|
||
with urllib.request.urlopen(req, timeout=self.timeout, context=self.ctx) as resp:
|
||
raw = resp.read()
|
||
return json.loads(raw.decode("utf-8")) if raw else {}
|
||
except urllib.error.HTTPError as e:
|
||
raise HttpError(e.code, e.read().decode("utf-8", "replace"), dict(e.headers)) from None
|
||
except (urllib.error.URLError, ConnectionError, TimeoutError, OSError) as e:
|
||
raise ConnectionError("%s недоступен: %s" % (self.base, e)) from None
|
||
|
||
|
||
class JiraClient(Http):
|
||
"""Все вызовы Jira идут здесь: счётчики вызовов и 429/503, время последнего вызова, повтор по Retry-After."""
|
||
|
||
def __init__(self, cfg: dict):
|
||
j = cfg["jira"]
|
||
auth = j.get("auth") or {}
|
||
kind = auth.get("type") or ("bearer" if auth.get("token") else "basic")
|
||
if kind == "bearer":
|
||
if not auth.get("token"):
|
||
sys.exit("jira.auth.token пуст")
|
||
header = "Bearer " + auth["token"]
|
||
else:
|
||
if not (auth.get("user") and auth.get("password")):
|
||
sys.exit("jira.auth.user / jira.auth.password пусты")
|
||
header = "Basic " + base64.b64encode(("%s:%s" % (auth["user"], auth["password"])).encode()).decode()
|
||
ctx = None
|
||
if not cfg.get("verify_ssl", True):
|
||
ctx = ssl.create_default_context()
|
||
ctx.check_hostname = False
|
||
ctx.verify_mode = ssl.CERT_NONE
|
||
elif cfg.get("ca_bundle"):
|
||
ctx = ssl.create_default_context(cafile=cfg["ca_bundle"])
|
||
super().__init__(j["base_url"], {"Authorization": header, "X-Atlassian-Token": "no-check"}, cfg.get("timeout", 60), ctx)
|
||
self.calls = 0
|
||
self.rate_limited = 0
|
||
self.last_call_at = None # type: Optional[float] (time.monotonic)
|
||
self.last_call_iso = None # type: Optional[str]
|
||
self.sleep = time.sleep
|
||
|
||
def reset_counters(self) -> None:
|
||
self.calls, self.rate_limited = 0, 0
|
||
|
||
def call(self, method, path, body=None, params=None):
|
||
for attempt in range(1, 6):
|
||
try:
|
||
out = super().call(method, path, body, params)
|
||
self.calls += 1
|
||
self.last_call_at, self.last_call_iso = time.monotonic(), now_iso()
|
||
return out
|
||
except HttpError as e:
|
||
if e.code in (429, 503) and attempt < 5:
|
||
retry = e.headers.get("Retry-After")
|
||
wait = int(retry) if retry and retry.isdigit() else 60 * attempt
|
||
self.rate_limited += 1
|
||
log(" ! HTTP %d, жду %d с (попытка %d/5)" % (e.code, wait, attempt))
|
||
self.sleep(wait)
|
||
continue
|
||
if e.code in (429, 503):
|
||
self.rate_limited += 1
|
||
raise
|
||
|
||
def get(self, path, **params):
|
||
return self.call("GET", path, params=params or None)
|
||
|
||
def post(self, path, body):
|
||
return self.call("POST", path, body)
|
||
|
||
def put(self, path, body):
|
||
return self.call("PUT", path, body)
|
||
|
||
def delete(self, path):
|
||
return self.call("DELETE", path)
|
||
|
||
|
||
class HubClient(Http):
|
||
def __init__(self, cfg: dict):
|
||
super().__init__(cfg["hub_url"], {"Authorization": "Bearer " + cfg["hub_token"]}, cfg.get("timeout", 60))
|
||
|
||
def claim(self, agent_version, settings_version, last_jira_call_at) -> dict:
|
||
return self.call("POST", "/agent/claim", {"agent_version": agent_version, "settings_version": settings_version,
|
||
"last_jira_call_at": last_jira_call_at})
|
||
|
||
def settings(self) -> dict:
|
||
return self.call("GET", "/agent/settings")
|
||
|
||
def command(self, command_id) -> dict:
|
||
return self.call("GET", "/agent/commands/%d" % int(command_id))
|
||
|
||
def result(self, command_id, payload: dict) -> dict:
|
||
return self.call("POST", "/agent/commands/%d/result" % int(command_id), payload)
|
||
|
||
def state(self, payload: dict) -> dict:
|
||
return self.call("POST", "/agent/state", payload)
|
||
|
||
def known_keys(self) -> List[str]:
|
||
return self.call("GET", "/agent/known-keys")["keys"]
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# CLI
|
||
# ---------------------------------------------------------------------------
|
||
def cmd_check(cfg: dict) -> int:
|
||
ok = True
|
||
try:
|
||
s = HubClient(cfg).settings()
|
||
log("хаб: доступен, настройки v%s, проект %s" % (s.get("settings_version"), s.get("project_key")))
|
||
except (ConnectionError, HttpError) as e:
|
||
log("хаб: НЕДОСТУПЕН — %s" % e)
|
||
ok = False
|
||
try:
|
||
me = JiraClient(cfg).get(API + "/myself")
|
||
log("Jira: доступен, пользователь %s" % me.get("name"))
|
||
except (ConnectionError, HttpError) as e:
|
||
log("Jira: НЕДОСТУПЕН — %s" % e)
|
||
ok = False
|
||
return 0 if ok else 1
|
||
|
||
|
||
def main(argv=None) -> int:
|
||
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
||
ap.add_argument("--config", default=os.path.join(HERE, "agent.json"))
|
||
ap.add_argument("--journal", default=os.path.join(HERE, "journal.jsonl"))
|
||
ap.add_argument("cmd", nargs="?", default="run", choices=["run", "check", "prune"])
|
||
args = ap.parse_args(argv)
|
||
cfg = load_config(args.config)
|
||
if args.cmd == "check":
|
||
return cmd_check(cfg)
|
||
log("команда %s ещё не реализована" % args.cmd)
|
||
return 2
|
||
|
||
|
||
if __name__ == "__main__":
|
||
sys.exit(main())
|
||
```
|
||
|
||
- [ ] **Step 5: Запустить — зелёный**
|
||
|
||
Run: `/usr/bin/python3 -m unittest tests.test_clients -v`
|
||
Expected: 7 passed
|
||
|
||
- [ ] **Step 6: Коммит**
|
||
|
||
```bash
|
||
git add src/agent.py src/agent.example.json tests/__init__.py tests/_common.py tests/fake_jira.py tests/test_clients.py
|
||
git commit -m "feat(agent): скелет, клиенты Jira и хаба, фейковый Jira, команда check
|
||
|
||
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 2: Журнал на диске и `prune`
|
||
|
||
**Files:**
|
||
- Modify: `src/agent.py` (класс `Journal` после `HubClient`; `prune` в `main`)
|
||
- Test: `tests/test_journal.py`
|
||
|
||
**Interfaces:**
|
||
- Produces: `Journal(path)`: `.append(record)`, `.records() -> list`, `.started(command_id)`, `.finished(command_id, status, result, error, jira_calls, rate_limited)`,
|
||
`.acked(command_id)`, `.unacked_finished() -> list[dict]` (последняя `finished` на команду без `acked` после неё, в порядке журнала),
|
||
`.unfinished_started() -> list[int]`, `.prune(days=30) -> int` (сколько записей удалено).
|
||
|
||
- [ ] **Step 1: Тест**
|
||
|
||
`tests/test_journal.py`:
|
||
```python
|
||
import json
|
||
import os
|
||
import tempfile
|
||
import unittest
|
||
|
||
from _common import agent
|
||
|
||
|
||
class JournalTest(unittest.TestCase):
|
||
def setUp(self):
|
||
self.dir = tempfile.TemporaryDirectory()
|
||
self.path = os.path.join(self.dir.name, "journal.jsonl")
|
||
self.j = agent.Journal(self.path)
|
||
|
||
def tearDown(self):
|
||
self.dir.cleanup()
|
||
|
||
def test_empty(self):
|
||
self.assertEqual(self.j.records(), [])
|
||
self.assertEqual(self.j.unacked_finished(), [])
|
||
self.assertEqual(self.j.unfinished_started(), [])
|
||
|
||
def test_lifecycle(self):
|
||
self.j.started(1)
|
||
self.assertEqual(self.j.unfinished_started(), [1])
|
||
self.j.finished(1, "done", {"key": "A-1"}, None, 2, 0)
|
||
self.assertEqual(self.j.unfinished_started(), [])
|
||
pending = self.j.unacked_finished()
|
||
self.assertEqual(len(pending), 1)
|
||
self.assertEqual(pending[0]["command_id"], 1)
|
||
self.assertEqual(pending[0]["result"], {"key": "A-1"})
|
||
self.assertEqual(pending[0]["jira_calls"], 2)
|
||
self.j.acked(1)
|
||
self.assertEqual(self.j.unacked_finished(), [])
|
||
self.j.started(2)
|
||
self.j.started(3)
|
||
self.j.finished(3, "failed", None, "HTTP 400", 1, 0)
|
||
self.assertEqual(self.j.unfinished_started(), [2])
|
||
self.assertEqual([r["command_id"] for r in self.j.unacked_finished()], [3])
|
||
|
||
def test_broken_line_is_skipped(self):
|
||
self.j.started(1)
|
||
with open(self.path, "a", encoding="utf-8") as f:
|
||
f.write("{не json\n")
|
||
self.j.finished(1, "done", None, None, 1, 0)
|
||
self.assertEqual(len(self.j.records()), 2)
|
||
|
||
def test_prune_keeps_unacked_and_recent(self):
|
||
self.j.started(1)
|
||
self.j.finished(1, "done", None, None, 1, 0)
|
||
self.j.append({"kind": "acked", "command_id": 1, "at": "2020-01-01T00:00:00+00:00"})
|
||
self.j.started(2)
|
||
self.j.finished(2, "done", None, None, 1, 0)
|
||
self.j.acked(2)
|
||
self.j.started(3)
|
||
removed = self.j.prune(days=30)
|
||
self.assertEqual(removed, 3)
|
||
ids = [(r["kind"], r["command_id"]) for r in self.j.records()]
|
||
self.assertEqual(ids, [("started", 2), ("finished", 2), ("acked", 2), ("started", 3)])
|
||
self.assertEqual(self.j.prune(days=30), 0)
|
||
|
||
def test_no_secrets_in_records(self):
|
||
self.j.finished(1, "done", {"key": "A-1"}, None, 1, 0)
|
||
with open(self.path, encoding="utf-8") as f:
|
||
text = f.read()
|
||
self.assertNotIn("password", text)
|
||
self.assertEqual(json.loads(text.splitlines()[0])["kind"], "finished")
|
||
|
||
|
||
if __name__ == "__main__":
|
||
unittest.main()
|
||
```
|
||
|
||
- [ ] **Step 2: Запустить — падает**
|
||
|
||
Run: `/usr/bin/python3 -m unittest tests.test_journal -v`
|
||
Expected: FAIL — `AttributeError: module 'agent' has no attribute 'Journal'`
|
||
|
||
- [ ] **Step 3: Реализация**
|
||
|
||
В `src/agent.py` после класса `HubClient`:
|
||
```python
|
||
# ---------------------------------------------------------------------------
|
||
# Журнал: только дозапись; started → finished → acked по id команды
|
||
# ---------------------------------------------------------------------------
|
||
class Journal:
|
||
def __init__(self, path: str):
|
||
self.path = path
|
||
|
||
def append(self, record: dict) -> None:
|
||
record = dict(record)
|
||
record.setdefault("at", now_iso())
|
||
with open(self.path, "a", encoding="utf-8") as f:
|
||
f.write(json.dumps(record, ensure_ascii=False) + "\n")
|
||
|
||
def records(self) -> List[dict]:
|
||
if not os.path.exists(self.path):
|
||
return []
|
||
out = []
|
||
with open(self.path, encoding="utf-8") as f:
|
||
for n, line in enumerate(f, 1):
|
||
line = line.strip()
|
||
if not line:
|
||
continue
|
||
try:
|
||
out.append(json.loads(line))
|
||
except ValueError:
|
||
log("журнал: строка %d не читается, пропускаю" % n)
|
||
return out
|
||
|
||
def started(self, command_id) -> None:
|
||
self.append({"kind": "started", "command_id": int(command_id)})
|
||
|
||
def finished(self, command_id, status, result, error, jira_calls, rate_limited) -> None:
|
||
self.append({"kind": "finished", "command_id": int(command_id), "status": status, "result": result,
|
||
"error": error, "jira_calls": int(jira_calls or 0), "rate_limited": int(rate_limited or 0)})
|
||
|
||
def acked(self, command_id) -> None:
|
||
self.append({"kind": "acked", "command_id": int(command_id)})
|
||
|
||
def unacked_finished(self) -> List[dict]:
|
||
last, acked = {}, set()
|
||
for r in self.records():
|
||
if r.get("kind") == "finished":
|
||
last[r["command_id"]] = r
|
||
acked.discard(r["command_id"])
|
||
elif r.get("kind") == "acked":
|
||
acked.add(r["command_id"])
|
||
return [r for cid, r in last.items() if cid not in acked]
|
||
|
||
def unfinished_started(self) -> List[int]:
|
||
started, finished = [], set()
|
||
for r in self.records():
|
||
if r.get("kind") == "started":
|
||
started.append(r["command_id"])
|
||
elif r.get("kind") in ("finished", "acked"):
|
||
finished.add(r["command_id"])
|
||
return [cid for cid in started if cid not in finished]
|
||
|
||
def prune(self, days: int = 30) -> int:
|
||
"""Удалить записи команд, подтверждённых (acked) раньше, чем days дней назад. Неподтверждённые — остаются."""
|
||
records = self.records()
|
||
cutoff = datetime.now(timezone.utc).timestamp() - days * 86400
|
||
old = set()
|
||
for r in records:
|
||
if r.get("kind") == "acked":
|
||
try:
|
||
ts = datetime.fromisoformat(r.get("at", "")).timestamp()
|
||
except ValueError:
|
||
continue
|
||
if ts < cutoff:
|
||
old.add(r["command_id"])
|
||
keep = [r for r in records if r.get("command_id") not in old]
|
||
tmp = self.path + ".tmp"
|
||
with open(tmp, "w", encoding="utf-8") as f:
|
||
for r in keep:
|
||
f.write(json.dumps(r, ensure_ascii=False) + "\n")
|
||
os.replace(tmp, self.path)
|
||
return len(records) - len(keep)
|
||
```
|
||
|
||
В `main` заменить заглушку:
|
||
```python
|
||
if args.cmd == "prune":
|
||
removed = Journal(args.journal).prune(30)
|
||
log("журнал: удалено записей %d" % removed)
|
||
return 0
|
||
log("команда %s ещё не реализована" % args.cmd)
|
||
return 2
|
||
```
|
||
|
||
- [ ] **Step 4: Запустить — зелёный**
|
||
|
||
Run: `/usr/bin/python3 -m unittest tests.test_journal -v`
|
||
Expected: 5 passed
|
||
|
||
- [ ] **Step 5: Коммит**
|
||
|
||
```bash
|
||
git add src/agent.py tests/test_journal.py
|
||
git commit -m "feat(agent): журнал на диске, восстановление по нему, prune
|
||
|
||
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 3: Исполнитель — `ensure_issue`, `ensure_fields`, `ensure_comment`
|
||
|
||
**Files:**
|
||
- Modify: `src/agent.py` (класс `Executor` после `Journal`)
|
||
- Test: `tests/test_executor_fields.py`
|
||
|
||
**Interfaces:**
|
||
- Produces: `Executor(jira, settings, hub=None)`: `.run(cmd: dict) -> {"status", "result", "error", "jira_calls", "rate_limited"}`,
|
||
`.sleep` (подменяемый), `.gap(cmd_type)`, `.fid(logical)`, `.priority_value(name)`, `.issue_type_ref(kind)`, `.epic_fields(epic_key)`,
|
||
методы `do_ensure_issue(p, cmd)`, `do_ensure_fields(p, cmd)`, `do_ensure_comment(p, cmd)`.
|
||
- Правило сравнения дат: по первым 10 символам (`YYYY-MM-DD`), потому что кастомные даты Jira приходят со временем.
|
||
|
||
- [ ] **Step 1: Тесты**
|
||
|
||
`tests/test_executor_fields.py`:
|
||
```python
|
||
import unittest
|
||
|
||
from _common import SETTINGS, agent, jira_cfg, no_sleep
|
||
from fake_jira import FakeJira
|
||
|
||
|
||
class ExecutorFieldsTest(unittest.TestCase):
|
||
def setUp(self):
|
||
self.fake = FakeJira()
|
||
self.jira = agent.JiraClient(jira_cfg(self.fake))
|
||
self.jira.sleep = no_sleep
|
||
self.ex = agent.Executor(self.jira, dict(SETTINGS))
|
||
self.ex.sleep = no_sleep
|
||
|
||
def tearDown(self):
|
||
self.fake.stop()
|
||
|
||
def run_cmd(self, ctype, **params):
|
||
return self.ex.run({"id": 1, "type": ctype, "params": params})
|
||
|
||
def test_ensure_issue_creates_then_finds(self):
|
||
out = self.run_cmd("ensure_issue", temp_key="new:1", issue_type="task", summary="Новая задача", description="h3. Контекст",
|
||
priority="Высокий", duedate="2026-10-23", planned_start="2026-10-20", labels=["AWPCA", "kind-dev"],
|
||
epic_key="AURORA-1350", assignee="ivanov")
|
||
self.assertEqual(out["status"], "done")
|
||
key = out["result"]["key"]
|
||
self.assertTrue(out["result"]["created"])
|
||
f = self.fake.issues[key]
|
||
self.assertEqual(f["priority"], "Высокий")
|
||
self.assertEqual(f["customfield_10801"], "2026-10-20")
|
||
self.assertEqual(f["customfield_15302"], "AURORA-1350")
|
||
self.assertEqual(f["labels"], ["AWPCA", "kind-dev"])
|
||
self.assertEqual(out["jira_calls"], 2)
|
||
again = self.run_cmd("ensure_issue", temp_key="new:1", issue_type="task", summary="Новая задача")
|
||
self.assertEqual(again["status"], "done")
|
||
self.assertEqual(again["result"], {"key": key, "existed": True})
|
||
self.assertEqual(len(self.fake.issues), 1)
|
||
self.assertEqual(again["jira_calls"], 1)
|
||
|
||
def test_ensure_issue_epic_sets_epic_name(self):
|
||
out = self.run_cmd("ensure_issue", temp_key="new:2", issue_type="epic", summary="Приёмка")
|
||
f = self.fake.issues[out["result"]["key"]]
|
||
self.assertEqual(f["issuetype"], "Фича")
|
||
self.assertEqual(f["customfield_15304"], "Приёмка")
|
||
|
||
def test_ensure_issue_priority_mapped(self):
|
||
out = self.run_cmd("ensure_issue", temp_key="new:3", issue_type="task", summary="x", priority="Наивысший")
|
||
self.assertEqual(self.fake.issues[out["result"]["key"]]["priority"], "Критичный")
|
||
|
||
def test_ensure_fields_writes_only_diff(self):
|
||
self.fake.add("AURORA-1", duedate="2026-10-20", labels=["AWPCA", "fact-open"], priority="Средний")
|
||
out = self.run_cmd("ensure_fields", key="AURORA-1", duedate="2026-10-23", priority="Средний",
|
||
labels_add=["kind-dev", "AWPCA"], labels_remove=["fact-open"], assignee="ivanov",
|
||
planned_end="2026-10-23", epic_key="AURORA-1350")
|
||
self.assertEqual(out["status"], "done")
|
||
self.assertEqual(out["result"]["changed"], ["duedate", "planned_end", "epic_key", "assignee", "labels"])
|
||
f = self.fake.issues["AURORA-1"]
|
||
self.assertEqual(f["labels"], ["AWPCA", "kind-dev"])
|
||
self.assertEqual(f["assignee"], "ivanov")
|
||
self.assertEqual(f["customfield_10802"], "2026-10-23")
|
||
self.assertEqual(len(self.fake.writes()), 1)
|
||
again = self.run_cmd("ensure_fields", key="AURORA-1", duedate="2026-10-23", labels_add=["kind-dev"], assignee="ivanov")
|
||
self.assertEqual(again["result"], {"changed": []})
|
||
self.assertEqual(len(self.fake.writes()), 1)
|
||
|
||
def test_fields_description_exact_compare(self):
|
||
text = "h3. Контекст\n* пункт *жирный*\n\nh3. Что сделать"
|
||
self.fake.add("AURORA-1", description=text)
|
||
out = self.run_cmd("ensure_fields", key="AURORA-1", description=text)
|
||
self.assertEqual(out["result"]["changed"], [])
|
||
out = self.run_cmd("ensure_fields", key="AURORA-1", description=text + "\n")
|
||
self.assertEqual(out["result"]["changed"], ["description"])
|
||
|
||
def test_fields_date_with_time_from_jira(self):
|
||
self.fake.add("AURORA-1", customfield_10802="2026-10-23T00:00:00.000+0300")
|
||
out = self.run_cmd("ensure_fields", key="AURORA-1", planned_end="2026-10-23")
|
||
self.assertEqual(out["result"]["changed"], [])
|
||
|
||
def test_fields_clear_assignee_and_epic(self):
|
||
self.fake.add("AURORA-1", assignee="ivanov", customfield_15302="AURORA-1350")
|
||
out = self.run_cmd("ensure_fields", key="AURORA-1", assignee=None, epic_key=None)
|
||
self.assertEqual(out["result"]["changed"], ["epic_key", "assignee"])
|
||
self.assertIsNone(self.fake.issues["AURORA-1"]["assignee"])
|
||
self.assertIsNone(self.fake.issues["AURORA-1"]["customfield_15302"])
|
||
|
||
def test_fields_missing_issue_is_failed(self):
|
||
out = self.run_cmd("ensure_fields", key="AURORA-404", duedate="2026-10-23")
|
||
self.assertEqual(out["status"], "failed")
|
||
self.assertIn("HTTP 404", out["error"])
|
||
|
||
def test_ensure_comment_upsert(self):
|
||
self.fake.add("AURORA-1")
|
||
out = self.run_cmd("ensure_comment", key="AURORA-1", marker="Актуализация 10.10.2026", body="Сделано: список")
|
||
self.assertEqual(out["status"], "done")
|
||
self.assertTrue(out["result"]["created"])
|
||
self.assertEqual(self.fake.comments["AURORA-1"][0]["body"], "*Актуализация 10.10.2026.* Сделано: список")
|
||
same = self.run_cmd("ensure_comment", key="AURORA-1", marker="Актуализация 10.10.2026", body="Сделано: список")
|
||
self.assertEqual(same["result"]["changed"], False)
|
||
upd = self.run_cmd("ensure_comment", key="AURORA-1", marker="Актуализация 10.10.2026", body="Сделано: всё")
|
||
self.assertEqual(upd["result"]["changed"], True)
|
||
self.assertEqual(len(self.fake.comments["AURORA-1"]), 1)
|
||
self.assertEqual(self.fake.comments["AURORA-1"][0]["body"], "*Актуализация 10.10.2026.* Сделано: всё")
|
||
|
||
def test_unknown_type(self):
|
||
self.assertEqual(self.run_cmd("ensure_magic", key="x")["status"], "failed")
|
||
|
||
|
||
if __name__ == "__main__":
|
||
unittest.main()
|
||
```
|
||
|
||
- [ ] **Step 2: Запустить — падает**
|
||
|
||
Run: `/usr/bin/python3 -m unittest tests.test_executor_fields -v`
|
||
Expected: FAIL — `AttributeError: module 'agent' has no attribute 'Executor'`
|
||
|
||
- [ ] **Step 3: Реализация**
|
||
|
||
В `src/agent.py` после класса `Journal`:
|
||
```python
|
||
# ---------------------------------------------------------------------------
|
||
# Исполнитель: команда — желаемый результат; прочитать → сравнить → записать разницу
|
||
# ---------------------------------------------------------------------------
|
||
def _date(value) -> Optional[str]:
|
||
"""Даты Jira (duedate — YYYY-MM-DD, кастомные — с временем) сравниваем по дню."""
|
||
return (value or "")[:10] or None
|
||
|
||
|
||
class Executor:
|
||
def __init__(self, jira: JiraClient, settings: dict, hub: Optional[HubClient] = None):
|
||
self.jira, self.s, self.hub = jira, settings, hub
|
||
self.sleep = time.sleep
|
||
self._symmetric = None
|
||
self._sprints = None
|
||
|
||
def run(self, cmd: dict) -> dict:
|
||
self.jira.reset_counters()
|
||
self._sprints = None
|
||
handler = getattr(self, "do_" + str(cmd.get("type")), None)
|
||
if handler is None:
|
||
return self._out("failed", error="неизвестный тип команды %s" % cmd.get("type"))
|
||
try:
|
||
return handler(cmd.get("params") or {}, cmd)
|
||
except HttpError as e:
|
||
return self._out("failed", error="HTTP %d %s" % (e.code, e.text[:1500]))
|
||
except ConnectionError as e:
|
||
return self._out("failed", error=str(e))
|
||
|
||
def _out(self, status, result=None, error=None) -> dict:
|
||
return {"status": status, "result": result, "error": error,
|
||
"jira_calls": self.jira.calls, "rate_limited": self.jira.rate_limited}
|
||
|
||
def gap(self, cmd_type: str) -> None:
|
||
"""Зазор между записями внутри одной команды — по политике хаба для этого типа."""
|
||
seconds = int((self.s.get("pacing") or {}).get(cmd_type, 0) or 0)
|
||
if seconds > 0:
|
||
log(" пауза %d с" % seconds)
|
||
self.sleep(seconds)
|
||
|
||
def fid(self, logical: str) -> str:
|
||
return (self.s.get("field_ids") or {}).get(logical, logical)
|
||
|
||
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))
|
||
|
||
def priority_value(self, name: str) -> dict:
|
||
mapped = (self.s.get("priority_map") or {}).get(name, name)
|
||
return {self.s.get("priority_value_key") or "name": mapped}
|
||
|
||
def issue_type_ref(self, kind: str) -> dict:
|
||
it = (self.s.get("issue_types") or {}).get(kind) or {}
|
||
return {"id": str(it["id"])} if it.get("id") else {"name": it.get("name")}
|
||
|
||
def epic_fields(self, epic_key) -> dict:
|
||
ep = self.s.get("epic_link") or {}
|
||
if epic_key and ep.get("mode", "field") == "field" and ep.get("field_id"):
|
||
return {ep["field_id"]: epic_key}
|
||
return {}
|
||
|
||
# --- ensure_issue ----------------------------------------------------------
|
||
def do_ensure_issue(self, p: dict, cmd: dict) -> dict:
|
||
jql = "project = %s AND reporter = currentUser() AND created >= -7d" % self.s["project_key"]
|
||
found = self.jira.get(API + "/search", jql=jql, fields="summary", maxResults=200)
|
||
for it in found.get("issues", []):
|
||
if (it.get("fields", {}).get("summary") or "") == p["summary"]:
|
||
return self._out("done", {"key": it["key"], "existed": True})
|
||
fields = {"project": {"key": self.s["project_key"]}, "issuetype": self.issue_type_ref(p["issue_type"]),
|
||
"summary": p["summary"]}
|
||
if p.get("description") is not None:
|
||
fields[self.fid("description")] = p["description"]
|
||
if p.get("priority"):
|
||
fields[self.fid("priority")] = self.priority_value(p["priority"])
|
||
if p.get("duedate"):
|
||
fields[self.fid("duedate")] = p["duedate"]
|
||
for logical in ("planned_start", "planned_end"):
|
||
if p.get(logical) and self.has_field(logical):
|
||
fields[self.fid(logical)] = p[logical]
|
||
if p.get("labels") is not None:
|
||
fields[self.fid("labels")] = list(p["labels"])
|
||
if p.get("assignee"):
|
||
fields["assignee"] = {"name": p["assignee"]}
|
||
fields.update(self.epic_fields(p.get("epic_key")))
|
||
if p["issue_type"] == "epic" and self.s.get("epic_name_field_id"):
|
||
fields[self.s["epic_name_field_id"]] = p["summary"]
|
||
created = self.jira.post(API + "/issue", {"fields": fields})
|
||
return self._out("done", {"key": created["key"], "created": True})
|
||
|
||
# --- ensure_fields ---------------------------------------------------------
|
||
def do_ensure_fields(self, p: dict, cmd: dict) -> dict:
|
||
key = p["key"]
|
||
cur = self.jira.get(API + "/issue/%s" % key)["fields"]
|
||
fields, changed = {}, []
|
||
if "summary" in p and (cur.get("summary") or "") != (p["summary"] or ""):
|
||
fields["summary"] = p["summary"]
|
||
changed.append("summary")
|
||
if "description" in p and (cur.get(self.fid("description")) or "") != (p["description"] or ""):
|
||
fields[self.fid("description")] = p["description"]
|
||
changed.append("description")
|
||
if "priority" in p:
|
||
want = self.priority_value(p["priority"])
|
||
vkey = list(want)[0]
|
||
if ((cur.get(self.fid("priority")) or {}).get(vkey)) != want[vkey]:
|
||
fields[self.fid("priority")] = want
|
||
changed.append("priority")
|
||
for logical in ("duedate", "planned_start", "planned_end"):
|
||
if logical in p and self.has_field(logical) and _date(cur.get(self.fid(logical))) != _date(p[logical]):
|
||
fields[self.fid(logical)] = p[logical]
|
||
changed.append(logical)
|
||
if "epic_key" in p:
|
||
ep = self.s.get("epic_link") or {}
|
||
if ep.get("field_id") and (cur.get(ep["field_id"]) or None) != (p["epic_key"] or None):
|
||
fields[ep["field_id"]] = p["epic_key"]
|
||
changed.append("epic_key")
|
||
if "assignee" in p and ((cur.get("assignee") or {}).get("name") or None) != (p["assignee"] or None):
|
||
fields["assignee"] = {"name": p["assignee"]} if p["assignee"] else None
|
||
changed.append("assignee")
|
||
if "labels_add" in p or "labels_remove" in p:
|
||
labels = list(cur.get(self.fid("labels")) or [])
|
||
remove = set(p.get("labels_remove") or [])
|
||
new = [l for l in labels if l not in remove] + [l for l in (p.get("labels_add") or []) if l not in labels]
|
||
if new != labels:
|
||
fields[self.fid("labels")] = new
|
||
changed.append("labels")
|
||
if not fields:
|
||
return self._out("done", {"changed": []})
|
||
self.jira.put(API + "/issue/%s" % key, {"fields": fields})
|
||
return self._out("done", {"changed": changed})
|
||
|
||
# --- ensure_comment --------------------------------------------------------
|
||
def do_ensure_comment(self, p: dict, cmd: dict) -> dict:
|
||
key, marker, body = p["key"], p["marker"], p["body"]
|
||
if marker not in body[:80]:
|
||
body = "*%s.* %s" % (marker, body)
|
||
existing = None
|
||
for c in self.jira.get(API + "/issue/%s/comment" % key).get("comments", []):
|
||
if marker in (c.get("body") or "")[:80]:
|
||
existing = c
|
||
break
|
||
if existing and (existing.get("body") or "") == body:
|
||
return self._out("done", {"comment_id": existing["id"], "changed": False})
|
||
if existing:
|
||
self.jira.put(API + "/issue/%s/comment/%s" % (key, existing["id"]), {"body": body})
|
||
return self._out("done", {"comment_id": existing["id"], "changed": True})
|
||
c = self.jira.post(API + "/issue/%s/comment" % key, {"body": body})
|
||
return self._out("done", {"comment_id": c.get("id"), "created": True})
|
||
```
|
||
|
||
- [ ] **Step 4: Запустить — зелёный**
|
||
|
||
Run: `/usr/bin/python3 -m unittest tests.test_executor_fields -v`
|
||
Expected: 10 passed
|
||
|
||
- [ ] **Step 5: Коммит**
|
||
|
||
```bash
|
||
git add src/agent.py tests/test_executor_fields.py
|
||
git commit -m "feat(agent): исполнитель — ensure_issue, ensure_fields, ensure_comment
|
||
|
||
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 4: Исполнитель — `ensure_status`, `ensure_link`, `ensure_sprint`
|
||
|
||
**Files:**
|
||
- Modify: `src/agent.py` (методы в `Executor`)
|
||
- Test: `tests/test_executor_links.py`
|
||
|
||
**Interfaces:**
|
||
- Produces: `Executor.do_ensure_status`, `.do_ensure_link`, `.do_ensure_sprint`, `.symmetric_types() -> set`, `.sprints() -> dict[name, sprint]` (активные и будущие доски `board_id`; `{}` без доски).
|
||
|
||
- [ ] **Step 1: Тесты**
|
||
|
||
`tests/test_executor_links.py`:
|
||
```python
|
||
import unittest
|
||
|
||
from _common import SETTINGS, agent, jira_cfg, no_sleep
|
||
from fake_jira import FakeJira
|
||
|
||
|
||
class ExecutorLinksTest(unittest.TestCase):
|
||
def setUp(self):
|
||
self.fake = FakeJira()
|
||
self.jira = agent.JiraClient(jira_cfg(self.fake))
|
||
self.jira.sleep = no_sleep
|
||
self.ex = agent.Executor(self.jira, dict(SETTINGS))
|
||
self.gaps = []
|
||
self.ex.sleep = self.gaps.append
|
||
self.fake.add("AURORA-1")
|
||
self.fake.add("AURORA-2")
|
||
|
||
def tearDown(self):
|
||
self.fake.stop()
|
||
|
||
def run_cmd(self, ctype, **params):
|
||
return self.ex.run({"id": 1, "type": ctype, "params": params})
|
||
|
||
# --- статус ---
|
||
def test_status_path(self):
|
||
out = self.run_cmd("ensure_status", key="AURORA-1", target=["Разработка", "Готово"])
|
||
self.assertEqual(out["status"], "done")
|
||
self.assertEqual(out["result"], {"from": "Ожидание разработки", "to": "Готово", "steps": 2})
|
||
self.assertEqual(self.fake.issues["AURORA-1"]["status"], "Готово")
|
||
again = self.run_cmd("ensure_status", key="AURORA-1", target=["Разработка", "Готово"])
|
||
self.assertEqual(again["result"]["steps"], 0)
|
||
self.assertEqual(again["jira_calls"], 1)
|
||
|
||
def test_status_resumes_from_middle(self):
|
||
self.fake.issues["AURORA-1"]["status"] = "Разработка"
|
||
out = self.run_cmd("ensure_status", key="AURORA-1", target=["Разработка", "Готово к тестированию"])
|
||
self.assertEqual(out["result"]["steps"], 1)
|
||
self.assertEqual(self.fake.issues["AURORA-1"]["status"], "Готово к тестированию")
|
||
|
||
def test_status_single_and_by_transition_name(self):
|
||
out = self.run_cmd("ensure_status", key="AURORA-1", target="Взять в разработку")
|
||
self.assertEqual(out["result"]["to"], "Взять в разработку") # имя перехода принято как шаг
|
||
self.assertEqual(self.fake.issues["AURORA-1"]["status"], "Разработка")
|
||
|
||
def test_status_no_transition_is_failed(self):
|
||
out = self.run_cmd("ensure_status", key="AURORA-1", target="Тестирование")
|
||
self.assertEqual(out["status"], "failed")
|
||
self.assertIn("нет перехода из «Ожидание разработки» в «Тестирование»", out["error"])
|
||
self.assertIn("Взять в разработку→Разработка", out["error"])
|
||
self.assertEqual(out["result"], {"from": "Ожидание разработки", "reached": "Ожидание разработки"})
|
||
|
||
def test_status_gap_between_steps(self):
|
||
self.ex.s["pacing"] = {"ensure_status": 7}
|
||
self.run_cmd("ensure_status", key="AURORA-1", target=["Разработка", "Готово"])
|
||
self.assertEqual(self.gaps, [7])
|
||
|
||
# --- связи ---
|
||
def test_link_create_and_idempotent(self):
|
||
out = self.run_cmd("ensure_link", source="AURORA-1", target="AURORA-2", link_type="блокирует начало")
|
||
self.assertEqual(out["result"], {"created": True, "removed": []})
|
||
link = self.fake.links[0]
|
||
self.assertEqual((link["source"], link["target"], link["type"]), ("AURORA-1", "AURORA-2", "ASUPR_Блокер_Начала"))
|
||
again = self.run_cmd("ensure_link", source="AURORA-1", target="AURORA-2", link_type="блокирует начало")
|
||
self.assertEqual(again["result"], {"existed": True, "removed": []})
|
||
self.assertEqual(len(self.fake.links), 1)
|
||
|
||
def test_link_symmetric_any_direction(self):
|
||
self.fake.links.append({"id": 1, "type": "ASUPR_Связанные", "source": "AURORA-2", "target": "AURORA-1"})
|
||
out = self.run_cmd("ensure_link", source="AURORA-1", target="AURORA-2", link_type="связанные")
|
||
self.assertEqual(out["result"], {"existed": True, "removed": []})
|
||
|
||
def test_link_conflict_without_replace(self):
|
||
self.fake.links.append({"id": 1, "type": "ASUPR_Блокер_Начала", "source": "AURORA-2", "target": "AURORA-1"})
|
||
out = self.run_cmd("ensure_link", source="AURORA-1", target="AURORA-2", link_type="блокирует начало")
|
||
self.assertEqual(out["status"], "failed")
|
||
self.assertIn("обратное направление", out["error"])
|
||
self.assertEqual(len(self.fake.links), 1)
|
||
|
||
def test_link_replace_reverse_and_other_type(self):
|
||
self.ex.s["pacing"] = {"ensure_link": 3}
|
||
self.fake.links.append({"id": 1, "type": "ASUPR_Блокер_Начала", "source": "AURORA-2", "target": "AURORA-1"})
|
||
self.fake.links.append({"id": 2, "type": "ASUPR_Связанные", "source": "AURORA-1", "target": "AURORA-2"})
|
||
out = self.run_cmd("ensure_link", source="AURORA-1", target="AURORA-2", link_type="блокирует начало", replace=True)
|
||
self.assertEqual(out["status"], "done")
|
||
self.assertEqual(sorted(out["result"]["removed"]), ["ASUPR_Блокер_Начала", "ASUPR_Связанные"])
|
||
self.assertTrue(out["result"]["created"])
|
||
self.assertEqual([(l["source"], l["target"], l["type"]) for l in self.fake.links],
|
||
[("AURORA-1", "AURORA-2", "ASUPR_Блокер_Начала")])
|
||
self.assertEqual(self.gaps, [3, 3]) # между двумя DELETE и перед POST
|
||
self.assertEqual([r[0] for r in self.fake.writes()], ["DELETE", "DELETE", "POST"])
|
||
|
||
def test_link_unmanaged_type_ignored(self):
|
||
self.fake.links.append({"id": 1, "type": "Duplicate", "source": "AURORA-1", "target": "AURORA-2"})
|
||
out = self.run_cmd("ensure_link", source="AURORA-1", target="AURORA-2", link_type="связанные")
|
||
self.assertEqual(out["result"], {"created": True, "removed": []})
|
||
self.assertEqual(len(self.fake.links), 2)
|
||
|
||
# --- спринты ---
|
||
def test_sprint_move_and_idempotent(self):
|
||
out = self.run_cmd("ensure_sprint", key="AURORA-1", sprint="Спринт 2")
|
||
self.assertEqual(out["result"], {"sprint": "Спринт 2", "from": None})
|
||
self.assertEqual(self.fake.issues["AURORA-1"]["sprint"], "Спринт 2")
|
||
again = self.run_cmd("ensure_sprint", key="AURORA-1", sprint="Спринт 2")
|
||
self.assertEqual(again["result"], {"sprint": "Спринт 2", "changed": False})
|
||
self.assertEqual(again["jira_calls"], 1)
|
||
|
||
def test_sprint_backlog(self):
|
||
self.fake.issues["AURORA-1"]["sprint"] = "Спринт 1"
|
||
out = self.run_cmd("ensure_sprint", key="AURORA-1", sprint="backlog")
|
||
self.assertEqual(out["result"], {"sprint": None, "from": "Спринт 1"})
|
||
self.assertIsNone(self.fake.issues["AURORA-1"]["sprint"])
|
||
self.assertEqual(self.run_cmd("ensure_sprint", key="AURORA-1", sprint="backlog")["result"], {"sprint": None, "changed": False})
|
||
|
||
def test_sprint_unknown_name(self):
|
||
out = self.run_cmd("ensure_sprint", key="AURORA-1", sprint="Спринт 9")
|
||
self.assertEqual(out["status"], "failed")
|
||
self.assertIn("Спринт 9", out["error"])
|
||
self.assertIn("Спринт 2", out["error"])
|
||
|
||
def test_sprint_without_board(self):
|
||
self.ex.s["board_id"] = None
|
||
out = self.run_cmd("ensure_sprint", key="AURORA-1", sprint="Спринт 2")
|
||
self.assertEqual(out["status"], "failed")
|
||
self.assertIn("board_id", out["error"])
|
||
|
||
|
||
if __name__ == "__main__":
|
||
unittest.main()
|
||
```
|
||
|
||
- [ ] **Step 2: Запустить — падает**
|
||
|
||
Run: `/usr/bin/python3 -m unittest tests.test_executor_links -v`
|
||
Expected: FAIL — `AttributeError: 'Executor' object has no attribute 'do_ensure_status'` (команды `failed` с «неизвестный тип»)
|
||
|
||
- [ ] **Step 3: Реализация**
|
||
|
||
В класс `Executor` после `do_ensure_comment`:
|
||
```python
|
||
# --- ensure_status ---------------------------------------------------------
|
||
def do_ensure_status(self, p: dict, cmd: dict) -> dict:
|
||
key, target = p["key"], p["target"]
|
||
path = list(target) if isinstance(target, list) else [target]
|
||
final = path[-1]
|
||
cur = self.jira.get(API + "/issue/%s" % key, fields="status")["fields"]["status"]["name"]
|
||
start = cur
|
||
if cur == final:
|
||
return self._out("done", {"from": start, "to": final, "steps": 0})
|
||
if cur in path:
|
||
path = path[path.index(cur) + 1:]
|
||
steps = 0
|
||
for step in path:
|
||
if cur == step:
|
||
continue
|
||
trs = self.jira.get(API + "/issue/%s/transitions" % key).get("transitions", [])
|
||
match = [t for t in trs if (t.get("to") or {}).get("name") == step or t.get("name") == step]
|
||
if not match:
|
||
avail = ", ".join("%s→%s" % (t.get("name"), (t.get("to") or {}).get("name")) for t in trs)
|
||
return self._out("failed", {"from": start, "reached": cur},
|
||
"нет перехода из «%s» в «%s»; доступны: %s" % (cur, step, avail or "—"))
|
||
if steps:
|
||
self.gap("ensure_status")
|
||
self.jira.post(API + "/issue/%s/transitions" % key, {"transition": {"id": match[0]["id"]}})
|
||
cur = step
|
||
steps += 1
|
||
return self._out("done", {"from": start, "to": cur, "steps": steps})
|
||
|
||
# --- ensure_link -----------------------------------------------------------
|
||
def symmetric_types(self) -> set:
|
||
"""Типы, у которых inward == outward («Связанные»): направление не важно."""
|
||
if self._symmetric is None:
|
||
try:
|
||
types = self.jira.get(API + "/issueLinkType").get("issueLinkTypes", [])
|
||
self._symmetric = {t["name"] for t in types if t.get("inward") == t.get("outward")}
|
||
except HttpError:
|
||
self._symmetric = set()
|
||
return self._symmetric
|
||
|
||
def do_ensure_link(self, p: dict, cmd: dict) -> dict:
|
||
src, dst = p["source"], p["target"]
|
||
types = self.s.get("link_types") or {}
|
||
lt = types.get(p["link_type"], p["link_type"])
|
||
managed = set(types.values()) | {lt}
|
||
symmetric = self.symmetric_types()
|
||
raw = self.jira.get(API + "/issue/%s" % src, fields="issuelinks")["fields"].get("issuelinks") or []
|
||
links = [] # (тип, другая задача, направление, id)
|
||
for l in raw:
|
||
t = (l.get("type") or {}).get("name")
|
||
if l.get("outwardIssue"):
|
||
links.append((t, l["outwardIssue"]["key"], "out", l.get("id")))
|
||
if l.get("inwardIssue"):
|
||
links.append((t, l["inwardIssue"]["key"], "in", l.get("id")))
|
||
wanted = [x for x in links if x[1] == dst and x[0] == lt and (x[2] == "out" or lt in symmetric)]
|
||
conflicts = [x for x in links if x[1] == dst and x[0] in managed and x not in wanted]
|
||
if conflicts and not p.get("replace"):
|
||
desc = ", ".join("«%s» (%s)" % (t, "обратное направление" if t == lt else "другой тип") for t, _, _, _ in conflicts)
|
||
return self._out("failed", error="между %s и %s уже есть: %s; нужен replace" % (src, dst, desc))
|
||
removed = []
|
||
for t, other, direction, lid in conflicts:
|
||
if not lid:
|
||
return self._out("failed", {"removed": removed}, "у связи «%s» с %s нет id — снять нельзя" % (t, other))
|
||
if removed:
|
||
self.gap("ensure_link")
|
||
self.jira.delete(API + "/issueLink/%s" % lid)
|
||
removed.append(t)
|
||
if wanted:
|
||
return self._out("done", {"existed": True, "removed": removed})
|
||
if removed:
|
||
self.gap("ensure_link")
|
||
# inwardIssue — источник (получает outward-описание «Блокирует начало…»), outwardIssue — цель
|
||
self.jira.post(API + "/issueLink", {"type": {"name": lt}, "inwardIssue": {"key": src}, "outwardIssue": {"key": dst}})
|
||
return self._out("done", {"created": True, "removed": removed})
|
||
|
||
# --- ensure_sprint ---------------------------------------------------------
|
||
def sprints(self) -> Dict[str, dict]:
|
||
"""Имя → спринт (active и future) доски board_id; без доски — пусто."""
|
||
if self._sprints is None:
|
||
out, start = {}, 0
|
||
board = self.s.get("board_id")
|
||
while board:
|
||
page = self.jira.get(AGILE + "/board/%s/sprint" % board, state="active,future", startAt=start, maxResults=50)
|
||
for sp in page.get("values", []):
|
||
out[sp["name"]] = sp
|
||
if page.get("isLast", True) or not page.get("values"):
|
||
break
|
||
start += len(page["values"])
|
||
self._sprints = out
|
||
return self._sprints
|
||
|
||
def do_ensure_sprint(self, p: dict, cmd: dict) -> dict:
|
||
key, want = p["key"], p["sprint"]
|
||
if not self.s.get("board_id"):
|
||
return self._out("failed", error="board_id не задан в настройках хаба — спринты недоступны")
|
||
cur = ((self.jira.get(AGILE + "/issue/%s" % key, fields="sprint")["fields"].get("sprint") or {}).get("name"))
|
||
if want == "backlog":
|
||
if cur is None:
|
||
return self._out("done", {"sprint": None, "changed": False})
|
||
self.jira.post(AGILE + "/backlog/issue", {"issues": [key]})
|
||
return self._out("done", {"sprint": None, "from": cur})
|
||
if cur == want:
|
||
return self._out("done", {"sprint": want, "changed": False})
|
||
sp = self.sprints().get(want)
|
||
if not sp:
|
||
return self._out("failed", error="нет спринта «%s» среди активных и будущих доски %s: %s"
|
||
% (want, self.s["board_id"], ", ".join(sorted(self.sprints())) or "—"))
|
||
self.jira.post(AGILE + "/sprint/%s/issue" % sp["id"], {"issues": [key]})
|
||
return self._out("done", {"sprint": want, "from": cur})
|
||
```
|
||
|
||
- [ ] **Step 4: Запустить — зелёный**
|
||
|
||
Run: `/usr/bin/python3 -m unittest tests.test_executor_links -v`
|
||
Expected: 14 passed
|
||
|
||
- [ ] **Step 5: Коммит**
|
||
|
||
```bash
|
||
git add src/agent.py tests/test_executor_links.py
|
||
git commit -m "feat(agent): исполнитель — переходы статусов, связи с заменой, спринты
|
||
|
||
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 5: Чтение состояния (`refresh`)
|
||
|
||
**Files:**
|
||
- Modify: `src/agent.py` (методы в `Executor`)
|
||
- Create: `tests/fake_hub.py`
|
||
- Test: `tests/test_refresh.py`
|
||
|
||
**Interfaces:**
|
||
- Produces: `Executor.state_fields() -> str`, `.search_all(jql) -> list[raw]`, `.to_state(raw) -> dict` (ключи зеркала хаба),
|
||
`.links_from(raw) -> list[{"source_key","target_key","link_type"}]`, `.sprint_names(keys) -> dict`, `.do_refresh(p, cmd)`
|
||
(шлёт `hub.state(...)` страницами по 100, возвращает `{"issues", "neighbors", "pages"}`).
|
||
- Produces (тесты): `fake_hub.FakeHub(settings)`: `.base_url`, `.token = "agent-token"`, `.enqueue(type, params, pause=0) -> id`,
|
||
`.commands`, `.results`, `.states`, `.known`, `.claims`, `.fail_results` (сколько раз ответить 503 на отчёт),
|
||
`.reject_results` ({id: код}), `.stop()`.
|
||
|
||
- [ ] **Step 1: Фейковый хаб**
|
||
|
||
`tests/fake_hub.py`:
|
||
```python
|
||
"""Фейковый хаб для тестов агента: маршруты /agent/* в памяти."""
|
||
import json
|
||
import re
|
||
import threading
|
||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||
|
||
|
||
class FakeHub:
|
||
token = "agent-token"
|
||
|
||
def __init__(self, settings):
|
||
self.settings = dict(settings)
|
||
self.queue = []
|
||
self.commands = {}
|
||
self.results = {}
|
||
self.states = []
|
||
self.known = []
|
||
self.claims = 0
|
||
self.fail_results = 0
|
||
self.reject_results = {}
|
||
self.in_flight = None
|
||
self.lock = threading.Lock()
|
||
hub = self
|
||
|
||
class Handler(BaseHTTPRequestHandler):
|
||
def log_message(self, *a):
|
||
pass
|
||
|
||
def do_GET(self):
|
||
hub.handle(self, "GET")
|
||
|
||
def do_POST(self):
|
||
hub.handle(self, "POST")
|
||
|
||
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()
|
||
|
||
def enqueue(self, ctype, params, pause=0):
|
||
cid = len(self.commands) + 1
|
||
self.commands[cid] = {"id": cid, "type": ctype, "params": params, "pause_seconds": pause, "status": "queued"}
|
||
self.queue.append(cid)
|
||
return cid
|
||
|
||
def handle(self, h, method):
|
||
length = int(h.headers.get("Content-Length") or 0)
|
||
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"})
|
||
with self.lock:
|
||
code, payload = self.route(method, h.path.split("?")[0], body)
|
||
self._send(h, code, payload)
|
||
|
||
def _send(self, h, code, payload):
|
||
data = json.dumps(payload, ensure_ascii=False).encode("utf-8")
|
||
h.send_response(code)
|
||
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, body):
|
||
if path == "/agent/claim":
|
||
self.claims += 1
|
||
if self.in_flight and self.commands[self.in_flight]["status"] == "claimed":
|
||
return 200, {"command": None, "settings_version": self.settings["settings_version"]}
|
||
if not self.queue:
|
||
return 200, {"command": None, "settings_version": self.settings["settings_version"]}
|
||
cid = self.queue.pop(0)
|
||
self.commands[cid]["status"] = "claimed"
|
||
self.in_flight = cid
|
||
c = self.commands[cid]
|
||
return 200, {"command": {"id": cid, "type": c["type"], "params": c["params"], "pause_seconds": c["pause_seconds"]},
|
||
"settings_version": self.settings["settings_version"]}
|
||
if path == "/agent/settings":
|
||
return 200, self.settings
|
||
if path == "/agent/known-keys":
|
||
return 200, {"keys": list(self.known)}
|
||
if path == "/agent/state":
|
||
self.states.append(body)
|
||
return 200, {"issues": len(body.get("issues") or []), "links": len(body.get("links") or [])}
|
||
m = re.match(r"^/agent/commands/(\d+)$", path)
|
||
if m:
|
||
c = self.commands.get(int(m.group(1)))
|
||
return (200, c) if c else (404, {"error": "not_found"})
|
||
m = re.match(r"^/agent/commands/(\d+)/result$", path)
|
||
if m:
|
||
cid = int(m.group(1))
|
||
if self.fail_results > 0:
|
||
self.fail_results -= 1
|
||
return 503, {"error": "busy"}
|
||
if cid in self.reject_results:
|
||
return self.reject_results[cid], {"error": "rejected"}
|
||
if cid not in self.commands:
|
||
return 404, {"error": "not_found"}
|
||
self.results[cid] = body
|
||
self.commands[cid]["status"] = body.get("status")
|
||
if self.in_flight == cid:
|
||
self.in_flight = None
|
||
return 200, self.commands[cid]
|
||
return 404, {"error": "not_found", "detail": path}
|
||
```
|
||
|
||
- [ ] **Step 2: Тесты refresh**
|
||
|
||
`tests/test_refresh.py`:
|
||
```python
|
||
import unittest
|
||
|
||
from _common import SETTINGS, agent, jira_cfg, no_sleep
|
||
from fake_hub import FakeHub
|
||
from fake_jira import FakeJira
|
||
|
||
|
||
class RefreshTest(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 test_to_state_and_links(self):
|
||
self.fake.add("AURORA-1", assignee="ivanov", customfield_10801="2026-10-20T00:00:00.000+0300",
|
||
customfield_15302="AURORA-1350", labels=["AWPCA", "kind-dev"], sprint="Спринт 2")
|
||
self.fake.add("AURORA-2")
|
||
self.fake.links.append({"id": 1, "type": "ASUPR_Блокер_Начала", "source": "AURORA-1", "target": "AURORA-2"})
|
||
raw = self.fake.issue_json("AURORA-1")
|
||
st = self.ex.to_state(raw)
|
||
self.assertEqual(st["key"], "AURORA-1")
|
||
self.assertEqual(st["assignee"], "ivanov")
|
||
self.assertEqual(st["planned_start"], "2026-10-20")
|
||
self.assertEqual(st["epic_key"], "AURORA-1350")
|
||
self.assertEqual(st["labels"], ["AWPCA", "kind-dev"])
|
||
self.assertEqual(st["issue_type"], "Задача")
|
||
self.assertEqual(st["jira_updated"], "2026-10-01T10:00:00.000+0300")
|
||
self.assertIsNone(st["sprint"]) # спринт заполняет do_refresh
|
||
self.assertEqual(self.ex.links_from(raw), [{"source_key": "AURORA-1", "target_key": "AURORA-2", "link_type": "ASUPR_Блокер_Начала"}])
|
||
self.assertEqual(self.ex.links_from(self.fake.issue_json("AURORA-2")),
|
||
[{"source_key": "AURORA-1", "target_key": "AURORA-2", "link_type": "ASUPR_Блокер_Начала"}])
|
||
self.assertIn("customfield_15302", self.ex.state_fields())
|
||
|
||
def test_full_refresh_with_neighbors_and_sprints(self):
|
||
for i in range(1, 4):
|
||
self.fake.add("AURORA-%d" % i, sprint="Спринт 2" if i < 3 else None)
|
||
self.fake.add("OTHER-9", in_base=False)
|
||
self.fake.add("AURORA-7", in_base=False)
|
||
self.fake.links.append({"id": 1, "type": "ASUPR_Связанные", "source": "AURORA-1", "target": "OTHER-9"})
|
||
self.fake.links.append({"id": 2, "type": "ASUPR_Связанные", "source": "AURORA-7", "target": "AURORA-2"})
|
||
self.hub.known = ["AURORA-7"]
|
||
out = self.ex.run({"id": 5, "type": "refresh", "params": {"jql": "project = AURORA AND labels = AWPCA"}})
|
||
self.assertEqual(out["status"], "done")
|
||
self.assertEqual(out["result"], {"issues": 3, "neighbors": 1, "pages": 1})
|
||
state = self.hub.states[0]
|
||
self.assertEqual(state["command_id"], 5)
|
||
self.assertEqual(sorted(i["key"] for i in state["issues"]), ["AURORA-1", "AURORA-2", "AURORA-3", "OTHER-9"])
|
||
self.assertEqual(state["neighbor_keys"], ["OTHER-9"])
|
||
self.assertTrue(state["last_page"])
|
||
sprints = {i["key"]: i["sprint"] for i in state["issues"]}
|
||
self.assertEqual(sprints["AURORA-1"], "Спринт 2")
|
||
self.assertIsNone(sprints["AURORA-3"])
|
||
links = {(l["source_key"], l["target_key"]) for l in state["links"]}
|
||
self.assertEqual(links, {("AURORA-1", "OTHER-9"), ("AURORA-7", "AURORA-2")})
|
||
self.assertEqual([r[1] for r in self.fake.requests if r[1] == "/rest/api/2/search"].count("/rest/api/2/search"), 2)
|
||
|
||
def test_refresh_by_keys_pages(self):
|
||
for i in range(1, 121):
|
||
self.fake.add("AURORA-%d" % i)
|
||
keys = ["AURORA-%d" % i for i in range(1, 121)]
|
||
out = self.ex.run({"id": 6, "type": "refresh", "params": {"keys": keys}})
|
||
self.assertEqual(out["result"], {"issues": 120, "neighbors": 0, "pages": 2})
|
||
self.assertEqual(len(self.hub.states), 2)
|
||
self.assertEqual(self.hub.states[0]["page"], 1)
|
||
self.assertFalse(self.hub.states[0]["last_page"])
|
||
self.assertTrue(self.hub.states[1]["last_page"])
|
||
self.assertEqual(len(self.hub.states[0]["issues"]) + len(self.hub.states[1]["issues"]), 120)
|
||
|
||
def test_refresh_empty_keys(self):
|
||
out = self.ex.run({"id": 7, "type": "refresh", "params": {"keys": []}})
|
||
self.assertEqual(out["result"], {"issues": 0, "neighbors": 0, "pages": 0})
|
||
self.assertEqual(self.fake.requests, [])
|
||
self.assertEqual(self.hub.states, [])
|
||
|
||
|
||
if __name__ == "__main__":
|
||
unittest.main()
|
||
```
|
||
|
||
- [ ] **Step 3: Запустить — падает**
|
||
|
||
Run: `/usr/bin/python3 -m unittest tests.test_refresh -v`
|
||
Expected: FAIL — `AttributeError: 'Executor' object has no attribute 'to_state'`
|
||
|
||
- [ ] **Step 4: Реализация**
|
||
|
||
В класс `Executor` после `do_ensure_sprint`:
|
||
```python
|
||
# --- refresh ---------------------------------------------------------------
|
||
STATE_FIELDS = ["summary", "description", "status", "priority", "assignee", "reporter", "duedate", "labels",
|
||
"issuetype", "created", "updated", "issuelinks"]
|
||
|
||
def state_fields(self) -> str:
|
||
extra = [self.fid(x) for x in ("planned_start", "planned_end") if self.has_field(x)]
|
||
ep = (self.s.get("epic_link") or {})
|
||
if ep.get("mode", "field") == "field" and ep.get("field_id"):
|
||
extra.append(ep["field_id"])
|
||
return ",".join(self.STATE_FIELDS + extra)
|
||
|
||
def search_all(self, jql: str) -> List[dict]:
|
||
issues, start = [], 0
|
||
while True:
|
||
page = self.jira.get(API + "/search", jql=jql, fields=self.state_fields(), startAt=start, maxResults=100)
|
||
got = page.get("issues") or []
|
||
issues += got
|
||
start += len(got)
|
||
if not got or start >= int(page.get("total") or 0):
|
||
return issues
|
||
|
||
def to_state(self, raw: dict) -> dict:
|
||
f = raw.get("fields") or {}
|
||
|
||
def name(obj):
|
||
return (obj or {}).get("name")
|
||
|
||
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"),
|
||
"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")}
|
||
|
||
def links_from(self, raw: dict) -> List[dict]:
|
||
key, out = raw["key"], []
|
||
for l in (raw.get("fields") or {}).get("issuelinks") or []:
|
||
t = (l.get("type") or {}).get("name")
|
||
if l.get("outwardIssue"):
|
||
out.append({"source_key": key, "target_key": l["outwardIssue"]["key"], "link_type": t})
|
||
if l.get("inwardIssue"):
|
||
out.append({"source_key": l["inwardIssue"]["key"], "target_key": key, "link_type": t})
|
||
return out
|
||
|
||
def sprint_names(self, keys: List[str]) -> Dict[str, str]:
|
||
names = {}
|
||
if not keys or not self.s.get("board_id"):
|
||
return names
|
||
for name, sp in self.sprints().items():
|
||
for i in range(0, len(keys), 100):
|
||
chunk = keys[i:i + 100]
|
||
page = self.jira.get(AGILE + "/sprint/%s/issue" % sp["id"], jql="key in (%s)" % ",".join(chunk),
|
||
fields="key", maxResults=200)
|
||
for it in page.get("issues") or []:
|
||
names[it["key"]] = name
|
||
return names
|
||
|
||
def do_refresh(self, p: dict, cmd: dict) -> dict:
|
||
if p.get("jql"):
|
||
raws = self.search_all(p["jql"])
|
||
else:
|
||
keys = [k for k in (p.get("keys") or []) if k]
|
||
if not keys:
|
||
return self._out("done", {"issues": 0, "neighbors": 0, "pages": 0})
|
||
raws = []
|
||
for i in range(0, len(keys), 100):
|
||
raws += self.search_all("key in (%s)" % ",".join(keys[i:i + 100]))
|
||
fetched = {r["key"] for r in raws}
|
||
known = set(self.hub.known_keys()) if self.hub else set()
|
||
linked = set()
|
||
for r in raws:
|
||
for l in self.links_from(r):
|
||
linked.add(l["source_key"])
|
||
linked.add(l["target_key"])
|
||
missing = sorted(k for k in linked if k not in fetched and k not in known)
|
||
neighbors = []
|
||
for i in range(0, len(missing), 100):
|
||
neighbors += self.search_all("key in (%s)" % ",".join(missing[i:i + 100]))
|
||
everything = raws + neighbors
|
||
names = self.sprint_names([r["key"] for r in everything])
|
||
issues, links, seen = [], [], set()
|
||
for r in everything:
|
||
st = self.to_state(r)
|
||
st["sprint"] = names.get(r["key"])
|
||
issues.append(st)
|
||
for l in self.links_from(r):
|
||
sig = (l["source_key"], l["target_key"], l["link_type"])
|
||
if sig not in seen:
|
||
seen.add(sig)
|
||
links.append(l)
|
||
neighbor_keys = [r["key"] for r in neighbors]
|
||
pages = 0
|
||
for i in range(0, len(issues), 100):
|
||
chunk = issues[i:i + 100]
|
||
chunk_keys = {x["key"] for x in chunk}
|
||
pages += 1
|
||
self.hub.state({"command_id": cmd.get("id"), "issues": chunk,
|
||
"links": [l for l in links if l["source_key"] in chunk_keys or l["target_key"] in chunk_keys],
|
||
"neighbor_keys": [k for k in neighbor_keys if k in chunk_keys],
|
||
"page": pages, "last_page": i + 100 >= len(issues)})
|
||
return self._out("done", {"issues": len(raws), "neighbors": len(neighbors), "pages": pages})
|
||
```
|
||
|
||
- [ ] **Step 5: Запустить — зелёный**
|
||
|
||
Run: `/usr/bin/python3 -m unittest tests.test_refresh -v`
|
||
Expected: 4 passed
|
||
|
||
- [ ] **Step 6: Коммит**
|
||
|
||
```bash
|
||
git add src/agent.py tests/fake_hub.py tests/test_refresh.py
|
||
git commit -m "feat(agent): чтение состояния — поиск, соседи по связям, спринты, страницы в хаб
|
||
|
||
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 6: Цикл агента, восстановление, команда `run`
|
||
|
||
**Files:**
|
||
- Modify: `src/agent.py` (класс `Runner` после `Executor`, `cmd_run`, `main`)
|
||
- Test: `tests/test_runner.py`
|
||
|
||
**Interfaces:**
|
||
- Produces: `Runner(cfg, hub, jira, journal)`: `.settings`, `.executor`, `.stop` (флаг), `.sleep` (подменяемый), `.idle_limit` (None | int — выйти после N пустых опросов, для тестов),
|
||
`.load_settings()`, `.send_result(rec) -> bool`, `.wait_pause(pause_seconds) -> bool`, `.execute(cmd)`, `.recover()`, `.loop()`;
|
||
`cmd_run(cfg, journal_path) -> int`.
|
||
|
||
- [ ] **Step 1: Тесты**
|
||
|
||
`tests/test_runner.py`:
|
||
```python
|
||
import os
|
||
import tempfile
|
||
import time
|
||
import unittest
|
||
|
||
from _common import SETTINGS, agent, jira_cfg, no_sleep
|
||
from fake_hub import FakeHub
|
||
from fake_jira import FakeJira
|
||
|
||
|
||
class RunnerTest(unittest.TestCase):
|
||
def setUp(self):
|
||
self.fake = FakeJira()
|
||
self.hub = FakeHub(SETTINGS)
|
||
self.dir = tempfile.TemporaryDirectory()
|
||
self.journal = agent.Journal(os.path.join(self.dir.name, "journal.jsonl"))
|
||
cfg = jira_cfg(self.fake, hub_url=self.hub.base_url, hub_token=self.hub.token)
|
||
self.runner = agent.Runner(cfg, agent.HubClient(cfg), agent.JiraClient(cfg), self.journal)
|
||
self.sleeps = []
|
||
self.runner.sleep = self.sleeps.append
|
||
self.runner.jira.sleep = no_sleep
|
||
self.runner.idle_limit = 1
|
||
self.fake.add("AURORA-1", duedate="2026-10-20")
|
||
|
||
def tearDown(self):
|
||
self.fake.stop()
|
||
self.hub.stop()
|
||
self.dir.cleanup()
|
||
|
||
def kinds(self):
|
||
return [(r["kind"], r["command_id"]) for r in self.journal.records()]
|
||
|
||
def test_processes_queue(self):
|
||
self.hub.enqueue("ensure_fields", {"key": "AURORA-1", "duedate": "2026-10-23"})
|
||
self.hub.enqueue("refresh", {"keys": ["AURORA-1"]})
|
||
self.runner.loop()
|
||
self.assertEqual(self.hub.results[1]["status"], "done")
|
||
self.assertEqual(self.hub.results[1]["result"], {"changed": ["duedate"]})
|
||
self.assertEqual(self.hub.results[1]["jira_calls"], 2)
|
||
self.assertEqual(self.hub.results[2]["status"], "done")
|
||
self.assertEqual(len(self.hub.states), 1)
|
||
self.assertEqual(self.hub.states[0]["issues"][0]["duedate"], "2026-10-23")
|
||
self.assertEqual(self.kinds(), [("started", 1), ("finished", 1), ("acked", 1), ("started", 2), ("finished", 2), ("acked", 2)])
|
||
self.assertEqual(self.sleeps, []) # пауз не было: last_call_at пуст, потом pause 0
|
||
|
||
def test_pause_waits_from_last_jira_call(self):
|
||
self.runner.jira.last_call_at = time.monotonic()
|
||
self.hub.enqueue("ensure_fields", {"key": "AURORA-1", "duedate": "2026-10-23"}, pause=3)
|
||
self.runner.loop()
|
||
self.assertEqual(len(self.sleeps), 3)
|
||
self.assertAlmostEqual(sum(self.sleeps), 3, delta=0.1)
|
||
self.assertEqual(self.hub.results[1]["status"], "done")
|
||
|
||
def test_result_retried_when_hub_down(self):
|
||
self.hub.fail_results = 2
|
||
self.hub.enqueue("ensure_fields", {"key": "AURORA-1", "duedate": "2026-10-23"})
|
||
self.runner.loop()
|
||
self.assertEqual(self.hub.results[1]["status"], "done")
|
||
self.assertEqual(self.sleeps, [10, 10])
|
||
self.assertEqual(self.journal.unacked_finished(), [])
|
||
|
||
def test_result_rejected_is_acked(self):
|
||
self.hub.reject_results = {1: 404}
|
||
self.hub.enqueue("ensure_fields", {"key": "AURORA-1", "duedate": "2026-10-23"})
|
||
self.runner.loop()
|
||
self.assertNotIn(1, self.hub.results)
|
||
self.assertEqual(self.journal.unacked_finished(), [])
|
||
self.assertEqual(self.kinds()[-1], ("acked", 1))
|
||
|
||
def test_recover_unfinished_started(self):
|
||
cid = self.hub.enqueue("ensure_fields", {"key": "AURORA-1", "duedate": "2026-10-20"}, pause=99)
|
||
self.hub.queue.remove(cid)
|
||
self.hub.commands[cid]["status"] = "claimed"
|
||
self.hub.in_flight = cid
|
||
self.journal.started(cid)
|
||
self.runner.loop()
|
||
self.assertEqual(self.hub.results[cid]["result"], {"changed": []}) # уже так в Jira — записи нет
|
||
self.assertEqual(self.journal.unfinished_started(), [])
|
||
self.assertEqual(self.sleeps, []) # пауза при восстановлении не применяется
|
||
self.assertEqual([r[0] for r in self.fake.writes()], [])
|
||
|
||
def test_recover_skips_command_not_claimed_anymore(self):
|
||
cid = self.hub.enqueue("ensure_fields", {"key": "AURORA-1", "duedate": "2026-10-23"})
|
||
self.hub.queue.remove(cid)
|
||
self.hub.commands[cid]["status"] = "cancelled"
|
||
self.journal.started(cid)
|
||
self.runner.loop()
|
||
self.assertNotIn(cid, self.hub.results)
|
||
self.assertEqual(self.kinds(), [("started", cid), ("acked", cid)])
|
||
|
||
def test_recover_unacked_finished(self):
|
||
cid = self.hub.enqueue("ensure_fields", {"key": "AURORA-1", "duedate": "2026-10-23"})
|
||
self.hub.queue.remove(cid)
|
||
self.hub.commands[cid]["status"] = "claimed"
|
||
self.hub.in_flight = cid
|
||
self.journal.started(cid)
|
||
self.journal.finished(cid, "done", {"changed": ["duedate"]}, None, 2, 0)
|
||
self.hub.enqueue("refresh", {"keys": ["AURORA-1"]})
|
||
self.runner.loop()
|
||
self.assertEqual(self.hub.results[cid]["result"], {"changed": ["duedate"]})
|
||
self.assertEqual(self.hub.results[2]["status"], "done")
|
||
self.assertEqual(self.kinds()[:3], [("started", 1), ("finished", 1), ("acked", 1)])
|
||
|
||
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)
|
||
self.runner.sleep = lambda s: setattr(self.runner, "stop", True)
|
||
self.runner.loop()
|
||
self.assertEqual(self.journal.records(), [])
|
||
self.assertEqual(self.hub.results, {})
|
||
self.assertEqual(self.hub.commands[1]["status"], "claimed")
|
||
|
||
def test_settings_reload_on_version_change(self):
|
||
self.runner.load_settings()
|
||
self.runner.settings["settings_version"] = 0
|
||
self.runner.loop()
|
||
self.assertEqual(self.runner.settings["settings_version"], 1)
|
||
|
||
def test_hub_down_at_start_keeps_polling(self):
|
||
calls = []
|
||
|
||
def hub_settings():
|
||
calls.append(1)
|
||
if len(calls) < 3:
|
||
raise ConnectionError("нет связи")
|
||
return dict(SETTINGS)
|
||
|
||
self.runner.hub.settings = hub_settings
|
||
self.runner.loop()
|
||
self.assertEqual(len(calls), 3)
|
||
self.assertEqual(self.sleeps, [0, 0])
|
||
|
||
|
||
if __name__ == "__main__":
|
||
unittest.main()
|
||
```
|
||
|
||
- [ ] **Step 2: Запустить — падает**
|
||
|
||
Run: `/usr/bin/python3 -m unittest tests.test_runner -v`
|
||
Expected: FAIL — `AttributeError: module 'agent' has no attribute 'Runner'`
|
||
|
||
- [ ] **Step 3: Реализация**
|
||
|
||
В `src/agent.py` после класса `Executor`:
|
||
```python
|
||
# ---------------------------------------------------------------------------
|
||
# Цикл: claim → пауза → журнал → выполнение → отчёт до подтверждения; восстановление при старте
|
||
# ---------------------------------------------------------------------------
|
||
class Runner:
|
||
def __init__(self, cfg: dict, hub: HubClient, jira: JiraClient, journal: Journal):
|
||
self.cfg, self.hub, self.jira, self.journal = cfg, hub, jira, journal
|
||
self.settings = None # type: Optional[dict]
|
||
self.executor = None # type: Optional[Executor]
|
||
self.stop = False
|
||
self.sleep = time.sleep
|
||
self.idle_limit = None # type: Optional[int]
|
||
|
||
@property
|
||
def poll(self) -> float:
|
||
return max(float(self.cfg.get("poll_seconds", 5) or 0), 0)
|
||
|
||
def load_settings(self) -> None:
|
||
self.settings = self.hub.settings()
|
||
self.executor = Executor(self.jira, self.settings, self.hub)
|
||
self.executor.sleep = self.sleep
|
||
log("настройки хаба v%s (проект %s)" % (self.settings.get("settings_version"), self.settings.get("project_key")))
|
||
|
||
def send_result(self, rec: dict) -> bool:
|
||
"""Отчёт хабу; повторяется, пока хаб не подтвердит. 404/409/422 — хаб отверг (команда отменена или удалена):
|
||
считаем закрытой, чтобы не зациклиться."""
|
||
cid = rec["command_id"]
|
||
payload = {"status": rec["status"], "result": rec.get("result"), "error": rec.get("error"),
|
||
"jira_calls": rec.get("jira_calls", 0), "rate_limited": rec.get("rate_limited", 0)}
|
||
while not self.stop:
|
||
try:
|
||
self.hub.result(cid, payload)
|
||
self.journal.acked(cid)
|
||
return True
|
||
except HttpError as e:
|
||
if e.code in (404, 409, 422):
|
||
log("хаб отверг отчёт по #%s: %s — считаю закрытым" % (cid, e))
|
||
self.journal.acked(cid)
|
||
return True
|
||
log("хаб: %s, повтор через 10 с" % e)
|
||
except ConnectionError as e:
|
||
log("хаб недоступен: %s, повтор через 10 с" % e)
|
||
self.sleep(10)
|
||
return False
|
||
|
||
def wait_pause(self, pause_seconds) -> bool:
|
||
"""Досыпать до зазора от последнего вызова Jira; False — остановлены во время паузы."""
|
||
pause = float(pause_seconds or 0)
|
||
if pause > 0 and self.jira.last_call_at is not None:
|
||
remaining = pause - (time.monotonic() - self.jira.last_call_at)
|
||
if remaining > 0:
|
||
log(" пауза %.0f с до команды" % remaining)
|
||
while remaining > 0 and not self.stop:
|
||
step = min(remaining, 1.0)
|
||
self.sleep(step)
|
||
remaining -= step
|
||
return not self.stop
|
||
|
||
def execute(self, cmd: dict) -> None:
|
||
log("#%s %s %s" % (cmd["id"], cmd["type"], json.dumps(cmd.get("params"), ensure_ascii=False)[:200]))
|
||
if not self.wait_pause(cmd.get("pause_seconds")):
|
||
return
|
||
self.journal.started(cmd["id"])
|
||
out = self.executor.run(cmd)
|
||
self.journal.finished(cmd["id"], out["status"], out.get("result"), out.get("error"), out["jira_calls"], out["rate_limited"])
|
||
log(" → %s %s" % (out["status"], out.get("error") or json.dumps(out.get("result"), ensure_ascii=False)))
|
||
rec = dict(out)
|
||
rec["command_id"] = cmd["id"]
|
||
self.send_result(rec)
|
||
|
||
def recover(self) -> None:
|
||
for rec in self.journal.unacked_finished():
|
||
log("досылаю отчёт по #%s" % rec["command_id"])
|
||
self.send_result(rec)
|
||
for cid in self.journal.unfinished_started():
|
||
try:
|
||
cmd = self.hub.command(cid)
|
||
except HttpError as e:
|
||
log("#%s: хаб ответил %s — закрываю запись журнала" % (cid, e))
|
||
self.journal.acked(cid)
|
||
continue
|
||
if cmd.get("status") != "claimed":
|
||
log("#%s уже %s в хабе — пропускаю" % (cid, cmd.get("status")))
|
||
self.journal.acked(cid)
|
||
continue
|
||
log("#%s начата до перезапуска — выполняю заново через проверку" % cid)
|
||
cmd["pause_seconds"] = 0
|
||
self.execute(cmd)
|
||
|
||
def loop(self) -> None:
|
||
idle, recovered = 0, False
|
||
while not self.stop:
|
||
if self.settings is None:
|
||
try:
|
||
self.load_settings()
|
||
except (ConnectionError, HttpError) as e:
|
||
log("хаб: %s" % e)
|
||
self.sleep(self.poll)
|
||
continue
|
||
if not recovered:
|
||
self.recover()
|
||
recovered = True
|
||
try:
|
||
resp = self.hub.claim(AGENT_VERSION, self.settings.get("settings_version"), self.jira.last_call_iso)
|
||
except (ConnectionError, HttpError) as e:
|
||
log("хаб: %s" % e)
|
||
self.sleep(self.poll)
|
||
continue
|
||
if resp.get("settings_version") != self.settings.get("settings_version"):
|
||
try:
|
||
self.load_settings()
|
||
except (ConnectionError, HttpError) as e:
|
||
log("хаб: не перечитать настройки: %s" % e)
|
||
cmd = resp.get("command")
|
||
if cmd:
|
||
idle = 0
|
||
self.execute(cmd)
|
||
continue
|
||
idle += 1
|
||
if self.idle_limit is not None and idle >= self.idle_limit:
|
||
return
|
||
self.sleep(self.poll)
|
||
|
||
|
||
def cmd_run(cfg: dict, journal_path: str) -> int:
|
||
runner = Runner(cfg, HubClient(cfg), JiraClient(cfg), Journal(journal_path))
|
||
|
||
def on_signal(signum, frame):
|
||
if runner.stop:
|
||
log("повторный сигнал — выхожу немедленно")
|
||
sys.exit(130)
|
||
runner.stop = True
|
||
log("сигнал %s: доделываю текущую команду и выхожу" % signum)
|
||
|
||
signal.signal(signal.SIGINT, on_signal)
|
||
signal.signal(signal.SIGTERM, on_signal)
|
||
log("jira-agent v%d: хаб %s, Jira %s, журнал %s" % (AGENT_VERSION, cfg["hub_url"], cfg["jira"]["base_url"], journal_path))
|
||
runner.loop()
|
||
log("остановлен")
|
||
return 0
|
||
```
|
||
|
||
В `main` заменить хвост:
|
||
```python
|
||
if args.cmd == "check":
|
||
return cmd_check(cfg)
|
||
if args.cmd == "prune":
|
||
removed = Journal(args.journal).prune(30)
|
||
log("журнал: удалено записей %d" % removed)
|
||
return 0
|
||
return cmd_run(cfg, args.journal)
|
||
```
|
||
|
||
- [ ] **Step 4: Запустить — зелёный, затем весь набор**
|
||
|
||
Run: `/usr/bin/python3 -m unittest tests.test_runner -v && /usr/bin/python3 -m unittest discover -s tests -t . -v`
|
||
Expected: 10 passed; весь набор зелёный
|
||
|
||
- [ ] **Step 5: Коммит**
|
||
|
||
```bash
|
||
git add src/agent.py tests/test_runner.py
|
||
git commit -m "feat(agent): цикл claim → пауза → журнал → отчёт, восстановление после перезапуска, run
|
||
|
||
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 7: README, CLAUDE.md, ARCHITECTURE.md, проверка синтаксиса 3.9
|
||
|
||
**Files:**
|
||
- Create: `README.md`, `tests/test_py39.py`, `.claude/code-paths` (если хук require-plan не видит `src/` по умолчанию — проверить `cat .claude/hooks/*` в репозитории; при стандартной обвязке файл не нужен)
|
||
- Modify: `CLAUDE.md`, `ARCHITECTURE.md`
|
||
|
||
- [ ] **Step 1: Тест синтаксиса**
|
||
|
||
`tests/test_py39.py`:
|
||
```python
|
||
import ast
|
||
import os
|
||
import unittest
|
||
|
||
from _common import SRC
|
||
|
||
|
||
class Py39Test(unittest.TestCase):
|
||
def test_agent_parses_as_python_39(self):
|
||
with open(os.path.join(SRC, "agent.py"), encoding="utf-8") as f:
|
||
ast.parse(f.read(), feature_version=(3, 9))
|
||
|
||
def test_no_third_party_imports(self):
|
||
with open(os.path.join(SRC, "agent.py"), encoding="utf-8") as f:
|
||
tree = ast.parse(f.read())
|
||
allowed = {"argparse", "base64", "json", "os", "signal", "ssl", "sys", "time", "urllib", "datetime", "typing"}
|
||
for node in ast.walk(tree):
|
||
if isinstance(node, ast.Import):
|
||
for a in node.names:
|
||
self.assertIn(a.name.split(".")[0], allowed, a.name)
|
||
elif isinstance(node, ast.ImportFrom):
|
||
self.assertIn((node.module or "").split(".")[0], allowed, node.module)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
unittest.main()
|
||
```
|
||
|
||
Run: `/usr/bin/python3 -m unittest tests.test_py39 -v`
|
||
Expected: 2 passed
|
||
|
||
- [ ] **Step 2: README.md**
|
||
|
||
Написать полностью на русском по пунктам:
|
||
1. Что это и откуда спека (`../jira-hub/openspec/changes/bridge-core/specs/agent/spec.md`); связь с `jira-hub`.
|
||
2. Доставка на виртуалку: скопировать `src/agent.py` и `src/agent.example.json` через общую папку в каталог на виртуалке (например `~/jira-agent/`), переименовать в `agent.json`, заполнить `hub_url` (`http://192.168.0.106:8790`), `hub_token` (из `jira-hub tokens` на Mac mini), `jira.base_url`, `jira.auth` (логин/пароль или `token`), `verify_ssl`/`ca_bundle` при корпоративном сертификате. Напоминание: `agent.json` никуда не копировать, кроме виртуалки.
|
||
3. Проверка: `python3 agent.py check` — что печатает в норме и при ошибках.
|
||
4. Запуск: `python3 agent.py` в терминале; в фоне — `nohup python3 -u agent.py > agent.log 2>&1 &`; остановка — Ctrl-C или `kill <pid>` (текущая команда доделывается, повторный сигнал — выход немедленно).
|
||
5. Автозапуск через launchd: пример `~/Library/LaunchAgents/ru.aepif.jira-agent.plist` (`ProgramArguments` — `/usr/bin/python3`, `-u`, путь к `agent.py`; `WorkingDirectory`; `RunAtLoad`; `KeepAlive`; `StandardOutPath`/`StandardErrorPath`), команды `launchctl load/unload`.
|
||
6. Журнал: `journal.jsonl` рядом с агентом — что в нём, зачем, `python3 agent.py prune` (подтверждённые записи старше 30 дней); журнал содержит параметры команд (тексты задач) — не выносить с виртуалки.
|
||
7. Что делает агент с командами: таблица типов со ссылкой на спеку; правило «проверь, потом сделай»; паузы приходят от хаба; 429/503 — Retry-After; версия агента `AGENT_VERSION` и предупреждение хаба об устаревшем агенте.
|
||
8. Тесты: `/usr/bin/python3 -m unittest discover -s tests -t .` (тот же Python 3.9.6, что на виртуалке).
|
||
9. Обновление агента: скопировать новый `agent.py`, перезапустить; журнал и `agent.json` не трогать.
|
||
|
||
- [ ] **Step 3: CLAUDE.md и ARCHITECTURE.md**
|
||
|
||
Заменить `{{...}}` шаблона: что это (абзац из proposal хаба про агента), стек (Python 3.9.6, только stdlib, unittest), архитектура —
|
||
один файл `src/agent.py` с разделами (HTTP-клиенты, `Journal`, `Executor`, `Runner`, CLI) и тесты с фейковыми Jira/хабом,
|
||
команды (`/usr/bin/python3 -m unittest discover -s tests -t .`, `python3 src/agent.py check`), тесты (фейки в `tests/fake_*.py`,
|
||
внешних сервисов нет), первая подсистема — исполнитель команд. В ARCHITECTURE.md — таблица разделов файла и границы:
|
||
«пароль Jira только в `agent.json`», «политика темпа не хранится», «одна команда в полёте», «отчёт до подтверждения».
|
||
Указать, что спека и план живут в `jira-hub` (`openspec/changes/bridge-core`, `docs/superpowers/plans/2026-10-11-bridge-core-hub.md`).
|
||
|
||
- [ ] **Step 4: Весь набор и коммит**
|
||
|
||
Run: `/usr/bin/python3 -m unittest discover -s tests -t . -v`
|
||
Expected: все зелёные
|
||
|
||
```bash
|
||
git add README.md CLAUDE.md ARCHITECTURE.md tests/test_py39.py
|
||
git commit -m "docs(agent): README (доставка, запуск, launchd, prune), архитектура, проверка синтаксиса 3.9
|
||
|
||
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>"
|
||
```
|