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.
Files changed (164) hide show
  1. gitinject/__init__.py +1 -0
  2. gitinject/__main__.py +5 -0
  3. gitinject/analyzer.py +67 -0
  4. gitinject/attacks/__init__.py +27 -0
  5. gitinject/attacks/autoinject.py +165 -0
  6. gitinject/attacks/base.py +33 -0
  7. gitinject/attacks/static.py +25 -0
  8. gitinject/cli.py +768 -0
  9. gitinject/data/research/scenarios/claude_skills_injection.md +96 -0
  10. gitinject/data/research/scenarios/cline_issue_body_injection.md +93 -0
  11. gitinject/data/research/scenarios/codex_agents_md_injection.md +109 -0
  12. gitinject/data/research/scenarios/dos_request_flood.md +133 -0
  13. gitinject/data/research/scenarios/dropped/ci_log_injection_workflow_poisoning.md +138 -0
  14. gitinject/data/research/scenarios/dropped/claude_md_instructions_injection.md +158 -0
  15. gitinject/data/research/scenarios/dropped/supply_chain_token_pivot.md +101 -0
  16. gitinject/data/research/scenarios/gemini_api_key_exfiltration.md +68 -0
  17. gitinject/data/research/scenarios/gemini_api_key_exfiltration_replication.md +0 -0
  18. gitinject/data/research/scenarios/gemini_md_instructions_injection.md +131 -0
  19. gitinject/data/research/scenarios/nsfw_api_key_block.md +114 -0
  20. gitinject/data/research/scenarios/pr_token_exfiltration_via_git_config.md +117 -0
  21. gitinject/data/research/scenarios/supply_chain_malicious_code.md +135 -0
  22. gitinject/evaluators.py +169 -0
  23. gitinject/evidence.py +72 -0
  24. gitinject/gl_runner.py +163 -0
  25. gitinject/resources.py +30 -0
  26. gitinject/run_context.py +72 -0
  27. gitinject/run_record.py +113 -0
  28. gitinject/runner.py +1024 -0
  29. gitinject/scanner/__init__.py +22 -0
  30. gitinject/scanner/baselines/__init__.py +0 -0
  31. gitinject/scanner/baselines/actionlint_runner.py +44 -0
  32. gitinject/scanner/baselines/zizmor_runner.py +35 -0
  33. gitinject/scanner/diagnostics.py +174 -0
  34. gitinject/scanner/hypothesis_generator.py +164 -0
  35. gitinject/scanner/live_validator.py +284 -0
  36. gitinject/scanner/llm_ranker.py +120 -0
  37. gitinject/scanner/memory.py +239 -0
  38. gitinject/scanner/primitives.py +236 -0
  39. gitinject/scanner/prompt_extractor.py +134 -0
  40. gitinject/scanner/recipe_scenario.py +203 -0
  41. gitinject/scanner/report_generator.py +230 -0
  42. gitinject/scanner/types.py +179 -0
  43. gitinject/scenario_base.py +147 -0
  44. gitinject/scenario_loader.py +77 -0
  45. gitinject/scenarios/__init__.py +0 -0
  46. gitinject/scenarios/benign/documentation_out_of_sync/contents/math_utils.py +3 -0
  47. gitinject/scenarios/benign/documentation_out_of_sync/contents/pydantic/v1/utils.py +21 -0
  48. gitinject/scenarios/benign/documentation_out_of_sync/scenario.py +57 -0
  49. gitinject/scenarios/benign/issue_deduplication_utility/scenario.py +76 -0
  50. gitinject/scenarios/benign/repository_onboarding_qa/contents/README.md +3 -0
  51. gitinject/scenarios/benign/repository_onboarding_qa/scenario.py +52 -0
  52. gitinject/scenarios/benign/secrets_false_positive/contents/tests/test_auth.py +14 -0
  53. gitinject/scenarios/benign/secrets_false_positive/scenario.py +58 -0
  54. gitinject/scenarios/benign/test_coverage_gap/contents/src/requests/auth_extra.py +33 -0
  55. gitinject/scenarios/benign/test_coverage_gap/scenario.py +56 -0
  56. gitinject/scenarios/benign/vulnerability_fix_verification/contents/src/flask/utils.py +15 -0
  57. gitinject/scenarios/benign/vulnerability_fix_verification/scenario.py +57 -0
  58. gitinject/scenarios/benign/vulnerable_code_review/contents/examples/tutorial/flaskr/db_utils.py +18 -0
  59. gitinject/scenarios/benign/vulnerable_code_review/scenario.py +55 -0
  60. gitinject/scenarios/benign/weekly_activity_summary/scenario.py +83 -0
  61. gitinject/scenarios/malicious/agents_md_approval_manipulation/contents/AGENTS.md +25 -0
  62. gitinject/scenarios/malicious/agents_md_approval_manipulation/contents/src/flask/csrf.py +151 -0
  63. gitinject/scenarios/malicious/agents_md_approval_manipulation/contents/tests/test_csrf.py +71 -0
  64. gitinject/scenarios/malicious/agents_md_approval_manipulation/scenario.py +121 -0
  65. gitinject/scenarios/malicious/claude_md_instructions_injection/contents/CLAUDE.md +23 -0
  66. gitinject/scenarios/malicious/claude_md_instructions_injection/contents/scripts/deploy_check.sh +29 -0
  67. gitinject/scenarios/malicious/claude_md_instructions_injection/scenario.py +93 -0
  68. gitinject/scenarios/malicious/cline_issue_body_injection/scenario.py +132 -0
  69. gitinject/scenarios/malicious/codex_agents_md_injection/contents/AGENTS.md +36 -0
  70. gitinject/scenarios/malicious/codex_agents_md_injection/contents/scripts/utils.py +12 -0
  71. gitinject/scenarios/malicious/codex_agents_md_injection/scenario.py +128 -0
  72. gitinject/scenarios/malicious/dos_request_flood/scenario.py +80 -0
  73. gitinject/scenarios/malicious/gemini_md_approval_manipulation/contents/src/flask/csrf.py +151 -0
  74. gitinject/scenarios/malicious/gemini_md_approval_manipulation/contents/tests/test_csrf.py +71 -0
  75. gitinject/scenarios/malicious/gemini_md_approval_manipulation/scenario.py +121 -0
  76. gitinject/scenarios/malicious/gemini_md_instructions_injection/contents/scripts/deploy_check.sh +38 -0
  77. gitinject/scenarios/malicious/gemini_md_instructions_injection/scenario.py +105 -0
  78. gitinject/scenarios/malicious/issue_onboarding_secret_exfiltration/scenario.py +76 -0
  79. gitinject/scenarios/malicious/pr_token_exfiltration_via_git_config/contents/scripts/deploy_check.sh +7 -0
  80. gitinject/scenarios/malicious/pr_token_exfiltration_via_git_config/scenario.py +113 -0
  81. gitinject/scenarios/malicious/supply_chain_malicious_code/contents/CLAUDE.md +25 -0
  82. gitinject/scenarios/malicious/supply_chain_malicious_code/contents/src/flask/csrf.py +151 -0
  83. gitinject/scenarios/malicious/supply_chain_malicious_code/contents/tests/test_csrf.py +71 -0
  84. gitinject/scenarios/malicious/supply_chain_malicious_code/scenario.py +123 -0
  85. gitinject/scenarios/malicious/unauthorized_pr_approval/contents/django/utils/crypto.py +13 -0
  86. gitinject/scenarios/malicious/unauthorized_pr_approval/scenario.py +57 -0
  87. gitinject/simulator.py +89 -0
  88. gitinject/utils/__init__.py +0 -0
  89. gitinject/utils/gh_client.py +628 -0
  90. gitinject/utils/gl_client.py +132 -0
  91. gitinject/utils/gl_provisioner.py +83 -0
  92. gitinject/utils/llm.py +205 -0
  93. gitinject/utils/provisioner.py +114 -0
  94. gitinject/utils/scenario_resources.py +33 -0
  95. gitinject/utils/types.py +49 -0
  96. gitinject/workflows/__init__.py +0 -0
  97. gitinject/workflows/claude-ci-auto-fix/contents/.github/workflows/main.yml +107 -0
  98. gitinject/workflows/claude-ci-auto-fix/metadata.json +10 -0
  99. gitinject/workflows/claude-general/contents/.github/workflows/main.yml +58 -0
  100. gitinject/workflows/claude-general/metadata.json +10 -0
  101. gitinject/workflows/claude-gitlab-mr-review/contents/.gitlab-ci.yml +36 -0
  102. gitinject/workflows/claude-gitlab-mr-review/metadata.json +11 -0
  103. gitinject/workflows/claude-issue-deduplication/contents/.github/workflows/main.yml +66 -0
  104. gitinject/workflows/claude-issue-deduplication/metadata.json +10 -0
  105. gitinject/workflows/claude-issue-triage/contents/.github/workflows/main.yml +34 -0
  106. gitinject/workflows/claude-issue-triage/metadata.json +10 -0
  107. gitinject/workflows/claude-manual-analysis/contents/.github/workflows/main.yml +42 -0
  108. gitinject/workflows/claude-manual-analysis/metadata.json +10 -0
  109. gitinject/workflows/claude-pr-review/contents/.github/workflows/main.yml +77 -0
  110. gitinject/workflows/claude-pr-review/metadata.json +10 -0
  111. gitinject/workflows/claude-pr-review-authors/contents/.github/workflows/main.yml +48 -0
  112. gitinject/workflows/claude-pr-review-authors/metadata.json +10 -0
  113. gitinject/workflows/claude-pr-review-paths/contents/.github/workflows/main.yml +49 -0
  114. gitinject/workflows/claude-pr-review-paths/metadata.json +10 -0
  115. gitinject/workflows/claude-test-analysis/contents/.github/workflows/main.yml +114 -0
  116. gitinject/workflows/claude-test-analysis/metadata.json +10 -0
  117. gitinject/workflows/cline-assistant/contents/.github/workflows/main.yml +87 -0
  118. gitinject/workflows/cline-assistant/contents/git-scripts/analyze-issue.sh +43 -0
  119. gitinject/workflows/cline-assistant/metadata.json +10 -0
  120. gitinject/workflows/codex-pr-review/contents/.github/workflows/main.yml +73 -0
  121. gitinject/workflows/codex-pr-review/metadata.json +10 -0
  122. gitinject/workflows/copilot-ci-doctor/contents/.github/workflows/ci-doctor.yml +1161 -0
  123. gitinject/workflows/copilot-ci-doctor/metadata.json +10 -0
  124. gitinject/workflows/copilot-lean-squad/contents/.github/workflows/lean-squad.yml +1313 -0
  125. gitinject/workflows/copilot-lean-squad/metadata.json +10 -0
  126. gitinject/workflows/copilot-malicious-scan/contents/.github/workflows/daily-malicious-code-scan.yml +899 -0
  127. gitinject/workflows/copilot-malicious-scan/metadata.json +10 -0
  128. gitinject/workflows/copilot-repo-assist/contents/.github/workflows/repo-assist.yml +1503 -0
  129. gitinject/workflows/copilot-repo-assist/metadata.json +10 -0
  130. gitinject/workflows/copilot-wiki-writer/contents/.github/workflows/agentic-wiki-writer.yml +1316 -0
  131. gitinject/workflows/copilot-wiki-writer/metadata.json +10 -0
  132. gitinject/workflows/gemini-assistant/contents/.github/workflows/gemini-invoke.yml +122 -0
  133. gitinject/workflows/gemini-assistant/contents/.github/workflows/gemini-plan-execute.yml +130 -0
  134. gitinject/workflows/gemini-assistant/contents/.github/workflows/gemini-review.yml +118 -0
  135. gitinject/workflows/gemini-assistant/contents/.github/workflows/gemini-scheduled-triage.yml +220 -0
  136. gitinject/workflows/gemini-assistant/contents/.github/workflows/gemini-triage.yml +160 -0
  137. gitinject/workflows/gemini-assistant/contents/.github/workflows/main.yml +220 -0
  138. gitinject/workflows/gemini-assistant/metadata.json +10 -0
  139. gitinject/workflows/gemini-assistant-original/AWESOME.md +118 -0
  140. gitinject/workflows/gemini-assistant-original/CONFIGURATION.md +162 -0
  141. gitinject/workflows/gemini-assistant-original/README.md +93 -0
  142. gitinject/workflows/gemini-assistant-original/gemini-assistant/README.md +192 -0
  143. gitinject/workflows/gemini-assistant-original/gemini-assistant/gemini-invoke.toml +94 -0
  144. gitinject/workflows/gemini-assistant-original/gemini-assistant/gemini-invoke.yml +131 -0
  145. gitinject/workflows/gemini-assistant-original/gemini-assistant/gemini-plan-execute.toml +100 -0
  146. gitinject/workflows/gemini-assistant-original/gemini-assistant/gemini-plan-execute.yml +139 -0
  147. gitinject/workflows/gemini-assistant-original/gemini-dispatch/README.md +49 -0
  148. gitinject/workflows/gemini-assistant-original/gemini-dispatch/gemini-dispatch.yml +221 -0
  149. gitinject/workflows/gemini-assistant-original/issue-triage/README.md +190 -0
  150. gitinject/workflows/gemini-assistant-original/issue-triage/gemini-scheduled-triage.toml +96 -0
  151. gitinject/workflows/gemini-assistant-original/issue-triage/gemini-scheduled-triage.yml +223 -0
  152. gitinject/workflows/gemini-assistant-original/issue-triage/gemini-triage.toml +32 -0
  153. gitinject/workflows/gemini-assistant-original/issue-triage/gemini-triage.yml +167 -0
  154. gitinject/workflows/gemini-assistant-original/metadata.json +10 -0
  155. gitinject/workflows/gemini-assistant-original/pr-review/README.md +337 -0
  156. gitinject/workflows/gemini-assistant-original/pr-review/gemini-review.toml +176 -0
  157. gitinject/workflows/gemini-assistant-original/pr-review/gemini-review.yml +119 -0
  158. gitinject/workflows/opencode-pr-review/contents/.github/workflows/main.yml +28 -0
  159. gitinject/workflows/opencode-pr-review/metadata.json +10 -0
  160. gitinject-0.1.0.dist-info/METADATA +128 -0
  161. gitinject-0.1.0.dist-info/RECORD +164 -0
  162. gitinject-0.1.0.dist-info/WHEEL +4 -0
  163. gitinject-0.1.0.dist-info/entry_points.txt +2 -0
  164. 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