IssueTriage can now interact with task in YouTrack and it is idempotent
This commit is contained in:
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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 == []
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user