IssueTriage can now interact with task in YouTrack and it is idempotent

This commit is contained in:
2026-09-20 20:56:36 +03:00
parent 74577cb50e
commit f25d9a5fab
34 changed files with 1834 additions and 201 deletions
+49
View File
@@ -0,0 +1,49 @@
import hashlib
from dataclasses import dataclass, field
from typing import Optional
from contracts.ProjectContext import ProjectContext
@dataclass
class IssueContext:
issue_id: str
title: str
body: str
acceptance_criteria: str | None
comments: list[dict]
labels: list[str] = field(default_factory=list)
repo: Optional[str] = None
metadata: dict = field(default_factory=dict)
images: list[str] = field(default_factory=list)
project: ProjectContext | None = None
def body_hash(self) -> str:
h = hashlib.sha256()
h.update(self.title.encode())
h.update(self.body.encode())
h.update((self.acceptance_criteria or "").encode())
return h.hexdigest()[:8]
def to_prompt_text(self) -> str:
parts = [f"# Issue {self.issue_id}: {self.title}", "", self.body]
if self.project:
parts.append("\n## Project info")
parts.append(self.project.to_prompt_text())
meta_lines = []
for key in ("state", "url"):
if self.metadata.get(key):
meta_lines.append(f"- {key.capitalize()}: {self.metadata[key]}")
if meta_lines:
parts.append("\n## Issue Metadata")
parts.extend(meta_lines)
if self.comments:
parts.append("\n## Discussion / Comments")
for c in self.comments:
parts.append(f"- @{c['author']} ({c['created_at']}): {c['body']}")
return "\n".join(parts)
+45
View File
@@ -0,0 +1,45 @@
from dataclasses import field, dataclass
@dataclass
class ProjectContext:
"""Static, per-project knowledge injected into every decomposition."""
project_key: str # e.g. "ARCH"
language: str = ""
backend_framework: str = "" # "FastAPI", "Django", "Spring Boot"
frontend_framework: str = "" # "React + Vite", "Vue 3"
ui_library: str = "" # "shadcn/ui", "MUI", "Ant Design"
database: str = "" # "PostgreSQL 16 via SQLAlchemy 2"
orm: str = ""
test_framework: str = "" # "pytest + httpx.AsyncClient"
package_manager: str = "" # "uv", "poetry", "npm"
auth: str = "" # "JWT via fastapi-users"
deployment: str = "" # "Docker Compose on Hetzner"
conventions: list[str] = field(default_factory=list) # free-form notes
extra: dict[str, str] = field(default_factory=dict) # anything else
def to_prompt_text(self) -> str:
lines = [f"## Project Context ({self.project_key})"]
fields = [
("Language", self.language),
("Backend framework", self.backend_framework),
("Frontend framework", self.frontend_framework),
("UI library", self.ui_library),
("Database", self.database),
("ORM", self.orm),
("Test framework", self.test_framework),
("Package manager", self.package_manager),
("Auth", self.auth),
("Deployment", self.deployment),
]
for label, value in fields:
if value:
lines.append(f"- {label}: {value}")
for k, v in self.extra.items():
if v:
lines.append(f"- {k}: {v}")
if self.conventions:
lines.append("")
lines.append("Conventions:")
lines.extend(f"- {c}" for c in self.conventions)
return "\n".join(lines)
+67
View File
@@ -0,0 +1,67 @@
import uuid
from dataclasses import dataclass, field
from enum import Enum
from typing import Optional
from contracts.base import BaseContract
class Atomicity(str, Enum):
ATOMIC = "atomic" # single clear action, one owner, < 1 day
NEEDS_SPLIT = "needs_split" # still too big, recurse
class TaskKind(str, Enum):
INVESTIGATE = "investigate" # read code, reproduce, research
DESIGN = "design" # API/schema decisions
IMPLEMENT = "implement" # write code
MIGRATE = "migrate" # DB / data changes
TEST = "test" # unit/integration/e2e
DOCS = "docs" # docs, changelog
REVIEW = "review" # code review, verification
OPS = "ops" # deploy, config, feature flag
@dataclass
class Task(BaseContract):
id: str
title: str
kind: TaskKind
description: str
agent: str
estimate_p50: int
estimate_p90: int
risk: Literal["low", "medium", "high"]
acceptance_criteria: list[str] = field(default_factory=list)
depends_on: list[str] = field(default_factory=list)
atomicity: Atomicity = Atomicity.ATOMIC
estimate_minutes: Optional[float] = None
files_hint: list[str] = field(default_factory=list) # probable touch points
open_questions: list[str] = field(default_factory=list)
@staticmethod
def new(**kw) -> "Task":
return Task(id=str(uuid.uuid4())[:8], **kw)
def render_dbg(self) -> str:
lines = [f"# Task: {self.description}"]
deps = f" (after {', '.join(self.depends_on)})" if self.depends_on else ""
lines.append(f"- [{self.kind.value}] {self.id}: {self.title}{deps}")
for ac in self.acceptance_criteria:
lines.append(f" ✓ {ac}")
for q in self.open_questions:
lines.append(f" ? {q}")
return "\n".join(lines)
def to_prompt_text(self) -> str:
lines = [
f"# Task: {self.description}",
f"# Before start you must ensure that another tasks have been done: {', '.join(self.depends_on)}" if self.depends_on else "",
f"# Acceptance criteria: \n{"\n - ".join(self.acceptance_criteria)}",
]
return "\n".join(lines)
def is_filled(self) -> bool:
return len(self.open_questions) == 0 and self.atomicity == Atomicity.ATOMIC
+37
View File
@@ -0,0 +1,37 @@
from dataclasses import dataclass, field
from datetime import datetime
from typing import Optional
from contracts.Task import Task
from contracts.base import BaseContract
class TaskPlan(BaseContract):
issue_id: str
summary: str
tasks: list[Task]
parallel_groups: list[list[str]]
critical_path: list[str]
total_p50: int
total_p90: int
assumptions: list[str]
total_estimate_minutes: Optional[float] = None
unknowns: list[str] = field(default_factory=list)
created_at: datetime = field(default_factory=datetime.utcnow)
def render_dbg(self) -> str:
lines = [f"# Plan for {self.issue_id}", self.summary, ""]
for t in self.tasks:
lines.extend(t.render_dbg())
if self.unknowns:
lines.append("\nUnknowns:")
lines += [f" - {u}" for u in self.unknowns]
return "\n".join(lines)
def to_prompt_text(self) -> str:
return self.render_dbg()
def is_filled(self) -> bool:
return self.unknowns == []
View File
+16
View File
@@ -0,0 +1,16 @@
from abc import abstractmethod, ABC
from pydantic import BaseModel
'''
A contract between agents
'''
class BaseContract(BaseModel):
def to_prompt_text(self) -> str:
pass
def render_dbg(self) -> str:
pass
def is_filled(self) -> bool:
pass