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.
- snodo_tools-0.7.2/PKG-INFO +12 -0
- snodo_tools-0.7.2/pyproject.toml +27 -0
- snodo_tools-0.7.2/setup.cfg +4 -0
- snodo_tools-0.7.2/src/snodo/providers/__init__.py +16 -0
- snodo_tools-0.7.2/src/snodo/providers/base.py +106 -0
- snodo_tools-0.7.2/src/snodo/providers/github.py +158 -0
- snodo_tools-0.7.2/src/snodo/providers/local.py +54 -0
- snodo_tools-0.7.2/src/snodo/providers/registry.py +222 -0
- snodo_tools-0.7.2/src/snodo/tools/__init__.py +1 -0
- snodo_tools-0.7.2/src/snodo/tools/git.py +394 -0
- snodo_tools-0.7.2/src/snodo/tools/shell.py +414 -0
- snodo_tools-0.7.2/src/snodo/tools/workspace.py +380 -0
- snodo_tools-0.7.2/src/snodo_tools.egg-info/PKG-INFO +12 -0
- snodo_tools-0.7.2/src/snodo_tools.egg-info/SOURCES.txt +15 -0
- snodo_tools-0.7.2/src/snodo_tools.egg-info/dependency_links.txt +1 -0
- snodo_tools-0.7.2/src/snodo_tools.egg-info/requires.txt +3 -0
- snodo_tools-0.7.2/src/snodo_tools.egg-info/top_level.txt +1 -0
|
@@ -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,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
|