gitinject 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.
- gitinject/__init__.py +1 -0
- gitinject/__main__.py +5 -0
- gitinject/analyzer.py +67 -0
- gitinject/attacks/__init__.py +27 -0
- gitinject/attacks/autoinject.py +165 -0
- gitinject/attacks/base.py +33 -0
- gitinject/attacks/static.py +25 -0
- gitinject/cli.py +768 -0
- gitinject/data/research/scenarios/claude_skills_injection.md +96 -0
- gitinject/data/research/scenarios/cline_issue_body_injection.md +93 -0
- gitinject/data/research/scenarios/codex_agents_md_injection.md +109 -0
- gitinject/data/research/scenarios/dos_request_flood.md +133 -0
- gitinject/data/research/scenarios/dropped/ci_log_injection_workflow_poisoning.md +138 -0
- gitinject/data/research/scenarios/dropped/claude_md_instructions_injection.md +158 -0
- gitinject/data/research/scenarios/dropped/supply_chain_token_pivot.md +101 -0
- gitinject/data/research/scenarios/gemini_api_key_exfiltration.md +68 -0
- gitinject/data/research/scenarios/gemini_api_key_exfiltration_replication.md +0 -0
- gitinject/data/research/scenarios/gemini_md_instructions_injection.md +131 -0
- gitinject/data/research/scenarios/nsfw_api_key_block.md +114 -0
- gitinject/data/research/scenarios/pr_token_exfiltration_via_git_config.md +117 -0
- gitinject/data/research/scenarios/supply_chain_malicious_code.md +135 -0
- gitinject/evaluators.py +169 -0
- gitinject/evidence.py +72 -0
- gitinject/gl_runner.py +163 -0
- gitinject/resources.py +30 -0
- gitinject/run_context.py +72 -0
- gitinject/run_record.py +113 -0
- gitinject/runner.py +1024 -0
- gitinject/scanner/__init__.py +22 -0
- gitinject/scanner/baselines/__init__.py +0 -0
- gitinject/scanner/baselines/actionlint_runner.py +44 -0
- gitinject/scanner/baselines/zizmor_runner.py +35 -0
- gitinject/scanner/diagnostics.py +174 -0
- gitinject/scanner/hypothesis_generator.py +164 -0
- gitinject/scanner/live_validator.py +284 -0
- gitinject/scanner/llm_ranker.py +120 -0
- gitinject/scanner/memory.py +239 -0
- gitinject/scanner/primitives.py +236 -0
- gitinject/scanner/prompt_extractor.py +134 -0
- gitinject/scanner/recipe_scenario.py +203 -0
- gitinject/scanner/report_generator.py +230 -0
- gitinject/scanner/types.py +179 -0
- gitinject/scenario_base.py +147 -0
- gitinject/scenario_loader.py +77 -0
- gitinject/scenarios/__init__.py +0 -0
- gitinject/scenarios/benign/documentation_out_of_sync/contents/math_utils.py +3 -0
- gitinject/scenarios/benign/documentation_out_of_sync/contents/pydantic/v1/utils.py +21 -0
- gitinject/scenarios/benign/documentation_out_of_sync/scenario.py +57 -0
- gitinject/scenarios/benign/issue_deduplication_utility/scenario.py +76 -0
- gitinject/scenarios/benign/repository_onboarding_qa/contents/README.md +3 -0
- gitinject/scenarios/benign/repository_onboarding_qa/scenario.py +52 -0
- gitinject/scenarios/benign/secrets_false_positive/contents/tests/test_auth.py +14 -0
- gitinject/scenarios/benign/secrets_false_positive/scenario.py +58 -0
- gitinject/scenarios/benign/test_coverage_gap/contents/src/requests/auth_extra.py +33 -0
- gitinject/scenarios/benign/test_coverage_gap/scenario.py +56 -0
- gitinject/scenarios/benign/vulnerability_fix_verification/contents/src/flask/utils.py +15 -0
- gitinject/scenarios/benign/vulnerability_fix_verification/scenario.py +57 -0
- gitinject/scenarios/benign/vulnerable_code_review/contents/examples/tutorial/flaskr/db_utils.py +18 -0
- gitinject/scenarios/benign/vulnerable_code_review/scenario.py +55 -0
- gitinject/scenarios/benign/weekly_activity_summary/scenario.py +83 -0
- gitinject/scenarios/malicious/agents_md_approval_manipulation/contents/AGENTS.md +25 -0
- gitinject/scenarios/malicious/agents_md_approval_manipulation/contents/src/flask/csrf.py +151 -0
- gitinject/scenarios/malicious/agents_md_approval_manipulation/contents/tests/test_csrf.py +71 -0
- gitinject/scenarios/malicious/agents_md_approval_manipulation/scenario.py +121 -0
- gitinject/scenarios/malicious/claude_md_instructions_injection/contents/CLAUDE.md +23 -0
- gitinject/scenarios/malicious/claude_md_instructions_injection/contents/scripts/deploy_check.sh +29 -0
- gitinject/scenarios/malicious/claude_md_instructions_injection/scenario.py +93 -0
- gitinject/scenarios/malicious/cline_issue_body_injection/scenario.py +132 -0
- gitinject/scenarios/malicious/codex_agents_md_injection/contents/AGENTS.md +36 -0
- gitinject/scenarios/malicious/codex_agents_md_injection/contents/scripts/utils.py +12 -0
- gitinject/scenarios/malicious/codex_agents_md_injection/scenario.py +128 -0
- gitinject/scenarios/malicious/dos_request_flood/scenario.py +80 -0
- gitinject/scenarios/malicious/gemini_md_approval_manipulation/contents/src/flask/csrf.py +151 -0
- gitinject/scenarios/malicious/gemini_md_approval_manipulation/contents/tests/test_csrf.py +71 -0
- gitinject/scenarios/malicious/gemini_md_approval_manipulation/scenario.py +121 -0
- gitinject/scenarios/malicious/gemini_md_instructions_injection/contents/scripts/deploy_check.sh +38 -0
- gitinject/scenarios/malicious/gemini_md_instructions_injection/scenario.py +105 -0
- gitinject/scenarios/malicious/issue_onboarding_secret_exfiltration/scenario.py +76 -0
- gitinject/scenarios/malicious/pr_token_exfiltration_via_git_config/contents/scripts/deploy_check.sh +7 -0
- gitinject/scenarios/malicious/pr_token_exfiltration_via_git_config/scenario.py +113 -0
- gitinject/scenarios/malicious/supply_chain_malicious_code/contents/CLAUDE.md +25 -0
- gitinject/scenarios/malicious/supply_chain_malicious_code/contents/src/flask/csrf.py +151 -0
- gitinject/scenarios/malicious/supply_chain_malicious_code/contents/tests/test_csrf.py +71 -0
- gitinject/scenarios/malicious/supply_chain_malicious_code/scenario.py +123 -0
- gitinject/scenarios/malicious/unauthorized_pr_approval/contents/django/utils/crypto.py +13 -0
- gitinject/scenarios/malicious/unauthorized_pr_approval/scenario.py +57 -0
- gitinject/simulator.py +89 -0
- gitinject/utils/__init__.py +0 -0
- gitinject/utils/gh_client.py +628 -0
- gitinject/utils/gl_client.py +132 -0
- gitinject/utils/gl_provisioner.py +83 -0
- gitinject/utils/llm.py +205 -0
- gitinject/utils/provisioner.py +114 -0
- gitinject/utils/scenario_resources.py +33 -0
- gitinject/utils/types.py +49 -0
- gitinject/workflows/__init__.py +0 -0
- gitinject/workflows/claude-ci-auto-fix/contents/.github/workflows/main.yml +107 -0
- gitinject/workflows/claude-ci-auto-fix/metadata.json +10 -0
- gitinject/workflows/claude-general/contents/.github/workflows/main.yml +58 -0
- gitinject/workflows/claude-general/metadata.json +10 -0
- gitinject/workflows/claude-gitlab-mr-review/contents/.gitlab-ci.yml +36 -0
- gitinject/workflows/claude-gitlab-mr-review/metadata.json +11 -0
- gitinject/workflows/claude-issue-deduplication/contents/.github/workflows/main.yml +66 -0
- gitinject/workflows/claude-issue-deduplication/metadata.json +10 -0
- gitinject/workflows/claude-issue-triage/contents/.github/workflows/main.yml +34 -0
- gitinject/workflows/claude-issue-triage/metadata.json +10 -0
- gitinject/workflows/claude-manual-analysis/contents/.github/workflows/main.yml +42 -0
- gitinject/workflows/claude-manual-analysis/metadata.json +10 -0
- gitinject/workflows/claude-pr-review/contents/.github/workflows/main.yml +77 -0
- gitinject/workflows/claude-pr-review/metadata.json +10 -0
- gitinject/workflows/claude-pr-review-authors/contents/.github/workflows/main.yml +48 -0
- gitinject/workflows/claude-pr-review-authors/metadata.json +10 -0
- gitinject/workflows/claude-pr-review-paths/contents/.github/workflows/main.yml +49 -0
- gitinject/workflows/claude-pr-review-paths/metadata.json +10 -0
- gitinject/workflows/claude-test-analysis/contents/.github/workflows/main.yml +114 -0
- gitinject/workflows/claude-test-analysis/metadata.json +10 -0
- gitinject/workflows/cline-assistant/contents/.github/workflows/main.yml +87 -0
- gitinject/workflows/cline-assistant/contents/git-scripts/analyze-issue.sh +43 -0
- gitinject/workflows/cline-assistant/metadata.json +10 -0
- gitinject/workflows/codex-pr-review/contents/.github/workflows/main.yml +73 -0
- gitinject/workflows/codex-pr-review/metadata.json +10 -0
- gitinject/workflows/copilot-ci-doctor/contents/.github/workflows/ci-doctor.yml +1161 -0
- gitinject/workflows/copilot-ci-doctor/metadata.json +10 -0
- gitinject/workflows/copilot-lean-squad/contents/.github/workflows/lean-squad.yml +1313 -0
- gitinject/workflows/copilot-lean-squad/metadata.json +10 -0
- gitinject/workflows/copilot-malicious-scan/contents/.github/workflows/daily-malicious-code-scan.yml +899 -0
- gitinject/workflows/copilot-malicious-scan/metadata.json +10 -0
- gitinject/workflows/copilot-repo-assist/contents/.github/workflows/repo-assist.yml +1503 -0
- gitinject/workflows/copilot-repo-assist/metadata.json +10 -0
- gitinject/workflows/copilot-wiki-writer/contents/.github/workflows/agentic-wiki-writer.yml +1316 -0
- gitinject/workflows/copilot-wiki-writer/metadata.json +10 -0
- gitinject/workflows/gemini-assistant/contents/.github/workflows/gemini-invoke.yml +122 -0
- gitinject/workflows/gemini-assistant/contents/.github/workflows/gemini-plan-execute.yml +130 -0
- gitinject/workflows/gemini-assistant/contents/.github/workflows/gemini-review.yml +118 -0
- gitinject/workflows/gemini-assistant/contents/.github/workflows/gemini-scheduled-triage.yml +220 -0
- gitinject/workflows/gemini-assistant/contents/.github/workflows/gemini-triage.yml +160 -0
- gitinject/workflows/gemini-assistant/contents/.github/workflows/main.yml +220 -0
- gitinject/workflows/gemini-assistant/metadata.json +10 -0
- gitinject/workflows/gemini-assistant-original/AWESOME.md +118 -0
- gitinject/workflows/gemini-assistant-original/CONFIGURATION.md +162 -0
- gitinject/workflows/gemini-assistant-original/README.md +93 -0
- gitinject/workflows/gemini-assistant-original/gemini-assistant/README.md +192 -0
- gitinject/workflows/gemini-assistant-original/gemini-assistant/gemini-invoke.toml +94 -0
- gitinject/workflows/gemini-assistant-original/gemini-assistant/gemini-invoke.yml +131 -0
- gitinject/workflows/gemini-assistant-original/gemini-assistant/gemini-plan-execute.toml +100 -0
- gitinject/workflows/gemini-assistant-original/gemini-assistant/gemini-plan-execute.yml +139 -0
- gitinject/workflows/gemini-assistant-original/gemini-dispatch/README.md +49 -0
- gitinject/workflows/gemini-assistant-original/gemini-dispatch/gemini-dispatch.yml +221 -0
- gitinject/workflows/gemini-assistant-original/issue-triage/README.md +190 -0
- gitinject/workflows/gemini-assistant-original/issue-triage/gemini-scheduled-triage.toml +96 -0
- gitinject/workflows/gemini-assistant-original/issue-triage/gemini-scheduled-triage.yml +223 -0
- gitinject/workflows/gemini-assistant-original/issue-triage/gemini-triage.toml +32 -0
- gitinject/workflows/gemini-assistant-original/issue-triage/gemini-triage.yml +167 -0
- gitinject/workflows/gemini-assistant-original/metadata.json +10 -0
- gitinject/workflows/gemini-assistant-original/pr-review/README.md +337 -0
- gitinject/workflows/gemini-assistant-original/pr-review/gemini-review.toml +176 -0
- gitinject/workflows/gemini-assistant-original/pr-review/gemini-review.yml +119 -0
- gitinject/workflows/opencode-pr-review/contents/.github/workflows/main.yml +28 -0
- gitinject/workflows/opencode-pr-review/metadata.json +10 -0
- gitinject-0.1.0.dist-info/METADATA +128 -0
- gitinject-0.1.0.dist-info/RECORD +164 -0
- gitinject-0.1.0.dist-info/WHEEL +4 -0
- gitinject-0.1.0.dist-info/entry_points.txt +2 -0
- gitinject-0.1.0.dist-info/licenses/LICENSE +202 -0
|
@@ -0,0 +1,628 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import os
|
|
3
|
+
import subprocess
|
|
4
|
+
import time
|
|
5
|
+
import uuid
|
|
6
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
7
|
+
from urllib.parse import urlsplit
|
|
8
|
+
|
|
9
|
+
import click
|
|
10
|
+
import requests
|
|
11
|
+
from github import Github, GithubException, InputGitTreeElement, Repository
|
|
12
|
+
from tenacity import (
|
|
13
|
+
before_sleep_log,
|
|
14
|
+
retry,
|
|
15
|
+
retry_if_exception_type,
|
|
16
|
+
stop_after_attempt,
|
|
17
|
+
wait_exponential,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class RateLimiter:
|
|
24
|
+
"""Simple rate limiter to prevent hitting GitHub secondary rate limits."""
|
|
25
|
+
|
|
26
|
+
def __init__(self):
|
|
27
|
+
max_calls = os.environ.get("GITHUB_MAX_CALLS_PER_MINUTE", 20)
|
|
28
|
+
self.enabled = max_calls is not None
|
|
29
|
+
if self.enabled:
|
|
30
|
+
self.delay = 60.0 / float(max_calls)
|
|
31
|
+
self.last_call = 0.0
|
|
32
|
+
|
|
33
|
+
def wait(self):
|
|
34
|
+
if not self.enabled:
|
|
35
|
+
return
|
|
36
|
+
now = time.time()
|
|
37
|
+
elapsed = now - self.last_call
|
|
38
|
+
if elapsed < self.delay:
|
|
39
|
+
time.sleep(self.delay - elapsed)
|
|
40
|
+
self.last_call = time.time()
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
rate_limiter = RateLimiter()
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class GitHubClient:
|
|
47
|
+
"""A wrapper for PyGitHub to interact with repositories."""
|
|
48
|
+
|
|
49
|
+
def __init__(
|
|
50
|
+
self,
|
|
51
|
+
repo: str = "owner/repo",
|
|
52
|
+
token: Optional[str] = None,
|
|
53
|
+
token_env_var: str = "GITHUB_TOKEN",
|
|
54
|
+
auth_label: str = "GitHub",
|
|
55
|
+
record_event=None,
|
|
56
|
+
actor: str = "owner",
|
|
57
|
+
):
|
|
58
|
+
self.repo_name = repo
|
|
59
|
+
self.token_env_var = token_env_var
|
|
60
|
+
self.auth_label = auth_label
|
|
61
|
+
self.token = token or self._get_token()
|
|
62
|
+
self.gh = Github(self.token)
|
|
63
|
+
self._repo_cache: Optional[Repository.Repository] = None
|
|
64
|
+
self._owned_repo = None
|
|
65
|
+
self._authenticated_login = None
|
|
66
|
+
self.record_event = record_event
|
|
67
|
+
self.actor = actor
|
|
68
|
+
|
|
69
|
+
def _record(self, kind, **data):
|
|
70
|
+
if self.record_event is not None:
|
|
71
|
+
self.record_event(kind, actor=self.actor, **data)
|
|
72
|
+
|
|
73
|
+
def request(self, method: str, endpoint: str, **kwargs) -> requests.Response:
|
|
74
|
+
"""Call any GitHub REST endpoint, preserving the response and HTTP errors."""
|
|
75
|
+
base = self.gh.base_url.rstrip("/")
|
|
76
|
+
url = endpoint if endpoint.startswith("https://") else base + "/" + endpoint.lstrip("/")
|
|
77
|
+
parsed, origin = urlsplit(url), urlsplit(base)
|
|
78
|
+
if parsed.scheme != "https" or parsed.netloc != origin.netloc or parsed.username or parsed.password:
|
|
79
|
+
raise ValueError("Endpoint must belong to the authenticated GitHub API host")
|
|
80
|
+
headers = requests.structures.CaseInsensitiveDict(kwargs.pop("headers", {}))
|
|
81
|
+
if "Authorization" in headers or "auth" in kwargs:
|
|
82
|
+
raise ValueError("Select a GitHub actor instead of overriding its credentials")
|
|
83
|
+
headers["Authorization"] = f"Bearer {self.token}"
|
|
84
|
+
headers.setdefault("Accept", "application/vnd.github+json")
|
|
85
|
+
kwargs.setdefault("timeout", 60)
|
|
86
|
+
kwargs.setdefault("allow_redirects", False)
|
|
87
|
+
request_id = uuid.uuid4().hex
|
|
88
|
+
self._record("api_request", request_id=request_id, method=method.upper(), path=parsed.path)
|
|
89
|
+
try:
|
|
90
|
+
response = requests.request(method, url, headers=headers, **kwargs)
|
|
91
|
+
except requests.RequestException:
|
|
92
|
+
self._record("api_response", request_id=request_id, status=None)
|
|
93
|
+
raise
|
|
94
|
+
self._record(
|
|
95
|
+
"api_response",
|
|
96
|
+
request_id=request_id,
|
|
97
|
+
status=response.status_code,
|
|
98
|
+
github_request_id=response.headers.get("X-GitHub-Request-Id"),
|
|
99
|
+
)
|
|
100
|
+
response.raise_for_status()
|
|
101
|
+
return response
|
|
102
|
+
|
|
103
|
+
def graphql(self, query: str, variables: dict | None = None) -> dict:
|
|
104
|
+
result = self.request("POST", "/graphql", json={"query": query, "variables": variables or {}}).json()
|
|
105
|
+
if result.get("errors"):
|
|
106
|
+
raise RuntimeError(f"GitHub GraphQL errors: {result['errors']}")
|
|
107
|
+
return result["data"]
|
|
108
|
+
|
|
109
|
+
def _claim_repository(self, repo):
|
|
110
|
+
self._owned_repo = (repo.full_name, repo.id)
|
|
111
|
+
self._record("resource", name=repo.full_name, id=repo.id, state="created")
|
|
112
|
+
|
|
113
|
+
def _get_token(self) -> str:
|
|
114
|
+
"""Retrieves GitHub token from environment or gh CLI."""
|
|
115
|
+
token = os.environ.get(self.token_env_var)
|
|
116
|
+
if token:
|
|
117
|
+
return token
|
|
118
|
+
|
|
119
|
+
if self.token_env_var == "GITHUB_TOKEN":
|
|
120
|
+
try:
|
|
121
|
+
result = subprocess.run(["gh", "auth", "token"], capture_output=True, text=True, check=True)
|
|
122
|
+
return result.stdout.strip()
|
|
123
|
+
except (subprocess.CalledProcessError, FileNotFoundError):
|
|
124
|
+
pass
|
|
125
|
+
|
|
126
|
+
click.echo(
|
|
127
|
+
click.style(
|
|
128
|
+
f"Error: {self.auth_label} token not found. Please set {self.token_env_var}"
|
|
129
|
+
+ (" or run 'gh auth login'." if self.token_env_var == "GITHUB_TOKEN" else "."),
|
|
130
|
+
fg="red",
|
|
131
|
+
),
|
|
132
|
+
err=True,
|
|
133
|
+
)
|
|
134
|
+
raise RuntimeError("Missing GitHub authentication")
|
|
135
|
+
|
|
136
|
+
def get_authenticated_user_login(self) -> str:
|
|
137
|
+
"""Returns the login of the authenticated user for this client."""
|
|
138
|
+
if self._authenticated_login is None:
|
|
139
|
+
rate_limiter.wait()
|
|
140
|
+
self._authenticated_login = self.gh.get_user().login
|
|
141
|
+
return self._authenticated_login
|
|
142
|
+
|
|
143
|
+
@property
|
|
144
|
+
def repository(self) -> Repository.Repository:
|
|
145
|
+
"""Lazily loads and returns the Repository object."""
|
|
146
|
+
if self._repo_cache is None:
|
|
147
|
+
rate_limiter.wait()
|
|
148
|
+
self._repo_cache = self.gh.get_repo(self.repo_name)
|
|
149
|
+
return self._repo_cache
|
|
150
|
+
|
|
151
|
+
def get_repo_info(self) -> Optional[Dict[str, Any]]:
|
|
152
|
+
"""Fetches repository information."""
|
|
153
|
+
try:
|
|
154
|
+
repo = self.repository
|
|
155
|
+
return {
|
|
156
|
+
"name": repo.name,
|
|
157
|
+
"owner": {"login": repo.owner.login},
|
|
158
|
+
"defaultBranchRef": {"name": repo.default_branch},
|
|
159
|
+
"isEmpty": repo.size == 0,
|
|
160
|
+
"size": repo.size,
|
|
161
|
+
"default_branch": repo.default_branch,
|
|
162
|
+
}
|
|
163
|
+
except GithubException as e:
|
|
164
|
+
if e.status == 404:
|
|
165
|
+
return None
|
|
166
|
+
raise
|
|
167
|
+
|
|
168
|
+
def get_default_branch(self) -> str:
|
|
169
|
+
"""Returns the name of the default branch."""
|
|
170
|
+
try:
|
|
171
|
+
return self.repository.default_branch
|
|
172
|
+
except GithubException:
|
|
173
|
+
return "main"
|
|
174
|
+
|
|
175
|
+
def create_repo(self, public: bool = True) -> Tuple[bool, str]:
|
|
176
|
+
"""Creates the repository if it doesn't exist."""
|
|
177
|
+
if self._owned_repo is not None:
|
|
178
|
+
return False, "Client already owns a repository awaiting cleanup"
|
|
179
|
+
try:
|
|
180
|
+
name = self.repo_name.split("/")[-1]
|
|
181
|
+
if "/" in self.repo_name:
|
|
182
|
+
owner = self.repo_name.split("/", 1)[0]
|
|
183
|
+
user = self.gh.get_user()
|
|
184
|
+
if user.login.lower() == owner.lower():
|
|
185
|
+
repo = user.create_repo(name, private=not public)
|
|
186
|
+
else:
|
|
187
|
+
org = self.gh.get_organization(owner)
|
|
188
|
+
repo = org.create_repo(name, private=not public)
|
|
189
|
+
else:
|
|
190
|
+
repo = self.gh.get_user().create_repo(name, private=not public)
|
|
191
|
+
|
|
192
|
+
self.repo_name = repo.full_name
|
|
193
|
+
self._repo_cache = repo
|
|
194
|
+
self._claim_repository(repo)
|
|
195
|
+
return True, ""
|
|
196
|
+
except GithubException as e:
|
|
197
|
+
return False, str(e)
|
|
198
|
+
|
|
199
|
+
def fork_repo(self, template_repo_name: str) -> Tuple[bool, str]:
|
|
200
|
+
"""Fork the specified source, refusing collisions and existing owner forks."""
|
|
201
|
+
if self._owned_repo is not None:
|
|
202
|
+
return False, "Client already owns a repository awaiting cleanup"
|
|
203
|
+
try:
|
|
204
|
+
user_login = self.get_authenticated_user_login()
|
|
205
|
+
owner, name = self.repo_name.split("/", 1) if "/" in self.repo_name else (user_login, self.repo_name)
|
|
206
|
+
target = f"{owner}/{name}"
|
|
207
|
+
try:
|
|
208
|
+
self.gh.get_repo(target)
|
|
209
|
+
except GithubException as exc:
|
|
210
|
+
if exc.status != 404:
|
|
211
|
+
raise
|
|
212
|
+
else:
|
|
213
|
+
return False, f"Repository already exists: {target}"
|
|
214
|
+
template = self.gh.get_repo(template_repo_name)
|
|
215
|
+
for existing in template.get_forks():
|
|
216
|
+
if existing.owner.login.lower() == owner.lower():
|
|
217
|
+
return False, f"Owner already has a fork: {existing.full_name}; use a separate owner or repository copy"
|
|
218
|
+
kwargs = {"name": name, "default_branch_only": True}
|
|
219
|
+
if owner.lower() != user_login.lower():
|
|
220
|
+
kwargs["organization"] = owner
|
|
221
|
+
new_repo = template.create_fork(**kwargs)
|
|
222
|
+
if new_repo.full_name.lower() != target.lower():
|
|
223
|
+
return False, f"GitHub returned an unexpected fork: {new_repo.full_name}"
|
|
224
|
+
self.repo_name = new_repo.full_name
|
|
225
|
+
self._repo_cache = new_repo
|
|
226
|
+
self._claim_repository(new_repo)
|
|
227
|
+
return True, ""
|
|
228
|
+
except Exception as exc:
|
|
229
|
+
return False, str(exc)
|
|
230
|
+
|
|
231
|
+
@retry(
|
|
232
|
+
retry=retry_if_exception_type(GithubException),
|
|
233
|
+
stop=stop_after_attempt(15),
|
|
234
|
+
wait=wait_exponential(multiplier=1, min=2, max=10),
|
|
235
|
+
)
|
|
236
|
+
def wait_until_ready(self):
|
|
237
|
+
repo = self.gh.get_repo(self.repo_name)
|
|
238
|
+
repo.get_branch(repo.default_branch)
|
|
239
|
+
self._repo_cache = repo
|
|
240
|
+
|
|
241
|
+
def _delete_repository(self, name: str, repository_id: int) -> None:
|
|
242
|
+
"""Delete a recorded repository identity; an absent repository is already clean."""
|
|
243
|
+
try:
|
|
244
|
+
repo = self.gh.get_repo(name)
|
|
245
|
+
if repo.id != repository_id:
|
|
246
|
+
raise RuntimeError(f"Refusing cleanup: repository ID changed for {name}")
|
|
247
|
+
repo.delete()
|
|
248
|
+
except GithubException as exc:
|
|
249
|
+
if exc.status != 404:
|
|
250
|
+
raise
|
|
251
|
+
|
|
252
|
+
def delete_owned_repo(self) -> Tuple[bool, str]:
|
|
253
|
+
"""Delete only the exact repository created through this client."""
|
|
254
|
+
if self._owned_repo is None:
|
|
255
|
+
return True, ""
|
|
256
|
+
name, repo_id = self._owned_repo
|
|
257
|
+
try:
|
|
258
|
+
self._delete_repository(name, repo_id)
|
|
259
|
+
except (GithubException, RuntimeError) as exc:
|
|
260
|
+
return False, str(exc)
|
|
261
|
+
self._record("resource", name=name, id=repo_id, state="deleted")
|
|
262
|
+
self._owned_repo = None
|
|
263
|
+
self._repo_cache = None
|
|
264
|
+
return True, ""
|
|
265
|
+
|
|
266
|
+
@retry(
|
|
267
|
+
retry=retry_if_exception_type(GithubException),
|
|
268
|
+
stop=stop_after_attempt(10),
|
|
269
|
+
wait=wait_exponential(multiplier=2, min=4, max=30),
|
|
270
|
+
)
|
|
271
|
+
def delete_repo(self) -> Tuple[bool, str]:
|
|
272
|
+
"""Deletes the current repository."""
|
|
273
|
+
try:
|
|
274
|
+
repo = self.repository
|
|
275
|
+
repo.delete()
|
|
276
|
+
if self._owned_repo == (self.repo_name, repo.id):
|
|
277
|
+
self._owned_repo = None
|
|
278
|
+
self._repo_cache = None
|
|
279
|
+
return True, ""
|
|
280
|
+
except GithubException as e:
|
|
281
|
+
if e.status == 404:
|
|
282
|
+
return True, "" # Already gone
|
|
283
|
+
if e.status == 403:
|
|
284
|
+
if "delete_repo" in str(e):
|
|
285
|
+
msg = (
|
|
286
|
+
"\nERROR: Missing 'delete_repo' scope. Please run:\n gh auth refresh -h github.com -s delete_repo\n"
|
|
287
|
+
)
|
|
288
|
+
click.echo(click.style(msg, fg="yellow", bold=True))
|
|
289
|
+
elif "done being created on disk" in str(e):
|
|
290
|
+
# Re-raise to let the retry decorator handle it
|
|
291
|
+
raise e
|
|
292
|
+
return False, str(e)
|
|
293
|
+
|
|
294
|
+
def get_branch_info(self, branch_name: str) -> Optional[Dict[str, Any]]:
|
|
295
|
+
"""Checks if a branch exists and returns its info."""
|
|
296
|
+
try:
|
|
297
|
+
branch = self.repository.get_branch(branch_name)
|
|
298
|
+
return {"name": branch.name, "commit": {"sha": branch.commit.sha}}
|
|
299
|
+
except GithubException as e:
|
|
300
|
+
if e.status == 404:
|
|
301
|
+
return None
|
|
302
|
+
raise
|
|
303
|
+
|
|
304
|
+
def create_branch(self, new_branch: str, source_branch: Optional[str] = None) -> Tuple[bool, str]:
|
|
305
|
+
"""Creates a new branch from a source branch."""
|
|
306
|
+
if not source_branch:
|
|
307
|
+
source_branch = self.get_default_branch()
|
|
308
|
+
|
|
309
|
+
try:
|
|
310
|
+
sb = self.repository.get_branch(source_branch)
|
|
311
|
+
self.repository.create_git_ref(ref=f"refs/heads/{new_branch}", sha=sb.commit.sha)
|
|
312
|
+
return True, ""
|
|
313
|
+
except GithubException as e:
|
|
314
|
+
return False, str(e)
|
|
315
|
+
|
|
316
|
+
def get_file_sha(self, path: str, branch: Optional[str] = None) -> Optional[str]:
|
|
317
|
+
"""Gets the SHA of a file on a specific branch."""
|
|
318
|
+
try:
|
|
319
|
+
kwargs = {}
|
|
320
|
+
if branch:
|
|
321
|
+
kwargs["ref"] = branch
|
|
322
|
+
content = self.repository.get_contents(path, **kwargs)
|
|
323
|
+
if isinstance(content, list):
|
|
324
|
+
return None
|
|
325
|
+
return content.sha
|
|
326
|
+
except GithubException as e:
|
|
327
|
+
if e.status == 404:
|
|
328
|
+
return None
|
|
329
|
+
raise
|
|
330
|
+
|
|
331
|
+
@retry(
|
|
332
|
+
retry=retry_if_exception_type(GithubException),
|
|
333
|
+
wait=wait_exponential(multiplier=1, min=2, max=10),
|
|
334
|
+
stop=stop_after_attempt(5),
|
|
335
|
+
before_sleep=before_sleep_log(logger, logging.INFO),
|
|
336
|
+
)
|
|
337
|
+
def put_file(self, path: str, content: str, message: str, branch: Optional[str] = None) -> Tuple[bool, str]:
|
|
338
|
+
"""Uploads or updates a file using the GitHub API."""
|
|
339
|
+
if not branch:
|
|
340
|
+
branch = self.get_default_branch()
|
|
341
|
+
|
|
342
|
+
try:
|
|
343
|
+
sha = self.get_file_sha(path, branch)
|
|
344
|
+
if sha:
|
|
345
|
+
self.repository.update_file(path, message, content, sha, branch=branch)
|
|
346
|
+
else:
|
|
347
|
+
self.repository.create_file(path, message, content, branch=branch)
|
|
348
|
+
return True, ""
|
|
349
|
+
except GithubException as e:
|
|
350
|
+
if e.status == 409:
|
|
351
|
+
raise e
|
|
352
|
+
return False, str(e)
|
|
353
|
+
|
|
354
|
+
@retry(
|
|
355
|
+
retry=retry_if_exception_type(GithubException),
|
|
356
|
+
wait=wait_exponential(multiplier=1, min=2, max=10),
|
|
357
|
+
stop=stop_after_attempt(5),
|
|
358
|
+
)
|
|
359
|
+
def delete_file(self, path: str, message: str, branch: Optional[str] = None) -> Tuple[bool, str]:
|
|
360
|
+
"""Deletes a file from the repository."""
|
|
361
|
+
if not branch:
|
|
362
|
+
branch = self.get_default_branch()
|
|
363
|
+
|
|
364
|
+
try:
|
|
365
|
+
sha = self.get_file_sha(path, branch)
|
|
366
|
+
if sha:
|
|
367
|
+
self.repository.delete_file(path, message, sha, branch=branch)
|
|
368
|
+
return True, ""
|
|
369
|
+
return True, "File not found" # Already gone
|
|
370
|
+
except GithubException as e:
|
|
371
|
+
return False, str(e)
|
|
372
|
+
|
|
373
|
+
@staticmethod
|
|
374
|
+
def _artifact(item, kind):
|
|
375
|
+
created = item.submitted_at if kind == "review" else item.created_at
|
|
376
|
+
return {
|
|
377
|
+
"id": item.id,
|
|
378
|
+
"kind": kind,
|
|
379
|
+
"body": item.body or "",
|
|
380
|
+
"author": item.user.login if item.user else "",
|
|
381
|
+
"author_type": item.user.type if item.user else "",
|
|
382
|
+
"created_at": created.isoformat() if created else None,
|
|
383
|
+
"state": item.state if kind == "review" else None,
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
def get_pr_details(self, pr_number: int) -> Dict[str, Any]:
|
|
387
|
+
"""Fetch complete, attributed comments and formal reviews; propagate read errors."""
|
|
388
|
+
pr = self.repository.get_pull(pr_number)
|
|
389
|
+
comments = [self._artifact(c, "issue_comment") for c in pr.get_issue_comments()]
|
|
390
|
+
reviews = [self._artifact(r, "review") for r in pr.get_reviews()]
|
|
391
|
+
comments.extend(reviews)
|
|
392
|
+
comments.extend(self._artifact(c, "review_comment") for c in pr.get_review_comments())
|
|
393
|
+
return {
|
|
394
|
+
"title": pr.title,
|
|
395
|
+
"body": pr.body,
|
|
396
|
+
"state": pr.state,
|
|
397
|
+
"comments": [c["body"] for c in comments],
|
|
398
|
+
"comment_details": comments,
|
|
399
|
+
"reviews": reviews,
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
def get_issue_details(self, issue_number: int) -> Dict[str, Any]:
|
|
403
|
+
issue = self.repository.get_issue(issue_number)
|
|
404
|
+
comments = [self._artifact(c, "issue_comment") for c in issue.get_comments()]
|
|
405
|
+
return {
|
|
406
|
+
"title": issue.title,
|
|
407
|
+
"body": issue.body,
|
|
408
|
+
"state": issue.state,
|
|
409
|
+
"comments": [c["body"] for c in comments],
|
|
410
|
+
"comment_details": comments,
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
@retry(
|
|
414
|
+
retry=retry_if_exception_type(GithubException),
|
|
415
|
+
wait=wait_exponential(multiplier=1, min=1, max=5),
|
|
416
|
+
stop=stop_after_attempt(5),
|
|
417
|
+
)
|
|
418
|
+
def list_files(self, branch: Optional[str] = None) -> List[str]:
|
|
419
|
+
"""Lists files in a specific branch."""
|
|
420
|
+
try:
|
|
421
|
+
ref = branch or self.get_default_branch()
|
|
422
|
+
tree = self.repository.get_git_tree(ref, recursive=True)
|
|
423
|
+
return [item.path for item in tree.tree if item.type == "blob"]
|
|
424
|
+
except GithubException as e:
|
|
425
|
+
if e.status == 409:
|
|
426
|
+
return []
|
|
427
|
+
raise
|
|
428
|
+
|
|
429
|
+
def set_secret(self, name: str, value: str) -> Tuple[bool, str]:
|
|
430
|
+
"""Sets a repository secret."""
|
|
431
|
+
try:
|
|
432
|
+
self.repository.create_secret(name, value)
|
|
433
|
+
return True, ""
|
|
434
|
+
except GithubException as e:
|
|
435
|
+
return False, str(e)
|
|
436
|
+
|
|
437
|
+
def set_variable(self, name: str, value: str) -> Tuple[bool, str]:
|
|
438
|
+
"""Sets a repository variable."""
|
|
439
|
+
try:
|
|
440
|
+
self.repository.create_variable(name, value)
|
|
441
|
+
return True, ""
|
|
442
|
+
except GithubException as e:
|
|
443
|
+
return False, str(e)
|
|
444
|
+
|
|
445
|
+
def enable_actions(self) -> Tuple[bool, str]:
|
|
446
|
+
"""Enables GitHub Actions for the repository."""
|
|
447
|
+
try:
|
|
448
|
+
# Using gh CLI for simplicity as PyGitHub doesn't have a direct method for this
|
|
449
|
+
stdout, stderr = self.run_gh(
|
|
450
|
+
["api", "-X", "PUT", f"repos/{self.repo_name}/actions/permissions", "-F", "enabled=true"]
|
|
451
|
+
)
|
|
452
|
+
if stderr and "error" in stderr.lower():
|
|
453
|
+
return False, stderr
|
|
454
|
+
return True, ""
|
|
455
|
+
except Exception as e:
|
|
456
|
+
return False, str(e)
|
|
457
|
+
|
|
458
|
+
def set_fork_pr_approval_policy(self, policy: str = "first_time_contributors_new_to_github") -> Tuple[bool, str]:
|
|
459
|
+
"""Sets the fork PR workflow approval policy for the repository."""
|
|
460
|
+
try:
|
|
461
|
+
stdout, stderr = self.run_gh(
|
|
462
|
+
[
|
|
463
|
+
"api",
|
|
464
|
+
"-X",
|
|
465
|
+
"PUT",
|
|
466
|
+
f"/repos/{self.repo_name}/actions/permissions/fork-pr-contributor-approval",
|
|
467
|
+
"-H",
|
|
468
|
+
"X-GitHub-Api-Version: 2026-03-10",
|
|
469
|
+
"-f",
|
|
470
|
+
f"approval_policy={policy}",
|
|
471
|
+
]
|
|
472
|
+
)
|
|
473
|
+
if stderr and "error" in stderr.lower():
|
|
474
|
+
return False, stderr
|
|
475
|
+
return True, ""
|
|
476
|
+
except Exception as e:
|
|
477
|
+
return False, str(e)
|
|
478
|
+
|
|
479
|
+
def enable_issues(self) -> Tuple[bool, str]:
|
|
480
|
+
"""Enables GitHub Issues for the repository."""
|
|
481
|
+
try:
|
|
482
|
+
# Using gh CLI for simplicity as PyGitHub doesn't have a direct method for this
|
|
483
|
+
stdout, stderr = self.run_gh(["repo", "edit", self.repo_name, "--enable-issues"])
|
|
484
|
+
if stderr:
|
|
485
|
+
return False, stderr
|
|
486
|
+
return True, ""
|
|
487
|
+
except Exception as e:
|
|
488
|
+
return False, str(e)
|
|
489
|
+
|
|
490
|
+
def list_repos(self, limit: int = 100) -> List[Dict[str, str]]:
|
|
491
|
+
"""Lists repositories for the authenticated user."""
|
|
492
|
+
try:
|
|
493
|
+
repos = self.gh.get_user().get_repos()
|
|
494
|
+
return [{"name": r.name, "nameWithOwner": r.full_name} for r in repos[:limit]]
|
|
495
|
+
except GithubException:
|
|
496
|
+
return []
|
|
497
|
+
|
|
498
|
+
def get_workflow_runs(self, workflow_id: str = None) -> List[Any]:
|
|
499
|
+
"""Fetches recent runs of a specific workflow or all workflows."""
|
|
500
|
+
try:
|
|
501
|
+
if workflow_id:
|
|
502
|
+
runs = self.repository.get_workflow(workflow_id).get_runs()
|
|
503
|
+
else:
|
|
504
|
+
runs = self.repository.get_workflow_runs()
|
|
505
|
+
return [r for r in runs[:10]]
|
|
506
|
+
except GithubException:
|
|
507
|
+
return []
|
|
508
|
+
|
|
509
|
+
def batch_sync(
|
|
510
|
+
self,
|
|
511
|
+
additions: Dict[str, str],
|
|
512
|
+
deletions: List[str],
|
|
513
|
+
message: str,
|
|
514
|
+
branch: Optional[str] = None,
|
|
515
|
+
) -> Tuple[bool, str]:
|
|
516
|
+
"""Performs multiple additions and deletions in a single commit."""
|
|
517
|
+
if not branch:
|
|
518
|
+
branch = self.get_default_branch()
|
|
519
|
+
|
|
520
|
+
try:
|
|
521
|
+
repo = self.repository
|
|
522
|
+
|
|
523
|
+
@retry(
|
|
524
|
+
retry=retry_if_exception_type(GithubException),
|
|
525
|
+
stop=stop_after_attempt(10),
|
|
526
|
+
wait=wait_exponential(multiplier=1, min=2, max=10),
|
|
527
|
+
)
|
|
528
|
+
def get_ref():
|
|
529
|
+
rate_limiter.wait()
|
|
530
|
+
return repo.get_git_ref(f"heads/{branch}")
|
|
531
|
+
|
|
532
|
+
ref = get_ref()
|
|
533
|
+
old_commit = repo.get_git_commit(ref.object.sha)
|
|
534
|
+
base_tree_sha = old_commit.tree.sha
|
|
535
|
+
|
|
536
|
+
# We avoid recursive=True because it fails with 502 on large repos like Sentry.
|
|
537
|
+
# Instead, we will build the new tree by navigating only what we need.
|
|
538
|
+
|
|
539
|
+
def get_tree_without_path(current_tree_sha, path_parts):
|
|
540
|
+
"""Recursively navigates trees to 'delete' a path by omitting it from a new tree."""
|
|
541
|
+
tree = repo.get_git_tree(current_tree_sha, recursive=False)
|
|
542
|
+
elements = []
|
|
543
|
+
|
|
544
|
+
target = path_parts[0]
|
|
545
|
+
remaining = path_parts[1:]
|
|
546
|
+
|
|
547
|
+
found_target = False
|
|
548
|
+
for item in tree.tree:
|
|
549
|
+
if item.path == target:
|
|
550
|
+
found_target = True
|
|
551
|
+
if remaining:
|
|
552
|
+
# We need to go deeper into this subtree
|
|
553
|
+
if item.type == "tree":
|
|
554
|
+
new_subtree_sha = get_tree_without_path(item.sha, remaining)
|
|
555
|
+
if new_subtree_sha:
|
|
556
|
+
elements.append(
|
|
557
|
+
InputGitTreeElement(
|
|
558
|
+
path=item.path, mode=item.mode, type=item.type, sha=new_subtree_sha
|
|
559
|
+
)
|
|
560
|
+
)
|
|
561
|
+
# if new_subtree_sha is None, it means the whole subtree was deleted
|
|
562
|
+
else:
|
|
563
|
+
# Target is a file, but we have remaining path parts?
|
|
564
|
+
# This means the path doesn't match the structure. Keep it as is.
|
|
565
|
+
elements.append(
|
|
566
|
+
InputGitTreeElement(path=item.path, mode=item.mode, type=item.type, sha=item.sha)
|
|
567
|
+
)
|
|
568
|
+
|
|
569
|
+
else:
|
|
570
|
+
# This is the item to delete! Just don't add it to elements.
|
|
571
|
+
pass
|
|
572
|
+
else:
|
|
573
|
+
# Not our target, keep it
|
|
574
|
+
elements.append(InputGitTreeElement(path=item.path, mode=item.mode, type=item.type, sha=item.sha))
|
|
575
|
+
|
|
576
|
+
if not found_target:
|
|
577
|
+
# Target not found in this tree, nothing to delete here
|
|
578
|
+
return current_tree_sha
|
|
579
|
+
|
|
580
|
+
if not elements:
|
|
581
|
+
# Entire tree is empty now
|
|
582
|
+
return None
|
|
583
|
+
|
|
584
|
+
new_tree = repo.create_git_tree(elements)
|
|
585
|
+
return new_tree.sha
|
|
586
|
+
|
|
587
|
+
current_tree_sha = base_tree_sha
|
|
588
|
+
for deletion_path in deletions:
|
|
589
|
+
# Normalize path: remove leading/trailing slashes
|
|
590
|
+
clean_path = deletion_path.strip("/")
|
|
591
|
+
if not clean_path:
|
|
592
|
+
continue
|
|
593
|
+
|
|
594
|
+
path_parts = clean_path.split("/")
|
|
595
|
+
current_tree_sha = get_tree_without_path(current_tree_sha, path_parts)
|
|
596
|
+
if not current_tree_sha:
|
|
597
|
+
# We deleted everything? (unlikely but possible)
|
|
598
|
+
# Create an empty tree to avoid errors
|
|
599
|
+
empty_tree = repo.create_git_tree([])
|
|
600
|
+
current_tree_sha = empty_tree.sha
|
|
601
|
+
|
|
602
|
+
# 2. Handle additions using the base_tree merge capability
|
|
603
|
+
elements = []
|
|
604
|
+
for path, content in additions.items():
|
|
605
|
+
elements.append(InputGitTreeElement(path=path, mode="100644", type="blob", content=content))
|
|
606
|
+
|
|
607
|
+
# Create the final tree by merging additions into our modified tree
|
|
608
|
+
final_tree = repo.create_git_tree(elements, base_tree=repo.get_git_tree(current_tree_sha))
|
|
609
|
+
|
|
610
|
+
new_commit = repo.create_git_commit(message, final_tree, [old_commit])
|
|
611
|
+
ref.edit(new_commit.sha)
|
|
612
|
+
|
|
613
|
+
return True, ""
|
|
614
|
+
except Exception as e:
|
|
615
|
+
return False, str(e)
|
|
616
|
+
|
|
617
|
+
def run_gh(self, args, **kwargs):
|
|
618
|
+
"""Legacy compatibility method. SHOULD BE REMOVED eventually."""
|
|
619
|
+
cmd = ["gh"] + args
|
|
620
|
+
if kwargs.get("use_repo"):
|
|
621
|
+
cmd += ["-R", self.repo_name]
|
|
622
|
+
|
|
623
|
+
env = os.environ.copy()
|
|
624
|
+
env["GH_TOKEN"] = self.token
|
|
625
|
+
result = subprocess.run(cmd, capture_output=True, text=True, env=env)
|
|
626
|
+
if result.returncode != 0:
|
|
627
|
+
raise RuntimeError(f"gh exited with status {result.returncode}: {result.stderr.strip()}")
|
|
628
|
+
return result.stdout, result.stderr
|