developer(ET): auto-commit from developer run_id=363
This commit is contained in:
@@ -307,7 +307,7 @@ def render_task_tracker(task_id: int) -> str:
|
||||
conn = get_db()
|
||||
task = conn.execute(
|
||||
"SELECT id, work_item_id, title, stage, created_at, updated_at, "
|
||||
"brd_review_started_at, brd_review_ended_at "
|
||||
"brd_review_started_at, brd_review_ended_at, repo, plane_issue_id "
|
||||
"FROM tasks WHERE id=?",
|
||||
(task_id,),
|
||||
).fetchone()
|
||||
@@ -358,13 +358,27 @@ def render_task_tracker(task_id: int) -> str:
|
||||
agent_seconds += d
|
||||
|
||||
esc_title = html.escape(title)
|
||||
# ORCH-067 (req 3): the issue number in the header is now a clickable link to
|
||||
# the Plane issue (degrades to the escaped number when no web URL \u2014 fail-safe).
|
||||
task_repo = _row_get(task, "repo")
|
||||
task_issue_id = _row_get(task, "plane_issue_id")
|
||||
num_html = plane_issue_link(work_item_id, plane_issue_id=task_issue_id, repo=task_repo)
|
||||
header = (
|
||||
f"\U0001f389 {html.escape(work_item_id)} \u00b7 {esc_title} \u2014 \u0413\u041e\u0422\u041e\u0412\u041e"
|
||||
f"\U0001f389 {num_html} \u00b7 {esc_title} \u2014 \u0413\u041e\u0422\u041e\u0412\u041e"
|
||||
if done
|
||||
else f"\U0001f6e0\ufe0f {html.escape(work_item_id)} \u00b7 {esc_title}"
|
||||
else f"\U0001f6e0\ufe0f {num_html} \u00b7 {esc_title}"
|
||||
)
|
||||
bar = "\u2501" * 22
|
||||
lines = [header, bar]
|
||||
# ORCH-067 (req 2): a Plane-status line (model ORCH-066) under the header.
|
||||
# Built fail-safe: any error degrades to a stage default, never breaks render.
|
||||
try:
|
||||
status_label = _card_status_label(
|
||||
task, repo=task_repo, plane_issue_id=task_issue_id
|
||||
)
|
||||
except Exception:
|
||||
status_label = _DEFAULT_STATUS_LABEL
|
||||
status_line = f"\U0001f4cd {status_label}"
|
||||
lines = [header, status_line, bar]
|
||||
|
||||
def _stage_line(label, run):
|
||||
usage = {
|
||||
@@ -704,38 +718,276 @@ def _build_brd_link(repo, branch, work_item_id) -> str | None:
|
||||
)
|
||||
|
||||
|
||||
def _plane_issue_url(repo, plane_issue_id, project_id=None) -> str | None:
|
||||
"""ORCH-067 (Р-5): build the Plane issue browser URL, or None if unbuildable.
|
||||
|
||||
Single source of the URL + guards, shared by ``plane_issue_link`` (link text =
|
||||
issue number) and ``_build_plane_issue_link`` (link text = '✅ Задача в Plane'),
|
||||
so the project resolution and loopback-guard live in ONE place (ORCH-017 Р-2).
|
||||
|
||||
Full path: ``{web_base}/{workspace}/projects/{project_id}/issues/{issue_id}/``.
|
||||
web_base = plane_web_url or plane_api_url; a loopback base counts as "no web
|
||||
URL" -> None. ``project_id`` is taken explicitly when given, else resolved from
|
||||
``repo``. Never raises.
|
||||
"""
|
||||
try:
|
||||
s = _get_settings()
|
||||
web_base = (
|
||||
getattr(s, "plane_web_url", "") or getattr(s, "plane_api_url", "")
|
||||
).rstrip("/")
|
||||
workspace = getattr(s, "plane_workspace_slug", "")
|
||||
if not (web_base and workspace and plane_issue_id) or _is_loopback_base(web_base):
|
||||
return None
|
||||
if not project_id:
|
||||
try:
|
||||
from .projects import get_project_by_repo
|
||||
project = get_project_by_repo(repo) if repo else None
|
||||
except Exception:
|
||||
project = None
|
||||
project_id = getattr(project, "plane_project_id", "") if project else ""
|
||||
if not project_id:
|
||||
return None
|
||||
return (
|
||||
f"{web_base}/{workspace}/projects/{project_id}/issues/{plane_issue_id}/"
|
||||
)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _build_plane_issue_link(repo, plane_issue_id) -> str | None:
|
||||
"""ORCH-017: '<a>' to the Plane issue browser page, or None if unusable.
|
||||
|
||||
Full path per ADR-001 Р-2:
|
||||
``{web_base}/{workspace_slug}/projects/{project_id}/issues/{issue_id}/``.
|
||||
web_base = plane_web_url or plane_api_url (AC-3); a loopback base is treated
|
||||
as "no web URL" and the link is omitted (loopback-guard, AC-2/AC-6).
|
||||
Link text = '✅ Задача в Plane'. URL built by the shared ``_plane_issue_url``
|
||||
(loopback / workspace / project guards, ADR-001 Р-2 / ORCH-067 Р-5).
|
||||
"""
|
||||
s = _get_settings()
|
||||
web_base = (
|
||||
getattr(s, "plane_web_url", "") or getattr(s, "plane_api_url", "")
|
||||
).rstrip("/")
|
||||
workspace = getattr(s, "plane_workspace_slug", "")
|
||||
if not (web_base and workspace and plane_issue_id) or _is_loopback_base(web_base):
|
||||
url = _plane_issue_url(repo, plane_issue_id)
|
||||
if not url:
|
||||
return None
|
||||
try:
|
||||
from .projects import get_project_by_repo
|
||||
project = get_project_by_repo(repo) if repo else None
|
||||
except Exception:
|
||||
project = None
|
||||
if not project or not getattr(project, "plane_project_id", ""):
|
||||
return None
|
||||
url = (
|
||||
f"{web_base}/{workspace}/projects/{project.plane_project_id}"
|
||||
f"/issues/{plane_issue_id}/"
|
||||
)
|
||||
return (
|
||||
f'<a href="{html.escape(url, quote=True)}">'
|
||||
f"✅ Задача в Plane</a>"
|
||||
)
|
||||
|
||||
|
||||
def plane_issue_link(work_item_id, plane_issue_id=None, project_id=None, repo=None) -> str:
|
||||
"""ORCH-067 (Р-5): clickable issue number for cards / alerts.
|
||||
|
||||
Returns ``<a href=...>ORCH-NNN</a>`` when a Plane web URL can be built, else
|
||||
``html.escape(work_item_id)`` (number without a link). Never raises.
|
||||
|
||||
Link text is always ``html.escape(work_item_id)``; the href is built by the
|
||||
shared ``_plane_issue_url`` (same loopback / workspace / project guards as the
|
||||
'✅ Задача в Plane' link). On any missing piece -> the escaped number.
|
||||
"""
|
||||
label = html.escape(str(work_item_id)) if work_item_id is not None else ""
|
||||
try:
|
||||
url = _plane_issue_url(repo, plane_issue_id, project_id)
|
||||
if not url:
|
||||
return label
|
||||
return f'<a href="{html.escape(url, quote=True)}">{label}</a>'
|
||||
except Exception:
|
||||
return label
|
||||
|
||||
|
||||
def link_for(work_item_id, task_id=None) -> str:
|
||||
"""ORCH-067 (Р-6): clickable issue number for alert points that hold only a
|
||||
``work_item_id`` (or ``task_id``).
|
||||
|
||||
Resolves ``(repo, plane_issue_id)`` from the DB (by ``task_id`` when given,
|
||||
else the latest task row for ``work_item_id``) and delegates to
|
||||
``plane_issue_link``. On any missing data -> ``html.escape(work_item_id)``.
|
||||
Never raises.
|
||||
"""
|
||||
if not work_item_id:
|
||||
return html.escape(str(work_item_id)) if work_item_id is not None else ""
|
||||
repo = None
|
||||
plane_issue_id = None
|
||||
try:
|
||||
from .db import get_db
|
||||
conn = get_db()
|
||||
if task_id is not None:
|
||||
row = conn.execute(
|
||||
"SELECT repo, plane_issue_id FROM tasks WHERE id=?", (task_id,)
|
||||
).fetchone()
|
||||
else:
|
||||
row = conn.execute(
|
||||
"SELECT repo, plane_issue_id FROM tasks WHERE work_item_id=? "
|
||||
"ORDER BY id DESC LIMIT 1",
|
||||
(work_item_id,),
|
||||
).fetchone()
|
||||
conn.close()
|
||||
if row:
|
||||
repo = row["repo"]
|
||||
plane_issue_id = row["plane_issue_id"]
|
||||
except Exception as e:
|
||||
logger.debug(f"link_for({work_item_id}) DB lookup failed: {e}")
|
||||
return plane_issue_link(work_item_id, plane_issue_id=plane_issue_id, repo=repo)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# ORCH-067: Plane status label for the live card (layer B indication, ADR Р-1)
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
# Offline stage -> Plane status label. Names are the final ORCH-066 status names
|
||||
# (_PLANE_NAME_TO_KEY). Pure / deterministic — derived entirely from tasks.stage
|
||||
# (+ the brd-clock for In Review), NEVER from the network.
|
||||
_STAGE_STATUS_LABEL = {
|
||||
"created": "To Analyse",
|
||||
"analysis": "Analysis",
|
||||
"architecture": "Architecture",
|
||||
"development": "Development",
|
||||
"review": "Code-Review",
|
||||
"testing": "Testing",
|
||||
"deploy": "⏸️ Awaiting Deploy — ожидание Confirm Deploy",
|
||||
"done": "Done",
|
||||
}
|
||||
_DEFAULT_STATUS_LABEL = "To Analyse"
|
||||
_IN_REVIEW_LABEL = (
|
||||
"⏸️ In Review — ожидание "
|
||||
"согласования BRD"
|
||||
)
|
||||
|
||||
# Live-overlay branch labels (keys not derivable offline from tasks.stage).
|
||||
_LIVE_BRANCH_LABELS = {
|
||||
"needs_input": "❓ Needs Input — нужны уточнения",
|
||||
"blocked": "Blocked",
|
||||
"rejected": "Rejected",
|
||||
"cancelled": "Cancelled",
|
||||
"deploying": "Deploying",
|
||||
"monitoring": "Monitoring after Deploy",
|
||||
}
|
||||
# ORCH-066 (Р-1 anti-false-positive): deploying/monitoring alias their BASE key's
|
||||
# UUID on a project without dedicated statuses (enduro). Override is applied ONLY
|
||||
# when the project really defined a SEPARATE UUID for the branch key.
|
||||
_LIVE_BRANCH_BASE = {
|
||||
"deploying": "in_progress",
|
||||
"monitoring": "done",
|
||||
}
|
||||
|
||||
|
||||
def _row_get(row, key, default=None):
|
||||
"""Safe sqlite3.Row / dict / object getter. Never raises."""
|
||||
try:
|
||||
return row[key]
|
||||
except Exception:
|
||||
try:
|
||||
return getattr(row, key, default)
|
||||
except Exception:
|
||||
return default
|
||||
|
||||
|
||||
def plane_status_label(task_row) -> str:
|
||||
"""ORCH-067 (Р-1, layer 1): current Plane status label for the card header.
|
||||
|
||||
Pure / deterministic from the task row, NEVER hits the network, NEVER raises.
|
||||
On unknown / broken input -> a safe stage default. ``⏸️ In Review`` and
|
||||
``⏸️ Awaiting Deploy`` are produced here (offline), so both work without a
|
||||
network connection (AC-7, AC-8). Branch statuses that are indistinguishable
|
||||
offline (Needs Input / Blocked / …) are drawn by ``_live_plane_branch_override``.
|
||||
"""
|
||||
try:
|
||||
stage = _row_get(task_row, "stage") or "created"
|
||||
except Exception:
|
||||
return _DEFAULT_STATUS_LABEL
|
||||
try:
|
||||
if stage == "analysis":
|
||||
started = _row_get(task_row, "brd_review_started_at")
|
||||
ended = _row_get(task_row, "brd_review_ended_at")
|
||||
if started and not ended:
|
||||
return _IN_REVIEW_LABEL
|
||||
return _STAGE_STATUS_LABEL.get(stage, _DEFAULT_STATUS_LABEL)
|
||||
except Exception:
|
||||
return _DEFAULT_STATUS_LABEL
|
||||
|
||||
|
||||
# ORCH-067 (Р-3): per-issue TTL cache of the live state uuid -> {issue_id: (ts, uuid)}.
|
||||
_LIVE_STATE_CACHE: dict[str, tuple] = {}
|
||||
|
||||
|
||||
def _live_state_uuid_cached(plane_issue_id, project_id):
|
||||
"""ORCH-067 (Р-3/Р-4): TTL-cached single live-state read for the render path.
|
||||
|
||||
At most one ``fetch_issue_state`` per issue per ``tracker_live_status_ttl_s``
|
||||
with a SHORT timeout. Never raises -> None on any failure.
|
||||
"""
|
||||
try:
|
||||
import time
|
||||
s = _get_settings()
|
||||
ttl = getattr(s, "tracker_live_status_ttl_s", 60)
|
||||
now = time.monotonic()
|
||||
hit = _LIVE_STATE_CACHE.get(plane_issue_id)
|
||||
if hit is not None and (now - hit[0]) <= ttl:
|
||||
return hit[1]
|
||||
from .plane_sync import fetch_issue_state
|
||||
timeout = getattr(s, "tracker_live_status_timeout_s", 3)
|
||||
uuid = fetch_issue_state(plane_issue_id, project_id, timeout=timeout)
|
||||
_LIVE_STATE_CACHE[plane_issue_id] = (now, uuid)
|
||||
return uuid
|
||||
except Exception as e:
|
||||
logger.debug(f"_live_state_uuid_cached({plane_issue_id}) failed: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def _live_plane_branch_override(repo, plane_issue_id, base_label) -> str:
|
||||
"""ORCH-067 (Р-1 layer 2 / Р-2): best-effort live-status overlay.
|
||||
|
||||
Draws the branch statuses that are indistinguishable from ``tasks.stage``
|
||||
offline (Needs Input / Blocked / Rejected / Cancelled / Deploying / Monitoring
|
||||
after Deploy) by reading the LIVE Plane status (short timeout, TTL cache). Any
|
||||
failure / disabled kill-switch / missing data -> ``base_label`` (offline). The
|
||||
pipeline is NEVER blocked. Never raises.
|
||||
"""
|
||||
try:
|
||||
s = _get_settings()
|
||||
if not getattr(s, "tracker_live_status", True):
|
||||
return base_label
|
||||
if not plane_issue_id:
|
||||
return base_label
|
||||
try:
|
||||
from .projects import get_project_by_repo
|
||||
project = get_project_by_repo(repo) if repo else None
|
||||
except Exception:
|
||||
project = None
|
||||
project_id = getattr(project, "plane_project_id", "") if project else ""
|
||||
if not project_id:
|
||||
return base_label
|
||||
live_uuid = _live_state_uuid_cached(plane_issue_id, project_id)
|
||||
if not live_uuid:
|
||||
return base_label
|
||||
from .plane_sync import get_project_states
|
||||
states = get_project_states(project_id)
|
||||
for key, label in _LIVE_BRANCH_LABELS.items():
|
||||
uuid = states.get(key)
|
||||
if not uuid or uuid != live_uuid:
|
||||
continue
|
||||
base_key = _LIVE_BRANCH_BASE.get(key)
|
||||
if base_key and states.get(base_key) == uuid:
|
||||
# deploying/monitoring just alias their base key on this project
|
||||
# (enduro / no dedicated status) -> not a real branch, don't override.
|
||||
continue
|
||||
return label
|
||||
return base_label
|
||||
except Exception as e:
|
||||
logger.debug(f"_live_plane_branch_override failed: {e}")
|
||||
return base_label
|
||||
|
||||
|
||||
def _card_status_label(task_row, repo=None, plane_issue_id=None) -> str:
|
||||
"""ORCH-067: full status label for the card = offline core + live overlay.
|
||||
|
||||
Precedence (Р-1): if the offline core resolved ``⏸️ In Review`` (brd-clock,
|
||||
authoritative) the overlay is NOT consulted; otherwise the overlay may draw a
|
||||
branch status. Never raises (AC-9).
|
||||
"""
|
||||
try:
|
||||
base = plane_status_label(task_row)
|
||||
if base == _IN_REVIEW_LABEL:
|
||||
return base
|
||||
return _live_plane_branch_override(repo, plane_issue_id, base)
|
||||
except Exception:
|
||||
return _DEFAULT_STATUS_LABEL
|
||||
|
||||
|
||||
def notify_approve_requested(task_id: int):
|
||||
"""ALERT (separate, notifying): BRD/TZ/AC ready -> flip Plane to Approved.
|
||||
|
||||
@@ -749,7 +1001,7 @@ def notify_approve_requested(task_id: int):
|
||||
except Exception as e:
|
||||
logger.warning(f"notify_approve_requested: brd clock start failed: {e}")
|
||||
msg = (
|
||||
f"\U0001f4cb {html.escape(work_item_id)}: BRD/\u0422\u0417/AC \u0433\u043e\u0442\u043e\u0432\u044b. "
|
||||
f"\U0001f4cb {link_for(work_item_id, task_id)}: BRD/\u0422\u0417/AC \u0433\u043e\u0442\u043e\u0432\u044b. "
|
||||
f"\u041f\u0435\u0440\u0435\u0432\u0435\u0434\u0438\u0442\u0435 \u0437\u0430\u0434\u0430\u0447\u0443 \u0432 \u0441\u0442\u0430\u0442\u0443\u0441 Approved "
|
||||
f"\u0432 Plane \u0434\u043b\u044f \u043f\u0440\u043e\u0434\u043e\u043b\u0436\u0435\u043d\u0438\u044f."
|
||||
)
|
||||
@@ -783,8 +1035,14 @@ def notify_done(task_id: int):
|
||||
|
||||
|
||||
def notify_error(task_id: int, error: str):
|
||||
"""ALERT (separate, notifying): task error."""
|
||||
"""ALERT (separate, notifying): task error.
|
||||
|
||||
ORCH-067 (req 4): the issue number is a clickable Plane link (fail-safe ->
|
||||
raw number) and the error text is html-escaped so it cannot break the <a>
|
||||
markup under parse_mode=HTML (AC-14).
|
||||
"""
|
||||
work_item_id = _get_work_item_id(task_id) if task_id else "system"
|
||||
msg = f"\U0001f534 {work_item_id}: ERROR \u2014 {error}"
|
||||
num = link_for(work_item_id, task_id) if task_id else html.escape(work_item_id)
|
||||
msg = f"\U0001f534 {num}: ERROR \u2014 {html.escape(str(error))}"
|
||||
logger.error(msg)
|
||||
send_telegram(msg) # separate, notifying
|
||||
|
||||
Reference in New Issue
Block a user