snodo-provider-github 0.18.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.
- snodo_provider_github/__init__.py +158 -0
- snodo_provider_github-0.18.0.dist-info/METADATA +7 -0
- snodo_provider_github-0.18.0.dist-info/RECORD +6 -0
- snodo_provider_github-0.18.0.dist-info/WHEEL +5 -0
- snodo_provider_github-0.18.0.dist-info/entry_points.txt +2 -0
- snodo_provider_github-0.18.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
"""GitHub code-host provider plugin using PyGithub."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import logging
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
import subprocess
|
|
8
|
+
from typing import Optional
|
|
9
|
+
|
|
10
|
+
from snodo.providers.base import CodeHostProvider, ProviderError
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
try:
|
|
15
|
+
from github import Github
|
|
16
|
+
except ImportError: # pragma: no cover - dependency is declared by this plugin
|
|
17
|
+
Github = None # type: ignore[assignment,misc]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class GitHubProvider(CodeHostProvider):
|
|
21
|
+
"""Code-host provider for GitHub repositories."""
|
|
22
|
+
|
|
23
|
+
def __init__(self, project_root: str = ".", metadata: Optional[dict] = None,
|
|
24
|
+
repo_slug: Optional[str] = None, token: Optional[str] = None):
|
|
25
|
+
metadata = metadata or {}
|
|
26
|
+
# Preserve the original public GitHubProvider(repo_slug, token=...) API.
|
|
27
|
+
if repo_slug is None and "/" in project_root and not os.path.exists(project_root):
|
|
28
|
+
repo_slug = project_root
|
|
29
|
+
repo_slug = repo_slug or metadata.get("github_repo")
|
|
30
|
+
if not repo_slug:
|
|
31
|
+
remote = _get_git_remote(project_root)
|
|
32
|
+
repo_slug = parse_github_slug(remote) if remote else None
|
|
33
|
+
if not repo_slug:
|
|
34
|
+
raise ProviderError(
|
|
35
|
+
"Could not determine GitHub repo. Set metadata.github_repo "
|
|
36
|
+
"in protocol.yml or add a github.com git remote."
|
|
37
|
+
)
|
|
38
|
+
if Github is None:
|
|
39
|
+
raise ProviderError("PyGithub is required. Install snodo-provider-github.")
|
|
40
|
+
self._token = token or metadata.get("github_token") or self._resolve_token()
|
|
41
|
+
if not self._token:
|
|
42
|
+
raise ProviderError(
|
|
43
|
+
"GitHub token required. Set GITHUB_TOKEN env var "
|
|
44
|
+
"or configure via: snodo config set github <token>"
|
|
45
|
+
)
|
|
46
|
+
self._repo_slug = repo_slug
|
|
47
|
+
try:
|
|
48
|
+
self._github = Github(self._token)
|
|
49
|
+
self._repo = self._github.get_repo(repo_slug)
|
|
50
|
+
except Exception as exc:
|
|
51
|
+
raise ProviderError(f"Failed to connect to GitHub repo '{repo_slug}': {exc}") from exc
|
|
52
|
+
|
|
53
|
+
@classmethod
|
|
54
|
+
def claims_remote(cls, url: str) -> bool:
|
|
55
|
+
return _remote_host(url) == "github.com"
|
|
56
|
+
|
|
57
|
+
@staticmethod
|
|
58
|
+
def _resolve_token() -> Optional[str]:
|
|
59
|
+
token = os.environ.get("GITHUB_TOKEN")
|
|
60
|
+
if token:
|
|
61
|
+
return token
|
|
62
|
+
try:
|
|
63
|
+
from snodo.config import ConfigManager
|
|
64
|
+
return ConfigManager().get_key("github")
|
|
65
|
+
except Exception:
|
|
66
|
+
return None
|
|
67
|
+
|
|
68
|
+
def create_change_request(self, branch: str, title: str, body: str,
|
|
69
|
+
target_branch: Optional[str] = None) -> str:
|
|
70
|
+
try:
|
|
71
|
+
pr = self._repo.create_pull(title=title, body=body, head=branch,
|
|
72
|
+
base=target_branch or self._repo.default_branch)
|
|
73
|
+
try:
|
|
74
|
+
try:
|
|
75
|
+
label = self._repo.get_label("snodo")
|
|
76
|
+
except Exception:
|
|
77
|
+
label = self._repo.create_label(name="snodo", color="6f42c1")
|
|
78
|
+
pr.add_to_labels(label)
|
|
79
|
+
except Exception:
|
|
80
|
+
logger.warning("Could not apply the snodo label to pull request %s", pr.html_url,
|
|
81
|
+
exc_info=True)
|
|
82
|
+
return pr.html_url
|
|
83
|
+
except Exception as exc:
|
|
84
|
+
raise ProviderError(f"Failed to create PR: {exc}") from exc
|
|
85
|
+
|
|
86
|
+
def read_change_request_diff(self, change_request_id: str) -> str:
|
|
87
|
+
try:
|
|
88
|
+
patches = []
|
|
89
|
+
for file in self._repo.get_pull(int(change_request_id)).get_files():
|
|
90
|
+
header = f"diff --git a/{file.filename} b/{file.filename}"
|
|
91
|
+
patches.append(f"{header}\n{file.patch}" if file.patch else f"{header}\n(binary file)")
|
|
92
|
+
return "\n".join(patches) if patches else "(no changes)"
|
|
93
|
+
except Exception as exc:
|
|
94
|
+
raise ProviderError(f"Failed to read PR diff: {exc}") from exc
|
|
95
|
+
|
|
96
|
+
def post_change_request_comment(self, change_request_id: str, comment: str) -> str:
|
|
97
|
+
try:
|
|
98
|
+
return self._repo.get_pull(int(change_request_id)).create_issue_comment(comment).html_url
|
|
99
|
+
except Exception as exc:
|
|
100
|
+
raise ProviderError(f"Failed to post comment: {exc}") from exc
|
|
101
|
+
|
|
102
|
+
def approve_change_request(self, change_request_id: str) -> str:
|
|
103
|
+
try:
|
|
104
|
+
self._repo.get_pull(int(change_request_id)).create_review(event="APPROVE")
|
|
105
|
+
return f"Change request {change_request_id} approved"
|
|
106
|
+
except Exception as exc:
|
|
107
|
+
raise ProviderError(f"Failed to approve PR: {exc}") from exc
|
|
108
|
+
|
|
109
|
+
def request_change_request_changes(self, change_request_id: str, reason: str) -> str:
|
|
110
|
+
try:
|
|
111
|
+
self._repo.get_pull(int(change_request_id)).create_review(body=reason, event="REQUEST_CHANGES")
|
|
112
|
+
return f"Change request {change_request_id}: changes requested"
|
|
113
|
+
except Exception as exc:
|
|
114
|
+
raise ProviderError(f"Failed to reject PR: {exc}") from exc
|
|
115
|
+
|
|
116
|
+
def merge_change_request(self, change_request_id: str) -> str:
|
|
117
|
+
try:
|
|
118
|
+
result = self._repo.get_pull(int(change_request_id)).merge()
|
|
119
|
+
return f"Change request {change_request_id} merged: {result.sha[:8]}"
|
|
120
|
+
except Exception as exc:
|
|
121
|
+
raise ProviderError(f"Failed to merge PR: {exc}") from exc
|
|
122
|
+
|
|
123
|
+
def read_change_request_discussion(self, change_request_id: str) -> str:
|
|
124
|
+
try:
|
|
125
|
+
pr = self._repo.get_pull(int(change_request_id))
|
|
126
|
+
return json.dumps({
|
|
127
|
+
"title": pr.title,
|
|
128
|
+
"comments": [{"author": c.user.login, "body": c.body or ""} for c in pr.get_issue_comments()],
|
|
129
|
+
"reviews": [{"author": r.user.login, "body": r.body or "", "state": r.state}
|
|
130
|
+
for r in pr.get_reviews()],
|
|
131
|
+
})
|
|
132
|
+
except Exception as exc:
|
|
133
|
+
raise ProviderError(f"Failed to read PR comments: {exc}") from exc
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def parse_github_slug(url: str) -> Optional[str]:
|
|
137
|
+
match = re.search(r"github\.com[:/]([^/]+/[^/]+?)(?:\.git)?$", url)
|
|
138
|
+
return match.group(1) if match else None
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _remote_host(url: str) -> str:
|
|
142
|
+
from urllib.parse import urlparse
|
|
143
|
+
if "://" in url:
|
|
144
|
+
return (urlparse(url).hostname or "").lower().rstrip(".")
|
|
145
|
+
match = re.match(r"(?:[^@]+@)?([^:]+):", url)
|
|
146
|
+
if match:
|
|
147
|
+
return match.group(1).lower().rstrip(".")
|
|
148
|
+
return ""
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _get_git_remote(project_root: str) -> Optional[str]:
|
|
152
|
+
try:
|
|
153
|
+
result = subprocess.run(["git", "remote", "get-url", "origin"], # noqa: S607 - git resolved from PATH by design; argv list, no shell, controlled flags
|
|
154
|
+
cwd=project_root,
|
|
155
|
+
capture_output=True, text=True, check=True)
|
|
156
|
+
return result.stdout.strip()
|
|
157
|
+
except (subprocess.CalledProcessError, FileNotFoundError):
|
|
158
|
+
return None
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
snodo_provider_github/__init__.py,sha256=rTVw27JgrDPYKoAs1tlJyvJWWstSouFgffM_zEn9UfE,6835
|
|
2
|
+
snodo_provider_github-0.18.0.dist-info/METADATA,sha256=82liVOP2Q_hSld2LiMGPUUtodnczD4GUOzQHk856HQE,208
|
|
3
|
+
snodo_provider_github-0.18.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
4
|
+
snodo_provider_github-0.18.0.dist-info/entry_points.txt,sha256=_WgHFBEMI2PUNO-ZO7d_agl8G5IxO52kNfVhF4Op2GY,64
|
|
5
|
+
snodo_provider_github-0.18.0.dist-info/top_level.txt,sha256=QSAP6gjld-NJ8pQn8SmhUicdqnMFJFCDEBFFFLqy0nM,22
|
|
6
|
+
snodo_provider_github-0.18.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
snodo_provider_github
|