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 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
@@ -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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: poetry-core 2.4.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ patchwork=cli:app
3
+
@@ -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(), "โšช")
@@ -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()