devops-bot-sdk 1.4.37__tar.gz → 1.4.45__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {devops_bot_sdk-1.4.37 → devops_bot_sdk-1.4.45}/PKG-INFO +1 -1
- {devops_bot_sdk-1.4.37 → devops_bot_sdk-1.4.45}/devops_bot_sdk.egg-info/PKG-INFO +1 -1
- {devops_bot_sdk-1.4.37 → devops_bot_sdk-1.4.45}/pyproject.toml +1 -1
- {devops_bot_sdk-1.4.37 → devops_bot_sdk-1.4.45}/sdk/__init__.py +2 -2
- {devops_bot_sdk-1.4.37 → devops_bot_sdk-1.4.45}/sdk/client.py +66 -1
- {devops_bot_sdk-1.4.37 → devops_bot_sdk-1.4.45}/sdk/ipc/handlers.py +386 -0
- {devops_bot_sdk-1.4.37 → devops_bot_sdk-1.4.45}/README.md +0 -0
- {devops_bot_sdk-1.4.37 → devops_bot_sdk-1.4.45}/devops_bot_sdk.egg-info/SOURCES.txt +0 -0
- {devops_bot_sdk-1.4.37 → devops_bot_sdk-1.4.45}/devops_bot_sdk.egg-info/dependency_links.txt +0 -0
- {devops_bot_sdk-1.4.37 → devops_bot_sdk-1.4.45}/devops_bot_sdk.egg-info/entry_points.txt +0 -0
- {devops_bot_sdk-1.4.37 → devops_bot_sdk-1.4.45}/devops_bot_sdk.egg-info/requires.txt +0 -0
- {devops_bot_sdk-1.4.37 → devops_bot_sdk-1.4.45}/devops_bot_sdk.egg-info/top_level.txt +0 -0
- {devops_bot_sdk-1.4.37 → devops_bot_sdk-1.4.45}/sdk/collectors/__init__.py +0 -0
- {devops_bot_sdk-1.4.37 → devops_bot_sdk-1.4.45}/sdk/collectors/files.py +0 -0
- {devops_bot_sdk-1.4.37 → devops_bot_sdk-1.4.45}/sdk/collectors/process.py +0 -0
- {devops_bot_sdk-1.4.37 → devops_bot_sdk-1.4.45}/sdk/collectors/screenshot.py +0 -0
- {devops_bot_sdk-1.4.37 → devops_bot_sdk-1.4.45}/sdk/config.py +0 -0
- {devops_bot_sdk-1.4.37 → devops_bot_sdk-1.4.45}/sdk/crucial.py +0 -0
- {devops_bot_sdk-1.4.37 → devops_bot_sdk-1.4.45}/sdk/exceptions.py +0 -0
- {devops_bot_sdk-1.4.37 → devops_bot_sdk-1.4.45}/sdk/git_ops.py +0 -0
- {devops_bot_sdk-1.4.37 → devops_bot_sdk-1.4.45}/sdk/graphify.py +0 -0
- {devops_bot_sdk-1.4.37 → devops_bot_sdk-1.4.45}/sdk/hooks/__init__.py +0 -0
- {devops_bot_sdk-1.4.37 → devops_bot_sdk-1.4.45}/sdk/hooks/crucial_guard.py +0 -0
- {devops_bot_sdk-1.4.37 → devops_bot_sdk-1.4.45}/sdk/ipc/__init__.py +0 -0
- {devops_bot_sdk-1.4.37 → devops_bot_sdk-1.4.45}/sdk/ipc/electron_bridge.py +0 -0
- {devops_bot_sdk-1.4.37 → devops_bot_sdk-1.4.45}/sdk/local_exec.py +0 -0
- {devops_bot_sdk-1.4.37 → devops_bot_sdk-1.4.45}/sdk/models/__init__.py +0 -0
- {devops_bot_sdk-1.4.37 → devops_bot_sdk-1.4.45}/sdk/models/envelope.py +0 -0
- {devops_bot_sdk-1.4.37 → devops_bot_sdk-1.4.45}/sdk/models/requests.py +0 -0
- {devops_bot_sdk-1.4.37 → devops_bot_sdk-1.4.45}/sdk/models/responses.py +0 -0
- {devops_bot_sdk-1.4.37 → devops_bot_sdk-1.4.45}/sdk/models/snapshots.py +0 -0
- {devops_bot_sdk-1.4.37 → devops_bot_sdk-1.4.45}/sdk/py.typed +0 -0
- {devops_bot_sdk-1.4.37 → devops_bot_sdk-1.4.45}/sdk/run_auto.py +0 -0
- {devops_bot_sdk-1.4.37 → devops_bot_sdk-1.4.45}/sdk/sse.py +0 -0
- {devops_bot_sdk-1.4.37 → devops_bot_sdk-1.4.45}/sdk/test.py +0 -0
- {devops_bot_sdk-1.4.37 → devops_bot_sdk-1.4.45}/sdk/test_pipeline.py +0 -0
- {devops_bot_sdk-1.4.37 → devops_bot_sdk-1.4.45}/sdk/updater.py +0 -0
- {devops_bot_sdk-1.4.37 → devops_bot_sdk-1.4.45}/setup.cfg +0 -0
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "devops-bot-sdk"
|
|
7
|
-
version = "1.4.
|
|
7
|
+
version = "1.4.45"
|
|
8
8
|
description = "DevOps Bot Desktop SDK — thin client for the AgentOS Electron desktop app"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = "LicenseRef-Proprietary"
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"""AgentOS Desktop SDK — thin HTTPS/SSE client for the Electron app.
|
|
2
2
|
|
|
3
|
-
Version: 1.4.
|
|
3
|
+
Version: 1.4.45
|
|
4
4
|
|
|
5
5
|
Public surface:
|
|
6
6
|
BackendClient.from_config() — create client from ~/.agentos/config.toml
|
|
@@ -30,7 +30,7 @@ Rules:
|
|
|
30
30
|
- All data egress through submit_webhook only
|
|
31
31
|
"""
|
|
32
32
|
|
|
33
|
-
__version__ = "1.4.
|
|
33
|
+
__version__ = "1.4.45"
|
|
34
34
|
__author__ = "AgentOS"
|
|
35
35
|
|
|
36
36
|
from sdk.client import BackendClient
|
|
@@ -58,7 +58,7 @@ def _notify_status(task_id: str, status: str) -> None:
|
|
|
58
58
|
pass
|
|
59
59
|
|
|
60
60
|
|
|
61
|
-
SDK_VERSION = "1.4.
|
|
61
|
+
SDK_VERSION = "1.4.45"
|
|
62
62
|
_POLL_INTERVAL = 3.0
|
|
63
63
|
_POLL_TIMEOUT = 600.0
|
|
64
64
|
_ORCHESTRATE_TIMEOUT = 2700.0 # 45 min — covers approval wait + VPS execution time
|
|
@@ -803,6 +803,71 @@ class BackendClient:
|
|
|
803
803
|
logger.warning("client.mark_in_progress error task=%s err=%s", task_id, exc)
|
|
804
804
|
return False
|
|
805
805
|
|
|
806
|
+
async def update_task_ml(
|
|
807
|
+
self, task_id: str, fields: dict, model_used: list[str] | None = None,
|
|
808
|
+
) -> bool:
|
|
809
|
+
"""PUT {BE}/tasks-ml/{id} with arbitrary multipart form fields (best-effort).
|
|
810
|
+
|
|
811
|
+
Generic form-update used e.g. to mark an Epic Done while attaching the
|
|
812
|
+
generated detailed description + AI plan. `model_used` is sent as repeated
|
|
813
|
+
`modelUsed` fields. None-valued fields are dropped. Returns True on 2xx.
|
|
814
|
+
"""
|
|
815
|
+
from sdk.config import BE_DEFAULT_URL
|
|
816
|
+
|
|
817
|
+
url = f"{BE_DEFAULT_URL.rstrip('/')}/tasks-ml/{task_id}"
|
|
818
|
+
headers = {k: v for k, v in self._headers.items() if k.lower() != "content-type"}
|
|
819
|
+
headers.update({"accept": "*/*", "developer-name": "null", "product": ""})
|
|
820
|
+
# httpx multipart wants a list of (name, (filename, value)) tuples so a
|
|
821
|
+
# field like `modelUsed` can repeat.
|
|
822
|
+
files = [(k, (None, str(v))) for k, v in fields.items() if v is not None]
|
|
823
|
+
for m in (model_used or []):
|
|
824
|
+
files.append(("modelUsed", (None, str(m))))
|
|
825
|
+
try:
|
|
826
|
+
async with httpx.AsyncClient(timeout=30.0) as client:
|
|
827
|
+
resp = await client.put(url, files=files, headers=headers)
|
|
828
|
+
if resp.status_code >= 400:
|
|
829
|
+
logger.warning("client.update_task_ml failed task=%s status=%s body=%s",
|
|
830
|
+
task_id, resp.status_code, resp.text[:200])
|
|
831
|
+
return False
|
|
832
|
+
return True
|
|
833
|
+
except Exception as exc: # noqa: BLE001
|
|
834
|
+
logger.warning("client.update_task_ml error task=%s err=%s", task_id, exc)
|
|
835
|
+
return False
|
|
836
|
+
|
|
837
|
+
async def create_task_ml(
|
|
838
|
+
self, fields: dict, model_used: list[str] | None = None,
|
|
839
|
+
) -> str | None:
|
|
840
|
+
"""POST {BE}/tasks-ml — create a task via multipart form; return its id.
|
|
841
|
+
|
|
842
|
+
Required fields per the API: projectKey, type, summary (description
|
|
843
|
+
optional but recommended). Used to spin a To Do child Task off a decomposed
|
|
844
|
+
Epic. Returns the new task id on success, else None (best-effort).
|
|
845
|
+
"""
|
|
846
|
+
from sdk.config import BE_DEFAULT_URL
|
|
847
|
+
|
|
848
|
+
url = f"{BE_DEFAULT_URL.rstrip('/')}/tasks-ml"
|
|
849
|
+
headers = {k: v for k, v in self._headers.items() if k.lower() != "content-type"}
|
|
850
|
+
headers.update({"accept": "*/*", "developer-name": "null", "product": ""})
|
|
851
|
+
files = [(k, (None, str(v))) for k, v in fields.items() if v is not None]
|
|
852
|
+
for m in (model_used or []):
|
|
853
|
+
files.append(("modelUsed", (None, str(m))))
|
|
854
|
+
try:
|
|
855
|
+
async with httpx.AsyncClient(timeout=30.0) as client:
|
|
856
|
+
resp = await client.post(url, files=files, headers=headers)
|
|
857
|
+
if resp.status_code >= 400:
|
|
858
|
+
logger.warning("client.create_task_ml failed status=%s body=%s",
|
|
859
|
+
resp.status_code, resp.text[:300])
|
|
860
|
+
return None
|
|
861
|
+
body = resp.json() or {}
|
|
862
|
+
rec = body.get("data") or body
|
|
863
|
+
# The created record's id may sit at data.id or data.record.id.
|
|
864
|
+
if isinstance(rec, dict):
|
|
865
|
+
return rec.get("id") or ((rec.get("record") or {}).get("id"))
|
|
866
|
+
return None
|
|
867
|
+
except Exception as exc: # noqa: BLE001
|
|
868
|
+
logger.warning("client.create_task_ml error err=%s", exc)
|
|
869
|
+
return None
|
|
870
|
+
|
|
806
871
|
async def log_activity(self, activity: str) -> bool:
|
|
807
872
|
"""POST /ml-api/agent-activity — emit an agent activity event (best-effort).
|
|
808
873
|
|
|
@@ -351,6 +351,349 @@ def _parse_scope(recon_text: str | None) -> str:
|
|
|
351
351
|
return "in"
|
|
352
352
|
|
|
353
353
|
|
|
354
|
+
# ── Epic decomposition ────────────────────────────────────────────────────
|
|
355
|
+
# An EPIC is not coded directly. It is analysed (grounded in the codebase when a
|
|
356
|
+
# project path exists) into a detailed user-story description + an implementation
|
|
357
|
+
# plan; the Epic is then marked Done with that content and one To Do child Task is
|
|
358
|
+
# created per generated story (inheriting the Epic's project/assignee/priority/…).
|
|
359
|
+
_MAX_EPIC_STORIES = int(os.getenv("AGENTOS_MAX_EPIC_STORIES", "") or 8)
|
|
360
|
+
# Label recorded in the ticket's modelUsed on Epic completion.
|
|
361
|
+
_EPIC_MODELS = [m.strip() for m in (os.getenv("AGENTOS_EPIC_MODELS", "") or "claude-code").split(",") if m.strip()]
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def _build_epic_prompt(jira_key: str, summary: str, description: str | None,
|
|
365
|
+
human_instructions: str | None) -> str:
|
|
366
|
+
"""Read-only prompt that turns an Epic into a decomposition JSON."""
|
|
367
|
+
parts: list[str] = [
|
|
368
|
+
"You are decomposing a JIRA EPIC into implementable work. This is ANALYSIS "
|
|
369
|
+
"ONLY — do NOT create, edit, or delete any file.",
|
|
370
|
+
f"=== EPIC {jira_key} ===\nSummary: {summary}",
|
|
371
|
+
]
|
|
372
|
+
if description and description.strip():
|
|
373
|
+
parts.append("Description:\n" + _clip_text(description.strip()))
|
|
374
|
+
if human_instructions and human_instructions.strip():
|
|
375
|
+
parts.append(
|
|
376
|
+
"Operator instructions (authoritative — carry any project location / "
|
|
377
|
+
"constraints into each story's humanInstructions):\n"
|
|
378
|
+
+ human_instructions.strip()
|
|
379
|
+
)
|
|
380
|
+
parts.append(
|
|
381
|
+
"First understand the existing codebase in this directory (read CLAUDE.md, "
|
|
382
|
+
"use graphify / Read / Glob / Grep) so the stories fit the REAL architecture. "
|
|
383
|
+
"Then produce the decomposition.\n\n"
|
|
384
|
+
"Output ONLY a single fenced ```json block and nothing after it, with shape:\n"
|
|
385
|
+
"{\n"
|
|
386
|
+
' "epic_description": "A detailed description for the epic: a full user-story '
|
|
387
|
+
"write-up covering goal, the user stories, acceptance criteria and scope.\",\n"
|
|
388
|
+
' "plan": "An ordered, numbered high-level implementation plan.",\n'
|
|
389
|
+
' "stories": [\n'
|
|
390
|
+
' {\n'
|
|
391
|
+
' "summary": "Short imperative story title (a JIRA task summary)",\n'
|
|
392
|
+
' "description": "Detailed description: what to build + acceptance criteria.",\n'
|
|
393
|
+
' "humanInstructions": "Operator instructions to implement THIS story. MUST '
|
|
394
|
+
"repeat the same project location/path and constraints from the epic so the "
|
|
395
|
+
'coding run works in the right place.",\n'
|
|
396
|
+
' "assigneeEmail": "dev@company.com",\n'
|
|
397
|
+
' "points": 5\n'
|
|
398
|
+
" }\n"
|
|
399
|
+
" ]\n"
|
|
400
|
+
"}\n"
|
|
401
|
+
"Create as many stories as the epic genuinely needs (do not pad or truncate "
|
|
402
|
+
"artificially); each story must be independently implementable.\n"
|
|
403
|
+
"assigneeEmail is OPTIONAL per story: set it ONLY when the epic explicitly "
|
|
404
|
+
"assigns that specific work to a named person/email (e.g. 'assign the API to "
|
|
405
|
+
"alice@x.com'); otherwise omit it."
|
|
406
|
+
)
|
|
407
|
+
return "\n\n".join(parts)
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def _extract_json_block(text: str | None) -> dict | None:
|
|
411
|
+
"""Parse the decomposition JSON from a claude response (fenced block preferred)."""
|
|
412
|
+
if not text:
|
|
413
|
+
return None
|
|
414
|
+
import json as _json
|
|
415
|
+
candidates = re.findall(r"```(?:json)?\s*(\{.*?\})\s*```", text, re.S | re.I)
|
|
416
|
+
if not candidates:
|
|
417
|
+
start, end = text.find("{"), text.rfind("}")
|
|
418
|
+
if start != -1 and end > start:
|
|
419
|
+
candidates = [text[start:end + 1]]
|
|
420
|
+
for c in reversed(candidates):
|
|
421
|
+
try:
|
|
422
|
+
obj = _json.loads(c)
|
|
423
|
+
if isinstance(obj, dict):
|
|
424
|
+
return obj
|
|
425
|
+
except Exception: # noqa: BLE001
|
|
426
|
+
continue
|
|
427
|
+
return None
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
_EMAIL_RE = re.compile(r"[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}")
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def _extract_email(text: str | None) -> str | None:
|
|
434
|
+
"""First email address mentioned in `text`, lower-cased, or None."""
|
|
435
|
+
if not text:
|
|
436
|
+
return None
|
|
437
|
+
m = _EMAIL_RE.search(text)
|
|
438
|
+
return m.group(0).strip().lower() if m else None
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def _epic_inherited_fields(task: dict) -> dict:
|
|
442
|
+
"""Parent-Epic fields a child Task should inherit (drops empties)."""
|
|
443
|
+
def _date(v: object) -> str | None:
|
|
444
|
+
s = str(v or "")
|
|
445
|
+
return s[:10] if s else None # ISO datetime → YYYY-MM-DD
|
|
446
|
+
|
|
447
|
+
fields = {
|
|
448
|
+
"projectKey": task.get("projectKey"),
|
|
449
|
+
"priority": task.get("priority"),
|
|
450
|
+
"assigneeEmail": (task.get("assignee") or {}).get("email"),
|
|
451
|
+
"teamId": task.get("teamId"),
|
|
452
|
+
"startDate": _date(task.get("startDate")),
|
|
453
|
+
"dueDate": _date(task.get("dueDate")),
|
|
454
|
+
"slaTarget": task.get("slaTarget"),
|
|
455
|
+
}
|
|
456
|
+
sprint = task.get("sprint") or {}
|
|
457
|
+
if sprint.get("jiraSprintId"):
|
|
458
|
+
fields["jiraSprintId"] = sprint["jiraSprintId"]
|
|
459
|
+
return {k: v for k, v in fields.items() if v}
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
async def _handle_epic(
|
|
463
|
+
client: BackendClient, task: dict, payload: dict, send: Callable, thread_id: str,
|
|
464
|
+
) -> None:
|
|
465
|
+
"""Decompose an Epic: generate user stor(y|ies), mark the Epic Done with the
|
|
466
|
+
detailed description + plan, then create a To Do child Task per story.
|
|
467
|
+
|
|
468
|
+
Child tasks are created in "To Do"; a later `agentos-auto` run picks them up and
|
|
469
|
+
implements them through the normal grounded pipeline. Best-effort throughout —
|
|
470
|
+
a failure returns the Epic to Backlog for human review rather than raising.
|
|
471
|
+
"""
|
|
472
|
+
from sdk import local_exec, graphify
|
|
473
|
+
|
|
474
|
+
tid = str(task.get("id", ""))
|
|
475
|
+
jira_key = task.get("jiraIssueKey") or task.get("jiraKey", "") or "EPIC"
|
|
476
|
+
summary = task.get("summary", "") or ""
|
|
477
|
+
description = task.get("description")
|
|
478
|
+
human = task.get("humanInstructions")
|
|
479
|
+
project_path = _resolve_local_project_path(task, payload)
|
|
480
|
+
|
|
481
|
+
await client.mark_in_progress(tid)
|
|
482
|
+
await send(Envelope(
|
|
483
|
+
type="step_update", thread_id=thread_id,
|
|
484
|
+
data={"jira_task_id": tid, "task_id": tid,
|
|
485
|
+
"current_step": f"Epic {jira_key}: analysing & generating user stories"},
|
|
486
|
+
).model_dump())
|
|
487
|
+
|
|
488
|
+
# 1. Generate the decomposition, grounded in the codebase where one exists.
|
|
489
|
+
try:
|
|
490
|
+
await graphify.prepare(project_path)
|
|
491
|
+
except Exception: # noqa: BLE001
|
|
492
|
+
pass
|
|
493
|
+
gen = await local_exec.run_claude_local(
|
|
494
|
+
_build_epic_prompt(jira_key, summary, description, human),
|
|
495
|
+
project_path, allowed_tools=_RECON_ALLOWED_TOOLS, # read-only analysis
|
|
496
|
+
github_token=await client.github_token(),
|
|
497
|
+
)
|
|
498
|
+
doc = _extract_json_block(gen.get("result")) if gen.get("ok") else None
|
|
499
|
+
stories = (doc or {}).get("stories") or []
|
|
500
|
+
if not doc or not stories:
|
|
501
|
+
reason = (gen.get("error") or "could not generate a valid decomposition")[:200]
|
|
502
|
+
await client.log_activity(f"Epic {jira_key}: decomposition failed — {reason}")
|
|
503
|
+
await client.move_to_backlog(tid)
|
|
504
|
+
await send(Envelope(
|
|
505
|
+
type=Envelope.TYPE_ERROR, thread_id=thread_id,
|
|
506
|
+
data={"code": "EPIC_DECOMPOSE_FAILED", "task_id": tid, "message": reason},
|
|
507
|
+
).model_dump())
|
|
508
|
+
return
|
|
509
|
+
|
|
510
|
+
# 2. Mark the Epic Done with the detailed description + plan.
|
|
511
|
+
epic_desc = (doc.get("epic_description") or description or summary or "")[:20000]
|
|
512
|
+
plan = (doc.get("plan") or "")[:8000]
|
|
513
|
+
await client.update_task_ml(
|
|
514
|
+
tid, {"status": "Done", "description": epic_desc, "initialAiPlan": plan},
|
|
515
|
+
model_used=_EPIC_MODELS,
|
|
516
|
+
)
|
|
517
|
+
await send(Envelope(
|
|
518
|
+
type="step_update", thread_id=thread_id,
|
|
519
|
+
data={"jira_task_id": tid, "current_step": f"Epic {jira_key}: marked Done — "
|
|
520
|
+
f"creating {min(len(stories), _MAX_EPIC_STORIES)} user Story(ies)"},
|
|
521
|
+
).model_dump())
|
|
522
|
+
|
|
523
|
+
# 3. Create a To Do child Task per story, inheriting the Epic's fields.
|
|
524
|
+
inherited = _epic_inherited_fields(task)
|
|
525
|
+
# An assignee email named in the EPIC's description/instructions overrides the
|
|
526
|
+
# inherited assignee — but ONLY on the CHILD task, never on the parent Epic.
|
|
527
|
+
desc_email = _extract_email(description) or _extract_email(human)
|
|
528
|
+
created: list[str] = []
|
|
529
|
+
for st in stories[:_MAX_EPIC_STORIES]:
|
|
530
|
+
if not isinstance(st, dict) or not (st.get("summary") or "").strip():
|
|
531
|
+
continue
|
|
532
|
+
# Precedence: a per-story email the model extracted → an email in the
|
|
533
|
+
# epic description/instructions → the inherited parent assignee.
|
|
534
|
+
story_email = _extract_email(str(st.get("assigneeEmail") or ""))
|
|
535
|
+
child_email = story_email or desc_email or inherited.get("assigneeEmail")
|
|
536
|
+
fields = {
|
|
537
|
+
**inherited,
|
|
538
|
+
"type": "Story", # Epic decomposes into user Stories; a Story later
|
|
539
|
+
# becomes a Task (see _handle_story) when it is run.
|
|
540
|
+
"status": "To Do",
|
|
541
|
+
"summary": str(st.get("summary", "")).strip()[:250],
|
|
542
|
+
"description": str(st.get("description", "") or "")[:20000],
|
|
543
|
+
"humanInstructions": str(st.get("humanInstructions") or human or "")[:20000] or None,
|
|
544
|
+
"parentTaskId": tid,
|
|
545
|
+
}
|
|
546
|
+
if child_email:
|
|
547
|
+
fields["assigneeEmail"] = child_email
|
|
548
|
+
if st.get("points"):
|
|
549
|
+
fields["points"] = st["points"]
|
|
550
|
+
new_id = await client.create_task_ml(fields, model_used=_EPIC_MODELS)
|
|
551
|
+
if new_id:
|
|
552
|
+
created.append(new_id)
|
|
553
|
+
await send(Envelope(
|
|
554
|
+
type="step_update", thread_id=thread_id,
|
|
555
|
+
data={"current_step": f" ↳ created To Do task: {fields['summary']}"},
|
|
556
|
+
).model_dump())
|
|
557
|
+
|
|
558
|
+
await client.log_activity(
|
|
559
|
+
f"Epic {jira_key}: Done — created {len(created)}/{len(stories)} To Do user Story(ies)"
|
|
560
|
+
)
|
|
561
|
+
await send(Envelope(
|
|
562
|
+
type="step_update", thread_id=thread_id,
|
|
563
|
+
data={"jira_task_id": tid,
|
|
564
|
+
"current_step": f"Epic {jira_key} complete — {len(created)} Story(ies) queued "
|
|
565
|
+
"(run again: each Story → PRD + Task)"},
|
|
566
|
+
).model_dump())
|
|
567
|
+
|
|
568
|
+
|
|
569
|
+
# ── Story → Task (with optional PRD) ───────────────────────────────────────
|
|
570
|
+
# A Story (produced by decomposing an Epic) is not coded directly. When it is
|
|
571
|
+
# run: if a PRD is asked for in the instructions, generate one and fold it into
|
|
572
|
+
# the description alongside the user story; then convert the ticket to a Task
|
|
573
|
+
# (kept To Do) so the next run implements it through the normal pipeline.
|
|
574
|
+
_PRD_RE = re.compile(r"\bprd\b|product\s+requirements?\s+document", re.I)
|
|
575
|
+
# Section header the generated PRD is filed under in the description. Also the
|
|
576
|
+
# idempotency marker: if it's already present we neither regenerate nor re-write.
|
|
577
|
+
_PRD_MARKER = "# PRD — Product Requirements Document"
|
|
578
|
+
|
|
579
|
+
|
|
580
|
+
def _wants_prd(text: str | None) -> bool:
|
|
581
|
+
"""True when the text asks for a PRD / product-requirements document."""
|
|
582
|
+
return bool(text and _PRD_RE.search(text))
|
|
583
|
+
|
|
584
|
+
|
|
585
|
+
def _build_prd_prompt(jira_key: str, summary: str, user_story: str | None,
|
|
586
|
+
human_instructions: str | None) -> str:
|
|
587
|
+
"""Read-only prompt that produces a PRD (Markdown) for a user story."""
|
|
588
|
+
parts: list[str] = [
|
|
589
|
+
"Write a PRD (Product Requirements Document) for the user story below. This is "
|
|
590
|
+
"ANALYSIS ONLY — do NOT create, edit, or delete any file.",
|
|
591
|
+
f"=== USER STORY {jira_key} ===\nSummary: {summary}",
|
|
592
|
+
]
|
|
593
|
+
if user_story and user_story.strip():
|
|
594
|
+
parts.append("User story / description:\n" + _clip_text(user_story.strip()))
|
|
595
|
+
if human_instructions and human_instructions.strip():
|
|
596
|
+
parts.append("Operator instructions:\n" + human_instructions.strip())
|
|
597
|
+
parts.append(
|
|
598
|
+
"Explore the codebase (CLAUDE.md, graphify, Read/Glob/Grep) to ground the PRD in "
|
|
599
|
+
"the REAL system. Output ONLY the PRD as Markdown — no preamble, no code edits — "
|
|
600
|
+
"with sections: Overview, Goals & Success Metrics, User Stories, Functional "
|
|
601
|
+
"Requirements, Non-Functional Requirements, Acceptance Criteria, Out of Scope, "
|
|
602
|
+
"Dependencies & Risks."
|
|
603
|
+
)
|
|
604
|
+
return "\n\n".join(parts)
|
|
605
|
+
|
|
606
|
+
|
|
607
|
+
async def _handle_story(
|
|
608
|
+
client: BackendClient, task: dict, payload: dict, send: Callable, thread_id: str,
|
|
609
|
+
) -> None:
|
|
610
|
+
"""Run a Story: optionally generate a PRD (when instructed) into the description,
|
|
611
|
+
then convert the ticket to a Task (To Do) for the coding run.
|
|
612
|
+
|
|
613
|
+
Best-effort. If a PRD was explicitly instructed but generation fails, the ticket
|
|
614
|
+
goes to Backlog for retry (so the PRD requirement isn't silently dropped); a
|
|
615
|
+
conversion failure likewise backlogs it.
|
|
616
|
+
"""
|
|
617
|
+
from sdk import local_exec, graphify
|
|
618
|
+
|
|
619
|
+
tid = str(task.get("id", ""))
|
|
620
|
+
jira_key = task.get("jiraIssueKey") or task.get("jiraKey", "") or "STORY"
|
|
621
|
+
summary = task.get("summary", "") or ""
|
|
622
|
+
user_story = task.get("description") or ""
|
|
623
|
+
human = task.get("humanInstructions")
|
|
624
|
+
wants_prd = _wants_prd(human) or _wants_prd(user_story)
|
|
625
|
+
|
|
626
|
+
await send(Envelope(
|
|
627
|
+
type="step_update", thread_id=thread_id,
|
|
628
|
+
data={"jira_task_id": tid, "task_id": tid,
|
|
629
|
+
"current_step": f"Story {jira_key}: "
|
|
630
|
+
+ ("generating PRD, then " if wants_prd else "")
|
|
631
|
+
+ "converting to Task"},
|
|
632
|
+
).model_dump())
|
|
633
|
+
|
|
634
|
+
# Idempotent: if a PRD is already in the description, don't regenerate it or
|
|
635
|
+
# re-write the description — only the type conversion (if still needed) runs.
|
|
636
|
+
already_has_prd = _PRD_MARKER in (user_story or "")
|
|
637
|
+
new_description: str | None = None
|
|
638
|
+
if wants_prd and already_has_prd:
|
|
639
|
+
await send(Envelope(
|
|
640
|
+
type="step_update", thread_id=thread_id,
|
|
641
|
+
data={"jira_task_id": tid,
|
|
642
|
+
"current_step": f"Story {jira_key}: PRD already present — skipping regeneration"},
|
|
643
|
+
).model_dump())
|
|
644
|
+
elif wants_prd:
|
|
645
|
+
project_path = _resolve_local_project_path(task, payload)
|
|
646
|
+
try:
|
|
647
|
+
await graphify.prepare(project_path)
|
|
648
|
+
except Exception: # noqa: BLE001
|
|
649
|
+
pass
|
|
650
|
+
gen = await local_exec.run_claude_local(
|
|
651
|
+
_build_prd_prompt(jira_key, summary, user_story, human),
|
|
652
|
+
project_path, allowed_tools=_RECON_ALLOWED_TOOLS, # read-only analysis
|
|
653
|
+
github_token=await client.github_token(),
|
|
654
|
+
)
|
|
655
|
+
prd = (gen.get("result") or "").strip() if gen.get("ok") else ""
|
|
656
|
+
if not prd:
|
|
657
|
+
reason = (gen.get("error") or "PRD generation produced no content")[:200]
|
|
658
|
+
# Leave the Story To Do (never mark it In Progress/Done/Backlog) so a
|
|
659
|
+
# retry or the developer can pick it up.
|
|
660
|
+
await client.log_activity(f"Story {jira_key}: PRD generation failed — {reason}")
|
|
661
|
+
await send(Envelope(
|
|
662
|
+
type=Envelope.TYPE_ERROR, thread_id=thread_id,
|
|
663
|
+
data={"code": "STORY_PRD_FAILED", "task_id": tid, "message": reason},
|
|
664
|
+
).model_dump())
|
|
665
|
+
return
|
|
666
|
+
# PRD lives alongside the user story in the (now Task) description.
|
|
667
|
+
new_description = f"{user_story}\n\n---\n\n{_PRD_MARKER}\n\n{prd}"[:20000]
|
|
668
|
+
|
|
669
|
+
# Convert Story → Task on the SAME ticket; leave it To Do so the next run codes
|
|
670
|
+
# it. humanInstructions are untouched (they still carry the location + any PRD
|
|
671
|
+
# directive, so the coding run can also write the PRD.md file if asked).
|
|
672
|
+
update: dict = {"type": "Task"}
|
|
673
|
+
if new_description:
|
|
674
|
+
update["description"] = new_description
|
|
675
|
+
if not await client.update_task_ml(tid, update, model_used=_EPIC_MODELS):
|
|
676
|
+
# Leave the Story To Do (never In Progress/Done/Backlog) on failure.
|
|
677
|
+
await client.log_activity(f"Story {jira_key}: convert-to-Task failed")
|
|
678
|
+
await send(Envelope(
|
|
679
|
+
type=Envelope.TYPE_ERROR, thread_id=thread_id,
|
|
680
|
+
data={"code": "STORY_CONVERT_FAILED", "task_id": tid,
|
|
681
|
+
"message": "could not convert Story to Task"},
|
|
682
|
+
).model_dump())
|
|
683
|
+
return
|
|
684
|
+
|
|
685
|
+
await client.log_activity(
|
|
686
|
+
f"Story {jira_key}: converted to Task{' with PRD' if wants_prd else ''} (To Do)"
|
|
687
|
+
)
|
|
688
|
+
await send(Envelope(
|
|
689
|
+
type="step_update", thread_id=thread_id,
|
|
690
|
+
data={"jira_task_id": tid,
|
|
691
|
+
"current_step": f"Story {jira_key} → Task (To Do)"
|
|
692
|
+
+ (" + PRD in description" if wants_prd else "")
|
|
693
|
+
+ " — run again to implement"},
|
|
694
|
+
).model_dump())
|
|
695
|
+
|
|
696
|
+
|
|
354
697
|
async def _run_local_pipeline(
|
|
355
698
|
client: BackendClient, payload: dict, send: Callable
|
|
356
699
|
) -> None:
|
|
@@ -2182,6 +2525,22 @@ async def handle_orchestrate_auto_local_run(
|
|
|
2182
2525
|
jira_key = task.get("jiraIssueKey") or task.get("jiraKey", "")
|
|
2183
2526
|
agent_mode = bool(task.get("agentMode", True)) # default True (fail-safe)
|
|
2184
2527
|
project_path = _resolve_local_project_path(task, payload)
|
|
2528
|
+
ttype = str(task.get("type") or "").strip().lower()
|
|
2529
|
+
|
|
2530
|
+
# User STORIES are for the ASSIGNED DEVELOPERS, not the bot: the default
|
|
2531
|
+
# auto-loop skips Story-type tickets and never marks them In Progress or
|
|
2532
|
+
# Done. (An explicit target — `agentos-auto ticket <KEY>`, i.e. only_keys
|
|
2533
|
+
# set — still lets the bot act on a Story: generate its PRD and convert
|
|
2534
|
+
# it to a Task.) Once a ticket is a TASK it is coded normally
|
|
2535
|
+
# (In Progress → Done), even when it descended from an Epic/Story.
|
|
2536
|
+
if ttype == "story" and not only_keys:
|
|
2537
|
+
await send(Envelope(
|
|
2538
|
+
type="step_update", thread_id=thread_id,
|
|
2539
|
+
data={"jira_task_id": tid,
|
|
2540
|
+
"current_step": f"({idx}/{total}) {jira_key}: skipped — user Story "
|
|
2541
|
+
f"left for the assigned developer"},
|
|
2542
|
+
).model_dump())
|
|
2543
|
+
continue
|
|
2185
2544
|
|
|
2186
2545
|
await send(Envelope(
|
|
2187
2546
|
type="step_update", thread_id=thread_id,
|
|
@@ -2189,6 +2548,33 @@ async def handle_orchestrate_auto_local_run(
|
|
|
2189
2548
|
"project_path": project_path, "jira_task_id": tid, "agent_mode": agent_mode},
|
|
2190
2549
|
).model_dump())
|
|
2191
2550
|
|
|
2551
|
+
# Staged decomposition — an EPIC and a STORY are transformed, not coded:
|
|
2552
|
+
# Epic → decompose into user Stories (To Do), mark Epic Done
|
|
2553
|
+
# Story → (optional PRD) + convert to Task (To Do) [only when targeted]
|
|
2554
|
+
# Task → implemented by the normal pipeline below
|
|
2555
|
+
# Each is isolated: a failure must not abort the remaining queue.
|
|
2556
|
+
if ttype in ("epic", "story"):
|
|
2557
|
+
handler = _handle_epic if ttype == "epic" else _handle_story
|
|
2558
|
+
code = "EPIC_DECOMPOSE_FAILED" if ttype == "epic" else "STORY_HANDLE_FAILED"
|
|
2559
|
+
try:
|
|
2560
|
+
await handler(client, task, payload, send, thread_id)
|
|
2561
|
+
except Exception as exc: # noqa: BLE001
|
|
2562
|
+
logger.warning("auto_local.%s_failed task=%s err=%s", ttype, tid, exc)
|
|
2563
|
+
try:
|
|
2564
|
+
await client.log_activity(f"Task {tid}: {ttype} handling errored — {str(exc)[:200]}")
|
|
2565
|
+
# Backlog only Epics for retry; a Story is left To Do (never
|
|
2566
|
+
# marked In Progress/Done/Backlog) so developers keep it.
|
|
2567
|
+
if ttype == "epic":
|
|
2568
|
+
await client.move_to_backlog(tid)
|
|
2569
|
+
except Exception: # noqa: BLE001
|
|
2570
|
+
pass
|
|
2571
|
+
await send(Envelope(
|
|
2572
|
+
type=Envelope.TYPE_ERROR, thread_id=thread_id,
|
|
2573
|
+
data={"code": code, "task_id": tid, "message": str(exc)[:200]},
|
|
2574
|
+
).model_dump())
|
|
2575
|
+
processed += 1
|
|
2576
|
+
continue
|
|
2577
|
+
|
|
2192
2578
|
task_payload = {
|
|
2193
2579
|
"thread_id": thread_id,
|
|
2194
2580
|
"jira_task_id": tid,
|
|
File without changes
|
|
File without changes
|
{devops_bot_sdk-1.4.37 → devops_bot_sdk-1.4.45}/devops_bot_sdk.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|