diff --git a/ductor_bot/config.py b/ductor_bot/config.py index 8916318..173a6cc 100644 --- a/ductor_bot/config.py +++ b/ductor_bot/config.py @@ -9,6 +9,8 @@ from pydantic import BaseModel, Field, field_validator, model_validator +from ductor_bot.integrations.linear.config import IntakeConfig, LinearConfig + logger = logging.getLogger(__name__) NULLISH_TEXT_VALUES: frozenset[str] = frozenset({"null", "none"}) DEFAULT_EMPTY_GEMINI_API_KEY: str = "null" @@ -314,6 +316,8 @@ class AgentConfig(BaseModel): allowed_user_ids: list[int] = Field(default_factory=list) allowed_group_ids: list[int] = Field(default_factory=list) matrix: MatrixConfig = Field(default_factory=MatrixConfig) + linear: LinearConfig = Field(default_factory=LinearConfig) + intake: IntakeConfig = Field(default_factory=IntakeConfig) @field_validator("gemini_api_key", mode="before") @classmethod diff --git a/ductor_bot/integrations/__init__.py b/ductor_bot/integrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/ductor_bot/integrations/linear/__init__.py b/ductor_bot/integrations/linear/__init__.py new file mode 100644 index 0000000..e449ef1 --- /dev/null +++ b/ductor_bot/integrations/linear/__init__.py @@ -0,0 +1,22 @@ +"""Linear integration exports.""" + +from __future__ import annotations + +from ductor_bot.integrations.linear.client import LinearClient +from ductor_bot.integrations.linear.config import IntakeConfig, LinearConfig +from ductor_bot.integrations.linear.models import ( + LinearIssue, + LinearIssueDetails, + LinearIssueDraft, + LinearTeam, +) + +__all__ = [ + "IntakeConfig", + "LinearClient", + "LinearConfig", + "LinearIssue", + "LinearIssueDetails", + "LinearIssueDraft", + "LinearTeam", +] diff --git a/ductor_bot/integrations/linear/client.py b/ductor_bot/integrations/linear/client.py new file mode 100644 index 0000000..c64cc35 --- /dev/null +++ b/ductor_bot/integrations/linear/client.py @@ -0,0 +1,371 @@ +"""Async GraphQL client for Linear.""" + +from __future__ import annotations + +from collections.abc import Mapping, Sequence + +import aiohttp + +from ductor_bot.integrations.linear.config import LinearConfig +from ductor_bot.integrations.linear.models import LinearIssue, LinearIssueDetails, LinearTeam + + +def _expect_mapping(value: object, *, field: str) -> Mapping[str, object]: + if not isinstance(value, Mapping): + msg = f"Linear response field '{field}' is not an object" + raise TypeError(msg) + return value + + +def _expect_str(value: object, *, field: str) -> str: + if not isinstance(value, str): + msg = f"Linear response field '{field}' is not a string" + raise TypeError(msg) + return value + + +def _extract_state_name(state_obj: object) -> str: + if not isinstance(state_obj, Mapping): + return "" + raw = state_obj.get("name") + if isinstance(raw, str): + return raw + return "" + + +def _issue_from_node(node_obj: object) -> LinearIssue: + node = _expect_mapping(node_obj, field="issue") + return LinearIssue( + id=_expect_str(node.get("id"), field="issue.id"), + identifier=_expect_str(node.get("identifier"), field="issue.identifier"), + title=_expect_str(node.get("title"), field="issue.title"), + url=_expect_str(node.get("url"), field="issue.url"), + state_name=_extract_state_name(node.get("state")), + ) + + +def _issue_details_from_node(node_obj: object) -> LinearIssueDetails: + issue = _issue_from_node(node_obj) + node = _expect_mapping(node_obj, field="issue") + description = node.get("description") + return LinearIssueDetails( + **issue.model_dump(), + description=description if isinstance(description, str) else "", + ) + + +class LinearClient: + """Thin async wrapper around Linear GraphQL API.""" + + _ENDPOINT = "https://api.linear.app/graphql" + + def __init__(self, config: LinearConfig) -> None: + self._config = config + timeout = aiohttp.ClientTimeout(total=20) + headers: dict[str, str] = {"Content-Type": "application/json"} + if config.api_token: + headers["Authorization"] = config.api_token + self._session = aiohttp.ClientSession(timeout=timeout, headers=headers) + + async def close(self) -> None: + """Close underlying HTTP session.""" + if not self._session.closed: + await self._session.close() + + async def _graphql( + self, + query: str, + *, + variables: Mapping[str, object] | None = None, + ) -> dict[str, object]: + if not self._config.api_token: + msg = "Linear API token is not configured" + raise RuntimeError(msg) + + payload: dict[str, object] = { + "query": query, + "variables": dict(variables or {}), + } + + async with self._session.post(self._ENDPOINT, json=payload) as response: + body = await response.json(content_type=None) + if response.status >= 400: + msg = f"Linear HTTP {response.status}: {body}" + raise RuntimeError(msg) + + if not isinstance(body, Mapping): + msg = "Linear response is not a JSON object" + raise TypeError(msg) + + errors = body.get("errors") + if isinstance(errors, list) and errors: + first = errors[0] + if isinstance(first, Mapping) and isinstance(first.get("message"), str): + msg = first["message"] + else: + msg = str(errors) + raise RuntimeError(f"Linear GraphQL error: {msg}") + + data = body.get("data") + if not isinstance(data, Mapping): + msg = "Linear GraphQL response has no data object" + raise TypeError(msg) + + return dict(data) + + async def list_teams(self) -> list[LinearTeam]: + query = """ + query ListTeams { + teams { + nodes { + id + key + name + } + } + } + """ + data = await self._graphql(query) + teams_obj = _expect_mapping(data.get("teams"), field="teams") + nodes = teams_obj.get("nodes") + if not isinstance(nodes, list): + return [] + + teams: list[LinearTeam] = [] + for node_obj in nodes: + node = _expect_mapping(node_obj, field="teams.nodes") + teams.append( + LinearTeam( + id=_expect_str(node.get("id"), field="team.id"), + key=_expect_str(node.get("key"), field="team.key"), + name=_expect_str(node.get("name"), field="team.name"), + ) + ) + return teams + + async def create_issue(self, team_id: str, title: str, description: str) -> LinearIssue: + mutation = """ + mutation CreateIssue($teamId: String!, $title: String!, $description: String!) { + issueCreate( + input: { + teamId: $teamId + title: $title + description: $description + } + ) { + issue { + id + identifier + title + url + state { + name + } + } + } + } + """ + data = await self._graphql( + mutation, + variables={ + "teamId": team_id, + "title": title, + "description": description, + }, + ) + payload = _expect_mapping(data.get("issueCreate"), field="issueCreate") + return _issue_from_node(payload.get("issue")) + + async def list_recent_issues(self, team_id: str, limit: int = 10) -> list[LinearIssue]: + query = """ + query ListRecentIssues($teamId: String!, $limit: Int!) { + issues( + filter: { team: { id: { eq: $teamId } } } + first: $limit + orderBy: updatedAt + ) { + nodes { + id + identifier + title + url + state { + name + } + } + } + } + """ + data = await self._graphql(query, variables={"teamId": team_id, "limit": limit}) + issues_obj = _expect_mapping(data.get("issues"), field="issues") + nodes = issues_obj.get("nodes") + if not isinstance(nodes, list): + return [] + return [_issue_from_node(node_obj) for node_obj in nodes] + + async def get_issue(self, identifier: str) -> LinearIssueDetails | None: + query = """ + query GetIssue($identifier: String!) { + issue(identifier: $identifier) { + id + identifier + title + url + description + state { + name + } + } + } + """ + data = await self._graphql(query, variables={"identifier": identifier}) + issue_obj = data.get("issue") + if issue_obj is None: + return None + return _issue_details_from_node(issue_obj) + + async def append_issue_description( + self, + identifier: str, + appendix: str, + ) -> LinearIssueDetails: + issue = await self.get_issue(identifier) + if issue is None: + msg = f"Linear issue not found: {identifier}" + raise ValueError(msg) + + suffix = appendix.strip() + if issue.description.strip(): + description = f"{issue.description.rstrip()}\n\n{suffix}" + else: + description = suffix + return await self.update_issue_description(issue.id, description) + + async def add_comment(self, identifier: str, body: str) -> str: + issue = await self.get_issue(identifier) + if issue is None: + msg = f"Linear issue not found: {identifier}" + raise ValueError(msg) + + mutation = """ + mutation AddComment($issueId: String!, $body: String!) { + commentCreate(input: { issueId: $issueId, body: $body }) { + comment { + url + } + } + } + """ + data = await self._graphql( + mutation, + variables={"issueId": issue.id, "body": body}, + ) + payload = _expect_mapping(data.get("commentCreate"), field="commentCreate") + comment_obj = payload.get("comment") + if isinstance(comment_obj, Mapping): + comment_url = comment_obj.get("url") + if isinstance(comment_url, str) and comment_url: + return comment_url + return issue.url + + async def set_issue_state_by_name( + self, + identifier: str, + state_names: Sequence[str], + ) -> str: + query = """ + query GetIssueForState($identifier: String!) { + issue(identifier: $identifier) { + id + state { + name + } + team { + states { + nodes { + id + name + } + } + } + } + } + """ + data = await self._graphql(query, variables={"identifier": identifier}) + issue_obj = data.get("issue") + if issue_obj is None: + msg = f"Linear issue not found: {identifier}" + raise ValueError(msg) + + issue = _expect_mapping(issue_obj, field="issue") + issue_id = _expect_str(issue.get("id"), field="issue.id") + + target_names = {name.casefold().strip() for name in state_names if name.strip()} + if not target_names: + msg = "state_names is empty" + raise ValueError(msg) + + team_obj = _expect_mapping(issue.get("team"), field="issue.team") + states_obj = _expect_mapping(team_obj.get("states"), field="issue.team.states") + nodes = states_obj.get("nodes") + if not isinstance(nodes, list): + msg = "Linear issue team has no states" + raise TypeError(msg) + + selected_state_id = "" + selected_state_name = "" + for node_obj in nodes: + node = _expect_mapping(node_obj, field="issue.team.states.nodes") + name = _expect_str(node.get("name"), field="issue.team.states.nodes.name") + if name.casefold() in target_names: + selected_state_id = _expect_str(node.get("id"), field="state.id") + selected_state_name = name + break + + if not selected_state_id: + msg = f"No matching state found for {list(state_names)}" + raise ValueError(msg) + + mutation = """ + mutation SetIssueState($issueId: String!, $stateId: String!) { + issueUpdate(id: $issueId, input: { stateId: $stateId }) { + issue { + state { + name + } + } + } + } + """ + result = await self._graphql( + mutation, + variables={"issueId": issue_id, "stateId": selected_state_id}, + ) + payload = _expect_mapping(result.get("issueUpdate"), field="issueUpdate") + issue_out = _expect_mapping(payload.get("issue"), field="issueUpdate.issue") + state_name = _extract_state_name(issue_out.get("state")) + return state_name or selected_state_name + + async def update_issue_description(self, issue_id: str, description: str) -> LinearIssueDetails: + mutation = """ + mutation UpdateIssueDescription($issueId: String!, $description: String!) { + issueUpdate(id: $issueId, input: { description: $description }) { + issue { + id + identifier + title + url + description + state { + name + } + } + } + } + """ + data = await self._graphql( + mutation, + variables={"issueId": issue_id, "description": description}, + ) + payload = _expect_mapping(data.get("issueUpdate"), field="issueUpdate") + return _issue_details_from_node(payload.get("issue")) diff --git a/ductor_bot/integrations/linear/commands.py b/ductor_bot/integrations/linear/commands.py new file mode 100644 index 0000000..665ebfc --- /dev/null +++ b/ductor_bot/integrations/linear/commands.py @@ -0,0 +1,218 @@ +"""Telegram command handlers and callbacks for Linear integration.""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING + +from ductor_bot.integrations.linear.models import LinearIssueDraft +from ductor_bot.orchestrator.registry import OrchestratorResult +from ductor_bot.orchestrator.selectors.models import Button, ButtonGrid + +if TYPE_CHECKING: + from ductor_bot.orchestrator.core import Orchestrator + from ductor_bot.session.key import SessionKey + +logger = logging.getLogger(__name__) + + +def _extract_command_argument(text: str, command: str) -> str: + stripped = text.strip() + cmd_with_bot, _, tail_with_bot = stripped.partition(" ") + cmd_without_bot = cmd_with_bot.split("@", 1)[0] + if cmd_without_bot != command: + return tail_with_bot.strip() + return tail_with_bot.strip() + + +def _status_emoji(state_name: str) -> str: + normalized = state_name.casefold() + if "todo" in normalized or "to do" in normalized: + return "⬜" + if "in progress" in normalized or "progress" in normalized: + return "🔵" + if "done" in normalized: + return "✅" + return "🟡" + + +async def cmd_tasks(orch: Orchestrator, key: SessionKey, text: str) -> OrchestratorResult: + """Handle /tasks command and show latest Linear issues.""" + del key, text + + team_id = orch.config.linear.default_team_id.strip() + if not team_id: + return OrchestratorResult( + text="Linear is not configured: set linear.default_team_id in config.json", + ) + + try: + issues = await orch.linear_client.list_recent_issues(team_id=team_id, limit=15) + except (RuntimeError, ValueError, TypeError) as exc: + return OrchestratorResult(text=f"Failed to load Linear issues: {exc}") + + if not issues: + return OrchestratorResult(text="No Linear issues found for the configured team.") + + lines = ["Latest Linear issues:"] + rows: list[list[Button]] = [] + for index, issue in enumerate(issues, start=1): + lines.append(f"{index}. {_status_emoji(issue.state_name)} {issue.identifier} {issue.title}") + rows.append( + [Button(text=issue.identifier, callback_data=f"linear:task:{issue.identifier}")] + ) + + return OrchestratorResult( + text="\n".join(lines), + buttons=ButtonGrid(rows=rows), + ) + + +async def cmd_task(orch: Orchestrator, key: SessionKey, text: str) -> OrchestratorResult: + """Handle /task command.""" + del key + + identifier = _extract_command_argument(text, "/task") + if not identifier: + return OrchestratorResult(text="Usage: /task ") + + try: + issue = await orch.linear_client.get_issue(identifier=identifier) + except (RuntimeError, ValueError, TypeError) as exc: + return OrchestratorResult(text=f"Failed to fetch Linear issue: {exc}") + + if issue is None: + return OrchestratorResult(text=f"Issue {identifier} was not found.") + + description = issue.description.strip() or "Description is empty." + if len(description) > 2000: + description = f"{description[:2000].rstrip()}..." + + body = ( + f"{issue.identifier}: {issue.title}\n" + f"Status: {issue.state_name}\n" + f"URL: {issue.url}\n\n" + f"{description}" + ) + + buttons = ButtonGrid( + rows=[ + [ + Button(text="Проработать", callback_data=f"linear:refine:{issue.identifier}"), + Button(text="Комментарий", callback_data=f"linear:comment:{issue.identifier}"), + Button(text="Статус", callback_data=f"linear:status:{issue.identifier}"), + ] + ] + ) + + return OrchestratorResult(text=body, buttons=buttons) + + +async def cmd_create(orch: Orchestrator, key: SessionKey, text: str) -> OrchestratorResult: + """Handle /create command with AI intake draft generation.""" + payload = _extract_command_argument(text, "/create") + if not payload: + return OrchestratorResult(text="Напиши описание задачи после /create") + + cfg = orch.config.intake + try: + from ductor_bot.integrations.linear.intake import structure_task + + draft = await structure_task( + payload, + provider=cfg.provider, + model=cfg.model, + api_key=cfg.api_key, + ) + except Exception: + logger.exception("AI intake failed") + draft = LinearIssueDraft(title=payload[:80], description=payload) + + orch._linear_create_drafts[key.storage_key] = draft + + preview = ( + "📋 Задача (draft):\n\n" + f"**{draft.title}**\n\n" + f"{draft.description}\n\n" + f"Acceptance: {draft.acceptance or '—'}\n" + f"Priority: {draft.priority}" + ) + + buttons = ButtonGrid( + rows=[ + [ + Button(text="✅ Создать", callback_data="linear:draft:confirm"), + Button(text="✏️ Изменить", callback_data="linear:draft:edit"), + Button(text="❌ Отмена", callback_data="linear:draft:cancel"), + ] + ] + ) + + return OrchestratorResult(text=preview, buttons=buttons) + + +async def handle_linear_callback( + orch: Orchestrator, + key: SessionKey, + callback_data: str, +) -> OrchestratorResult | None: + """Route linear:* callbacks.""" + parts = callback_data.split(":") + if len(parts) < 3 or parts[0] != "linear": + return None + + action = parts[1] + value = parts[2] + + if action == "draft": + return await _handle_draft_callback(orch, key, value) + + if action == "task": + try: + issue = await orch.linear_client.get_issue(identifier=value) + except (RuntimeError, ValueError, TypeError) as exc: + return OrchestratorResult(text=f"Failed to fetch Linear issue: {exc}") + if not issue: + return OrchestratorResult(text=f"Issue {value} not found") + return await cmd_task(orch, key, f"/task {issue.identifier}") + + return None + + +async def _handle_draft_callback( + orch: Orchestrator, + key: SessionKey, + action: str, +) -> OrchestratorResult: + draft_obj = orch._linear_create_drafts.pop(key.storage_key, None) + draft = draft_obj if isinstance(draft_obj, LinearIssueDraft) else None + + if action == "cancel": + return OrchestratorResult(text="Создание отменено.") + + if action == "edit": + if draft is not None: + orch._linear_create_drafts[key.storage_key] = draft + return OrchestratorResult(text="Отправь исправленное описание - я пересоберу задачу.") + + if action != "confirm": + return OrchestratorResult(text="Unknown action") + + if draft is None: + return OrchestratorResult(text="Draft not found. Use /create again.") + + team_id = orch.config.linear.default_team_id + if not team_id.strip(): + return OrchestratorResult(text="Linear team is not configured.") + + try: + issue = await orch.linear_client.create_issue( + team_id=team_id, + title=draft.title, + description=f"{draft.description}\n\n## Acceptance\n{draft.acceptance}", + ) + except Exception as exc: + text = f"Ошибка создания: {exc}" + else: + text = f"✅ Создано: {issue.identifier}\n{issue.url}" + return OrchestratorResult(text=text) diff --git a/ductor_bot/integrations/linear/config.py b/ductor_bot/integrations/linear/config.py new file mode 100644 index 0000000..aa7e522 --- /dev/null +++ b/ductor_bot/integrations/linear/config.py @@ -0,0 +1,17 @@ +"""Config for Linear integration.""" + +from __future__ import annotations + +from pydantic import BaseModel + + +class LinearConfig(BaseModel): + api_token: str = "" + default_team_id: str = "" + default_team_key: str = "" + + +class IntakeConfig(BaseModel): + provider: str = "openai" + model: str = "gpt-4.1-mini" + api_key: str = "" diff --git a/ductor_bot/integrations/linear/intake.py b/ductor_bot/integrations/linear/intake.py new file mode 100644 index 0000000..2f4be73 --- /dev/null +++ b/ductor_bot/integrations/linear/intake.py @@ -0,0 +1,156 @@ +"""Turn raw brainstorm text into a structured LinearIssueDraft.""" + +from __future__ import annotations + +import json +import logging +from collections.abc import Mapping + +import aiohttp + +from ductor_bot.integrations.linear.models import LinearIssueDraft + +logger = logging.getLogger(__name__) + +SYSTEM_PROMPT = """You are a task structuring assistant. +The user sends raw brainstorm/idea text in Russian (or mixed languages). +Turn it into a structured dev task. + +Return ONLY valid JSON: +{ + "title": "concise task title, max 80 chars, Russian", + "description": "## Контекст\\n...\\n## Что сделать\\n...\\n## Ожидаемый результат\\n...", + "acceptance": "- criterion 1\\n- criterion 2", + "priority": 3 +} + +Rules: +- Keep user's intent exactly, don't add scope +- If user mentions specific tools/services, include them +- priority: 0=none, 1=urgent, 2=high, 3=medium, 4=low +- Write in Russian""" + + +_DEFAULT_TIMEOUT = aiohttp.ClientTimeout(total=30) + + +def _as_mapping(value: object, *, field: str) -> Mapping[str, object]: + if not isinstance(value, Mapping): + msg = f"{field} is not an object" + raise TypeError(msg) + return value + + +def _as_str(value: object, *, field: str) -> str: + if not isinstance(value, str): + msg = f"{field} is not a string" + raise TypeError(msg) + return value + + +def _extract_json_text(raw: str) -> str: + stripped = raw.strip() + if stripped.startswith("{") and stripped.endswith("}"): + return stripped + + start = stripped.find("{") + end = stripped.rfind("}") + if start < 0 or end < 0 or end <= start: + msg = "AI response does not contain JSON object" + raise ValueError(msg) + return stripped[start : end + 1] + + +async def structure_task( + raw_text: str, + *, + provider: str = "openai", + model: str = "gpt-4.1-mini", + api_key: str = "", +) -> LinearIssueDraft: + if provider == "passthrough": + return LinearIssueDraft(title=raw_text[:80], description=raw_text) + if provider == "openai": + return await _call_openai(raw_text, model, api_key) + if provider == "anthropic": + return await _call_anthropic(raw_text, model, api_key) + msg = f"Unknown intake provider: {provider}" + raise ValueError(msg) + + +async def _call_openai(text: str, model: str, api_key: str) -> LinearIssueDraft: + if not api_key.strip(): + msg = "OpenAI API key is empty" + raise ValueError(msg) + + url = "https://api.openai.com/v1/chat/completions" + headers = { + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json", + } + payload: dict[str, object] = { + "model": model, + "messages": [ + {"role": "system", "content": SYSTEM_PROMPT}, + {"role": "user", "content": text}, + ], + "temperature": 0.3, + "response_format": {"type": "json_object"}, + } + + async with ( + aiohttp.ClientSession(timeout=_DEFAULT_TIMEOUT) as session, + session.post(url, json=payload, headers=headers) as response, + ): + body = await response.json(content_type=None) + if response.status >= 400: + msg = f"OpenAI HTTP {response.status}: {body}" + raise RuntimeError(msg) + + data = _as_mapping(body, field="openai.response") + choices_obj = data.get("choices") + if not isinstance(choices_obj, list) or not choices_obj: + msg = "openai.response.choices is empty" + raise ValueError(msg) + first_choice = _as_mapping(choices_obj[0], field="openai.response.choices[0]") + message = _as_mapping(first_choice.get("message"), field="openai.response.choices[0].message") + content = _as_str(message.get("content"), field="openai.response.choices[0].message.content") + return LinearIssueDraft(**json.loads(_extract_json_text(content))) + + +async def _call_anthropic(text: str, model: str, api_key: str) -> LinearIssueDraft: + if not api_key.strip(): + msg = "Anthropic API key is empty" + raise ValueError(msg) + + url = "https://api.anthropic.com/v1/messages" + headers = { + "x-api-key": api_key, + "anthropic-version": "2023-06-01", + "Content-Type": "application/json", + } + payload: dict[str, object] = { + "model": model, + "max_tokens": 1024, + "system": SYSTEM_PROMPT, + "messages": [{"role": "user", "content": text}], + } + + async with ( + aiohttp.ClientSession(timeout=_DEFAULT_TIMEOUT) as session, + session.post(url, json=payload, headers=headers) as response, + ): + body = await response.json(content_type=None) + if response.status >= 400: + msg = f"Anthropic HTTP {response.status}: {body}" + raise RuntimeError(msg) + + data = _as_mapping(body, field="anthropic.response") + content_obj = data.get("content") + if not isinstance(content_obj, list) or not content_obj: + msg = "anthropic.response.content is empty" + raise ValueError(msg) + first_block = _as_mapping(content_obj[0], field="anthropic.response.content[0]") + content = _as_str(first_block.get("text"), field="anthropic.response.content[0].text") + json_text = _extract_json_text(content) + return LinearIssueDraft(**json.loads(json_text)) diff --git a/ductor_bot/integrations/linear/models.py b/ductor_bot/integrations/linear/models.py new file mode 100644 index 0000000..d528d31 --- /dev/null +++ b/ductor_bot/integrations/linear/models.py @@ -0,0 +1,33 @@ +"""Pydantic models for Linear entities.""" + +from __future__ import annotations + +from pydantic import BaseModel, Field + + +class LinearIssue(BaseModel): + id: str + identifier: str + title: str + url: str + state_name: str + + +class LinearIssueDetails(LinearIssue): + description: str = Field(default="") + + +class LinearIssueDraft(BaseModel): + """AI-generated draft before creating in Linear.""" + + title: str + description: str + acceptance: str = Field(default="") + project_key: str = Field(default="") + priority: int = Field(default=0) + + +class LinearTeam(BaseModel): + id: str + key: str + name: str diff --git a/ductor_bot/messenger/callback_router.py b/ductor_bot/messenger/callback_router.py index e2bc379..6abf51c 100644 --- a/ductor_bot/messenger/callback_router.py +++ b/ductor_bot/messenger/callback_router.py @@ -71,24 +71,26 @@ async def route_callback( is_task_selector_callback, ) - if is_model_selector_callback(callback_data): - resp = await handle_model_callback(orch, key, callback_data) - return CallbackResult(text=resp.text, buttons=resp.buttons) - - if is_cron_selector_callback(callback_data): - resp = await handle_cron_callback(orch, callback_data) - return CallbackResult(text=resp.text, buttons=resp.buttons) - - if is_session_selector_callback(callback_data): - resp = await handle_session_callback(orch, key.chat_id, callback_data) - return CallbackResult(text=resp.text, buttons=resp.buttons) + linear_result = await orch.handle_callback(key, callback_data) + if linear_result is not None: + return CallbackResult(text=linear_result.text, buttons=linear_result.buttons) - if is_task_selector_callback(callback_data): + if is_model_selector_callback(callback_data): + selector_response = await handle_model_callback(orch, key, callback_data) + elif is_cron_selector_callback(callback_data): + selector_response = await handle_cron_callback(orch, callback_data) + elif is_session_selector_callback(callback_data): + selector_response = await handle_session_callback(orch, key.chat_id, callback_data) + elif is_task_selector_callback(callback_data): hub = orch.task_hub if hub is None: return CallbackResult(text="Task system not available.", buttons=None) - resp = await handle_task_callback(hub, key.chat_id, callback_data) - return CallbackResult(text=resp.text, buttons=resp.buttons) + selector_response = await handle_task_callback(hub, key.chat_id, callback_data) + else: + selector_response = None + + if selector_response is not None: + return CallbackResult(text=selector_response.text, buttons=selector_response.buttons) # Transport-specific prefixes -- signal the caller to handle them. return CallbackResult(handled=False) diff --git a/ductor_bot/messenger/telegram/app.py b/ductor_bot/messenger/telegram/app.py index 1c0643d..207d38c 100644 --- a/ductor_bot/messenger/telegram/app.py +++ b/ductor_bot/messenger/telegram/app.py @@ -1128,30 +1128,48 @@ async def _route_prefix_callback( ) -> bool: """Handle prefix-based callback namespaces. Returns True when handled.""" chat_id = key.chat_id - if data.startswith(MQ_PREFIX): + handled = False + if data.startswith("linear:"): + await self._handle_linear_callback(key, message_id, data) + handled = True + elif data.startswith(MQ_PREFIX): await self._handle_queue_cancel(chat_id, data) - return True - - if data.startswith("upg:"): + handled = True + elif data.startswith("upg:"): await self._handle_upgrade_callback(chat_id, message_id, data, thread_id=thread_id) - return True - - from ductor_bot.orchestrator.selectors.session_selector import is_session_selector_callback - from ductor_bot.orchestrator.selectors.task_selector import is_task_selector_callback + handled = True + else: + from ductor_bot.orchestrator.selectors.session_selector import ( + is_session_selector_callback, + ) + from ductor_bot.orchestrator.selectors.task_selector import is_task_selector_callback - if is_session_selector_callback(data): - await self._handle_session_selector(chat_id, message_id, data) - return True + if is_session_selector_callback(data): + await self._handle_session_selector(chat_id, message_id, data) + handled = True + elif is_task_selector_callback(data): + await self._handle_task_selector(chat_id, message_id, data) + handled = True + elif data.startswith("ns:"): + await self._handle_ns_callback(key, data, thread_id=thread_id) + handled = True - if is_task_selector_callback(data): - await self._handle_task_selector(chat_id, message_id, data) - return True + return handled - if data.startswith("ns:"): - await self._handle_ns_callback(key, data, thread_id=thread_id) - return True + async def _handle_linear_callback(self, key: SessionKey, message_id: int, data: str) -> None: + """Handle linear integration callbacks by editing the message in-place.""" + from ductor_bot.orchestrator.selectors.models import SelectorResponse - return False + async with self._sequential.get_lock(key.lock_key): + result = await self._orch.handle_callback(key, data) + if result is None: + return + await edit_selector_response( + self._bot, + key.chat_id, + message_id, + SelectorResponse(text=result.text, buttons=result.buttons), + ) async def _handle_model_selector(self, key: SessionKey, message_id: int, data: str) -> None: """Handle model selector wizard by editing the message in-place.""" diff --git a/ductor_bot/orchestrator/core.py b/ductor_bot/orchestrator/core.py index df666b5..101678a 100644 --- a/ductor_bot/orchestrator/core.py +++ b/ductor_bot/orchestrator/core.py @@ -34,7 +34,6 @@ cmd_reset, cmd_sessions, cmd_status, - cmd_tasks, cmd_upgrade, ) from ductor_bot.orchestrator.directives import parse_directives @@ -66,6 +65,8 @@ from ductor_bot.background import BackgroundObserver from ductor_bot.bus.bus import MessageBus from ductor_bot.config import ModelRegistry + from ductor_bot.integrations.linear.client import LinearClient + from ductor_bot.integrations.linear.models import LinearIssueDraft from ductor_bot.multiagent.bus import AsyncInterAgentResult from ductor_bot.multiagent.supervisor import AgentSupervisor from ductor_bot.session.named import NamedSession @@ -179,6 +180,8 @@ async def _heartbeat_handler( self._hook_registry.register(DELEGATION_REMINDER) self._supervisor: AgentSupervisor | None = None # Set by AgentSupervisor after creation self._task_hub: TaskHub | None = None # Set by supervisor or __main__.py + self._linear_client: LinearClient | None = None + self._linear_create_drafts: dict[str, LinearIssueDraft] = {} self._command_registry = CommandRegistry() self._register_commands() @@ -202,6 +205,15 @@ def inflight_tracker(self) -> InflightTracker: """Public access to the inflight turn tracker.""" return self._inflight_tracker + @property + def linear_client(self) -> LinearClient: + """Lazily initialized Linear API client.""" + if self._linear_client is None: + from ductor_bot.integrations.linear.client import LinearClient + + self._linear_client = LinearClient(self._config.linear) + return self._linear_client + @property def named_sessions(self) -> NamedSessionRegistry: """Public access to the named session registry.""" @@ -276,6 +288,18 @@ async def handle_message(self, key: SessionKey, text: str) -> OrchestratorResult dispatch = _MessageDispatch(key=key, text=text, cmd=text.strip().lower()) return await self._handle_message_impl(dispatch) + async def handle_callback( + self, + key: SessionKey, + callback_data: str, + ) -> OrchestratorResult | None: + """Route callback namespaces handled by the orchestrator.""" + from ductor_bot.integrations.linear.commands import handle_linear_callback + + if callback_data.startswith("linear:"): + return await handle_linear_callback(self, key, callback_data) + return None + async def handle_message_streaming( self, key: SessionKey, @@ -371,6 +395,8 @@ async def _route_message(self, dispatch: _MessageDispatch) -> OrchestratorResult ) def _register_commands(self) -> None: + from ductor_bot.integrations.linear.commands import cmd_create, cmd_task, cmd_tasks + reg = self._command_registry reg.register_async("/new", cmd_reset) # /stop is handled entirely by the Middleware abort path (before the lock) @@ -384,6 +410,10 @@ def _register_commands(self) -> None: reg.register_async("/upgrade", cmd_upgrade) reg.register_async("/sessions", cmd_sessions) reg.register_async("/tasks", cmd_tasks) + reg.register_async("/task", cmd_task) + reg.register_async("/task ", cmd_task) + reg.register_async("/create", cmd_create) + reg.register_async("/create ", cmd_create) def register_multiagent_commands(self) -> None: """Register /agents, /agent_start, /agent_stop, /agent_restart commands. diff --git a/ductor_bot/orchestrator/lifecycle.py b/ductor_bot/orchestrator/lifecycle.py index 10eb257..81028cd 100644 --- a/ductor_bot/orchestrator/lifecycle.py +++ b/ductor_bot/orchestrator/lifecycle.py @@ -191,6 +191,8 @@ async def shutdown(orch: Orchestrator) -> None: killed = await orch._process_registry.kill_all_active() if killed: logger.info("Shutdown terminated %d active CLI process(es)", killed) + if orch._linear_client is not None: + await orch._linear_client.close() if orch._api_stop is not None: await orch._api_stop() await asyncio.to_thread(cleanup_ductor_links, orch._paths) diff --git a/tests/integrations/__init__.py b/tests/integrations/__init__.py new file mode 100644 index 0000000..c66cd71 --- /dev/null +++ b/tests/integrations/__init__.py @@ -0,0 +1 @@ +"""Integration tests package.""" diff --git a/tests/integrations/linear/__init__.py b/tests/integrations/linear/__init__.py new file mode 100644 index 0000000..98819df --- /dev/null +++ b/tests/integrations/linear/__init__.py @@ -0,0 +1 @@ +"""Linear integration tests package.""" diff --git a/tests/integrations/linear/test_client.py b/tests/integrations/linear/test_client.py new file mode 100644 index 0000000..23644b4 --- /dev/null +++ b/tests/integrations/linear/test_client.py @@ -0,0 +1,232 @@ +"""HTTP client tests for Linear integration.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass, field +from typing import Self + +import pytest + +import ductor_bot.integrations.linear.client as linear_client_module +from ductor_bot.integrations.linear.client import LinearClient +from ductor_bot.integrations.linear.config import LinearConfig + + +@dataclass +class _MockResponse: + status: int + payload: object + + async def __aenter__(self) -> Self: + return self + + async def __aexit__(self, *_args: object) -> None: + return None + + async def json(self, *, content_type: str | None = None) -> object: + del content_type + return self.payload + + +@dataclass +class _MockSession: + responses: list[_MockResponse] + closed: bool = False + calls: list[dict[str, object]] = field(default_factory=list) + init_kwargs: dict[str, object] = field(default_factory=dict) + + def post(self, url: str, *, json: dict[str, object]) -> _MockResponse: + self.calls.append({"url": url, "json": json}) + if not self.responses: + msg = "No mock response configured" + raise AssertionError(msg) + return self.responses.pop(0) + + async def close(self) -> None: + self.closed = True + + +@pytest.fixture +def install_mock_session( + monkeypatch: pytest.MonkeyPatch, +) -> Callable[[list[_MockResponse]], _MockSession]: + def _install(responses: list[_MockResponse]) -> _MockSession: + session = _MockSession(responses=responses) + + def _factory(*_args: object, **kwargs: object) -> _MockSession: + session.init_kwargs = dict(kwargs) + return session + + monkeypatch.setattr(linear_client_module.aiohttp, "ClientSession", _factory) + return session + + return _install + + +async def test_list_teams( + install_mock_session: Callable[[list[_MockResponse]], _MockSession], +) -> None: + session = install_mock_session( + [ + _MockResponse( + status=200, + payload={ + "data": { + "teams": { + "nodes": [ + {"id": "team_1", "key": "SSU", "name": "Ssunbles"}, + ] + } + } + }, + ) + ] + ) + client = LinearClient(LinearConfig(api_token="lin_api_token")) + + teams = await client.list_teams() + + assert len(teams) == 1 + assert teams[0].key == "SSU" + headers = session.init_kwargs["headers"] + assert isinstance(headers, dict) + assert headers["Authorization"] == "lin_api_token" + await client.close() + assert session.closed is True + + +async def test_get_issue_not_found( + install_mock_session: Callable[[list[_MockResponse]], _MockSession], +) -> None: + install_mock_session([_MockResponse(status=200, payload={"data": {"issue": None}})]) + client = LinearClient(LinearConfig(api_token="lin_api_token")) + + issue = await client.get_issue("SSU-999") + + assert issue is None + await client.close() + + +async def test_append_issue_description( + install_mock_session: Callable[[list[_MockResponse]], _MockSession], +) -> None: + session = install_mock_session( + [ + _MockResponse( + status=200, + payload={ + "data": { + "issue": { + "id": "issue_1", + "identifier": "SSU-1", + "title": "Test", + "url": "https://linear.app/ssu/issue/SSU-1/test", + "description": "Initial description", + "state": {"name": "Todo"}, + } + } + }, + ), + _MockResponse( + status=200, + payload={ + "data": { + "issueUpdate": { + "issue": { + "id": "issue_1", + "identifier": "SSU-1", + "title": "Test", + "url": "https://linear.app/ssu/issue/SSU-1/test", + "description": "Initial description\n\nAppendix", + "state": {"name": "Todo"}, + } + } + } + }, + ), + ] + ) + client = LinearClient(LinearConfig(api_token="lin_api_token")) + + updated = await client.append_issue_description("SSU-1", "Appendix") + + assert "Appendix" in updated.description + second_payload = session.calls[1]["json"] + assert isinstance(second_payload, dict) + variables = second_payload["variables"] + assert isinstance(variables, dict) + assert variables["description"] == "Initial description\n\nAppendix" + await client.close() + + +async def test_set_issue_state_by_name( + install_mock_session: Callable[[list[_MockResponse]], _MockSession], +) -> None: + session = install_mock_session( + [ + _MockResponse( + status=200, + payload={ + "data": { + "issue": { + "id": "issue_2", + "state": {"name": "Todo"}, + "team": { + "states": { + "nodes": [ + {"id": "state_1", "name": "Todo"}, + {"id": "state_2", "name": "In Progress"}, + ] + } + }, + } + } + }, + ), + _MockResponse( + status=200, + payload={ + "data": { + "issueUpdate": { + "issue": { + "state": { + "name": "In Progress", + } + } + } + } + }, + ), + ] + ) + client = LinearClient(LinearConfig(api_token="lin_api_token")) + + state_name = await client.set_issue_state_by_name("SSU-2", ["in progress", "done"]) + + assert state_name == "In Progress" + second_payload = session.calls[1]["json"] + assert isinstance(second_payload, dict) + variables = second_payload["variables"] + assert isinstance(variables, dict) + assert variables["stateId"] == "state_2" + await client.close() + + +async def test_graphql_error_raises_runtime_error( + install_mock_session: Callable[[list[_MockResponse]], _MockSession], +) -> None: + install_mock_session( + [ + _MockResponse( + status=200, + payload={"errors": [{"message": "broken"}]}, + ) + ] + ) + client = LinearClient(LinearConfig(api_token="lin_api_token")) + + with pytest.raises(RuntimeError, match="broken"): + await client.list_teams() + + await client.close() diff --git a/tests/integrations/linear/test_commands.py b/tests/integrations/linear/test_commands.py new file mode 100644 index 0000000..9a0e5bc --- /dev/null +++ b/tests/integrations/linear/test_commands.py @@ -0,0 +1,216 @@ +"""Command handler tests for Linear integration.""" + +from __future__ import annotations + +from typing import cast +from unittest.mock import AsyncMock, patch + +from ductor_bot.integrations.linear.commands import ( + cmd_create, + cmd_task, + cmd_tasks, + handle_linear_callback, +) +from ductor_bot.integrations.linear.config import IntakeConfig, LinearConfig +from ductor_bot.integrations.linear.models import LinearIssue, LinearIssueDetails, LinearIssueDraft +from ductor_bot.orchestrator.core import Orchestrator +from ductor_bot.session.key import SessionKey + + +class _DummyConfig: + def __init__(self) -> None: + self.linear = LinearConfig(default_team_id="team_1") + self.intake = IntakeConfig(provider="passthrough") + + +class _DummyOrchestrator: + def __init__(self) -> None: + self.config = _DummyConfig() + self.linear_client = type("LinearClientStub", (), {})() + self._linear_create_drafts: dict[str, LinearIssueDraft] = {} + + +def _dummy_orchestrator() -> Orchestrator: + return cast("Orchestrator", _DummyOrchestrator()) + + +async def test_cmd_tasks_success() -> None: + orch = _dummy_orchestrator() + orch.linear_client.list_recent_issues = AsyncMock( + return_value=[ + LinearIssue( + id="issue_1", + identifier="SSU-1", + title="Task one", + url="https://linear.app/ssu/issue/SSU-1/task-one", + state_name="Todo", + ), + LinearIssue( + id="issue_2", + identifier="SSU-2", + title="Task two", + url="https://linear.app/ssu/issue/SSU-2/task-two", + state_name="In Progress", + ), + ] + ) + + result = await cmd_tasks(orch, SessionKey(chat_id=1), "/tasks") + + assert "Latest Linear issues" in result.text + assert "⬜ SSU-1" in result.text + assert "🔵 SSU-2" in result.text + assert result.buttons is not None + assert result.buttons.rows[0][0].text == "SSU-1" + assert result.buttons.rows[0][0].callback_data == "linear:task:SSU-1" + + +async def test_cmd_task_success() -> None: + orch = _dummy_orchestrator() + orch.linear_client.get_issue = AsyncMock( + return_value=LinearIssueDetails( + id="issue_3", + identifier="SSU-3", + title="Task details", + url="https://linear.app/ssu/issue/SSU-3/task-details", + state_name="Done", + description="Описание задачи", + ) + ) + + result = await cmd_task(orch, SessionKey(chat_id=1), "/task SSU-3") + + assert "SSU-3: Task details" in result.text + assert "Status: Done" in result.text + assert "URL: https://linear.app/ssu/issue/SSU-3/task-details" in result.text + assert result.buttons is not None + assert result.buttons.rows[0][0].text == "Проработать" + + +async def test_cmd_task_without_identifier() -> None: + orch = _dummy_orchestrator() + result = await cmd_task(orch, SessionKey(chat_id=1), "/task") + + assert result.text == "Usage: /task " + + +async def test_cmd_create_stores_draft_preview() -> None: + orch = _dummy_orchestrator() + key = SessionKey(chat_id=42) + + result = await cmd_create(orch, key, "/create Need to build integration") + + assert "📋 Задача (draft):" in result.text + assert "**Need to build integration**" in result.text + assert result.buttons is not None + assert result.buttons.rows[0][0].callback_data == "linear:draft:confirm" + + stored = orch._linear_create_drafts[key.storage_key] + assert stored.title == "Need to build integration" + assert stored.description == "Need to build integration" + + +async def test_cmd_create_fallback_when_ai_fails() -> None: + orch = _dummy_orchestrator() + orch.config.intake = IntakeConfig(provider="openai", api_key="test") + key = SessionKey(chat_id=7) + + with patch( + "ductor_bot.integrations.linear.intake.structure_task", + new_callable=AsyncMock, + side_effect=RuntimeError("boom"), + ): + result = await cmd_create(orch, key, "/create Build integration draft") + + assert "📋 Задача (draft):" in result.text + assert "Build integration draft" in result.text + stored = orch._linear_create_drafts[key.storage_key] + assert stored.title == "Build integration draft" + + +async def test_cmd_create_requires_text() -> None: + orch = _dummy_orchestrator() + + result = await cmd_create(orch, SessionKey(chat_id=42), "/create") + + assert result.text == "Напиши описание задачи после /create" + + +async def test_handle_linear_callback_draft_cancel() -> None: + orch = _dummy_orchestrator() + key = SessionKey(chat_id=1) + orch._linear_create_drafts[key.storage_key] = LinearIssueDraft(title="t", description="d") + + result = await handle_linear_callback(orch, key, "linear:draft:cancel") + + assert result is not None + assert result.text == "Создание отменено." + assert key.storage_key not in orch._linear_create_drafts + + +async def test_handle_linear_callback_draft_edit() -> None: + orch = _dummy_orchestrator() + key = SessionKey(chat_id=1) + draft = LinearIssueDraft(title="t", description="d") + orch._linear_create_drafts[key.storage_key] = draft + + result = await handle_linear_callback(orch, key, "linear:draft:edit") + + assert result is not None + assert "Отправь исправленное описание" in result.text + assert orch._linear_create_drafts[key.storage_key] == draft + + +async def test_handle_linear_callback_draft_confirm_success() -> None: + orch = _dummy_orchestrator() + key = SessionKey(chat_id=1) + orch._linear_create_drafts[key.storage_key] = LinearIssueDraft( + title="Title", + description="Body", + acceptance="- ok", + priority=3, + ) + orch.linear_client.create_issue = AsyncMock( + return_value=LinearIssue( + id="issue_9", + identifier="SSU-9", + title="Title", + url="https://linear.app/ssu/issue/SSU-9/title", + state_name="Todo", + ) + ) + + result = await handle_linear_callback(orch, key, "linear:draft:confirm") + + assert result is not None + assert "✅ Создано: SSU-9" in result.text + orch.linear_client.create_issue.assert_awaited_once() + call_kwargs = orch.linear_client.create_issue.call_args.kwargs + assert call_kwargs["team_id"] == "team_1" + assert "## Acceptance" in call_kwargs["description"] + + +async def test_handle_linear_callback_task_routes_to_cmd_task() -> None: + orch = _dummy_orchestrator() + details = LinearIssueDetails( + id="issue_2", + identifier="SSU-2", + title="Task two", + url="https://linear.app/ssu/issue/SSU-2/task-two", + state_name="Todo", + description="Desc", + ) + orch.linear_client.get_issue = AsyncMock(side_effect=[details, details]) + + result = await handle_linear_callback(orch, SessionKey(chat_id=2), "linear:task:SSU-2") + + assert result is not None + assert "SSU-2: Task two" in result.text + + +async def test_handle_linear_callback_unknown_namespace_returns_none() -> None: + orch = _dummy_orchestrator() + + result = await handle_linear_callback(orch, SessionKey(chat_id=2), "abc:def:ghi") + + assert result is None diff --git a/tests/integrations/linear/test_intake.py b/tests/integrations/linear/test_intake.py new file mode 100644 index 0000000..32ebb39 --- /dev/null +++ b/tests/integrations/linear/test_intake.py @@ -0,0 +1,158 @@ +"""Tests for AI intake structuring flow.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass, field +from typing import Self + +import pytest + +import ductor_bot.integrations.linear.intake as intake_module +from ductor_bot.integrations.linear.intake import structure_task + + +@dataclass +class _MockResponse: + status: int + payload: object + + async def __aenter__(self) -> Self: + return self + + async def __aexit__(self, *_args: object) -> None: + return None + + async def json(self, *, content_type: str | None = None) -> object: + del content_type + return self.payload + + +@dataclass +class _MockSession: + responses: list[_MockResponse] + init_kwargs: dict[str, object] = field(default_factory=dict) + calls: list[dict[str, object]] = field(default_factory=list) + + async def __aenter__(self) -> Self: + return self + + async def __aexit__(self, *_args: object) -> None: + return None + + def post(self, url: str, **kwargs: object) -> _MockResponse: + self.calls.append({"url": url, **kwargs}) + if not self.responses: + msg = "No mock responses configured" + raise AssertionError(msg) + return self.responses.pop(0) + + +@pytest.fixture +def install_mock_session( + monkeypatch: pytest.MonkeyPatch, +) -> Callable[[list[_MockResponse]], _MockSession]: + def _install(responses: list[_MockResponse]) -> _MockSession: + session = _MockSession(responses=responses) + + def _factory(*_args: object, **kwargs: object) -> _MockSession: + session.init_kwargs = dict(kwargs) + return session + + monkeypatch.setattr(intake_module.aiohttp, "ClientSession", _factory) + return session + + return _install + + +async def test_structure_task_passthrough() -> None: + draft = await structure_task("сырой текст", provider="passthrough") + + assert draft.title == "сырой текст" + assert draft.description == "сырой текст" + assert draft.acceptance == "" + + +async def test_structure_task_openai_parses_json( + install_mock_session: Callable[[list[_MockResponse]], _MockSession], +) -> None: + session = install_mock_session( + [ + _MockResponse( + status=200, + payload={ + "choices": [ + { + "message": { + "content": ( + '{"title":"Тестовая задача",' + '"description":"## Контекст\\nctx",' + '"acceptance":"- ok",' + '"priority":2}' + ) + } + } + ] + }, + ) + ] + ) + + draft = await structure_task( + "нужно сделать выгрузку", + provider="openai", + model="gpt-4.1-mini", + api_key="openai-key", + ) + + assert draft.title == "Тестовая задача" + assert draft.acceptance == "- ok" + assert draft.priority == 2 + + call = session.calls[0] + assert call["url"] == "https://api.openai.com/v1/chat/completions" + headers = call["headers"] + assert isinstance(headers, dict) + assert headers["Authorization"] == "Bearer openai-key" + + +async def test_structure_task_anthropic_extracts_embedded_json( + install_mock_session: Callable[[list[_MockResponse]], _MockSession], +) -> None: + install_mock_session( + [ + _MockResponse( + status=200, + payload={ + "content": [ + { + "text": ( + "Вот результат:\n" + '{"title":"Задача из Claude",' + '"description":"## Контекст\\nctx",' + '"acceptance":"- done",' + '"priority":3}\n' + "Спасибо" + ) + } + ] + }, + ) + ] + ) + + draft = await structure_task( + "разобрать бриф", + provider="anthropic", + model="claude-3-5-sonnet", + api_key="anthropic-key", + ) + + assert draft.title == "Задача из Claude" + assert draft.acceptance == "- done" + assert draft.priority == 3 + + +async def test_structure_task_unknown_provider() -> None: + with pytest.raises(ValueError, match="Unknown intake provider"): + await structure_task("text", provider="invalid") diff --git a/tests/integrations/linear/test_models.py b/tests/integrations/linear/test_models.py new file mode 100644 index 0000000..40be4f3 --- /dev/null +++ b/tests/integrations/linear/test_models.py @@ -0,0 +1,51 @@ +"""Model validation tests for Linear integration.""" + +from __future__ import annotations + +import pytest +from pydantic import ValidationError + +from ductor_bot.integrations.linear.models import ( + LinearIssue, + LinearIssueDetails, + LinearIssueDraft, + LinearTeam, +) + + +def test_linear_issue_validation() -> None: + issue = LinearIssue( + id="issue_1", + identifier="SSU-1", + title="Test", + url="https://linear.app/ssu/issue/SSU-1/test", + state_name="Todo", + ) + + assert issue.identifier == "SSU-1" + assert issue.state_name == "Todo" + + +def test_linear_issue_details_defaults() -> None: + details = LinearIssueDetails( + id="issue_2", + identifier="SSU-2", + title="Detailed", + url="https://linear.app/ssu/issue/SSU-2/detailed", + state_name="In Progress", + ) + + assert details.description == "" + + +def test_linear_issue_draft_defaults() -> None: + draft = LinearIssueDraft(title="Draft title", description="Draft description") + + assert draft.acceptance == "" + assert draft.project_key == "" + assert draft.priority == 0 + + +def test_linear_team_validation_error() -> None: + with pytest.raises(ValidationError): + LinearTeam(id="team", key="SSU")