Skip to content

Commit 56fa699

Browse files
Avangardclaude
authored andcommitted
ci: add GitHub Actions pipeline with lint, typecheck, and tests
Add CI workflow (ruff, mypy, pytest) and fix all lint/type errors to ensure clean pipeline on push and PR. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 734aef2 commit 56fa699

File tree

11 files changed

+147
-50
lines changed

11 files changed

+147
-50
lines changed

.github/workflows/ci.yml

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
name: CI
2+
3+
on:
4+
push:
5+
branches: [main]
6+
pull_request:
7+
branches: [main]
8+
9+
env:
10+
TELEGRAM_BOT_TOKEN: test-token-for-ci
11+
ANTHROPIC_API_KEY: sk-ant-test-key
12+
13+
jobs:
14+
lint:
15+
name: Lint & Format
16+
runs-on: ubuntu-latest
17+
steps:
18+
- uses: actions/checkout@v4
19+
20+
- uses: actions/setup-python@v5
21+
with:
22+
python-version: "3.11"
23+
24+
- name: Install dependencies
25+
run: pip install -e ".[dev]"
26+
27+
- name: Ruff check
28+
run: ruff check src/ tests/
29+
30+
- name: Ruff format check
31+
run: ruff format --check src/ tests/
32+
33+
typecheck:
34+
name: Type Check
35+
runs-on: ubuntu-latest
36+
steps:
37+
- uses: actions/checkout@v4
38+
39+
- uses: actions/setup-python@v5
40+
with:
41+
python-version: "3.11"
42+
43+
- name: Install dependencies
44+
run: pip install -e ".[dev]"
45+
46+
- name: MyPy
47+
run: mypy src/
48+
49+
test:
50+
name: Tests
51+
runs-on: ubuntu-latest
52+
steps:
53+
- uses: actions/checkout@v4
54+
55+
- uses: actions/setup-python@v5
56+
with:
57+
python-version: "3.11"
58+
59+
- name: Install dependencies
60+
run: pip install -e ".[dev]"
61+
62+
- name: Run tests
63+
run: pytest --cov=src --cov-report=term-missing

src/agent.py

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,15 +14,16 @@
1414
from typing import TYPE_CHECKING, Any
1515

1616
from anthropic import AsyncAnthropic
17-
from anthropic.types import Message, ToolUseBlock
1817

1918
from src.config import settings
20-
from src.security import SecurityGuard
21-
from src.state import StateManager
22-
from src.tools import ToolRegistry, ToolResult
2319

2420
if TYPE_CHECKING:
21+
from anthropic.types import Message, ToolUseBlock
22+
23+
from src.security import SecurityGuard
2524
from src.ssh_manager import SSHManager
25+
from src.state import StateManager
26+
from src.tools import ToolRegistry, ToolResult
2627

2728
logger = logging.getLogger(__name__)
2829

@@ -211,7 +212,10 @@ async def run(
211212

212213
while iterations < self._max_iterations:
213214
iterations += 1
214-
logger.info(f"Agent iteration {iterations}/{self._max_iterations}, model={active_model}")
215+
logger.info(
216+
f"Agent iteration {iterations}/{self._max_iterations},"
217+
f" model={active_model}"
218+
)
215219

216220
# Call Claude API
217221
response = await self._call_claude(messages, tool_schemas, active_model)

src/bot.py

Lines changed: 28 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,12 @@
1313
from aiogram import Bot, Dispatcher, F, Router
1414
from aiogram.client.default import DefaultBotProperties
1515
from aiogram.enums import ParseMode
16-
from aiogram.types import CallbackQuery, InlineKeyboardButton, InlineKeyboardMarkup, Message
16+
from aiogram.types import (
17+
CallbackQuery,
18+
InlineKeyboardButton,
19+
InlineKeyboardMarkup,
20+
Message,
21+
)
1722

1823
from src.agent import DevOpsAgent
1924
from src.config import settings
@@ -308,7 +313,9 @@ async def _handle_health(self, message: Message) -> None:
308313
# Use agent to check health via SSH
309314
result = await self._agent.run(
310315
user_id=user_id,
311-
query="Покажи состояние системы: CPU, память, диск (df -h, free -m, uptime)",
316+
query=(
317+
"Покажи состояние системы: CPU, память, диск (df -h, free -m, uptime)"
318+
),
312319
model=model_id,
313320
)
314321

@@ -348,7 +355,10 @@ async def _handle_logs(self, message: Message) -> None:
348355
# Use agent to read logs via SSH
349356
result = await self._agent.run(
350357
user_id=user_id,
351-
query=f"Покажи последние 50 строк логов сервиса {service} (journalctl -u {service} -n 50)",
358+
query=(
359+
f"Покажи последние 50 строк логов сервиса {service}"
360+
f" (journalctl -u {service} -n 50)"
361+
),
352362
model=model_id,
353363
)
354364

@@ -446,10 +456,13 @@ async def _handle_model(self, message: Message) -> None:
446456

447457
# Build inline keyboard
448458
buttons = []
449-
for key, (model_id, name) in MODELS.items():
459+
for key, (_model_id, name) in MODELS.items():
450460
marker = " ✓" if key == current_key else ""
451461
buttons.append(
452-
InlineKeyboardButton(text=f"{name}{marker}", callback_data=f"model:{key}")
462+
InlineKeyboardButton(
463+
text=f"{name}{marker}",
464+
callback_data=f"model:{key}",
465+
)
453466
)
454467

455468
keyboard = InlineKeyboardMarkup(inline_keyboard=[buttons])
@@ -487,18 +500,22 @@ async def _handle_model_callback(self, callback: CallbackQuery) -> None:
487500

488501
# Update keyboard with new selection
489502
buttons = []
490-
for key, (model_id, name) in MODELS.items():
503+
for key, (_model_id, name) in MODELS.items():
491504
marker = " ✓" if key == model_key else ""
492505
buttons.append(
493-
InlineKeyboardButton(text=f"{name}{marker}", callback_data=f"model:{key}")
506+
InlineKeyboardButton(
507+
text=f"{name}{marker}",
508+
callback_data=f"model:{key}",
509+
)
494510
)
495511

496512
keyboard = InlineKeyboardMarkup(inline_keyboard=[buttons])
497513

498-
await callback.message.edit_text(
499-
f"<b>Выбор модели Claude</b>\n\nТекущая: {model_name}",
500-
reply_markup=keyboard,
501-
)
514+
if callback.message and hasattr(callback.message, "edit_text"):
515+
await callback.message.edit_text(
516+
f"<b>Выбор модели Claude</b>\n\nТекущая: {model_name}",
517+
reply_markup=keyboard,
518+
)
502519
await callback.answer(f"Модель: {model_name}")
503520

504521
async def _handle_message(self, message: Message) -> None:

src/config.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,4 +57,4 @@ def effective_ssh_permissions_path(self) -> Path:
5757
return self.base_dir / "config" / "ssh_permissions.json"
5858

5959

60-
settings = Settings()
60+
settings = Settings() # type: ignore[call-arg]

src/security.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -278,7 +278,8 @@ def validate_command(
278278
Args:
279279
user_id: User requesting command execution.
280280
command: Command to validate.
281-
skip_allowlist: If True, skip allowlist check (for SSH per-level validation).
281+
skip_allowlist: If True, skip allowlist check
282+
(for SSH per-level validation).
282283
283284
Returns:
284285
Tuple of (is_allowed, list_of_warnings).

src/ssh_manager.py

Lines changed: 17 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
import json
1212
import re
1313
from dataclasses import dataclass, field
14-
from enum import Enum
14+
from enum import StrEnum
1515
from pathlib import Path
1616
from typing import TYPE_CHECKING
1717

@@ -24,7 +24,7 @@
2424
logger = structlog.get_logger()
2525

2626

27-
class PermissionLevel(str, Enum):
27+
class PermissionLevel(StrEnum):
2828
"""Permission levels for SSH hosts."""
2929

3030
READONLY = "readonly"
@@ -122,7 +122,8 @@ class SSHSettings:
122122
r"^ifconfig(\s+|$)",
123123
]
124124

125-
OPERATOR_PATTERNS: list[str] = READONLY_PATTERNS + [
125+
OPERATOR_PATTERNS: list[str] = [
126+
*READONLY_PATTERNS,
126127
# Service management
127128
r"^systemctl\s+(restart|start|stop|reload)\s+",
128129
r"^systemctl\s+daemon-reload$",
@@ -160,7 +161,9 @@ def __init__(
160161
self._permissions_path = permissions_path
161162
self._security = security
162163
self._ssh_config_path = ssh_config_path or Path.home() / ".ssh" / "config"
163-
self._known_hosts_path = known_hosts_path or Path.home() / ".ssh" / "known_hosts"
164+
self._known_hosts_path = (
165+
known_hosts_path or Path.home() / ".ssh" / "known_hosts"
166+
)
164167
self._settings: SSHSettings | None = None
165168
self._logger = logger.bind(component="ssh_manager")
166169

@@ -246,11 +249,7 @@ def is_command_allowed_for_level(
246249
)
247250

248251
# Check if command matches any allowed pattern
249-
for pattern in patterns:
250-
if re.match(pattern, command):
251-
return True
252-
253-
return False
252+
return any(re.match(pattern, command) for pattern in patterns)
254253

255254
def _truncate_output(self, output: str) -> tuple[str, bool, str | None]:
256255
"""Truncate output if it exceeds limits.
@@ -332,8 +331,12 @@ async def execute(
332331
return SSHResult(
333332
success=False,
334333
output="",
335-
error=f"Команда запрещена на {host} (уровень: {host_config.level.value}). "
336-
f"На этом сервере разрешены только команды для уровня '{host_config.level.value}'.",
334+
error=(
335+
f"Команда запрещена на {host}"
336+
f" (уровень: {host_config.level.value}). "
337+
f"На этом сервере разрешены только команды"
338+
f" для уровня '{host_config.level.value}'."
339+
),
337340
exit_code=-1,
338341
host=host,
339342
)
@@ -369,8 +372,8 @@ async def execute(
369372
timeout=timeout,
370373
)
371374

372-
stdout = result.stdout or ""
373-
stderr = result.stderr or ""
375+
stdout = str(result.stdout or "")
376+
stderr = str(result.stderr or "")
374377
exit_code = result.exit_status or 0
375378

376379
# Truncate output if needed
@@ -443,7 +446,7 @@ async def execute(
443446
host=host,
444447
)
445448

446-
except asyncio.TimeoutError:
449+
except TimeoutError:
447450
log.error("SSH command timeout", timeout=timeout)
448451
return SSHResult(
449452
success=False,

src/state.py

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -356,7 +356,7 @@ async def add_message(
356356
await self.update_session_activity(session_id)
357357

358358
return Message(
359-
id=message_id,
359+
id=message_id or 0,
360360
session_id=session_id,
361361
role=role,
362362
content=content,
@@ -557,7 +557,7 @@ async def save_incident(
557557
await db.commit()
558558

559559
return Incident(
560-
id=incident_id,
560+
id=incident_id or 0,
561561
user_id=user_id,
562562
timestamp=now,
563563
query=query,
@@ -748,12 +748,14 @@ async def get_user_model(self, user_id: int) -> str:
748748
"""
749749
await self._ensure_initialized()
750750

751-
async with aiosqlite.connect(self._db_path) as db:
752-
async with db.execute(
751+
async with (
752+
aiosqlite.connect(self._db_path) as db,
753+
db.execute(
753754
"SELECT model FROM user_settings WHERE user_id = ?",
754755
(user_id,),
755-
) as cursor:
756-
row = await cursor.fetchone()
756+
) as cursor,
757+
):
758+
row = await cursor.fetchone()
757759

758760
return row[0] if row else "sonnet"
759761

src/tools.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,7 @@ class SSHExecuteTool(Tool):
116116
"required": ["command"],
117117
}
118118

119-
async def execute(
119+
async def execute( # type: ignore[override]
120120
self,
121121
command: str,
122122
host: str | None = None,
@@ -172,8 +172,9 @@ class SSHListHostsTool(Tool):
172172

173173
name: ClassVar[str] = "ssh_list_hosts"
174174
description: ClassVar[str] = (
175-
"List all available SSH hosts with their permission levels and descriptions. "
176-
"Use this to see which servers are available and what actions are allowed on each."
175+
"List all available SSH hosts with their permission "
176+
"levels and descriptions. Use this to see which servers "
177+
"are available and what actions are allowed on each."
177178
)
178179
parameters: ClassVar[dict[str, Any]] = {
179180
"type": "object",

tests/conftest.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
"""Shared test configuration."""
2+
3+
import os
4+
5+
# Set test environment variables before any src imports
6+
os.environ.setdefault("TELEGRAM_BOT_TOKEN", "test-token-for-ci")
7+
os.environ.setdefault("ANTHROPIC_API_KEY", "sk-ant-test-key")

tests/test_config.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,29 @@
11
"""Tests for configuration."""
22

3-
from pathlib import Path
4-
5-
import pytest
6-
73

84
class TestSettings:
95
"""Tests for Settings class."""
106

117
def test_base_dir_exists(self) -> None:
128
"""Base directory should exist."""
139
from src.config import settings
10+
1411
assert settings.base_dir.exists()
1512

1613
def test_default_model(self) -> None:
1714
"""Default model should be set."""
1815
from src.config import settings
16+
1917
assert "claude" in settings.model
2018

2119
def test_max_iterations_positive(self) -> None:
2220
"""Max iterations should be positive."""
2321
from src.config import settings
22+
2423
assert settings.max_iterations > 0
2524

2625
def test_tool_timeout_positive(self) -> None:
2726
"""Tool timeout should be positive."""
2827
from src.config import settings
28+
2929
assert settings.tool_timeout > 0

0 commit comments

Comments
 (0)