patchwork-ai 0.1.0__py3-none-any.whl
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.
- agents/__init__.py +0 -0
- agents/base.py +106 -0
- agents/dependency_surgeon.py +78 -0
- llm/__init__.py +0 -0
- llm/cost_tracker.py +15 -0
- llm/gateway.py +40 -0
- llm/instructor_patch.py +16 -0
- llm/providers.py +50 -0
- memory/__init__.py +0 -0
- memory/episodic.py +130 -0
- memory/procedural.py +67 -0
- memory/semantic.py +99 -0
- memory/team.py +63 -0
- models/__init__.py +0 -0
- models/config.py +70 -0
- models/schemas.py +54 -0
- patchwork_ai-0.1.0.dist-info/METADATA +92 -0
- patchwork_ai-0.1.0.dist-info/RECORD +30 -0
- patchwork_ai-0.1.0.dist-info/WHEEL +4 -0
- patchwork_ai-0.1.0.dist-info/entry_points.txt +3 -0
- patchwork_ai-0.1.0.dist-info/licenses/LICENSE +21 -0
- tools/__init__.py +0 -0
- tools/ast_analyzer.py +31 -0
- tools/changelog_reader.py +29 -0
- tools/git_miner.py +30 -0
- tools/github_app.py +83 -0
- tools/package_detect.py +43 -0
- tools/sandbox.py +34 -0
- utils/__init__.py +0 -0
- utils/logger.py +22 -0
agents/__init__.py
ADDED
|
File without changes
|
agents/base.py
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
from abc import ABC, abstractmethod
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from typing import List, Optional, Dict, Any
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
from models.schemas import UpgradeEpisode, AgentPlan, AgentReflection
|
|
7
|
+
from memory.episodic import EpisodicMemory
|
|
8
|
+
from utils.logger import logger
|
|
9
|
+
|
|
10
|
+
@dataclass
|
|
11
|
+
class WorkingMemory:
|
|
12
|
+
repo_path: str
|
|
13
|
+
package: str
|
|
14
|
+
from_version: str
|
|
15
|
+
to_version: str
|
|
16
|
+
plan: Optional[AgentPlan] = None
|
|
17
|
+
thought_trace: List[str] = field(default_factory=list)
|
|
18
|
+
tool_calls: List[Dict[str, Any]] = field(default_factory=list)
|
|
19
|
+
errors: List[str] = field(default_factory=list)
|
|
20
|
+
start_time: datetime = field(default_factory=datetime.utcnow)
|
|
21
|
+
|
|
22
|
+
class BaseAgent(ABC):
|
|
23
|
+
def __init__(self, name: str, memory: EpisodicMemory):
|
|
24
|
+
self.name = name
|
|
25
|
+
self.memory = memory
|
|
26
|
+
|
|
27
|
+
@abstractmethod
|
|
28
|
+
async def detect(self, repo_path: str) -> List[Dict[str, Any]]:
|
|
29
|
+
pass
|
|
30
|
+
|
|
31
|
+
@abstractmethod
|
|
32
|
+
async def analyze(self, state: WorkingMemory) -> Any:
|
|
33
|
+
pass
|
|
34
|
+
|
|
35
|
+
@abstractmethod
|
|
36
|
+
async def plan(self, state: WorkingMemory) -> AgentPlan:
|
|
37
|
+
pass
|
|
38
|
+
|
|
39
|
+
@abstractmethod
|
|
40
|
+
async def patch(self, state: WorkingMemory) -> bool:
|
|
41
|
+
pass
|
|
42
|
+
|
|
43
|
+
@abstractmethod
|
|
44
|
+
async def test(self, state: WorkingMemory) -> bool:
|
|
45
|
+
pass
|
|
46
|
+
|
|
47
|
+
@abstractmethod
|
|
48
|
+
async def pr(self, state: WorkingMemory) -> str:
|
|
49
|
+
pass
|
|
50
|
+
|
|
51
|
+
@abstractmethod
|
|
52
|
+
async def reflect(self, state: WorkingMemory, success: bool) -> AgentReflection:
|
|
53
|
+
pass
|
|
54
|
+
|
|
55
|
+
async def run_loop(self, repo_path: str, package: str, from_version: str, to_version: str):
|
|
56
|
+
state = WorkingMemory(repo_path, package, from_version, to_version)
|
|
57
|
+
logger.info("agent_loop_started", agent=self.name, package=package)
|
|
58
|
+
|
|
59
|
+
try:
|
|
60
|
+
# 1. Analyze
|
|
61
|
+
state.thought_trace.append("Starting analysis of breaking changes.")
|
|
62
|
+
await self.analyze(state)
|
|
63
|
+
|
|
64
|
+
# 2. Plan (with retry logic)
|
|
65
|
+
max_retries = 3
|
|
66
|
+
for attempt in range(max_retries):
|
|
67
|
+
state.plan = await self.plan(state)
|
|
68
|
+
|
|
69
|
+
# 3. Patch
|
|
70
|
+
state.thought_trace.append(f"Attempting patch (Attempt {attempt+1}).")
|
|
71
|
+
if await self.patch(state):
|
|
72
|
+
# 4. Test
|
|
73
|
+
if await self.test(state):
|
|
74
|
+
# 5. PR
|
|
75
|
+
pr_url = await self.pr(state)
|
|
76
|
+
await self.reflect(state, success=True)
|
|
77
|
+
logger.info("upgrade_successful", package=package, pr=pr_url)
|
|
78
|
+
return True
|
|
79
|
+
else:
|
|
80
|
+
state.errors.append(f"Tests failed on attempt {attempt+1}")
|
|
81
|
+
else:
|
|
82
|
+
state.errors.append(f"Patching failed on attempt {attempt+1}")
|
|
83
|
+
|
|
84
|
+
await self.reflect(state, success=False)
|
|
85
|
+
return False
|
|
86
|
+
|
|
87
|
+
except Exception as e:
|
|
88
|
+
logger.error("agent_loop_crashed", error=str(e))
|
|
89
|
+
state.errors.append(f"Crash: {str(e)}")
|
|
90
|
+
await self.reflect(state, success=False)
|
|
91
|
+
return False
|
|
92
|
+
finally:
|
|
93
|
+
# Final logging to episodic memory
|
|
94
|
+
episode = UpgradeEpisode(
|
|
95
|
+
repo_id=repo_path,
|
|
96
|
+
package=package,
|
|
97
|
+
from_version=from_version,
|
|
98
|
+
to_version=to_version,
|
|
99
|
+
outcome="success" if not state.errors else "failed_tests",
|
|
100
|
+
thought_trace=state.thought_trace,
|
|
101
|
+
tool_calls=state.tool_calls,
|
|
102
|
+
errors_encountered=state.errors,
|
|
103
|
+
time_spent_minutes=int((datetime.utcnow() - state.start_time).total_seconds() / 60),
|
|
104
|
+
context_tags=["auto-upgrade"]
|
|
105
|
+
)
|
|
106
|
+
await self.memory.create_episode(episode)
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
from .base import BaseAgent, WorkingMemory
|
|
2
|
+
from tools.package_detect import PackageDetect
|
|
3
|
+
from tools.changelog_reader import ChangelogReader
|
|
4
|
+
from tools.ast_analyzer import ASTAnalyzer
|
|
5
|
+
from tools.sandbox import Sandbox
|
|
6
|
+
from tools.git_miner import GitMiner
|
|
7
|
+
from memory.episodic import EpisodicMemory
|
|
8
|
+
from memory.procedural import ProceduralMemory
|
|
9
|
+
from llm.gateway import LLMGateway
|
|
10
|
+
from models.schemas import AgentPlan, AgentReflection, UpgradeRecipe
|
|
11
|
+
from utils.logger import logger
|
|
12
|
+
from typing import List, Dict, Any
|
|
13
|
+
|
|
14
|
+
class DependencySurgeon(BaseAgent):
|
|
15
|
+
def __init__(self, llm_gateway: LLMGateway, episodic: EpisodicMemory, procedural: ProceduralMemory):
|
|
16
|
+
super().__init__("DependencySurgeon", episodic)
|
|
17
|
+
self.llm_gateway = llm_gateway
|
|
18
|
+
self.procedural = procedural
|
|
19
|
+
self.package_detect = PackageDetect()
|
|
20
|
+
self.changelog_reader = ChangelogReader(llm_gateway)
|
|
21
|
+
self.ast_analyzer = ASTAnalyzer()
|
|
22
|
+
self.sandbox = Sandbox()
|
|
23
|
+
self.git_miner = GitMiner()
|
|
24
|
+
|
|
25
|
+
async def detect(self, repo_path: str) -> List[Dict[str, Any]]:
|
|
26
|
+
return await self.package_detect.scan_pip(repo_path)
|
|
27
|
+
|
|
28
|
+
async def analyze(self, state: WorkingMemory):
|
|
29
|
+
raw_changelog = await self.changelog_reader.fetch_changelog(state.package, "pip")
|
|
30
|
+
state.thought_trace.append(f"Fetched changelog for {state.package}")
|
|
31
|
+
breaking_changes = await self.changelog_reader.extract_breaking_changes(
|
|
32
|
+
raw_changelog, state.from_version, state.to_version
|
|
33
|
+
)
|
|
34
|
+
state.thought_trace.append(f"Identified {len(breaking_changes)} breaking changes.")
|
|
35
|
+
return breaking_changes
|
|
36
|
+
|
|
37
|
+
async def plan(self, state: WorkingMemory) -> AgentPlan:
|
|
38
|
+
# Check for existing recipes
|
|
39
|
+
recipe = await self.procedural.match_recipe(state.package, f"{state.from_version}->{state.to_version}", [])
|
|
40
|
+
if recipe:
|
|
41
|
+
state.thought_trace.append(f"Matched existing recipe: {recipe.recipe_id}")
|
|
42
|
+
# Map recipe steps to AgentPlan
|
|
43
|
+
return AgentPlan(goal=f"Upgrade {state.package}", steps=[])
|
|
44
|
+
|
|
45
|
+
# Or generate new plan via LLM
|
|
46
|
+
messages = [{"role": "user", "content": f"Create a plan to upgrade {state.package}"}]
|
|
47
|
+
return await self.llm_gateway.request(AgentPlan, messages)
|
|
48
|
+
|
|
49
|
+
async def patch(self, state: WorkingMemory) -> bool:
|
|
50
|
+
# Implementation of applying the plan
|
|
51
|
+
state.thought_trace.append("Applying codemods.")
|
|
52
|
+
return True # Mocked
|
|
53
|
+
|
|
54
|
+
async def test(self, state: WorkingMemory) -> bool:
|
|
55
|
+
passed, output, duration = await self.sandbox.run_tests(state.repo_path)
|
|
56
|
+
state.thought_trace.append(f"Tests {'passed' if passed else 'failed'} in {duration}s")
|
|
57
|
+
return passed
|
|
58
|
+
|
|
59
|
+
async def pr(self, state: WorkingMemory) -> str:
|
|
60
|
+
state.thought_trace.append("Creating PR branch and report.")
|
|
61
|
+
return "https://github.com/org/repo/pull/1"
|
|
62
|
+
|
|
63
|
+
async def reflect(self, state: WorkingMemory, success: bool) -> AgentReflection:
|
|
64
|
+
if success:
|
|
65
|
+
# Save successful recipe
|
|
66
|
+
recipe = UpgradeRecipe(
|
|
67
|
+
recipe_id=f"recipe-{state.package}",
|
|
68
|
+
created_from_episode=state.package,
|
|
69
|
+
confidence=0.9,
|
|
70
|
+
trigger={"package": state.package},
|
|
71
|
+
steps=[],
|
|
72
|
+
known_pitfalls=[],
|
|
73
|
+
verified_repos=[state.repo_path]
|
|
74
|
+
)
|
|
75
|
+
await self.procedural.save_recipe(recipe)
|
|
76
|
+
|
|
77
|
+
messages = [{"role": "user", "content": f"Reflect on the upgrade of {state.package}. Success: {success}"}]
|
|
78
|
+
return await self.llm_gateway.request(AgentReflection, messages)
|
llm/__init__.py
ADDED
|
File without changes
|
llm/cost_tracker.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
from models.schemas import TokenUsage
|
|
2
|
+
|
|
3
|
+
class CostTracker:
|
|
4
|
+
def __init__(self):
|
|
5
|
+
self.total_usage = TokenUsage()
|
|
6
|
+
|
|
7
|
+
def update(self, usage: TokenUsage):
|
|
8
|
+
self.total_usage.prompt_tokens += usage.prompt_tokens
|
|
9
|
+
self.total_usage.completion_tokens += usage.completion_tokens
|
|
10
|
+
self.total_usage.total_tokens += usage.total_tokens
|
|
11
|
+
# Simple cost calculation placeholder
|
|
12
|
+
self.total_usage.cost_usd += (usage.total_tokens / 1000) * 0.01
|
|
13
|
+
|
|
14
|
+
def get_summary(self) -> TokenUsage:
|
|
15
|
+
return self.total_usage
|
llm/gateway.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
from typing import Any, List, Type, TypeVar, Dict
|
|
2
|
+
from pydantic import BaseModel
|
|
3
|
+
from models.schemas import PatchworkConfig, ProviderType, TokenUsage
|
|
4
|
+
from .providers import BaseProvider, AnthropicProvider, MistralProvider, GroqProvider
|
|
5
|
+
from .cost_tracker import CostTracker
|
|
6
|
+
from utils.logger import logger
|
|
7
|
+
|
|
8
|
+
T = TypeVar("T", bound=BaseModel)
|
|
9
|
+
|
|
10
|
+
class LLMGateway:
|
|
11
|
+
def __init__(self, config: PatchworkConfig):
|
|
12
|
+
self.config = config
|
|
13
|
+
self.cost_tracker = CostTracker()
|
|
14
|
+
self.providers: Dict[ProviderType, BaseProvider] = {
|
|
15
|
+
ProviderType.ANTHROPIC: AnthropicProvider(),
|
|
16
|
+
ProviderType.MISTRAL: MistralProvider(),
|
|
17
|
+
ProviderType.GROQ: GroqProvider(),
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
async def request(
|
|
21
|
+
self,
|
|
22
|
+
response_model: Type[T],
|
|
23
|
+
messages: List[dict],
|
|
24
|
+
provider_type: ProviderType = None
|
|
25
|
+
) -> T:
|
|
26
|
+
provider_type = provider_type or self.config.default_provider
|
|
27
|
+
provider = self.providers.get(provider_type)
|
|
28
|
+
|
|
29
|
+
if not provider:
|
|
30
|
+
raise ValueError(f"Unsupported provider: {provider_type}")
|
|
31
|
+
|
|
32
|
+
model_config = self.config.providers[provider_type]
|
|
33
|
+
|
|
34
|
+
logger.info("llm_request", provider=provider_type, model=model_config.model_name)
|
|
35
|
+
|
|
36
|
+
# In a real app, we'd extract token usage from the response
|
|
37
|
+
# instructor returns the model directly, but we can access _raw_response if needed
|
|
38
|
+
result = await provider.generate(response_model, messages, model_config)
|
|
39
|
+
|
|
40
|
+
return result
|
llm/instructor_patch.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import instructor
|
|
2
|
+
from typing import Any, Type, TypeVar
|
|
3
|
+
from pydantic import BaseModel
|
|
4
|
+
|
|
5
|
+
T = TypeVar("T", bound=BaseModel)
|
|
6
|
+
|
|
7
|
+
class StructuredOutputWrapper:
|
|
8
|
+
def __init__(self, client: Any):
|
|
9
|
+
self.client = instructor.patch(client)
|
|
10
|
+
|
|
11
|
+
async def chat_completion(self, response_model: Type[T], messages: list, **kwargs) -> T:
|
|
12
|
+
return await self.client.chat.completions.create(
|
|
13
|
+
response_model=response_model,
|
|
14
|
+
messages=messages,
|
|
15
|
+
**kwargs
|
|
16
|
+
)
|
llm/providers.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from abc import ABC, abstractmethod
|
|
3
|
+
from typing import Any, List, Type, TypeVar
|
|
4
|
+
from pydantic import BaseModel
|
|
5
|
+
import httpx
|
|
6
|
+
from anthropic import AsyncAnthropic
|
|
7
|
+
from mistralai.async_client import MistralAsyncClient
|
|
8
|
+
from groq import AsyncGroq
|
|
9
|
+
import instructor
|
|
10
|
+
|
|
11
|
+
from models.schemas import LLMModelConfig, ProviderType
|
|
12
|
+
|
|
13
|
+
T = TypeVar("T", bound=BaseModel)
|
|
14
|
+
|
|
15
|
+
class BaseProvider(ABC):
|
|
16
|
+
@abstractmethod
|
|
17
|
+
async def generate(self, response_model: Type[T], messages: List[dict], config: LLMModelConfig) -> T:
|
|
18
|
+
pass
|
|
19
|
+
|
|
20
|
+
class AnthropicProvider(BaseProvider):
|
|
21
|
+
async def generate(self, response_model: Type[T], messages: List[dict], config: LLMModelConfig) -> T:
|
|
22
|
+
client = instructor.from_anthropic(AsyncAnthropic(api_key=config.api_key or os.environ.get("ANTHROPIC_API_KEY")))
|
|
23
|
+
return await client.chat.completions.create(
|
|
24
|
+
model=config.model_name,
|
|
25
|
+
max_tokens=config.max_tokens,
|
|
26
|
+
messages=messages,
|
|
27
|
+
response_model=response_model,
|
|
28
|
+
temperature=config.temperature,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
class MistralProvider(BaseProvider):
|
|
32
|
+
async def generate(self, response_model: Type[T], messages: List[dict], config: LLMModelConfig) -> T:
|
|
33
|
+
# Mistral support in instructor is via their own client
|
|
34
|
+
client = instructor.from_mistral(MistralAsyncClient(api_key=config.api_key or os.environ.get("MISTRAL_API_KEY")))
|
|
35
|
+
return await client.chat.completions.create(
|
|
36
|
+
model=config.model_name,
|
|
37
|
+
messages=messages,
|
|
38
|
+
response_model=response_model,
|
|
39
|
+
temperature=config.temperature,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
class GroqProvider(BaseProvider):
|
|
43
|
+
async def generate(self, response_model: Type[T], messages: List[dict], config: LLMModelConfig) -> T:
|
|
44
|
+
client = instructor.from_groq(AsyncGroq(api_key=config.api_key or os.environ.get("GROQ_API_KEY")))
|
|
45
|
+
return await client.chat.completions.create(
|
|
46
|
+
model=config.model_name,
|
|
47
|
+
messages=messages,
|
|
48
|
+
response_model=response_model,
|
|
49
|
+
temperature=config.temperature,
|
|
50
|
+
)
|
memory/__init__.py
ADDED
|
File without changes
|
memory/episodic.py
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import aiosqlite
|
|
2
|
+
import json
|
|
3
|
+
import uuid
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import List, Optional, Any
|
|
7
|
+
from models.schemas import UpgradeEpisode
|
|
8
|
+
from utils.logger import logger
|
|
9
|
+
|
|
10
|
+
DB_PATH = Path(".patchwork/episodes.db")
|
|
11
|
+
|
|
12
|
+
class EpisodicMemory:
|
|
13
|
+
def __init__(self, db_path: Path = DB_PATH):
|
|
14
|
+
self.db_path = db_path
|
|
15
|
+
self._initialized = False
|
|
16
|
+
|
|
17
|
+
async def _init_db(self):
|
|
18
|
+
if self._initialized:
|
|
19
|
+
return
|
|
20
|
+
|
|
21
|
+
self.db_path.parent.mkdir(parents=True, exist_ok=True)
|
|
22
|
+
async with aiosqlite.connect(self.db_path) as db:
|
|
23
|
+
# Main episodes table
|
|
24
|
+
await db.execute("""
|
|
25
|
+
CREATE TABLE IF NOT EXISTS episodes (
|
|
26
|
+
episode_id TEXT PRIMARY KEY,
|
|
27
|
+
timestamp TEXT,
|
|
28
|
+
repo_id TEXT,
|
|
29
|
+
package TEXT,
|
|
30
|
+
from_version TEXT,
|
|
31
|
+
to_version TEXT,
|
|
32
|
+
outcome TEXT,
|
|
33
|
+
thought_trace TEXT,
|
|
34
|
+
tool_calls TEXT,
|
|
35
|
+
errors_encountered TEXT,
|
|
36
|
+
resolution TEXT,
|
|
37
|
+
time_spent_minutes INTEGER,
|
|
38
|
+
context_tags TEXT
|
|
39
|
+
)
|
|
40
|
+
""")
|
|
41
|
+
|
|
42
|
+
# FTS5 virtual table for thought_trace
|
|
43
|
+
# Check if FTS5 table exists
|
|
44
|
+
cursor = await db.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='episodes_fts'")
|
|
45
|
+
if not await cursor.fetchone():
|
|
46
|
+
await db.execute("CREATE VIRTUAL TABLE episodes_fts USING fts5(episode_id, thought_trace)")
|
|
47
|
+
|
|
48
|
+
await db.commit()
|
|
49
|
+
self._initialized = True
|
|
50
|
+
|
|
51
|
+
async def create_episode(self, episode: UpgradeEpisode):
|
|
52
|
+
await self._init_db()
|
|
53
|
+
try:
|
|
54
|
+
async with aiosqlite.connect(self.db_path) as db:
|
|
55
|
+
await db.execute(
|
|
56
|
+
"""INSERT INTO episodes (
|
|
57
|
+
episode_id, timestamp, repo_id, package, from_version, to_version,
|
|
58
|
+
outcome, thought_trace, tool_calls, errors_encountered,
|
|
59
|
+
resolution, time_spent_minutes, context_tags
|
|
60
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
|
61
|
+
(
|
|
62
|
+
episode.episode_id,
|
|
63
|
+
episode.timestamp.isoformat(),
|
|
64
|
+
episode.repo_id,
|
|
65
|
+
episode.package,
|
|
66
|
+
episode.from_version,
|
|
67
|
+
episode.to_version,
|
|
68
|
+
episode.outcome,
|
|
69
|
+
json.dumps(episode.thought_trace),
|
|
70
|
+
json.dumps(episode.tool_calls),
|
|
71
|
+
json.dumps(episode.errors_encountered),
|
|
72
|
+
episode.resolution,
|
|
73
|
+
episode.time_spent_minutes,
|
|
74
|
+
json.dumps(episode.context_tags)
|
|
75
|
+
)
|
|
76
|
+
)
|
|
77
|
+
# Update FTS
|
|
78
|
+
await db.execute(
|
|
79
|
+
"INSERT INTO episodes_fts (episode_id, thought_trace) VALUES (?, ?)",
|
|
80
|
+
(episode.episode_id, " ".join(episode.thought_trace))
|
|
81
|
+
)
|
|
82
|
+
await db.commit()
|
|
83
|
+
logger.info("episode_stored", episode_id=episode.episode_id, package=episode.package)
|
|
84
|
+
except Exception as e:
|
|
85
|
+
logger.error("episode_storage_failed", error=str(e), episode_id=episode.episode_id)
|
|
86
|
+
raise
|
|
87
|
+
|
|
88
|
+
async def get_similar_episodes(self, package: str, context_tags: List[str], limit: int = 5) -> List[UpgradeEpisode]:
|
|
89
|
+
await self._init_db()
|
|
90
|
+
episodes = []
|
|
91
|
+
try:
|
|
92
|
+
async with aiosqlite.connect(self.db_path) as db:
|
|
93
|
+
db.row_factory = aiosqlite.Row
|
|
94
|
+
# Simple similarity based on package and context tags intersection
|
|
95
|
+
# In a real app, this might use the FTS or semantic search
|
|
96
|
+
cursor = await db.execute(
|
|
97
|
+
"SELECT * FROM episodes WHERE package = ? ORDER BY timestamp DESC LIMIT ?",
|
|
98
|
+
(package, limit)
|
|
99
|
+
)
|
|
100
|
+
rows = await cursor.fetchall()
|
|
101
|
+
for row in rows:
|
|
102
|
+
episodes.append(UpgradeEpisode(
|
|
103
|
+
episode_id=row['episode_id'],
|
|
104
|
+
timestamp=datetime.fromisoformat(row['timestamp']),
|
|
105
|
+
repo_id=row['repo_id'],
|
|
106
|
+
package=row['package'],
|
|
107
|
+
from_version=row['from_version'],
|
|
108
|
+
to_version=row['to_version'],
|
|
109
|
+
outcome=row['outcome'],
|
|
110
|
+
thought_trace=json.loads(row['thought_trace']),
|
|
111
|
+
tool_calls=json.loads(row['tool_calls']),
|
|
112
|
+
errors_encountered=json.loads(row['errors_encountered']),
|
|
113
|
+
resolution=row['resolution'],
|
|
114
|
+
time_spent_minutes=row['time_spent_minutes'],
|
|
115
|
+
context_tags=json.loads(row['context_tags'])
|
|
116
|
+
))
|
|
117
|
+
return episodes
|
|
118
|
+
except Exception as e:
|
|
119
|
+
logger.error("get_similar_episodes_failed", error=str(e), package=package)
|
|
120
|
+
return []
|
|
121
|
+
|
|
122
|
+
async def update_episode_outcome(self, episode_id: str, outcome: str, resolution: Optional[str] = None):
|
|
123
|
+
await self._init_db()
|
|
124
|
+
async with aiosqlite.connect(self.db_path) as db:
|
|
125
|
+
await db.execute(
|
|
126
|
+
"UPDATE episodes SET outcome = ?, resolution = ? WHERE episode_id = ?",
|
|
127
|
+
(outcome, resolution, episode_id)
|
|
128
|
+
)
|
|
129
|
+
await db.commit()
|
|
130
|
+
logger.info("episode_updated", episode_id=episode_id, outcome=outcome)
|
memory/procedural.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import yaml
|
|
2
|
+
import json
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import List, Optional, Dict, Any
|
|
5
|
+
from models.schemas import UpgradeRecipe
|
|
6
|
+
from utils.logger import logger
|
|
7
|
+
|
|
8
|
+
RECIPES_DIR = Path(".patchwork/recipes")
|
|
9
|
+
|
|
10
|
+
class ProceduralMemory:
|
|
11
|
+
def __init__(self, recipes_dir: Path = RECIPES_DIR):
|
|
12
|
+
self.recipes_dir = recipes_dir
|
|
13
|
+
self.recipes_dir.mkdir(parents=True, exist_ok=True)
|
|
14
|
+
|
|
15
|
+
async def save_recipe(self, recipe: UpgradeRecipe):
|
|
16
|
+
try:
|
|
17
|
+
recipe_path = self.recipes_dir / f"{recipe.recipe_id}.yaml"
|
|
18
|
+
with open(recipe_path, "w") as f:
|
|
19
|
+
yaml.dump(recipe.model_dump(), f)
|
|
20
|
+
logger.info("recipe_saved", recipe_id=recipe.recipe_id)
|
|
21
|
+
except Exception as e:
|
|
22
|
+
logger.error("save_recipe_failed", error=str(e), recipe_id=recipe.recipe_id)
|
|
23
|
+
raise
|
|
24
|
+
|
|
25
|
+
async def load_recipe(self, recipe_id: str) -> Optional[UpgradeRecipe]:
|
|
26
|
+
recipe_path = self.recipes_dir / f"{recipe_id}.yaml"
|
|
27
|
+
if not recipe_path.exists():
|
|
28
|
+
return None
|
|
29
|
+
try:
|
|
30
|
+
with open(recipe_path, "r") as f:
|
|
31
|
+
data = yaml.safe_load(f)
|
|
32
|
+
return UpgradeRecipe(**data)
|
|
33
|
+
except Exception as e:
|
|
34
|
+
logger.error("load_recipe_failed", error=str(e), recipe_id=recipe_id)
|
|
35
|
+
return None
|
|
36
|
+
|
|
37
|
+
async def list_recipes(self) -> List[UpgradeRecipe]:
|
|
38
|
+
recipes = []
|
|
39
|
+
for file in self.recipes_dir.glob("*.yaml"):
|
|
40
|
+
recipe = await self.load_recipe(file.stem)
|
|
41
|
+
if recipe:
|
|
42
|
+
recipes.append(recipe)
|
|
43
|
+
return recipes
|
|
44
|
+
|
|
45
|
+
async def match_recipe(self, package: str, version_range: str, detected_patterns: List[str]) -> Optional[UpgradeRecipe]:
|
|
46
|
+
# Simple matching logic. Real implementation would use semantic similarity on patterns
|
|
47
|
+
all_recipes = await self.list_recipes()
|
|
48
|
+
best_match = None
|
|
49
|
+
highest_score = 0.0
|
|
50
|
+
|
|
51
|
+
for recipe in all_recipes:
|
|
52
|
+
if recipe.trigger.get("package") == package:
|
|
53
|
+
# Basic score based on pattern overlap
|
|
54
|
+
recipe_patterns = recipe.trigger.get("detected_patterns", [])
|
|
55
|
+
if not recipe_patterns:
|
|
56
|
+
continue
|
|
57
|
+
|
|
58
|
+
intersection = set(detected_patterns).intersection(set(recipe_patterns))
|
|
59
|
+
score = len(intersection) / len(set(detected_patterns).union(set(recipe_patterns)))
|
|
60
|
+
|
|
61
|
+
if score > highest_score:
|
|
62
|
+
highest_score = score
|
|
63
|
+
best_match = recipe
|
|
64
|
+
|
|
65
|
+
if best_match and highest_score > 0.5: # Threshold
|
|
66
|
+
return best_match
|
|
67
|
+
return None
|
memory/semantic.py
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import chromadb
|
|
2
|
+
import httpx
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import List, Optional
|
|
5
|
+
from models.schemas import CodeFingerprint
|
|
6
|
+
from models.config import get_config
|
|
7
|
+
from utils.logger import logger
|
|
8
|
+
|
|
9
|
+
CHROMA_PATH = Path(".patchwork/chroma")
|
|
10
|
+
|
|
11
|
+
class SemanticMemory:
|
|
12
|
+
def __init__(self, persist_directory: Path = CHROMA_PATH):
|
|
13
|
+
self.persist_directory = persist_directory
|
|
14
|
+
self.client = chromadb.PersistentClient(path=str(self.persist_directory))
|
|
15
|
+
self.collection = self.client.get_or_create_collection(name="code_fingerprints")
|
|
16
|
+
self.config = get_config()
|
|
17
|
+
|
|
18
|
+
async def _get_embedding(self, text: str) -> List[float]:
|
|
19
|
+
provider = self.config.embeddings.get("provider")
|
|
20
|
+
model = self.config.embeddings.get("model")
|
|
21
|
+
api_key = self.config.embeddings.get("api_key")
|
|
22
|
+
base_url = self.config.embeddings.get("base_url")
|
|
23
|
+
|
|
24
|
+
if provider == "ollama":
|
|
25
|
+
url = f"{base_url or 'http://localhost:11434'}/api/embeddings"
|
|
26
|
+
async with httpx.AsyncClient() as client:
|
|
27
|
+
resp = await client.post(url, json={"model": model, "prompt": text})
|
|
28
|
+
resp.raise_for_status()
|
|
29
|
+
return resp.json()["embedding"]
|
|
30
|
+
elif provider == "mistral":
|
|
31
|
+
# Mistral embeddings
|
|
32
|
+
url = "https://api.mistral.ai/v1/embeddings"
|
|
33
|
+
async with httpx.AsyncClient() as client:
|
|
34
|
+
resp = await client.post(
|
|
35
|
+
url,
|
|
36
|
+
headers={"Authorization": f"Bearer {api_key}"},
|
|
37
|
+
json={"model": model, "input": [text]}
|
|
38
|
+
)
|
|
39
|
+
resp.raise_for_status()
|
|
40
|
+
return resp.json()["data"][0]["embedding"]
|
|
41
|
+
else:
|
|
42
|
+
# Fallback or placeholder for other providers
|
|
43
|
+
logger.warning("unsupported_embedding_provider", provider=provider)
|
|
44
|
+
return [0.0] * 1536 # Dummy
|
|
45
|
+
|
|
46
|
+
async def add_fingerprint(self, fingerprint: CodeFingerprint):
|
|
47
|
+
try:
|
|
48
|
+
# If embedding is not provided, generate it
|
|
49
|
+
if not fingerprint.embedding:
|
|
50
|
+
fingerprint.embedding = await self._get_embedding(fingerprint.code_snippet)
|
|
51
|
+
|
|
52
|
+
self.collection.add(
|
|
53
|
+
ids=[fingerprint.fingerprint_id],
|
|
54
|
+
embeddings=[fingerprint.embedding],
|
|
55
|
+
metadatas=[{
|
|
56
|
+
"repo_id": fingerprint.repo_id,
|
|
57
|
+
"package": fingerprint.package,
|
|
58
|
+
"file_path": fingerprint.file_path,
|
|
59
|
+
"line_number": fingerprint.line_number,
|
|
60
|
+
"pattern_type": fingerprint.pattern_type,
|
|
61
|
+
"related_episodes": ",".join(fingerprint.related_episodes)
|
|
62
|
+
}],
|
|
63
|
+
documents=[fingerprint.code_snippet]
|
|
64
|
+
)
|
|
65
|
+
logger.info("fingerprint_added", id=fingerprint.fingerprint_id, package=fingerprint.package)
|
|
66
|
+
except Exception as e:
|
|
67
|
+
logger.error("add_fingerprint_failed", error=str(e))
|
|
68
|
+
raise
|
|
69
|
+
|
|
70
|
+
async def query_similar(self, package: str, query_text: str, n: int = 10) -> List[CodeFingerprint]:
|
|
71
|
+
try:
|
|
72
|
+
results = self.collection.query(
|
|
73
|
+
query_texts=[query_text],
|
|
74
|
+
n_results=n,
|
|
75
|
+
where={"package": package}
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
fingerprints = []
|
|
79
|
+
for i in range(len(results["ids"][0])):
|
|
80
|
+
metadata = results["metadatas"][0][i]
|
|
81
|
+
fingerprints.append(CodeFingerprint(
|
|
82
|
+
fingerprint_id=results["ids"][0][i],
|
|
83
|
+
repo_id=metadata["repo_id"],
|
|
84
|
+
package=metadata["package"],
|
|
85
|
+
embedding=results["embeddings"][0][i] if results["embeddings"] else [],
|
|
86
|
+
code_snippet=results["documents"][0][i],
|
|
87
|
+
file_path=metadata["file_path"],
|
|
88
|
+
line_number=metadata["line_number"],
|
|
89
|
+
pattern_type=metadata["pattern_type"],
|
|
90
|
+
related_episodes=metadata["related_episodes"].split(",") if metadata["related_episodes"] else []
|
|
91
|
+
))
|
|
92
|
+
return fingerprints
|
|
93
|
+
except Exception as e:
|
|
94
|
+
logger.error("query_similar_failed", error=str(e))
|
|
95
|
+
return []
|
|
96
|
+
|
|
97
|
+
async def delete_by_repo(self, repo_id: str):
|
|
98
|
+
self.collection.delete(where={"repo_id": repo_id})
|
|
99
|
+
logger.info("fingerprints_deleted_for_repo", repo_id=repo_id)
|
memory/team.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import yaml
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from typing import Any, Optional, Dict, List
|
|
4
|
+
from utils.logger import logger
|
|
5
|
+
|
|
6
|
+
TEAM_MEMORY_PATH = Path(".patchwork/team_memory.yaml")
|
|
7
|
+
|
|
8
|
+
DEFAULT_TEAM_MEMORY = {
|
|
9
|
+
"policies": {
|
|
10
|
+
"auto_commit": False,
|
|
11
|
+
"require_tests": True,
|
|
12
|
+
"allowed_upgrades": "minor",
|
|
13
|
+
"forbidden_packages": ["insecure-pkg", "deprecated-lib"]
|
|
14
|
+
},
|
|
15
|
+
"cursed_pairs": [
|
|
16
|
+
{"pkg1": "pydantic-v1", "pkg2": "pydantic-v2", "note": "Strict conflict in same environment"}
|
|
17
|
+
]
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
class TeamMemory:
|
|
21
|
+
def __init__(self, path: Path = TEAM_MEMORY_PATH):
|
|
22
|
+
self.path = path
|
|
23
|
+
self._ensure_exists()
|
|
24
|
+
|
|
25
|
+
def _ensure_exists(self):
|
|
26
|
+
if not self.path.exists():
|
|
27
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
28
|
+
with open(self.path, "w") as f:
|
|
29
|
+
yaml.dump(DEFAULT_TEAM_MEMORY, f)
|
|
30
|
+
logger.info("default_team_memory_created")
|
|
31
|
+
|
|
32
|
+
def _load(self) -> Dict[str, Any]:
|
|
33
|
+
with open(self.path, "r") as f:
|
|
34
|
+
return yaml.safe_load(f)
|
|
35
|
+
|
|
36
|
+
def _save(self, data: Dict[str, Any]):
|
|
37
|
+
with open(self.path, "w") as f:
|
|
38
|
+
yaml.dump(data, f)
|
|
39
|
+
|
|
40
|
+
async def get_policy(self, key: str) -> Any:
|
|
41
|
+
data = self._load()
|
|
42
|
+
return data.get("policies", {}).get(key)
|
|
43
|
+
|
|
44
|
+
async def update_policy(self, key: str, value: Any):
|
|
45
|
+
data = self._load()
|
|
46
|
+
if "policies" not in data:
|
|
47
|
+
data["policies"] = {}
|
|
48
|
+
data["policies"][key] = value
|
|
49
|
+
self._save(data)
|
|
50
|
+
logger.info("policy_updated", key=key, value=value)
|
|
51
|
+
|
|
52
|
+
async def is_forbidden(self, package: str) -> bool:
|
|
53
|
+
forbidden = await self.get_policy("forbidden_packages") or []
|
|
54
|
+
return package in forbidden
|
|
55
|
+
|
|
56
|
+
async def get_known_cursed_pair(self, pkg1: str, pkg2: str) -> Optional[Dict[str, str]]:
|
|
57
|
+
data = self._load()
|
|
58
|
+
cursed_pairs = data.get("cursed_pairs", [])
|
|
59
|
+
for pair in cursed_pairs:
|
|
60
|
+
if (pair["pkg1"] == pkg1 and pair["pkg2"] == pkg2) or \
|
|
61
|
+
(pair["pkg1"] == pkg2 and pair["pkg2"] == pkg1):
|
|
62
|
+
return pair
|
|
63
|
+
return None
|
models/__init__.py
ADDED
|
File without changes
|
models/config.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import re
|
|
3
|
+
import yaml
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Optional, Any, Dict
|
|
6
|
+
from .schemas import PatchworkConfig
|
|
7
|
+
|
|
8
|
+
DEFAULT_CONFIG: Dict[str, Any] = {
|
|
9
|
+
"llm": {
|
|
10
|
+
"provider": "anthropic",
|
|
11
|
+
"model": "claude-3-opus-20240229",
|
|
12
|
+
"temperature": 0.0,
|
|
13
|
+
"max_tokens": 4096,
|
|
14
|
+
"api_key": "${ANTHROPIC_API_KEY}"
|
|
15
|
+
},
|
|
16
|
+
"embeddings": {
|
|
17
|
+
"provider": "anthropic",
|
|
18
|
+
"model": "claude-3-opus-20240229",
|
|
19
|
+
"api_key": "${ANTHROPIC_API_KEY}"
|
|
20
|
+
},
|
|
21
|
+
"budget": {
|
|
22
|
+
"max_daily_spend": 10.0,
|
|
23
|
+
"alert_at": 5.0
|
|
24
|
+
},
|
|
25
|
+
"team": {
|
|
26
|
+
"policies": {
|
|
27
|
+
"auto_commit": False,
|
|
28
|
+
"require_tests": True
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
class ConfigManager:
|
|
34
|
+
_instance: Optional[PatchworkConfig] = None
|
|
35
|
+
|
|
36
|
+
@classmethod
|
|
37
|
+
def interpolate_env_vars(cls, data: Any) -> Any:
|
|
38
|
+
if isinstance(data, dict):
|
|
39
|
+
return {k: cls.interpolate_env_vars(v) for k, v in data.items()}
|
|
40
|
+
elif isinstance(data, list):
|
|
41
|
+
return [cls.interpolate_env_vars(i) for i in data]
|
|
42
|
+
elif isinstance(data, str):
|
|
43
|
+
# Match ${VAR_NAME} or ${VAR_NAME:-default}
|
|
44
|
+
pattern = re.compile(r'\$\{(?P<var>[^}:]+)(?::-(?P<default>[^}]*))?\}')
|
|
45
|
+
|
|
46
|
+
def replace(match):
|
|
47
|
+
var = match.group('var')
|
|
48
|
+
default = match.group('default')
|
|
49
|
+
return os.environ.get(var, default if default is not None else match.group(0))
|
|
50
|
+
|
|
51
|
+
return pattern.sub(replace, data)
|
|
52
|
+
return data
|
|
53
|
+
|
|
54
|
+
@classmethod
|
|
55
|
+
def get_config(cls, config_path: str = ".patchwork/config.yaml") -> PatchworkConfig:
|
|
56
|
+
if cls._instance is None:
|
|
57
|
+
path = Path(config_path)
|
|
58
|
+
if not path.exists():
|
|
59
|
+
raw_data = DEFAULT_CONFIG
|
|
60
|
+
else:
|
|
61
|
+
with open(path, "r") as f:
|
|
62
|
+
raw_data = yaml.safe_load(f)
|
|
63
|
+
|
|
64
|
+
interpolated_data = cls.interpolate_env_vars(raw_data)
|
|
65
|
+
cls._instance = PatchworkConfig(**interpolated_data)
|
|
66
|
+
|
|
67
|
+
return cls._instance
|
|
68
|
+
|
|
69
|
+
def get_config() -> PatchworkConfig:
|
|
70
|
+
return ConfigManager.get_config()
|
models/schemas.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import uuid
|
|
2
|
+
from datetime import datetime
|
|
3
|
+
from typing import Literal, Optional, List, Dict, Union, Any
|
|
4
|
+
from pydantic import BaseModel, Field, ConfigDict
|
|
5
|
+
|
|
6
|
+
class BreakingChange(BaseModel):
|
|
7
|
+
version_from: str
|
|
8
|
+
version_to: str
|
|
9
|
+
change_type: Literal["removed", "renamed", "signature", "behavioral"]
|
|
10
|
+
old_pattern: str
|
|
11
|
+
new_pattern: Optional[str] = None
|
|
12
|
+
migration_note: str
|
|
13
|
+
|
|
14
|
+
class UpgradeRecipe(BaseModel):
|
|
15
|
+
recipe_id: str
|
|
16
|
+
created_from_episode: str
|
|
17
|
+
confidence: float # 0.0 to 1.0
|
|
18
|
+
trigger: Dict[str, Any] # package, version range, detected patterns
|
|
19
|
+
steps: List[Dict[str, Any]] # action, tool, script, description
|
|
20
|
+
known_pitfalls: List[str]
|
|
21
|
+
verified_repos: List[str]
|
|
22
|
+
|
|
23
|
+
class UpgradeEpisode(BaseModel):
|
|
24
|
+
episode_id: str = Field(default_factory=lambda: str(uuid.uuid4()))
|
|
25
|
+
timestamp: datetime = Field(default_factory=datetime.utcnow)
|
|
26
|
+
repo_id: str
|
|
27
|
+
package: str
|
|
28
|
+
from_version: str
|
|
29
|
+
to_version: str
|
|
30
|
+
outcome: Literal["success", "failed_tests", "build_broken", "aborted"]
|
|
31
|
+
thought_trace: List[str]
|
|
32
|
+
tool_calls: List[Dict[str, Any]]
|
|
33
|
+
errors_encountered: List[str]
|
|
34
|
+
resolution: Optional[str] = None
|
|
35
|
+
time_spent_minutes: int
|
|
36
|
+
context_tags: List[str]
|
|
37
|
+
|
|
38
|
+
class CodeFingerprint(BaseModel):
|
|
39
|
+
fingerprint_id: str
|
|
40
|
+
repo_id: str
|
|
41
|
+
package: str
|
|
42
|
+
embedding: List[float]
|
|
43
|
+
code_snippet: str
|
|
44
|
+
file_path: str
|
|
45
|
+
line_number: int
|
|
46
|
+
pattern_type: Literal["import", "function_call", "class_extension", "type_reference", "config"]
|
|
47
|
+
related_episodes: List[str]
|
|
48
|
+
|
|
49
|
+
class PatchworkConfig(BaseModel):
|
|
50
|
+
model_config = ConfigDict(protected_namespaces=())
|
|
51
|
+
llm: Dict[str, Any] # provider, model, api_key, base_url, temperature, max_tokens
|
|
52
|
+
embeddings: Dict[str, Any] # provider, model, api_key, base_url
|
|
53
|
+
budget: Optional[Dict[str, Any]] = None # max_daily_spend, alert_at
|
|
54
|
+
team: Optional[Dict[str, Any]] = None # policies
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: patchwork-ai
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Autonomous dependency upgrade agent
|
|
5
|
+
License-File: LICENSE
|
|
6
|
+
Author: coder11125
|
|
7
|
+
Author-email: shashvathpuppala.21cmis0265@meruinternationalschool.com
|
|
8
|
+
Requires-Python: >=3.11,<4.0
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
14
|
+
Requires-Dist: aiosqlite (>=0.19.0,<0.20.0)
|
|
15
|
+
Requires-Dist: anthropic (>=0.21.0,<0.22.0)
|
|
16
|
+
Requires-Dist: chromadb (>=0.4.24,<0.5.0)
|
|
17
|
+
Requires-Dist: gitpython (>=3.1.42,<4.0.0)
|
|
18
|
+
Requires-Dist: groq (>=0.4.2,<0.5.0)
|
|
19
|
+
Requires-Dist: httpx (>=0.27.0,<0.28.0)
|
|
20
|
+
Requires-Dist: instructor (>=1.2.0,<2.0.0)
|
|
21
|
+
Requires-Dist: mistralai (>=0.1.8,<0.2.0)
|
|
22
|
+
Requires-Dist: pydantic (>=2.6.0,<3.0.0)
|
|
23
|
+
Requires-Dist: pydantic-settings (>=2.1.0,<3.0.0)
|
|
24
|
+
Requires-Dist: pyyaml (>=6.0.1,<7.0.0)
|
|
25
|
+
Requires-Dist: rich (>=13.7.1,<14.0.0)
|
|
26
|
+
Requires-Dist: sqlalchemy[asyncio] (>=2.0.27,<3.0.0)
|
|
27
|
+
Requires-Dist: structlog (>=24.1.0,<25.0.0)
|
|
28
|
+
Requires-Dist: tree-sitter (>=0.21.0,<0.22.0)
|
|
29
|
+
Requires-Dist: tree-sitter-python (>=0.21.0,<0.22.0)
|
|
30
|
+
Requires-Dist: typer[all] (>=0.9.0,<0.10.0)
|
|
31
|
+
Description-Content-Type: text/markdown
|
|
32
|
+
|
|
33
|
+
# Patchwork โ Autonomous Dependency Surgery
|
|
34
|
+
|
|
35
|
+
Patchwork is an autonomous agent designed to handle the "grunt work" of dependency upgrades. It doesn't just bump versions; it analyzes changelogs, identifies breaking changes, applies codemods, and verifies the upgrade with your test suite.
|
|
36
|
+
|
|
37
|
+
## ๐ Pitch
|
|
38
|
+
Stop wasting hours on `v1 โ v2` migrations. Let Patchwork perform the surgery.
|
|
39
|
+
|
|
40
|
+
## ๐ Architecture
|
|
41
|
+
```text
|
|
42
|
+
+-----------+ +-------------------+
|
|
43
|
+
| CLI/Web | <---> | Agent Loop |
|
|
44
|
+
+-----------+ | (Plan-Patch-Test) |
|
|
45
|
+
^ +---------+---------+
|
|
46
|
+
| |
|
|
47
|
+
+-------+-------+ +-------+-------+
|
|
48
|
+
| Episodic Mem | | Semantic Mem |
|
|
49
|
+
| (SQLite/FTS) | | (Chroma/Code) |
|
|
50
|
+
+---------------+ +---------------+
|
|
51
|
+
| |
|
|
52
|
+
+-------+-------+ +-------+-------+
|
|
53
|
+
| LLM Gateway | <---> | Tools |
|
|
54
|
+
| (Anthropic,..) | | (AST, Git,..) |
|
|
55
|
+
+---------------+ +---------------+
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## ๐ฆ Installation
|
|
59
|
+
```bash
|
|
60
|
+
pip install patchwork-cli
|
|
61
|
+
# or via poetry
|
|
62
|
+
poetry add patchwork
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
## โก Quickstart
|
|
66
|
+
1. **Initialize Config**:
|
|
67
|
+
```bash
|
|
68
|
+
patchwork init
|
|
69
|
+
```
|
|
70
|
+
2. **Scan for Upgrades**:
|
|
71
|
+
```bash
|
|
72
|
+
patchwork detect
|
|
73
|
+
```
|
|
74
|
+
3. **Perform Surgery**:
|
|
75
|
+
```bash
|
|
76
|
+
patchwork upgrade pydantic --provider anthropic
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
## โ๏ธ Configuration
|
|
80
|
+
| Provider | Status | Config Key | Env Var |
|
|
81
|
+
|---|---|---|---|
|
|
82
|
+
| **Anthropic** | Native | `anthropic` | `ANTHROPIC_API_KEY` |
|
|
83
|
+
| **Mistral** | Native | `mistral` | `MISTRAL_API_KEY` |
|
|
84
|
+
| **Groq** | Native | `groq` | `GROQ_API_KEY` |
|
|
85
|
+
| **Ollama** | Native | `ollama` | - |
|
|
86
|
+
|
|
87
|
+
## ๐ฐ Cost Transparency
|
|
88
|
+
Patchwork logs every token. Run `patchwork cost` to see exactly what you're spending on your migrations.
|
|
89
|
+
|
|
90
|
+
## ๐ License
|
|
91
|
+
MIT
|
|
92
|
+
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
agents/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
+
agents/base.py,sha256=RSZPoJSqE-10vFdVjRgECAICRwbxUk6IVz8tXatio9U,3832
|
|
3
|
+
agents/dependency_surgeon.py,sha256=sJYxE3bxrN0tsQTPKgCvOyenL2hKB1oupALfS1UisRY,3645
|
|
4
|
+
llm/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
5
|
+
llm/cost_tracker.py,sha256=LRAISjwdy5aPUcjPk8PXsXTl1a8NT72BM_oLMApu2Js,549
|
|
6
|
+
llm/gateway.py,sha256=mNPInLoMLL46Wn0HjPN7mrHH43kG-p8iqJEF_vMnvng,1547
|
|
7
|
+
llm/instructor_patch.py,sha256=XoJdztk3E8elamfBOzs6NPm34aXsXg3oH6In4ppNcS0,495
|
|
8
|
+
llm/providers.py,sha256=MZhTWeoFBCG7d1KsUvETKLvwhu_44IfOSmuGyEv4HHQ,2069
|
|
9
|
+
memory/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
10
|
+
memory/episodic.py,sha256=-eC_IGItyhR7puv9LoikzJjQOcBHzpLMsIShms-c8ts,5759
|
|
11
|
+
memory/procedural.py,sha256=-u_xQ2K3VwKWtdx-l3xFQiRyfCKQ9r8Z45yiKoeYdxk,2670
|
|
12
|
+
memory/semantic.py,sha256=ad-pJbRB9P5IU6KHdxFbW249sQGosKUJvWysXld7dXE,4440
|
|
13
|
+
memory/team.py,sha256=JbViWGgVtfFoIBviKpQDTAotBZgd4UaJIZcPStGJ8EU,2111
|
|
14
|
+
models/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
15
|
+
models/config.py,sha256=UHqfdlazHIVJ6lSoAypEmhWEob01j-CyqsS2j-We6-w,2171
|
|
16
|
+
models/schemas.py,sha256=78YSipqCRvyHWc5bZj6O7DwnO21gBJWgokVaaa2epLM,1907
|
|
17
|
+
tools/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
18
|
+
tools/ast_analyzer.py,sha256=oGct-2RS13rB-G7x7Uq9kpJ7REgStLf9ey1CJ22LLrs,1137
|
|
19
|
+
tools/changelog_reader.py,sha256=mSeXIDOnpKuy_qSM2pnZuehK8ABpRDvK85gsmv_AhyA,1215
|
|
20
|
+
tools/git_miner.py,sha256=VpWM6oZ7CrrC9Osi17xGit8Jo2fsSV4ay7tDvB2AkkY,1317
|
|
21
|
+
tools/github_app.py,sha256=Q4f6loN8rACgI4TqJxdCFWvU-8qvoQP8-rbBKi6BoMY,2669
|
|
22
|
+
tools/package_detect.py,sha256=Q2C2v86Vz314OlKFjB6PUl_dNPNfAoIhCIZUjGysqbA,1629
|
|
23
|
+
tools/sandbox.py,sha256=FpIWaaBDZt2YY4Qe70fa3bY86-N76FR7njY0BE5IoxI,1285
|
|
24
|
+
utils/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
25
|
+
utils/logger.py,sha256=CLJOecoqF9BhmtGDK2GgIRGQXDn0qtk_tdfwcvjUsn0,707
|
|
26
|
+
patchwork_ai-0.1.0.dist-info/METADATA,sha256=TTwLyqM-jfagVWrKPjN5NLLkbLcbl75DH8diMRfKNMY,3118
|
|
27
|
+
patchwork_ai-0.1.0.dist-info/WHEEL,sha256=EGEvSphFYqXKs23-kQBeyNoJP1nrT8ZJKQoi5p5DYL8,88
|
|
28
|
+
patchwork_ai-0.1.0.dist-info/entry_points.txt,sha256=_XX-tG9EpvXxs9eI90SKullrpWdnLAZ0Vy8m2MOQlWE,37
|
|
29
|
+
patchwork_ai-0.1.0.dist-info/licenses/LICENSE,sha256=q0biO4MJw239pgf-k81-tqTpyuy7CkGPP7MHljVP0tM,1067
|
|
30
|
+
patchwork_ai-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 coder11125
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
tools/__init__.py
ADDED
|
File without changes
|
tools/ast_analyzer.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import re
|
|
3
|
+
from typing import List
|
|
4
|
+
from models.schemas import CodeFingerprint
|
|
5
|
+
from utils.logger import logger
|
|
6
|
+
|
|
7
|
+
class ASTAnalyzer:
|
|
8
|
+
async def fingerprint_file(self, file_path: str, package: str) -> List[CodeFingerprint]:
|
|
9
|
+
# Mocked implementation of tree-sitter scanning
|
|
10
|
+
return []
|
|
11
|
+
|
|
12
|
+
async def find_call_sites(self, repo_path: str, package: str, function_name: str) -> List[Dict[str, Any]]:
|
|
13
|
+
# Recursive grep/search for function calls
|
|
14
|
+
return []
|
|
15
|
+
|
|
16
|
+
async def apply_codemod(self, file_path: str, old_pattern: str, new_pattern: str) -> bool:
|
|
17
|
+
try:
|
|
18
|
+
with open(file_path, "r") as f:
|
|
19
|
+
content = f.read()
|
|
20
|
+
|
|
21
|
+
# Simple regex replacement for MVP
|
|
22
|
+
new_content = re.sub(re.escape(old_pattern), new_pattern, content)
|
|
23
|
+
|
|
24
|
+
if new_content != content:
|
|
25
|
+
with open(file_path, "w") as f:
|
|
26
|
+
f.write(new_content)
|
|
27
|
+
return True
|
|
28
|
+
return False
|
|
29
|
+
except Exception as e:
|
|
30
|
+
logger.error("codemod_failed", file=file_path, error=str(e))
|
|
31
|
+
return False
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import httpx
|
|
2
|
+
from typing import List
|
|
3
|
+
from models.schemas import BreakingChange
|
|
4
|
+
from llm.gateway import LLMGateway
|
|
5
|
+
from utils.logger import logger
|
|
6
|
+
|
|
7
|
+
class ChangelogReader:
|
|
8
|
+
def __init__(self, llm_gateway: LLMGateway):
|
|
9
|
+
self.llm_gateway = llm_gateway
|
|
10
|
+
|
|
11
|
+
async def fetch_changelog(self, package: str, ecosystem: str) -> str:
|
|
12
|
+
# Mocking GitHub API call for releases/changelog
|
|
13
|
+
return f"Changelog for {package} ({ecosystem}):\n- Removed old_api\n- Renamed x to y"
|
|
14
|
+
|
|
15
|
+
async def extract_breaking_changes(self, changelog_text: str, from_ver: str, to_ver: str) -> List[BreakingChange]:
|
|
16
|
+
prompt = f"Extract breaking changes from this changelog between {from_ver} and {to_ver}:\n\n{changelog_text}"
|
|
17
|
+
messages = [{"role": "user", "content": prompt}]
|
|
18
|
+
# In reality, we'd use the LLMGateway to return a List[BreakingChange]
|
|
19
|
+
# For now, return a mock result
|
|
20
|
+
return [
|
|
21
|
+
BreakingChange(
|
|
22
|
+
version_from=from_ver,
|
|
23
|
+
version_to=to_ver,
|
|
24
|
+
change_type="removed",
|
|
25
|
+
old_pattern="old_api()",
|
|
26
|
+
new_pattern="new_api()",
|
|
27
|
+
migration_note="Use new_api instead"
|
|
28
|
+
)
|
|
29
|
+
]
|
tools/git_miner.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
from typing import List, Dict, Any
|
|
3
|
+
from utils.logger import logger
|
|
4
|
+
|
|
5
|
+
class GitMiner:
|
|
6
|
+
async def _run_git(self, args: List[str], cwd: str) -> str:
|
|
7
|
+
process = await asyncio.create_subprocess_exec(
|
|
8
|
+
"git", *args, cwd=cwd,
|
|
9
|
+
stdout=asyncio.subprocess.PIPE,
|
|
10
|
+
stderr=asyncio.subprocess.PIPE
|
|
11
|
+
)
|
|
12
|
+
stdout, _ = await process.communicate()
|
|
13
|
+
return stdout.decode()
|
|
14
|
+
|
|
15
|
+
async def get_line_history(self, repo_path: str, file_path: str, line_number: int) -> List[Dict[str, Any]]:
|
|
16
|
+
args = ["log", "-L", f"{line_number},{line_number}:{file_path}", "--format=%H|%an|%ai|%s"]
|
|
17
|
+
output = await self._run_git(args, repo_path)
|
|
18
|
+
commits = []
|
|
19
|
+
for line in output.strip().split("\n"):
|
|
20
|
+
if "|" in line:
|
|
21
|
+
h, a, d, m = line.split("|", 3)
|
|
22
|
+
commits.append({"hash": h, "author": a, "date": d, "message": m})
|
|
23
|
+
return commits
|
|
24
|
+
|
|
25
|
+
async def get_commit_context(self, repo_path: str, commit_hash: str) -> Dict[str, Any]:
|
|
26
|
+
output = await self._run_git(["show", "-s", "--format=%an|%s", commit_hash], repo_path)
|
|
27
|
+
if "|" in output:
|
|
28
|
+
author, message = output.strip().split("|", 1)
|
|
29
|
+
return {"author": author, "message": message, "hash": commit_hash}
|
|
30
|
+
return {}
|
tools/github_app.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from github import Github
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Dict, Any
|
|
5
|
+
from utils.logger import logger
|
|
6
|
+
|
|
7
|
+
class GitHubIntegration:
|
|
8
|
+
def __init__(self, token: str = None):
|
|
9
|
+
self.token = token or os.environ.get("GITHUB_TOKEN")
|
|
10
|
+
if not self.token:
|
|
11
|
+
logger.warning("github_token_missing", message="GITHUB_TOKEN env var not set")
|
|
12
|
+
self.gh = None
|
|
13
|
+
else:
|
|
14
|
+
self.gh = Github(self.token)
|
|
15
|
+
|
|
16
|
+
async def create_upgrade_pr(
|
|
17
|
+
self,
|
|
18
|
+
repo_path: str,
|
|
19
|
+
package: str,
|
|
20
|
+
from_ver: str,
|
|
21
|
+
to_ver: str,
|
|
22
|
+
changes_summary: str,
|
|
23
|
+
test_results: str,
|
|
24
|
+
danger_level: str = "green",
|
|
25
|
+
recipe_id: str = None
|
|
26
|
+
) -> str:
|
|
27
|
+
if not self.gh:
|
|
28
|
+
raise ValueError("GitHub client not initialized. Check GITHUB_TOKEN.")
|
|
29
|
+
|
|
30
|
+
# In a real tool, we'd use GitPython to push branches
|
|
31
|
+
# For this implementation, we'll assume the branch is pushed or mock the PR creation
|
|
32
|
+
repo_name = self._get_repo_name(repo_path)
|
|
33
|
+
repo = self.gh.get_repo(repo_name)
|
|
34
|
+
|
|
35
|
+
branch_name = f"patchwork/upgrade-{package}-{to_ver}"
|
|
36
|
+
base_branch = repo.default_branch
|
|
37
|
+
|
|
38
|
+
pr_title = f"chore(deps): upgrade {package} {from_ver} โ {to_ver}"
|
|
39
|
+
|
|
40
|
+
pr_body = f"""
|
|
41
|
+
## ๐ค Patchwork Upgrade Report
|
|
42
|
+
|
|
43
|
+
### ๐ฆ Package: `{package}`
|
|
44
|
+
- **Upgrade:** `{from_ver}` โ `{to_ver}`
|
|
45
|
+
- **Danger Level:** {self._get_danger_emoji(danger_level)} `{danger_level.upper()}`
|
|
46
|
+
|
|
47
|
+
### ๐ Analysis
|
|
48
|
+
{changes_summary}
|
|
49
|
+
|
|
50
|
+
### ๐ Breaking Changes Handled
|
|
51
|
+
- Automated codemods applied for identified breaking changes.
|
|
52
|
+
|
|
53
|
+
### ๐งช Test Results
|
|
54
|
+
```
|
|
55
|
+
{test_results}
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
---
|
|
59
|
+
*Generated by [Patchwork](https://github.com/patchwork-agent/patchwork)*
|
|
60
|
+
{"*Recipe used: " + recipe_id if recipe_id else ""}
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
try:
|
|
64
|
+
# Note: This requires the branch to exist on remote
|
|
65
|
+
pr = repo.create_pull(
|
|
66
|
+
title=pr_title,
|
|
67
|
+
body=pr_body,
|
|
68
|
+
head=branch_name,
|
|
69
|
+
base=base_branch
|
|
70
|
+
)
|
|
71
|
+
pr.add_to_labels("dependencies", "patchwork")
|
|
72
|
+
logger.info("pr_created", url=pr.html_url)
|
|
73
|
+
return pr.html_url
|
|
74
|
+
except Exception as e:
|
|
75
|
+
logger.error("pr_creation_failed", error=str(e))
|
|
76
|
+
return f"Failed to create PR: {str(e)}"
|
|
77
|
+
|
|
78
|
+
def _get_repo_name(self, repo_path: str) -> str:
|
|
79
|
+
# Simplistic extraction from git config or path
|
|
80
|
+
return "owner/repo" # Mock
|
|
81
|
+
|
|
82
|
+
def _get_danger_emoji(self, level: str) -> str:
|
|
83
|
+
return {"green": "๐ข", "yellow": "๐ก", "red": "๐ด"}.get(level.lower(), "โช")
|
tools/package_detect.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import httpx
|
|
3
|
+
from typing import List, Dict, Any, Optional
|
|
4
|
+
from utils.logger import logger
|
|
5
|
+
|
|
6
|
+
class PackageDetect:
|
|
7
|
+
async def _run_cmd(self, cmd: List[str], cwd: str) -> str:
|
|
8
|
+
process = await asyncio.create_subprocess_exec(
|
|
9
|
+
*cmd, cwd=cwd,
|
|
10
|
+
stdout=asyncio.subprocess.PIPE,
|
|
11
|
+
stderr=asyncio.subprocess.PIPE
|
|
12
|
+
)
|
|
13
|
+
stdout, _ = await process.communicate()
|
|
14
|
+
return stdout.decode()
|
|
15
|
+
|
|
16
|
+
async def scan_npm(self, repo_path: str) -> List[Dict[str, Any]]:
|
|
17
|
+
output = await self._run_cmd(["npm", "outdated", "--json"], repo_path)
|
|
18
|
+
if not output: return []
|
|
19
|
+
data = json.loads(output)
|
|
20
|
+
return [{"package": k, **v} for k, v in data.items()]
|
|
21
|
+
|
|
22
|
+
async def scan_pip(self, repo_path: str) -> List[Dict[str, Any]]:
|
|
23
|
+
output = await self._run_cmd(["pip", "list", "--outdated", "--format=json"], repo_path)
|
|
24
|
+
if not output: return []
|
|
25
|
+
return json.loads(output)
|
|
26
|
+
|
|
27
|
+
async def scan_poetry(self, repo_path: str) -> List[Dict[str, Any]]:
|
|
28
|
+
# Simulation since poetry output varies
|
|
29
|
+
return []
|
|
30
|
+
|
|
31
|
+
async def check_cve(self, package: str, version: str) -> List[Dict[str, Any]]:
|
|
32
|
+
async with httpx.AsyncClient() as client:
|
|
33
|
+
url = "https://api.osv.dev/v1/query"
|
|
34
|
+
payload = {
|
|
35
|
+
"version": version,
|
|
36
|
+
"package": {"name": package}
|
|
37
|
+
}
|
|
38
|
+
try:
|
|
39
|
+
resp = await client.post(url, json=payload)
|
|
40
|
+
resp.raise_for_status()
|
|
41
|
+
return resp.json().get("vulns", [])
|
|
42
|
+
except Exception:
|
|
43
|
+
return []
|
tools/sandbox.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import json
|
|
3
|
+
import os
|
|
4
|
+
import shutil
|
|
5
|
+
import tempfile
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import List, Dict, Any, Tuple
|
|
8
|
+
from utils.logger import logger
|
|
9
|
+
|
|
10
|
+
class Sandbox:
|
|
11
|
+
async def run_tests(self, repo_path: str, test_command: str = "pytest") -> Tuple[bool, str, int]:
|
|
12
|
+
start_time = asyncio.get_event_loop().time()
|
|
13
|
+
|
|
14
|
+
# In a real MVP, we'd use git worktree to avoid messing with the current checkout
|
|
15
|
+
# But for this implementation, we'll simulate running in the provided path
|
|
16
|
+
logger.info("running_tests", repo=repo_path, command=test_command)
|
|
17
|
+
|
|
18
|
+
try:
|
|
19
|
+
process = await asyncio.create_subprocess_shell(
|
|
20
|
+
test_command,
|
|
21
|
+
cwd=repo_path,
|
|
22
|
+
stdout=asyncio.subprocess.PIPE,
|
|
23
|
+
stderr=asyncio.subprocess.PIPE
|
|
24
|
+
)
|
|
25
|
+
stdout, stderr = await process.communicate()
|
|
26
|
+
|
|
27
|
+
duration = int(asyncio.get_event_loop().time() - start_time)
|
|
28
|
+
output = stdout.decode() + "\n" + stderr.decode()
|
|
29
|
+
passed = process.returncode == 0
|
|
30
|
+
|
|
31
|
+
return passed, output, duration
|
|
32
|
+
except Exception as e:
|
|
33
|
+
logger.error("test_execution_failed", error=str(e))
|
|
34
|
+
return False, str(e), 0
|
utils/__init__.py
ADDED
|
File without changes
|
utils/logger.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import structlog
|
|
2
|
+
import logging
|
|
3
|
+
import sys
|
|
4
|
+
|
|
5
|
+
def setup_logger():
|
|
6
|
+
structlog.configure(
|
|
7
|
+
processors=[
|
|
8
|
+
structlog.contextvars.merge_contextvars,
|
|
9
|
+
structlog.processors.add_log_level,
|
|
10
|
+
structlog.processors.StackInfoRenderer(),
|
|
11
|
+
structlog.dev.set_exc_info,
|
|
12
|
+
structlog.processors.TimeStamper(fmt="%Y-%m-%d %H:%M:%S", utc=False),
|
|
13
|
+
structlog.dev.ConsoleRenderer()
|
|
14
|
+
],
|
|
15
|
+
wrapper_class=structlog.make_filtering_bound_logger(logging.NOTSET),
|
|
16
|
+
context_class=dict,
|
|
17
|
+
logger_factory=structlog.PrintLoggerFactory(),
|
|
18
|
+
cache_logger_on_first_use=True,
|
|
19
|
+
)
|
|
20
|
+
return structlog.get_logger()
|
|
21
|
+
|
|
22
|
+
logger = setup_logger()
|