snodo-tools 0.7.2__tar.gz

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.
@@ -0,0 +1,12 @@
1
+ Metadata-Version: 2.4
2
+ Name: snodo-tools
3
+ Version: 0.7.2
4
+ Summary: Snodo tools — git, shell, workspace primitives + providers
5
+ Author-email: The Snodo Authors <noreply@snodo.dev>
6
+ License: Apache-2.0
7
+ Project-URL: Homepage, https://snodo.dev
8
+ Project-URL: Repository, https://github.com/snodo-dev/snodo
9
+ Requires-Python: >=3.12
10
+ Requires-Dist: snodo-core==0.7.2
11
+ Requires-Dist: GitPython>=3.1.0
12
+ Requires-Dist: PyGithub>=2.0.0
@@ -0,0 +1,27 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61.0"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "snodo-tools"
7
+ version = "0.7.2"
8
+ description = "Snodo tools — git, shell, workspace primitives + providers"
9
+ requires-python = ">=3.12"
10
+ license = { text = "Apache-2.0" }
11
+ authors = [{ name = "The Snodo Authors", email = "noreply@snodo.dev" }]
12
+ dependencies = [
13
+ "snodo-core==0.7.2",
14
+ "GitPython>=3.1.0",
15
+ "PyGithub>=2.0.0",
16
+ ]
17
+
18
+ [project.urls]
19
+ Homepage = "https://snodo.dev"
20
+ Repository = "https://github.com/snodo-dev/snodo"
21
+
22
+ [tool.setuptools.packages.find]
23
+ where = ["src"]
24
+ namespaces = true
25
+
26
+ [tool.setuptools.package-data]
27
+ snodo = ["py.typed"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,16 @@
1
+ """Code host provider plugins.
2
+
3
+ FILE: snodo/providers/__init__.py
4
+ """
5
+
6
+ from snodo.providers.base import CodeHostProvider, ProviderError
7
+ from snodo.providers.local import LocalProvider
8
+ from snodo.providers.registry import detect_provider, list_providers
9
+
10
+ __all__ = [
11
+ "CodeHostProvider",
12
+ "ProviderError",
13
+ "LocalProvider",
14
+ "detect_provider",
15
+ "list_providers",
16
+ ]
@@ -0,0 +1,106 @@
1
+ """Code host provider abstract base class.
2
+
3
+ FILE: snodo/providers/base.py
4
+
5
+ Defines the interface that all code host providers must implement.
6
+ Providers decouple PR operations from any specific platform (GitHub, GitLab, etc).
7
+ """
8
+
9
+ from abc import ABC, abstractmethod
10
+
11
+
12
+ class ProviderError(Exception):
13
+ """Raised when a provider operation fails."""
14
+
15
+
16
+ class CodeHostProvider(ABC):
17
+ """Abstract base class for code host providers.
18
+
19
+ Each provider implements PR operations for a specific platform.
20
+ PrMCP delegates to a concrete provider instance.
21
+ """
22
+
23
+ @abstractmethod
24
+ def create_pr(self, branch: str, title: str, body: str) -> str:
25
+ """Create a pull request.
26
+
27
+ Args:
28
+ branch: Source branch name
29
+ title: PR title
30
+ body: PR description body
31
+
32
+ Returns:
33
+ PR URL or identifier string
34
+ """
35
+
36
+ @abstractmethod
37
+ def read_pr_diff(self, pr_number: int) -> str:
38
+ """Read the diff of a pull request.
39
+
40
+ Args:
41
+ pr_number: PR number
42
+
43
+ Returns:
44
+ Diff output as string
45
+ """
46
+
47
+ @abstractmethod
48
+ def post_review_comment(self, pr_number: int, comment: str) -> str:
49
+ """Post a comment on a pull request.
50
+
51
+ Args:
52
+ pr_number: PR number
53
+ comment: Comment text
54
+
55
+ Returns:
56
+ Confirmation string
57
+ """
58
+
59
+ @abstractmethod
60
+ def approve_pr(self, pr_number: int) -> str:
61
+ """Approve a pull request.
62
+
63
+ Args:
64
+ pr_number: PR number
65
+
66
+ Returns:
67
+ Confirmation string
68
+ """
69
+
70
+ @abstractmethod
71
+ def reject_pr(self, pr_number: int, reason: str) -> str:
72
+ """Request changes on a pull request.
73
+
74
+ Args:
75
+ pr_number: PR number
76
+ reason: Reason for rejection
77
+
78
+ Returns:
79
+ Confirmation string
80
+ """
81
+
82
+ @abstractmethod
83
+ def merge_pr(self, pr_number: int) -> str:
84
+ """Merge a pull request.
85
+
86
+ Args:
87
+ pr_number: PR number
88
+
89
+ Returns:
90
+ Confirmation string
91
+ """
92
+
93
+ @abstractmethod
94
+ def read_pr_comments(self, pr_number: int) -> str:
95
+ """Read comments and reviews on a pull request.
96
+
97
+ Returns JSON string with keys: title, comments, reviews.
98
+ Each comment has: author.login, body
99
+ Each review has: author.login, body, state
100
+
101
+ Args:
102
+ pr_number: PR number
103
+
104
+ Returns:
105
+ JSON string
106
+ """
@@ -0,0 +1,158 @@
1
+ """GitHub code host provider using PyGithub.
2
+
3
+ FILE: snodo/providers/github.py
4
+
5
+ Implements CodeHostProvider for GitHub using the PyGithub library.
6
+ No gh CLI required.
7
+ """
8
+
9
+ import json
10
+ import os
11
+ from typing import Optional
12
+
13
+ from snodo.providers.base import CodeHostProvider, ProviderError
14
+
15
+ try:
16
+ from github import Github
17
+ except ImportError:
18
+ Github = None # type: ignore[assignment,misc]
19
+
20
+
21
+ class GitHubProvider(CodeHostProvider):
22
+ """GitHub provider using PyGithub.
23
+
24
+ Authentication via GITHUB_TOKEN env var or snodo config.
25
+ Repo slug detected from git remote URL or passed explicitly.
26
+ """
27
+
28
+ def __init__(self, repo_slug: str, token: Optional[str] = None):
29
+ """Initialize GitHub provider.
30
+
31
+ Args:
32
+ repo_slug: Repository in "owner/repo" format
33
+ token: GitHub API token. If None, resolved from environment.
34
+
35
+ Raises:
36
+ ProviderError: If PyGithub is not installed or auth fails
37
+ """
38
+ if Github is None:
39
+ raise ProviderError(
40
+ "PyGithub is required for GitHub provider. "
41
+ "Install it with: pip install PyGithub"
42
+ )
43
+
44
+ self._token = token or self._resolve_token()
45
+ if not self._token:
46
+ raise ProviderError(
47
+ "GitHub token required. Set GITHUB_TOKEN env var "
48
+ "or configure via: snodo config set github <token>"
49
+ )
50
+
51
+ self._repo_slug = repo_slug
52
+
53
+ try:
54
+ self._github = Github(self._token)
55
+ self._repo = self._github.get_repo(repo_slug)
56
+ except Exception as e:
57
+ raise ProviderError(f"Failed to connect to GitHub repo '{repo_slug}': {e}") from e
58
+
59
+ @staticmethod
60
+ def _resolve_token() -> Optional[str]:
61
+ """Resolve GitHub token from env or snodo config."""
62
+ token = os.environ.get("GITHUB_TOKEN")
63
+ if token:
64
+ return token
65
+ try:
66
+ from snodo.config import ConfigManager
67
+ return ConfigManager().get_key("github")
68
+ except Exception:
69
+ return None
70
+
71
+ def create_pr(self, branch: str, title: str, body: str) -> str:
72
+ """Create a pull request on GitHub."""
73
+ try:
74
+ pr = self._repo.create_pull(
75
+ title=title, body=body, head=branch, base="main",
76
+ )
77
+ return pr.html_url
78
+ except Exception as e:
79
+ raise ProviderError(f"Failed to create PR: {e}") from e
80
+
81
+ def read_pr_diff(self, pr_number: int) -> str:
82
+ """Read PR diff by concatenating file patches."""
83
+ try:
84
+ pr = self._repo.get_pull(pr_number)
85
+ files = pr.get_files()
86
+ patches = []
87
+ for f in files:
88
+ header = f"diff --git a/{f.filename} b/{f.filename}"
89
+ if f.patch:
90
+ patches.append(f"{header}\n{f.patch}")
91
+ else:
92
+ patches.append(f"{header}\n(binary file)")
93
+ return "\n".join(patches) if patches else "(no changes)"
94
+ except Exception as e:
95
+ raise ProviderError(f"Failed to read PR diff: {e}") from e
96
+
97
+ def post_review_comment(self, pr_number: int, comment: str) -> str:
98
+ """Post a comment on a GitHub PR."""
99
+ try:
100
+ pr = self._repo.get_pull(pr_number)
101
+ c = pr.create_issue_comment(comment)
102
+ return c.html_url
103
+ except Exception as e:
104
+ raise ProviderError(f"Failed to post comment: {e}") from e
105
+
106
+ def approve_pr(self, pr_number: int) -> str:
107
+ """Approve a GitHub PR."""
108
+ try:
109
+ pr = self._repo.get_pull(pr_number)
110
+ pr.create_review(event="APPROVE")
111
+ return f"PR #{pr_number} approved"
112
+ except Exception as e:
113
+ raise ProviderError(f"Failed to approve PR: {e}") from e
114
+
115
+ def reject_pr(self, pr_number: int, reason: str) -> str:
116
+ """Request changes on a GitHub PR."""
117
+ try:
118
+ pr = self._repo.get_pull(pr_number)
119
+ pr.create_review(body=reason, event="REQUEST_CHANGES")
120
+ return f"PR #{pr_number} changes requested"
121
+ except Exception as e:
122
+ raise ProviderError(f"Failed to reject PR: {e}") from e
123
+
124
+ def merge_pr(self, pr_number: int) -> str:
125
+ """Merge a GitHub PR."""
126
+ try:
127
+ pr = self._repo.get_pull(pr_number)
128
+ result = pr.merge()
129
+ return f"PR #{pr_number} merged: {result.sha[:8]}"
130
+ except Exception as e:
131
+ raise ProviderError(f"Failed to merge PR: {e}") from e
132
+
133
+ def read_pr_comments(self, pr_number: int) -> str:
134
+ """Read PR comments and reviews as JSON.
135
+
136
+ Returns JSON compatible with _format_pr_comments in run_cmd.py.
137
+ """
138
+ try:
139
+ pr = self._repo.get_pull(pr_number)
140
+ comments = [
141
+ {"author": {"login": c.user.login}, "body": c.body or ""}
142
+ for c in pr.get_issue_comments()
143
+ ]
144
+ reviews = [
145
+ {
146
+ "author": {"login": r.user.login},
147
+ "body": r.body or "",
148
+ "state": r.state,
149
+ }
150
+ for r in pr.get_reviews()
151
+ ]
152
+ return json.dumps({
153
+ "title": pr.title,
154
+ "comments": comments,
155
+ "reviews": reviews,
156
+ })
157
+ except Exception as e:
158
+ raise ProviderError(f"Failed to read PR comments: {e}") from e
@@ -0,0 +1,54 @@
1
+ """Local (no-op) code host provider.
2
+
3
+ FILE: snodo/providers/local.py
4
+
5
+ Provider for repositories without a remote code host.
6
+ PR operations return stub responses. Useful for solo/offline workflows.
7
+ """
8
+
9
+
10
+ from snodo.providers.base import CodeHostProvider, ProviderError
11
+
12
+
13
+ class LocalProvider(CodeHostProvider):
14
+ """No-op provider for local-only repositories.
15
+
16
+ All mutating operations return stub responses.
17
+ Read operations return empty/placeholder data.
18
+ """
19
+
20
+ def create_pr(self, branch: str, title: str, body: str) -> str:
21
+ raise ProviderError(
22
+ "Cannot create PR: no remote code host configured. "
23
+ "Push to a remote and configure a provider."
24
+ )
25
+
26
+ def read_pr_diff(self, pr_number: int) -> str:
27
+ raise ProviderError(
28
+ f"Cannot read PR #{pr_number}: no remote code host configured."
29
+ )
30
+
31
+ def post_review_comment(self, pr_number: int, comment: str) -> str:
32
+ raise ProviderError(
33
+ "Cannot post comment: no remote code host configured."
34
+ )
35
+
36
+ def approve_pr(self, pr_number: int) -> str:
37
+ raise ProviderError(
38
+ "Cannot approve PR: no remote code host configured."
39
+ )
40
+
41
+ def reject_pr(self, pr_number: int, reason: str) -> str:
42
+ raise ProviderError(
43
+ "Cannot reject PR: no remote code host configured."
44
+ )
45
+
46
+ def merge_pr(self, pr_number: int) -> str:
47
+ raise ProviderError(
48
+ "Cannot merge PR: no remote code host configured."
49
+ )
50
+
51
+ def read_pr_comments(self, pr_number: int) -> str:
52
+ raise ProviderError(
53
+ f"Cannot read PR #{pr_number} comments: no remote code host configured."
54
+ )
@@ -0,0 +1,222 @@
1
+ """Provider registry: detection, resolution, and plugin discovery.
2
+
3
+ FILE: snodo/providers/registry.py
4
+
5
+ Resolves which CodeHostProvider to use for a project:
6
+ 1. Explicit provider in protocol.metadata["provider"]
7
+ 2. Auto-detect from git remote URL
8
+ 3. Setuptools entry points (snodo.providers group)
9
+ 4. Fallback to LocalProvider
10
+ """
11
+
12
+ import logging
13
+ import re
14
+ import subprocess
15
+ from typing import Dict, Optional, Type
16
+
17
+ from snodo.providers.base import CodeHostProvider, ProviderError
18
+ from snodo.providers.local import LocalProvider
19
+
20
+ _logger = logging.getLogger(__name__)
21
+
22
+
23
+ # Built-in provider name -> class mapping (lazy imports to avoid hard deps)
24
+ _BUILTIN_PROVIDERS = {"github", "local"}
25
+
26
+
27
+ def detect_provider(
28
+ project_root: str,
29
+ protocol_metadata: Optional[Dict] = None,
30
+ ) -> CodeHostProvider:
31
+ """Detect and create the appropriate code host provider.
32
+
33
+ Resolution order:
34
+ 1. Explicit "provider" key in protocol metadata
35
+ 2. Auto-detect from git remote URL
36
+ 3. Fallback to LocalProvider
37
+
38
+ Args:
39
+ project_root: Absolute path to project root
40
+ protocol_metadata: Optional protocol.metadata dict
41
+
42
+ Returns:
43
+ Configured CodeHostProvider instance
44
+ """
45
+ metadata = protocol_metadata or {}
46
+
47
+ # 1. Explicit provider in metadata
48
+ provider_name = metadata.get("provider")
49
+ if provider_name:
50
+ return _create_provider(provider_name, project_root, metadata)
51
+
52
+ # 2. Auto-detect from git remote
53
+ remote_url = _get_git_remote(project_root)
54
+ if remote_url:
55
+ detected = _detect_from_url(remote_url)
56
+ if detected:
57
+ return _create_provider(detected, project_root, metadata)
58
+
59
+ # 3. Fallback
60
+ return LocalProvider()
61
+
62
+
63
+ def _get_git_remote(project_root: str) -> Optional[str]:
64
+ """Get the origin remote URL from git.
65
+
66
+ Returns:
67
+ Remote URL string, or None if not available
68
+ """
69
+ try:
70
+ result = subprocess.run(
71
+ ["git", "remote", "get-url", "origin"], # noqa: S607 - git resolved from PATH by design; argv list, no shell, fully controlled flags
72
+ cwd=project_root,
73
+ capture_output=True,
74
+ text=True,
75
+ check=True,
76
+ )
77
+ return result.stdout.strip()
78
+ except (subprocess.CalledProcessError, FileNotFoundError):
79
+ return None
80
+
81
+
82
+ def _detect_from_url(url: str) -> Optional[str]:
83
+ """Detect provider name from a git remote URL.
84
+
85
+ Args:
86
+ url: Git remote URL (SSH or HTTPS)
87
+
88
+ Returns:
89
+ Provider name string, or None if no match
90
+ """
91
+ if "github.com" in url:
92
+ return "github"
93
+ # Future: gitlab.com, bitbucket.org, etc.
94
+ return None
95
+
96
+
97
+ def parse_github_slug(url: str) -> Optional[str]:
98
+ """Extract owner/repo slug from a GitHub remote URL.
99
+
100
+ Handles:
101
+ - git@github.com:owner/repo.git
102
+ - https://github.com/owner/repo.git
103
+ - https://github.com/owner/repo
104
+
105
+ Args:
106
+ url: Git remote URL
107
+
108
+ Returns:
109
+ "owner/repo" string, or None if not a GitHub URL
110
+ """
111
+ match = re.search(r"github\.com[:/]([^/]+/[^/]+?)(?:\.git)?$", url)
112
+ if match:
113
+ return match.group(1)
114
+ return None
115
+
116
+
117
+ def _create_provider(
118
+ name: str,
119
+ project_root: str,
120
+ metadata: Optional[Dict] = None,
121
+ ) -> CodeHostProvider:
122
+ """Create a provider instance by name.
123
+
124
+ Checks built-in providers first, then entry points.
125
+
126
+ Args:
127
+ name: Provider name (e.g., "github", "local")
128
+ project_root: Project root directory
129
+ metadata: Protocol metadata for provider config
130
+
131
+ Returns:
132
+ CodeHostProvider instance
133
+
134
+ Raises:
135
+ ProviderError: If provider not found or initialization fails
136
+ """
137
+ metadata = metadata or {}
138
+
139
+ if name == "local":
140
+ return LocalProvider()
141
+
142
+ if name == "github":
143
+ return _create_github(project_root, metadata)
144
+
145
+ # Check entry points for third-party providers
146
+ provider_cls = _load_entry_point(name)
147
+ if provider_cls:
148
+ try:
149
+ return provider_cls(project_root=project_root, metadata=metadata) # type: ignore[call-arg]
150
+ except TypeError:
151
+ # Provider may not accept these kwargs
152
+ return provider_cls()
153
+
154
+ raise ProviderError(
155
+ f"Unknown provider: '{name}'. "
156
+ f"Built-in providers: {', '.join(sorted(_BUILTIN_PROVIDERS))}. "
157
+ f"Install a plugin or check your protocol metadata."
158
+ )
159
+
160
+
161
+ def _create_github(project_root: str, metadata: Dict) -> CodeHostProvider:
162
+ """Create a GitHubProvider, resolving repo slug from git remote."""
163
+ from snodo.providers.github import GitHubProvider
164
+
165
+ # Repo slug from metadata or git remote
166
+ repo_slug = metadata.get("github_repo")
167
+ if not repo_slug:
168
+ remote_url = _get_git_remote(project_root)
169
+ if remote_url:
170
+ repo_slug = parse_github_slug(remote_url)
171
+ if not repo_slug:
172
+ raise ProviderError(
173
+ "Could not determine GitHub repo. Set metadata.github_repo "
174
+ "in protocol.yml or add a github.com git remote."
175
+ )
176
+
177
+ token = metadata.get("github_token")
178
+ return GitHubProvider(repo_slug=repo_slug, token=token)
179
+
180
+
181
+ def _load_entry_point(name: str) -> Optional[Type[CodeHostProvider]]:
182
+ """Load a provider class from setuptools entry points.
183
+
184
+ Looks in the 'snodo.providers' entry point group.
185
+
186
+ Args:
187
+ name: Entry point name
188
+
189
+ Returns:
190
+ Provider class, or None if not found
191
+ """
192
+ try:
193
+ from importlib.metadata import entry_points
194
+ eps = entry_points(group="snodo.providers")
195
+ for ep in eps:
196
+ if ep.name == name:
197
+ return ep.load()
198
+ except Exception as e:
199
+ _logger.debug("Failed to load provider entry point %s: %s", name, e)
200
+ return None
201
+
202
+
203
+ def list_providers() -> Dict[str, str]:
204
+ """List all available providers (built-in + plugins).
205
+
206
+ Returns:
207
+ Dict of provider_name -> description
208
+ """
209
+ providers = {
210
+ "github": "GitHub (PyGithub)",
211
+ "local": "Local only (no remote)",
212
+ }
213
+
214
+ try:
215
+ from importlib.metadata import entry_points
216
+ eps = entry_points(group="snodo.providers")
217
+ for ep in eps:
218
+ providers[ep.name] = f"Plugin: {ep.value}"
219
+ except Exception as e:
220
+ _logger.debug("Failed to discover provider entry points: %s", e)
221
+
222
+ return providers
@@ -0,0 +1 @@
1
+ # Snodo primitives / backing tools