Compare commits
3 Commits
feature/OR
...
feature/pl
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d305521067 | ||
|
|
30d6dd0557 | ||
| 12e2691a24 |
@@ -471,7 +471,8 @@ class AgentLauncher:
|
|||||||
set_issue_blocked(_wid)
|
set_issue_blocked(_wid)
|
||||||
plane_add_comment(
|
plane_add_comment(
|
||||||
_wid,
|
_wid,
|
||||||
"\u274c Deploy FAILED (smoke/healthcheck). Rolled back. Developer \u043d\u0443\u0436\u0435\u043d \u0434\u043b\u044f \u0444\u0438\u043a\u0441\u0430."
|
"\u274c Deploy FAILED (smoke/healthcheck). Rolled back. Developer \u043d\u0443\u0436\u0435\u043d \u0434\u043b\u044f \u0444\u0438\u043a\u0441\u0430.",
|
||||||
|
author="deployer",
|
||||||
)
|
)
|
||||||
from ..notifications import send_telegram
|
from ..notifications import send_telegram
|
||||||
send_telegram(f"\U0001f6a8 {_wid}: Deploy failed! Rolled back. Needs fix.")
|
send_telegram(f"\U0001f6a8 {_wid}: Deploy failed! Rolled back. Needs fix.")
|
||||||
|
|||||||
@@ -9,6 +9,17 @@ class Settings(BaseSettings):
|
|||||||
plane_webhook_secret: str = ""
|
plane_webhook_secret: str = ""
|
||||||
plane_project_id: str = ""
|
plane_project_id: str = ""
|
||||||
|
|
||||||
|
# Per-agent Plane bot tokens (feat: per-agent comment authorship).
|
||||||
|
# When set, add_comment posts under the matching bot so Plane shows the
|
||||||
|
# real author (Analyst/Architect/...). Empty -> fallback to plane_api_token.
|
||||||
|
plane_bot_analyst: str = ""
|
||||||
|
plane_bot_architect: str = ""
|
||||||
|
plane_bot_developer: str = ""
|
||||||
|
plane_bot_reviewer: str = ""
|
||||||
|
plane_bot_tester: str = ""
|
||||||
|
plane_bot_deployer: str = ""
|
||||||
|
plane_bot_stream: str = ""
|
||||||
|
|
||||||
# Gitea
|
# Gitea
|
||||||
gitea_url: str = "http://localhost:3000"
|
gitea_url: str = "http://localhost:3000"
|
||||||
gitea_token: str = ""
|
gitea_token: str = ""
|
||||||
|
|||||||
@@ -15,6 +15,44 @@ EMOJI_DONE = "\u2705" # task completed
|
|||||||
PLANE_BASE = f"{settings.plane_api_url}/api/v1"
|
PLANE_BASE = f"{settings.plane_api_url}/api/v1"
|
||||||
PLANE_HEADERS = {"X-API-Key": settings.plane_api_token}
|
PLANE_HEADERS = {"X-API-Key": settings.plane_api_token}
|
||||||
WORKSPACE = settings.plane_workspace_slug
|
WORKSPACE = settings.plane_workspace_slug
|
||||||
|
|
||||||
|
# feat(plane): per-agent comment authorship.
|
||||||
|
# Map an agent role -> its dedicated Plane bot token (read from config / env).
|
||||||
|
# When the token is present, add_comment() POSTs under that bot so Plane shows
|
||||||
|
# the real author. Empty/unknown role -> fallback to the shared orchestrator
|
||||||
|
# token (PLANE_HEADERS), so commenting stays autonomous.
|
||||||
|
PLANE_BOT_TOKENS = {
|
||||||
|
"analyst": settings.plane_bot_analyst,
|
||||||
|
"architect": settings.plane_bot_architect,
|
||||||
|
"developer": settings.plane_bot_developer,
|
||||||
|
"reviewer": settings.plane_bot_reviewer,
|
||||||
|
"tester": settings.plane_bot_tester,
|
||||||
|
"deployer": settings.plane_bot_deployer,
|
||||||
|
"stream": settings.plane_bot_stream,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Map a pipeline stage -> the agent role that owns work in that stage. Used to
|
||||||
|
# pick an author for rollback/stage notifications targeting a specific stage.
|
||||||
|
STAGE_AUTHORS = {
|
||||||
|
"analysis": "analyst",
|
||||||
|
"architecture": "architect",
|
||||||
|
"development": "developer",
|
||||||
|
"review": "reviewer",
|
||||||
|
"testing": "tester",
|
||||||
|
"deploy": "deployer",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _headers_for(author: str | None) -> dict:
|
||||||
|
"""Return X-API-Key headers for the given agent role.
|
||||||
|
|
||||||
|
Falls back to the shared orchestrator token (PLANE_HEADERS /
|
||||||
|
settings.plane_api_token) when the role is None, unknown, or its bot token
|
||||||
|
is not configured. This keeps comment posting autonomous: a comment is
|
||||||
|
always written, just attributed to the orchestrator if no bot is set.
|
||||||
|
"""
|
||||||
|
tok = PLANE_BOT_TOKENS.get(author or "") if author else None
|
||||||
|
return {"X-API-Key": tok} if tok else PLANE_HEADERS
|
||||||
PROJECT_ID = settings.plane_project_id or "7a79f0a9-5278-49cd-9007-9a338f238f9c"
|
PROJECT_ID = settings.plane_project_id or "7a79f0a9-5278-49cd-9007-9a338f238f9c"
|
||||||
|
|
||||||
|
|
||||||
@@ -159,8 +197,14 @@ def update_issue_state(work_item_id: str, stage: str, project_id: str = None):
|
|||||||
logger.error(f"Failed to update Plane state for {work_item_id}: {e}")
|
logger.error(f"Failed to update Plane state for {work_item_id}: {e}")
|
||||||
|
|
||||||
|
|
||||||
def add_comment(work_item_id: str, text: str, project_id: str = None):
|
def add_comment(work_item_id: str, text: str, project_id: str = None, author: str = None):
|
||||||
"""Add a comment to Plane issue."""
|
"""Add a comment to a Plane issue.
|
||||||
|
|
||||||
|
feat(plane): when ``author`` (an agent role) maps to a configured bot
|
||||||
|
token, the comment is POSTed under that bot so Plane shows the real author.
|
||||||
|
Otherwise it falls back to the shared orchestrator token (see
|
||||||
|
``_headers_for``). GET/PATCH calls elsewhere keep using PLANE_HEADERS.
|
||||||
|
"""
|
||||||
project_id = _resolve_project_id(work_item_id, project_id)
|
project_id = _resolve_project_id(work_item_id, project_id)
|
||||||
issue_id = find_issue_id(work_item_id, project_id)
|
issue_id = find_issue_id(work_item_id, project_id)
|
||||||
if not issue_id:
|
if not issue_id:
|
||||||
@@ -170,9 +214,9 @@ def add_comment(work_item_id: str, text: str, project_id: str = None):
|
|||||||
url = f"{PLANE_BASE}/workspaces/{WORKSPACE}/projects/{project_id}/issues/{issue_id}/comments/"
|
url = f"{PLANE_BASE}/workspaces/{WORKSPACE}/projects/{project_id}/issues/{issue_id}/comments/"
|
||||||
html = f"<p>{text}</p>"
|
html = f"<p>{text}</p>"
|
||||||
try:
|
try:
|
||||||
resp = httpx.post(url, headers=PLANE_HEADERS, json={"comment_html": html}, timeout=10)
|
resp = httpx.post(url, headers=_headers_for(author), json={"comment_html": html}, timeout=10)
|
||||||
resp.raise_for_status()
|
resp.raise_for_status()
|
||||||
logger.info(f"Plane: comment added to {work_item_id}")
|
logger.info(f"Plane: comment added to {work_item_id} (author={author or 'orchestrator'})")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to add comment to {work_item_id}: {e}")
|
logger.error(f"Failed to add comment to {work_item_id}: {e}")
|
||||||
|
|
||||||
@@ -252,16 +296,29 @@ def notify_stage_change(work_item_id: str, old_stage: str, new_stage: str, agent
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
add_comment(work_item_id, msg, project_id)
|
# Stage transition is the orchestrator's own voice -> attribute to stream.
|
||||||
|
add_comment(work_item_id, msg, project_id, author="stream")
|
||||||
|
|
||||||
|
|
||||||
def notify_qg_failure(work_item_id: str, stage: str, check: str, reason: str, project_id: str = None):
|
def notify_qg_failure(work_item_id: str, stage: str, check: str, reason: str, project_id: str = None):
|
||||||
"""Notify Plane about QG failure."""
|
"""Notify Plane about QG failure."""
|
||||||
add_comment(work_item_id, f"{EMOJI_QG_FAIL} QG failed at {stage}: {check} — {reason}", project_id)
|
# QG failure belongs to the agent that owns the failing stage.
|
||||||
|
add_comment(
|
||||||
|
work_item_id,
|
||||||
|
f"{EMOJI_QG_FAIL} QG failed at {stage}: {check} — {reason}",
|
||||||
|
project_id,
|
||||||
|
author=STAGE_AUTHORS.get(stage, "stream"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def notify_done(work_item_id: str, project_id: str = None):
|
def notify_done(work_item_id: str, project_id: str = None):
|
||||||
"""Mark issue as Done in Plane."""
|
"""Mark issue as Done in Plane."""
|
||||||
project_id = _resolve_project_id(work_item_id, project_id)
|
project_id = _resolve_project_id(work_item_id, project_id)
|
||||||
update_issue_state(work_item_id, "done", project_id)
|
update_issue_state(work_item_id, "done", project_id)
|
||||||
add_comment(work_item_id, f"{EMOJI_DONE} Task completed! PR merged and deployed.", project_id)
|
# Deploy finished the task -> attribute the completion comment to Deployer.
|
||||||
|
add_comment(
|
||||||
|
work_item_id,
|
||||||
|
f"{EMOJI_DONE} Task completed! PR merged and deployed.",
|
||||||
|
project_id,
|
||||||
|
author="deployer",
|
||||||
|
)
|
||||||
|
|||||||
@@ -286,6 +286,7 @@ def _handle_analysis_approved_flow(
|
|||||||
"\U0001f4cb BRD/\u0422\u0417/AC/TestPlan \u0433\u043e\u0442\u043e\u0432\u044b. "
|
"\U0001f4cb BRD/\u0422\u0417/AC/TestPlan \u0433\u043e\u0442\u043e\u0432\u044b. "
|
||||||
"\u041f\u0440\u043e\u0448\u0443 review \u0438 \u0440\u0435\u0430\u043a\u0446\u0438\u044e :approved: "
|
"\u041f\u0440\u043e\u0448\u0443 review \u0438 \u0440\u0435\u0430\u043a\u0446\u0438\u044e :approved: "
|
||||||
"\u0434\u043b\u044f \u043f\u0440\u043e\u0434\u0432\u0438\u0436\u0435\u043d\u0438\u044f \u0432 Architecture.",
|
"\u0434\u043b\u044f \u043f\u0440\u043e\u0434\u0432\u0438\u0436\u0435\u043d\u0438\u044f \u0432 Architecture.",
|
||||||
|
author="analyst",
|
||||||
)
|
)
|
||||||
notify_approve_requested(task_id)
|
notify_approve_requested(task_id)
|
||||||
result.note = "analysis-in-review"
|
result.note = "analysis-in-review"
|
||||||
@@ -305,6 +306,7 @@ def _handle_analysis_approved_flow(
|
|||||||
plane_add_comment(
|
plane_add_comment(
|
||||||
work_item_id,
|
work_item_id,
|
||||||
f"\u2753 Analyst \u043d\u0443\u0436\u0434\u0430\u0435\u0442\u0441\u044f \u0432 \u0443\u0442\u043e\u0447\u043d\u0435\u043d\u0438\u0438:\n\n{questions_text}",
|
f"\u2753 Analyst \u043d\u0443\u0436\u0434\u0430\u0435\u0442\u0441\u044f \u0432 \u0443\u0442\u043e\u0447\u043d\u0435\u043d\u0438\u0438:\n\n{questions_text}",
|
||||||
|
author="analyst",
|
||||||
)
|
)
|
||||||
send_telegram(
|
send_telegram(
|
||||||
f"\u2753 {work_item_id}: Analyst \u0437\u0430\u0434\u0430\u0451\u0442 \u0432\u043e\u043f\u0440\u043e\u0441\u044b. \u041e\u0442\u0432\u0435\u0442\u044c \u0432 Plane."
|
f"\u2753 {work_item_id}: Analyst \u0437\u0430\u0434\u0430\u0451\u0442 \u0432\u043e\u043f\u0440\u043e\u0441\u044b. \u041e\u0442\u0432\u0435\u0442\u044c \u0432 Plane."
|
||||||
@@ -316,6 +318,7 @@ def _handle_analysis_approved_flow(
|
|||||||
plane_add_comment(
|
plane_add_comment(
|
||||||
work_item_id,
|
work_item_id,
|
||||||
"\u26a0\ufe0f Analyst \u0437\u0430\u0432\u0435\u0440\u0448\u0438\u043b\u0441\u044f \u0431\u0435\u0437 \u0430\u0440\u0442\u0435\u0444\u0430\u043a\u0442\u043e\u0432 \u0438 \u0431\u0435\u0437 \u0432\u043e\u043f\u0440\u043e\u0441\u043e\u0432. \u041f\u0440\u043e\u0432\u0435\u0440\u044c\u0442\u0435 \u043b\u043e\u0433.",
|
"\u26a0\ufe0f Analyst \u0437\u0430\u0432\u0435\u0440\u0448\u0438\u043b\u0441\u044f \u0431\u0435\u0437 \u0430\u0440\u0442\u0435\u0444\u0430\u043a\u0442\u043e\u0432 \u0438 \u0431\u0435\u0437 \u0432\u043e\u043f\u0440\u043e\u0441\u043e\u0432. \u041f\u0440\u043e\u0432\u0435\u0440\u044c\u0442\u0435 \u043b\u043e\u0433.",
|
||||||
|
author="analyst",
|
||||||
)
|
)
|
||||||
result.note = "analysis-empty"
|
result.note = "analysis-empty"
|
||||||
|
|
||||||
@@ -370,6 +373,7 @@ def _handle_qg_failure_rollbacks(
|
|||||||
work_item_id,
|
work_item_id,
|
||||||
f"\u274c \u0422\u0435\u0441\u0442\u044b \u043d\u0435 \u043f\u0440\u043e\u0448\u043b\u0438: {reason}. "
|
f"\u274c \u0422\u0435\u0441\u0442\u044b \u043d\u0435 \u043f\u0440\u043e\u0448\u043b\u0438: {reason}. "
|
||||||
f"Developer \u043f\u0435\u0440\u0435\u0437\u0430\u043f\u0443\u0449\u0435\u043d \u0434\u043b\u044f \u0444\u0438\u043a\u0441\u0430.",
|
f"Developer \u043f\u0435\u0440\u0435\u0437\u0430\u043f\u0443\u0449\u0435\u043d \u0434\u043b\u044f \u0444\u0438\u043a\u0441\u0430.",
|
||||||
|
author="tester",
|
||||||
)
|
)
|
||||||
retry_count = _developer_retry_count(task_id)
|
retry_count = _developer_retry_count(task_id)
|
||||||
if retry_count < MAX_DEVELOPER_RETRIES:
|
if retry_count < MAX_DEVELOPER_RETRIES:
|
||||||
@@ -410,6 +414,7 @@ def _handle_qg_failure_rollbacks(
|
|||||||
work_item_id,
|
work_item_id,
|
||||||
f"\u26a0\ufe0f Architect \u043d\u0430\u0448\u0451\u043b \u043a\u043e\u043d\u0444\u043b\u0438\u043a\u0442 \u0441 \u0422\u0417. "
|
f"\u26a0\ufe0f Architect \u043d\u0430\u0448\u0451\u043b \u043a\u043e\u043d\u0444\u043b\u0438\u043a\u0442 \u0441 \u0422\u0417. "
|
||||||
f"\u0412\u043e\u0437\u0432\u0440\u0430\u0442 \u0432 Analysis.\n\n{conflict_text}",
|
f"\u0412\u043e\u0437\u0432\u0440\u0430\u0442 \u0432 Analysis.\n\n{conflict_text}",
|
||||||
|
author="architect",
|
||||||
)
|
)
|
||||||
task_desc = (
|
task_desc = (
|
||||||
f"Work item: {work_item_id}\nRepo: {repo}\nBranch: {branch}\n"
|
f"Work item: {work_item_id}\nRepo: {repo}\nBranch: {branch}\n"
|
||||||
|
|||||||
@@ -209,7 +209,7 @@ async def handle_work_item_created(data: dict, project_id: str = ""):
|
|||||||
logger.info(f"Task {task_id}: enqueued analyst (job_id={job_id})")
|
logger.info(f"Task {task_id}: enqueued analyst (job_id={job_id})")
|
||||||
# Post start comment to Plane
|
# Post start comment to Plane
|
||||||
from ..plane_sync import add_comment as _add_comment
|
from ..plane_sync import add_comment as _add_comment
|
||||||
_add_comment(work_item_id, "\U0001f50d Analyst \u0437\u0430\u043f\u0443\u0449\u0435\u043d. BRD/\u0422\u0417/AC/TestPlan \u0432 \u0440\u0430\u0431\u043e\u0442\u0435 (\u043e\u0436\u0438\u0434\u0430\u0439\u0442\u0435 8-15 \u043c\u0438\u043d).")
|
_add_comment(work_item_id, "\U0001f50d Analyst \u0437\u0430\u043f\u0443\u0449\u0435\u043d. BRD/\u0422\u0417/AC/TestPlan \u0432 \u0440\u0430\u0431\u043e\u0442\u0435 (\u043e\u0436\u0438\u0434\u0430\u0439\u0442\u0435 8-15 \u043c\u0438\u043d).", author="analyst")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to launch analyst for {work_item_id}: {e}")
|
logger.error(f"Failed to launch analyst for {work_item_id}: {e}")
|
||||||
|
|
||||||
@@ -252,7 +252,7 @@ async def handle_comment(data: dict, project_id: str = ""):
|
|||||||
)
|
)
|
||||||
new_job = enqueue_job("analyst", repo, task_desc, task_id=task_id)
|
new_job = enqueue_job("analyst", repo, task_desc, task_id=task_id)
|
||||||
from ..plane_sync import add_comment as _plane_comment
|
from ..plane_sync import add_comment as _plane_comment
|
||||||
_plane_comment(work_item_id, f"\U0001f504 Analyst \u043f\u0435\u0440\u0435\u0437\u0430\u043f\u0443\u0449\u0435\u043d. \u041f\u0440\u0438\u0447\u0438\u043d\u0430 \u043e\u0442\u043a\u043b\u043e\u043d\u0435\u043d\u0438\u044f: {reason}")
|
_plane_comment(work_item_id, f"\U0001f504 Analyst \u043f\u0435\u0440\u0435\u0437\u0430\u043f\u0443\u0449\u0435\u043d. \u041f\u0440\u0438\u0447\u0438\u043d\u0430 \u043e\u0442\u043a\u043b\u043e\u043d\u0435\u043d\u0438\u044f: {reason}", author="analyst")
|
||||||
logger.info(f"Task {task_id}: rejected at analysis, enqueued analyst (job_id={new_job})")
|
logger.info(f"Task {task_id}: rejected at analysis, enqueued analyst (job_id={new_job})")
|
||||||
else:
|
else:
|
||||||
# Rollback to previous stage
|
# Rollback to previous stage
|
||||||
@@ -263,8 +263,12 @@ async def handle_comment(data: dict, project_id: str = ""):
|
|||||||
set_issue_in_progress(work_item_id)
|
set_issue_in_progress(work_item_id)
|
||||||
notify_stage_change(task_id, current_stage, prev_stage)
|
notify_stage_change(task_id, current_stage, prev_stage)
|
||||||
plane_notify_stage(work_item_id, current_stage, prev_stage)
|
plane_notify_stage(work_item_id, current_stage, prev_stage)
|
||||||
from ..plane_sync import add_comment as _plane_comment
|
from ..plane_sync import add_comment as _plane_comment, STAGE_AUTHORS
|
||||||
_plane_comment(work_item_id, f"\U0001f504 \u041e\u0442\u043a\u0430\u0442: {current_stage} \u2192 {prev_stage}. \u041f\u0440\u0438\u0447\u0438\u043d\u0430: {reason}")
|
_plane_comment(
|
||||||
|
work_item_id,
|
||||||
|
f"\U0001f504 \u041e\u0442\u043a\u0430\u0442: {current_stage} \u2192 {prev_stage}. \u041f\u0440\u0438\u0447\u0438\u043d\u0430: {reason}",
|
||||||
|
author=STAGE_AUTHORS.get(prev_stage, "stream"),
|
||||||
|
)
|
||||||
logger.info(f"Task {task_id}: rejected, rolled back {current_stage} \u2192 {prev_stage}")
|
logger.info(f"Task {task_id}: rejected, rolled back {current_stage} \u2192 {prev_stage}")
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -310,7 +314,8 @@ async def handle_comment(data: dict, project_id: str = ""):
|
|||||||
_pc(
|
_pc(
|
||||||
work_item_id,
|
work_item_id,
|
||||||
"\U0001f6a8 3 \u0440\u0430\u0443\u043d\u0434\u0430 \u0443\u0442\u043e\u0447\u043d\u0435\u043d\u0438\u0439 \u0438\u0441\u0447\u0435\u0440\u043f\u0430\u043d\u044b. Analyst \u043d\u0435 \u043c\u043e\u0436\u0435\u0442 \u0441\u0444\u043e\u0440\u043c\u0438\u0440\u043e\u0432\u0430\u0442\u044c \u0422\u0417. "
|
"\U0001f6a8 3 \u0440\u0430\u0443\u043d\u0434\u0430 \u0443\u0442\u043e\u0447\u043d\u0435\u043d\u0438\u0439 \u0438\u0441\u0447\u0435\u0440\u043f\u0430\u043d\u044b. Analyst \u043d\u0435 \u043c\u043e\u0436\u0435\u0442 \u0441\u0444\u043e\u0440\u043c\u0438\u0440\u043e\u0432\u0430\u0442\u044c \u0422\u0417. "
|
||||||
"\u0422\u0440\u0435\u0431\u0443\u0435\u0442\u0441\u044f \u0431\u043e\u043b\u0435\u0435 \u0434\u0435\u0442\u0430\u043b\u044c\u043d\u043e\u0435 \u043e\u043f\u0438\u0441\u0430\u043d\u0438\u0435 \u0438\u043b\u0438 \u0432\u0441\u0442\u0440\u0435\u0447\u0430."
|
"\u0422\u0440\u0435\u0431\u0443\u0435\u0442\u0441\u044f \u0431\u043e\u043b\u0435\u0435 \u0434\u0435\u0442\u0430\u043b\u044c\u043d\u043e\u0435 \u043e\u043f\u0438\u0441\u0430\u043d\u0438\u0435 \u0438\u043b\u0438 \u0432\u0441\u0442\u0440\u0435\u0447\u0430.",
|
||||||
|
author="analyst",
|
||||||
)
|
)
|
||||||
from ..notifications import send_telegram
|
from ..notifications import send_telegram
|
||||||
send_telegram(f"\U0001f6a8 {work_item_id}: 3 \u0440\u0430\u0443\u043d\u0434\u0430 \u0432\u043e\u043f\u0440\u043e\u0441\u043e\u0432 analyst'\u0430 \u0438\u0441\u0447\u0435\u0440\u043f\u0430\u043d\u044b. \u041d\u0443\u0436\u043d\u0430 \u043f\u043e\u043c\u043e\u0449\u044c.")
|
send_telegram(f"\U0001f6a8 {work_item_id}: 3 \u0440\u0430\u0443\u043d\u0434\u0430 \u0432\u043e\u043f\u0440\u043e\u0441\u043e\u0432 analyst'\u0430 \u0438\u0441\u0447\u0435\u0440\u043f\u0430\u043d\u044b. \u041d\u0443\u0436\u043d\u0430 \u043f\u043e\u043c\u043e\u0449\u044c.")
|
||||||
@@ -326,7 +331,7 @@ async def handle_comment(data: dict, project_id: str = ""):
|
|||||||
)
|
)
|
||||||
new_job = enqueue_job("analyst", repo, task_desc, task_id=task_id)
|
new_job = enqueue_job("analyst", repo, task_desc, task_id=task_id)
|
||||||
from ..plane_sync import add_comment as _pc2
|
from ..plane_sync import add_comment as _pc2
|
||||||
_pc2(work_item_id, "\U0001f504 Analyst \u043f\u0435\u0440\u0435\u0437\u0430\u043f\u0443\u0449\u0435\u043d \u0441 \u043e\u0442\u0432\u0435\u0442\u0430\u043c\u0438 \u0441\u0442\u0435\u0439\u043a\u0445\u043e\u043b\u0434\u0435\u0440\u0430.")
|
_pc2(work_item_id, "\U0001f504 Analyst \u043f\u0435\u0440\u0435\u0437\u0430\u043f\u0443\u0449\u0435\u043d \u0441 \u043e\u0442\u0432\u0435\u0442\u0430\u043c\u0438 \u0441\u0442\u0435\u0439\u043a\u0445\u043e\u043b\u0434\u0435\u0440\u0430.", author="analyst")
|
||||||
logger.info(f"Task {task_id}: stakeholder answered questions, enqueued analyst (job_id={new_job})")
|
logger.info(f"Task {task_id}: stakeholder answered questions, enqueued analyst (job_id={new_job})")
|
||||||
return
|
return
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
99
tests/test_plane_author.py
Normal file
99
tests/test_plane_author.py
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
"""Tests for per-agent Plane comment authorship (feat: per-agent bot author).
|
||||||
|
|
||||||
|
Covers:
|
||||||
|
* _headers_for: role -> bot token; None/unknown/empty token -> shared fallback.
|
||||||
|
* add_comment: author is propagated into the POST headers; no author keeps
|
||||||
|
backward-compatible behaviour (shared orchestrator token).
|
||||||
|
|
||||||
|
GET/PATCH calls are intentionally NOT covered here: they stay on the shared
|
||||||
|
token by design and are unchanged by this feature.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
# Set env defaults before importing app modules (same convention as the other
|
||||||
|
# suites) so config/settings load cleanly without a real .env.
|
||||||
|
os.environ.setdefault("ORCH_PLANE_API_TOKEN", "shared-token")
|
||||||
|
os.environ.setdefault("ORCH_GITEA_TOKEN", "test-token")
|
||||||
|
|
||||||
|
from unittest.mock import patch, MagicMock # noqa: E402
|
||||||
|
|
||||||
|
from src import plane_sync # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# _headers_for
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
def test_headers_for_known_role_uses_bot_token():
|
||||||
|
"""A known role with a configured token -> that bot's X-API-Key."""
|
||||||
|
with patch.dict(plane_sync.PLANE_BOT_TOKENS, {"analyst": "analyst-tok"}, clear=False):
|
||||||
|
assert plane_sync._headers_for("analyst") == {"X-API-Key": "analyst-tok"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_headers_for_none_falls_back_to_shared():
|
||||||
|
"""author=None -> shared orchestrator headers."""
|
||||||
|
assert plane_sync._headers_for(None) is plane_sync.PLANE_HEADERS
|
||||||
|
|
||||||
|
|
||||||
|
def test_headers_for_unknown_role_falls_back_to_shared():
|
||||||
|
"""Unknown role -> shared orchestrator headers."""
|
||||||
|
assert plane_sync._headers_for("nope") is plane_sync.PLANE_HEADERS
|
||||||
|
|
||||||
|
|
||||||
|
def test_headers_for_empty_token_falls_back_to_shared():
|
||||||
|
"""Known role but empty/unconfigured token -> shared orchestrator headers."""
|
||||||
|
with patch.dict(plane_sync.PLANE_BOT_TOKENS, {"tester": ""}, clear=False):
|
||||||
|
assert plane_sync._headers_for("tester") is plane_sync.PLANE_HEADERS
|
||||||
|
|
||||||
|
|
||||||
|
def test_headers_for_empty_string_author_falls_back_to_shared():
|
||||||
|
"""author='' -> shared orchestrator headers."""
|
||||||
|
assert plane_sync._headers_for("") is plane_sync.PLANE_HEADERS
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# add_comment
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
def _mock_post_ok():
|
||||||
|
resp = MagicMock()
|
||||||
|
resp.raise_for_status.return_value = None
|
||||||
|
return resp
|
||||||
|
|
||||||
|
|
||||||
|
def test_add_comment_with_author_posts_with_bot_headers():
|
||||||
|
"""add_comment(author='developer') -> httpx.post called with the developer
|
||||||
|
bot's X-API-Key header."""
|
||||||
|
with patch.object(plane_sync, "find_issue_id", return_value="issue-uuid"), \
|
||||||
|
patch.object(plane_sync, "_resolve_project_id", return_value="proj-uuid"), \
|
||||||
|
patch.dict(plane_sync.PLANE_BOT_TOKENS, {"developer": "dev-tok"}, clear=False), \
|
||||||
|
patch.object(plane_sync.httpx, "post", return_value=_mock_post_ok()) as mock_post:
|
||||||
|
plane_sync.add_comment("ET-001", "hello", author="developer")
|
||||||
|
|
||||||
|
assert mock_post.called
|
||||||
|
_, kwargs = mock_post.call_args
|
||||||
|
assert kwargs["headers"] == {"X-API-Key": "dev-tok"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_add_comment_without_author_uses_shared_token():
|
||||||
|
"""add_comment without author -> shared orchestrator headers (backward
|
||||||
|
compatible)."""
|
||||||
|
with patch.object(plane_sync, "find_issue_id", return_value="issue-uuid"), \
|
||||||
|
patch.object(plane_sync, "_resolve_project_id", return_value="proj-uuid"), \
|
||||||
|
patch.object(plane_sync.httpx, "post", return_value=_mock_post_ok()) as mock_post:
|
||||||
|
plane_sync.add_comment("ET-001", "hello")
|
||||||
|
|
||||||
|
assert mock_post.called
|
||||||
|
_, kwargs = mock_post.call_args
|
||||||
|
assert kwargs["headers"] is plane_sync.PLANE_HEADERS
|
||||||
|
|
||||||
|
|
||||||
|
def test_add_comment_unknown_author_uses_shared_token():
|
||||||
|
"""add_comment with an unknown role -> shared orchestrator headers."""
|
||||||
|
with patch.object(plane_sync, "find_issue_id", return_value="issue-uuid"), \
|
||||||
|
patch.object(plane_sync, "_resolve_project_id", return_value="proj-uuid"), \
|
||||||
|
patch.object(plane_sync.httpx, "post", return_value=_mock_post_ok()) as mock_post:
|
||||||
|
plane_sync.add_comment("ET-001", "hello", author="ghost")
|
||||||
|
|
||||||
|
assert mock_post.called
|
||||||
|
_, kwargs = mock_post.call_args
|
||||||
|
assert kwargs["headers"] is plane_sync.PLANE_HEADERS
|
||||||
Reference in New Issue
Block a user