secondshift 0.1.0b12__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 (371) hide show
  1. secondshift/__init__.py +1 -0
  2. secondshift/__main__.py +3 -0
  3. secondshift/cli.py +17 -0
  4. secondshift/config/__init__.py +0 -0
  5. secondshift/config/api.py +23 -0
  6. secondshift/config/app.py +62 -0
  7. secondshift/config/constants.py +279 -0
  8. secondshift/config/container_env.py +118 -0
  9. secondshift/config/first_run.py +40 -0
  10. secondshift/config/formatting.py +5 -0
  11. secondshift/config/gunicorn.py +31 -0
  12. secondshift/config/logging.py +40 -0
  13. secondshift/config/settings/__init__.py +0 -0
  14. secondshift/config/settings/base.py +524 -0
  15. secondshift/config/settings/dev.py +14 -0
  16. secondshift/config/settings/local.py +23 -0
  17. secondshift/config/settings/production.py +113 -0
  18. secondshift/config/settings/runner.py +140 -0
  19. secondshift/config/settings/test.py +47 -0
  20. secondshift/config/static_storage.py +45 -0
  21. secondshift/config/urls.py +42 -0
  22. secondshift/connections/__init__.py +0 -0
  23. secondshift/connections/access.py +27 -0
  24. secondshift/connections/admin.py +25 -0
  25. secondshift/connections/agents.py +224 -0
  26. secondshift/connections/api.py +747 -0
  27. secondshift/connections/apps.py +6 -0
  28. secondshift/connections/catalog.py +96 -0
  29. secondshift/connections/checks.py +22 -0
  30. secondshift/connections/dao.py +305 -0
  31. secondshift/connections/errors.py +13 -0
  32. secondshift/connections/execution.py +262 -0
  33. secondshift/connections/external.py +258 -0
  34. secondshift/connections/github.py +123 -0
  35. secondshift/connections/http_api/__init__.py +1 -0
  36. secondshift/connections/http_api/client.py +161 -0
  37. secondshift/connections/http_api/connections.py +187 -0
  38. secondshift/connections/http_api/spec.py +382 -0
  39. secondshift/connections/mcp/__init__.py +1 -0
  40. secondshift/connections/mcp/base.py +139 -0
  41. secondshift/connections/mcp/client.py +353 -0
  42. secondshift/connections/mcp/custom.py +86 -0
  43. secondshift/connections/mcp/github.py +256 -0
  44. secondshift/connections/mcp/oauth.py +352 -0
  45. secondshift/connections/mcp/providers.py +15 -0
  46. secondshift/connections/mcp/schemas.py +77 -0
  47. secondshift/connections/mcp/sentry.py +135 -0
  48. secondshift/connections/mcp/signin.py +206 -0
  49. secondshift/connections/migration_helpers.py +28 -0
  50. secondshift/connections/migrations/0001_initial.py +101 -0
  51. secondshift/connections/migrations/0002_alter_connection_kind.py +18 -0
  52. secondshift/connections/migrations/0003_require_approval.py +23 -0
  53. secondshift/connections/migrations/0004_new_connections_skip_approval.py +18 -0
  54. secondshift/connections/migrations/0005_agentconnection.py +36 -0
  55. secondshift/connections/migrations/0006_remove_is_default.py +23 -0
  56. secondshift/connections/migrations/0007_agent_last_check.py +18 -0
  57. secondshift/connections/migrations/0008_agentcredentialcleanup.py +25 -0
  58. secondshift/connections/migrations/0009_release_0_1_0b11.py +52 -0
  59. secondshift/connections/migrations/0010_connection_attribution.py +18 -0
  60. secondshift/connections/migrations/__init__.py +0 -0
  61. secondshift/connections/model_providers.py +441 -0
  62. secondshift/connections/models.py +246 -0
  63. secondshift/connections/outbound.py +317 -0
  64. secondshift/connections/repository_ref.py +19 -0
  65. secondshift/connections/schemas.py +313 -0
  66. secondshift/connections/serialization.py +100 -0
  67. secondshift/connections/services.py +94 -0
  68. secondshift/connections/tools.py +38 -0
  69. secondshift/connections/webhooks.py +107 -0
  70. secondshift/core/__init__.py +0 -0
  71. secondshift/core/adapters.py +38 -0
  72. secondshift/core/admin.py +5 -0
  73. secondshift/core/api.py +104 -0
  74. secondshift/core/apps.py +20 -0
  75. secondshift/core/launcher.py +311 -0
  76. secondshift/core/local_agents.py +90 -0
  77. secondshift/core/local_output.py +120 -0
  78. secondshift/core/management/__init__.py +0 -0
  79. secondshift/core/management/commands/__init__.py +0 -0
  80. secondshift/core/management/commands/bootstrap_dev.py +33 -0
  81. secondshift/core/management/commands/dev_reset_db.py +204 -0
  82. secondshift/core/management/commands/login.py +23 -0
  83. secondshift/core/management/commands/logs.py +58 -0
  84. secondshift/core/management/commands/status.py +46 -0
  85. secondshift/core/management/commands/up.py +244 -0
  86. secondshift/core/middleware.py +21 -0
  87. secondshift/core/migrations/__init__.py +0 -0
  88. secondshift/core/schemas.py +71 -0
  89. secondshift/core/signin.py +58 -0
  90. secondshift/core/static/core/account.css +149 -0
  91. secondshift/core/static/core/admin.css +135 -0
  92. secondshift/core/templatetags/__init__.py +0 -0
  93. secondshift/core/templatetags/accounts.py +20 -0
  94. secondshift/core/terminal.py +27 -0
  95. secondshift/core/views.py +10 -0
  96. secondshift/gateway/__init__.py +0 -0
  97. secondshift/gateway/apps.py +6 -0
  98. secondshift/gateway/dao.py +137 -0
  99. secondshift/gateway/metering.py +171 -0
  100. secondshift/gateway/migrations/0001_initial.py +54 -0
  101. secondshift/gateway/migrations/0002_modelusage_gateway_usage_created_at_idx.py +19 -0
  102. secondshift/gateway/migrations/__init__.py +0 -0
  103. secondshift/gateway/models.py +61 -0
  104. secondshift/gateway/pricing.py +39 -0
  105. secondshift/gateway/proxy.py +369 -0
  106. secondshift/gateway/tokens.py +68 -0
  107. secondshift/gateway/urls.py +17 -0
  108. secondshift/gateway/views.py +11 -0
  109. secondshift/github/__init__.py +6 -0
  110. secondshift/github/app.py +109 -0
  111. secondshift/github/authors.py +56 -0
  112. secondshift/github/credentials.py +59 -0
  113. secondshift/github/errors.py +2 -0
  114. secondshift/github/git.py +56 -0
  115. secondshift/github/identity.py +35 -0
  116. secondshift/github/installations.py +74 -0
  117. secondshift/github/issues.py +75 -0
  118. secondshift/github/permissions.py +49 -0
  119. secondshift/github/pulls.py +49 -0
  120. secondshift/github/repos.py +52 -0
  121. secondshift/github/rest.py +93 -0
  122. secondshift/github/ruff.toml +16 -0
  123. secondshift/github/tokens.py +52 -0
  124. secondshift/jobs/__init__.py +0 -0
  125. secondshift/jobs/access.py +12 -0
  126. secondshift/jobs/admin.py +83 -0
  127. secondshift/jobs/api.py +184 -0
  128. secondshift/jobs/apps.py +6 -0
  129. secondshift/jobs/automatic_grants.py +115 -0
  130. secondshift/jobs/autosync.py +78 -0
  131. secondshift/jobs/capabilities.py +43 -0
  132. secondshift/jobs/dao.py +258 -0
  133. secondshift/jobs/drafts.py +112 -0
  134. secondshift/jobs/grants.py +116 -0
  135. secondshift/jobs/limits.py +26 -0
  136. secondshift/jobs/management/__init__.py +0 -0
  137. secondshift/jobs/management/commands/__init__.py +0 -0
  138. secondshift/jobs/manifest.py +364 -0
  139. secondshift/jobs/migrations/0001_initial.py +87 -0
  140. secondshift/jobs/migrations/0002_job_history.py +18 -0
  141. secondshift/jobs/migrations/0003_job_skill_text.py +23 -0
  142. secondshift/jobs/migrations/0004_jobgrant_tool_rules.py +18 -0
  143. secondshift/jobs/migrations/0005_job_triggers.py +18 -0
  144. secondshift/jobs/migrations/0006_job_webhook.py +18 -0
  145. secondshift/jobs/migrations/0007_job_deliveries.py +23 -0
  146. secondshift/jobs/migrations/0008_job_removed_at.py +18 -0
  147. secondshift/jobs/migrations/0009_job_subagents.py +18 -0
  148. secondshift/jobs/migrations/0010_job_skill_source.py +18 -0
  149. secondshift/jobs/migrations/__init__.py +0 -0
  150. secondshift/jobs/models.py +199 -0
  151. secondshift/jobs/needs.py +154 -0
  152. secondshift/jobs/plan.py +176 -0
  153. secondshift/jobs/planning.py +386 -0
  154. secondshift/jobs/readiness.py +46 -0
  155. secondshift/jobs/repository_sync.py +199 -0
  156. secondshift/jobs/scheduling.py +26 -0
  157. secondshift/jobs/schemas.py +207 -0
  158. secondshift/jobs/serialization.py +218 -0
  159. secondshift/jobs/skill_needs.py +189 -0
  160. secondshift/jobs/sync.py +252 -0
  161. secondshift/jobs/tool_policy.py +82 -0
  162. secondshift/jobs/tool_rules.py +200 -0
  163. secondshift/mcp_server/__init__.py +6 -0
  164. secondshift/mcp_server/api.py +79 -0
  165. secondshift/mcp_server/apps.py +7 -0
  166. secondshift/mcp_server/audit.py +22 -0
  167. secondshift/mcp_server/core.py +12 -0
  168. secondshift/mcp_server/dao.py +23 -0
  169. secondshift/mcp_server/migrations/0001_initial.py +33 -0
  170. secondshift/mcp_server/migrations/__init__.py +0 -0
  171. secondshift/mcp_server/models.py +40 -0
  172. secondshift/mcp_server/schemas.py +204 -0
  173. secondshift/mcp_server/tools/__init__.py +9 -0
  174. secondshift/mcp_server/tools/jobs.py +106 -0
  175. secondshift/mcp_server/tools/manifests.py +127 -0
  176. secondshift/mcp_server/tools/repositories.py +42 -0
  177. secondshift/mcp_server/tools/runs.py +86 -0
  178. secondshift/mcp_server/tools/tasks.py +103 -0
  179. secondshift/mcp_server/tools_registry.py +81 -0
  180. secondshift/repositories/__init__.py +0 -0
  181. secondshift/repositories/access.py +13 -0
  182. secondshift/repositories/admin.py +19 -0
  183. secondshift/repositories/api.py +296 -0
  184. secondshift/repositories/apps.py +6 -0
  185. secondshift/repositories/dao.py +114 -0
  186. secondshift/repositories/external_skills.py +224 -0
  187. secondshift/repositories/github.py +27 -0
  188. secondshift/repositories/github_access.py +57 -0
  189. secondshift/repositories/github_check.py +279 -0
  190. secondshift/repositories/github_sync.py +181 -0
  191. secondshift/repositories/github_views.py +90 -0
  192. secondshift/repositories/local.py +198 -0
  193. secondshift/repositories/management/__init__.py +0 -0
  194. secondshift/repositories/management/commands/__init__.py +0 -0
  195. secondshift/repositories/management/commands/prune_source_cache.py +15 -0
  196. secondshift/repositories/migrations/0001_initial.py +45 -0
  197. secondshift/repositories/migrations/0002_repository_last_sync_error.py +18 -0
  198. secondshift/repositories/migrations/0003_repository_checked_sha.py +18 -0
  199. secondshift/repositories/migrations/0004_pull_request_settings.py +23 -0
  200. secondshift/repositories/migrations/0005_remove_repository_installation_id.py +17 -0
  201. secondshift/repositories/migrations/0006_allow_issues.py +18 -0
  202. secondshift/repositories/migrations/__init__.py +0 -0
  203. secondshift/repositories/mirrors.py +50 -0
  204. secondshift/repositories/models.py +104 -0
  205. secondshift/repositories/pull_requests.py +199 -0
  206. secondshift/repositories/schemas.py +102 -0
  207. secondshift/repositories/serialization.py +133 -0
  208. secondshift/repositories/skill_source.py +64 -0
  209. secondshift/repositories/snapshots.py +238 -0
  210. secondshift/repositories/sources.py +256 -0
  211. secondshift/repositories/status.py +67 -0
  212. secondshift/runner/README.md +65 -0
  213. secondshift/runner/__init__.py +1 -0
  214. secondshift/runner/agent_credentials.py +89 -0
  215. secondshift/runner/agent_image/Dockerfile +16 -0
  216. secondshift/runner/agent_image/agent.py +132 -0
  217. secondshift/runner/agent_image/claude.py +254 -0
  218. secondshift/runner/agent_image/codex.py +84 -0
  219. secondshift/runner/agent_image/outcomes.py +120 -0
  220. secondshift/runner/agent_image/subagent_hook.py +71 -0
  221. secondshift/runner/agent_protocol.py +41 -0
  222. secondshift/runner/agents.py +254 -0
  223. secondshift/runner/api.py +123 -0
  224. secondshift/runner/apps.py +6 -0
  225. secondshift/runner/client.py +168 -0
  226. secondshift/runner/collection.py +276 -0
  227. secondshift/runner/dao.py +64 -0
  228. secondshift/runner/dev.py +19 -0
  229. secondshift/runner/images.py +57 -0
  230. secondshift/runner/migrations/0001_initial.py +48 -0
  231. secondshift/runner/migrations/0002_session_source_sha256.py +18 -0
  232. secondshift/runner/migrations/0003_session_collect_changes.py +18 -0
  233. secondshift/runner/migrations/0004_session_history_sha256.py +18 -0
  234. secondshift/runner/migrations/0005_require_source_sha256.py +18 -0
  235. secondshift/runner/migrations/0006_session_skill_sha256.py +18 -0
  236. secondshift/runner/migrations/__init__.py +0 -0
  237. secondshift/runner/models.py +53 -0
  238. secondshift/runner/protocol.py +97 -0
  239. secondshift/runner/schemas.py +36 -0
  240. secondshift/runner/services.py +465 -0
  241. secondshift/runner/sources.py +130 -0
  242. secondshift/runner/urls.py +7 -0
  243. secondshift/runs/__init__.py +0 -0
  244. secondshift/runs/access.py +13 -0
  245. secondshift/runs/admin.py +89 -0
  246. secondshift/runs/api.py +211 -0
  247. secondshift/runs/apps.py +11 -0
  248. secondshift/runs/artifacts.py +261 -0
  249. secondshift/runs/dao.py +640 -0
  250. secondshift/runs/deliveries.py +80 -0
  251. secondshift/runs/execution/__init__.py +0 -0
  252. secondshift/runs/execution/access_needs.py +89 -0
  253. secondshift/runs/execution/agent.py +91 -0
  254. secondshift/runs/execution/agent_broker.py +198 -0
  255. secondshift/runs/execution/budgets.py +177 -0
  256. secondshift/runs/execution/cli_agent.py +167 -0
  257. secondshift/runs/execution/consent.py +58 -0
  258. secondshift/runs/execution/deadlines.py +47 -0
  259. secondshift/runs/execution/external_tools.py +84 -0
  260. secondshift/runs/execution/issues.py +99 -0
  261. secondshift/runs/execution/model_gateway.py +90 -0
  262. secondshift/runs/execution/payload.py +63 -0
  263. secondshift/runs/execution/result.py +59 -0
  264. secondshift/runs/execution/run.py +345 -0
  265. secondshift/runs/execution/subagents.py +246 -0
  266. secondshift/runs/execution/tool_broker.py +123 -0
  267. secondshift/runs/execution/workspace_tools.py +248 -0
  268. secondshift/runs/history.py +182 -0
  269. secondshift/runs/inputs.py +49 -0
  270. secondshift/runs/issue_delivery.py +144 -0
  271. secondshift/runs/management/__init__.py +0 -0
  272. secondshift/runs/management/commands/__init__.py +0 -0
  273. secondshift/runs/management/commands/prune_runs.py +12 -0
  274. secondshift/runs/management/commands/run_dbos_worker.py +129 -0
  275. secondshift/runs/migration_helpers.py +313 -0
  276. secondshift/runs/migrations/0001_initial.py +69 -0
  277. secondshift/runs/migrations/0002_run_started_by.py +18 -0
  278. secondshift/runs/migrations/0003_agentlease.py +25 -0
  279. secondshift/runs/migrations/0004_task_assignment.py +39 -0
  280. secondshift/runs/migrations/0005_backfill_tasks.py +19 -0
  281. secondshift/runs/migrations/0006_standalone_tasks.py +50 -0
  282. secondshift/runs/migrations/0007_run_delivery.py +35 -0
  283. secondshift/runs/migrations/0008_run_outputs.py +18 -0
  284. secondshift/runs/migrations/0009_pull_request_delivery.py +28 -0
  285. secondshift/runs/migrations/0010_run_issue.py +32 -0
  286. secondshift/runs/migrations/0011_issues_delivery.py +23 -0
  287. secondshift/runs/migrations/0012_alter_rundelivery_status.py +18 -0
  288. secondshift/runs/migrations/0013_release_0_1_0b11.py +113 -0
  289. secondshift/runs/migrations/0014_run_access_need.py +30 -0
  290. secondshift/runs/migrations/__init__.py +0 -0
  291. secondshift/runs/models.py +340 -0
  292. secondshift/runs/orchestration/__init__.py +0 -0
  293. secondshift/runs/orchestration/admission.py +15 -0
  294. secondshift/runs/orchestration/dbos_runtime.py +43 -0
  295. secondshift/runs/orchestration/dispatch.py +130 -0
  296. secondshift/runs/orchestration/periodic.py +39 -0
  297. secondshift/runs/orchestration/reconciliation.py +98 -0
  298. secondshift/runs/orchestration/schedules.py +51 -0
  299. secondshift/runs/orchestration/workflows.py +480 -0
  300. secondshift/runs/outputs.py +52 -0
  301. secondshift/runs/publishing.py +162 -0
  302. secondshift/runs/queue.py +26 -0
  303. secondshift/runs/reporting/__init__.py +0 -0
  304. secondshift/runs/reporting/collection.py +26 -0
  305. secondshift/runs/reporting/connection_usage.py +45 -0
  306. secondshift/runs/reporting/failures.py +150 -0
  307. secondshift/runs/reporting/health.py +158 -0
  308. secondshift/runs/reporting/notifications.py +258 -0
  309. secondshift/runs/reporting/queries.py +60 -0
  310. secondshift/runs/retention.py +35 -0
  311. secondshift/runs/schemas.py +192 -0
  312. secondshift/runs/serialization.py +245 -0
  313. secondshift/runs/services.py +176 -0
  314. secondshift/runs/signals.py +25 -0
  315. secondshift/runs/submissions.py +236 -0
  316. secondshift/runs/transitions.py +84 -0
  317. secondshift/templates/account/password_reset_help_text.html +1 -0
  318. secondshift/templates/admin/base_site.html +24 -0
  319. secondshift/templates/allauth/elements/provider.html +9 -0
  320. secondshift/templates/allauth/layouts/base.html +67 -0
  321. secondshift/templates/socialaccount/snippets/login.html +9 -0
  322. secondshift/templates/workspaces/invite_unusable.html +10 -0
  323. secondshift/triggers/__init__.py +0 -0
  324. secondshift/triggers/admin.py +19 -0
  325. secondshift/triggers/api.py +116 -0
  326. secondshift/triggers/apps.py +6 -0
  327. secondshift/triggers/dao.py +66 -0
  328. secondshift/triggers/filters.py +48 -0
  329. secondshift/triggers/migrations/0001_initial.py +55 -0
  330. secondshift/triggers/migrations/0002_ignored_events_and_hourly_limit.py +23 -0
  331. secondshift/triggers/migrations/__init__.py +0 -0
  332. secondshift/triggers/models.py +83 -0
  333. secondshift/triggers/schemas.py +58 -0
  334. secondshift/triggers/serialization.py +91 -0
  335. secondshift/triggers/services.py +246 -0
  336. secondshift/triggers/verification.py +84 -0
  337. secondshift/web/assets/index-Bwy0hvk_.js +2419 -0
  338. secondshift/web/assets/index-D27gB9_L.css +1 -0
  339. secondshift/web/favicon.svg +19 -0
  340. secondshift/web/index.html +16 -0
  341. secondshift/web/secondshift-mark.svg +16 -0
  342. secondshift/workspaces/__init__.py +0 -0
  343. secondshift/workspaces/access.py +181 -0
  344. secondshift/workspaces/admin.py +37 -0
  345. secondshift/workspaces/api.py +264 -0
  346. secondshift/workspaces/apps.py +6 -0
  347. secondshift/workspaces/context_processors.py +11 -0
  348. secondshift/workspaces/dao.py +281 -0
  349. secondshift/workspaces/invites.py +150 -0
  350. secondshift/workspaces/limits.py +197 -0
  351. secondshift/workspaces/management/__init__.py +0 -0
  352. secondshift/workspaces/management/commands/__init__.py +0 -0
  353. secondshift/workspaces/management/commands/create_workspace.py +39 -0
  354. secondshift/workspaces/migrations/0001_initial.py +67 -0
  355. secondshift/workspaces/migrations/0002_api_token.py +34 -0
  356. secondshift/workspaces/migrations/0003_execution_default.py +29 -0
  357. secondshift/workspaces/migrations/0004_copy_default_model_key.py +26 -0
  358. secondshift/workspaces/migrations/__init__.py +0 -0
  359. secondshift/workspaces/models.py +169 -0
  360. secondshift/workspaces/owners.py +58 -0
  361. secondshift/workspaces/schemas.py +149 -0
  362. secondshift/workspaces/serialization.py +138 -0
  363. secondshift/workspaces/server_settings.py +140 -0
  364. secondshift/workspaces/services.py +94 -0
  365. secondshift/workspaces/tokens.py +108 -0
  366. secondshift/workspaces/views.py +32 -0
  367. secondshift-0.1.0b12.dist-info/METADATA +270 -0
  368. secondshift-0.1.0b12.dist-info/RECORD +371 -0
  369. secondshift-0.1.0b12.dist-info/WHEEL +4 -0
  370. secondshift-0.1.0b12.dist-info/entry_points.txt +2 -0
  371. secondshift-0.1.0b12.dist-info/licenses/LICENSE +202 -0
@@ -0,0 +1,224 @@
1
+ """Skills from another public GitHub repository, pinned to one commit.
2
+
3
+ A skill is a SKILL.md and the files in its folder. SecondShift fetches them
4
+ without credentials, so only public repositories work. A run gets the folder
5
+ at the pinned commit, mounted read-only at /skill. Nothing in the skill's
6
+ repository can change how the sandbox runs or what it may reach.
7
+ """
8
+
9
+ import fcntl
10
+ import hashlib
11
+ import os
12
+ import posixpath
13
+ import re
14
+ import subprocess
15
+ from collections.abc import Iterator
16
+ from contextlib import contextmanager
17
+ from dataclasses import dataclass
18
+ from pathlib import Path, PurePosixPath
19
+
20
+ from django.conf import settings
21
+
22
+ from secondshift.config.constants import COMMIT_SHA_PATTERN, GITHUB_REPOSITORY_PATTERN
23
+ from secondshift.github.errors import GitHubError
24
+ from secondshift.github.git import public_git
25
+ from secondshift.repositories.local import LocalRepositoryError, committed_file
26
+ from secondshift.repositories.mirrors import has_commit
27
+ from secondshift.repositories.snapshots import (
28
+ SKILL_LIMITS,
29
+ SnapshotError,
30
+ tree_entries,
31
+ )
32
+ from secondshift.repositories.sources import (
33
+ SourceSnapshot,
34
+ file_sha256,
35
+ skill_mirrors_root,
36
+ skill_snapshots_root,
37
+ write_atomically,
38
+ )
39
+
40
+ SKILL_FILE = "SKILL.md"
41
+ # Branch and tag names a person may give. Git checks them again.
42
+ REF_NAME = re.compile(r"[A-Za-z0-9._][A-Za-z0-9._/-]{0,199}")
43
+
44
+
45
+ class ExternalSkillError(ValueError):
46
+ """Worded for the person who named the skill."""
47
+
48
+
49
+ @dataclass(frozen=True)
50
+ class ExternalSkill:
51
+ repository: str
52
+ path: str
53
+ commit_sha: str
54
+ text: str
55
+ file_count: int
56
+ size_bytes: int
57
+
58
+
59
+ def check_reference(repository: str, path: str) -> None:
60
+ if not re.fullmatch(GITHUB_REPOSITORY_PATTERN, repository) or any(
61
+ part in {".", ".."} for part in repository.split("/")
62
+ ):
63
+ raise ExternalSkillError("Name the repository as owner/name.")
64
+ pure = PurePosixPath(path)
65
+ if (
66
+ pure.is_absolute()
67
+ or ".." in pure.parts
68
+ or "\0" in path
69
+ or posixpath.normpath(path) != path
70
+ ):
71
+ raise ExternalSkillError("The skill path must be a path inside its repository.")
72
+ if pure.name != SKILL_FILE:
73
+ raise ExternalSkillError(f"The skill path must point to a {SKILL_FILE} file.")
74
+
75
+
76
+ def check_ref(ref: str) -> None:
77
+ if re.fullmatch(COMMIT_SHA_PATTERN, ref):
78
+ return
79
+ if (
80
+ not REF_NAME.fullmatch(ref)
81
+ or ".." in ref
82
+ or "//" in ref
83
+ or ref.endswith(("/", ".lock", "."))
84
+ or "@{" in ref
85
+ ):
86
+ raise ExternalSkillError("Give a branch, a tag or a full commit SHA.")
87
+ completed = subprocess.run(
88
+ ["git", "check-ref-format", f"refs/heads/{ref}"],
89
+ capture_output=True,
90
+ check=False,
91
+ timeout=10,
92
+ )
93
+ if completed.returncode:
94
+ raise ExternalSkillError("Give a branch, a tag or a full commit SHA.")
95
+
96
+
97
+ def skill_folder(path: str) -> str:
98
+ """The folder whose files a run gets. Empty for a SKILL.md at the root."""
99
+ return posixpath.dirname(path)
100
+
101
+
102
+ def cache_key(repository: str) -> str:
103
+ # Repository names are case-insensitive on GitHub.
104
+ return hashlib.sha256(repository.lower().encode()).hexdigest()[:32]
105
+
106
+
107
+ @contextmanager
108
+ def locked_skill_mirror(repository: str) -> Iterator[Path]:
109
+ """A bare mirror of the skill's repository, held under a lock."""
110
+ key = cache_key(repository)
111
+ path = skill_mirrors_root() / f"{key}.git"
112
+ path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
113
+ locks = settings.SOURCE_CACHE_ROOT / "locks"
114
+ locks.mkdir(parents=True, exist_ok=True, mode=0o700)
115
+ with (locks / f"skill-{key}.lock").open("a+b") as lock:
116
+ fcntl.flock(lock, fcntl.LOCK_EX)
117
+ if not path.exists():
118
+ subprocess.run(
119
+ ["git", "init", "--quiet", "--bare", str(path)],
120
+ check=True,
121
+ capture_output=True,
122
+ timeout=30,
123
+ )
124
+ # Mark it used, so cache cleanup keeps it.
125
+ os.utime(path)
126
+ yield path
127
+
128
+
129
+ def clone_url(repository: str) -> str:
130
+ return f"{settings.EXTERNAL_SKILLS_BASE_URL}/{repository}.git"
131
+
132
+
133
+ def fetch(mirror: Path, repository: str, ref: str) -> str:
134
+ """Fetch one branch, tag or commit and return its full commit SHA."""
135
+ try:
136
+ public_git(
137
+ mirror, "fetch", "--no-tags", "--depth=1", "--", clone_url(repository), ref
138
+ )
139
+ commit = public_git(mirror, "rev-parse", "--verify", "FETCH_HEAD^{commit}")
140
+ except GitHubError as error:
141
+ raise ExternalSkillError(
142
+ f"Could not fetch {ref} from {repository}. Only public GitHub "
143
+ "repositories work, and the branch, tag or commit must exist."
144
+ ) from error
145
+ if not re.fullmatch(COMMIT_SHA_PATTERN, commit):
146
+ raise ExternalSkillError(f"{repository} did not resolve to a full commit.")
147
+ # Keep the commit's objects in the mirror until cache cleanup.
148
+ public_git(mirror, "update-ref", f"refs/secondshift/skills/{commit}", commit)
149
+ return commit
150
+
151
+
152
+ def mirrored_commit(mirror: Path, repository: str, ref: str) -> str:
153
+ if re.fullmatch(COMMIT_SHA_PATTERN, ref) and has_commit(mirror, ref):
154
+ return ref
155
+ commit = fetch(mirror, repository, ref)
156
+ if re.fullmatch(COMMIT_SHA_PATTERN, ref) and commit != ref:
157
+ raise ExternalSkillError(f"{repository} did not return commit {ref[:12]}.")
158
+ return commit
159
+
160
+
161
+ def require_enabled() -> None:
162
+ if not settings.EXTERNAL_SKILLS_ENABLED:
163
+ raise ExternalSkillError(
164
+ "This server does not run skills from other repositories."
165
+ )
166
+
167
+
168
+ def resolve_external_skill(
169
+ repository: str, path: str, ref: str | None = None
170
+ ) -> ExternalSkill:
171
+ """Fetch the skill at a branch, tag or commit, and pin it to a commit.
172
+
173
+ Without a ref, the repository's default branch is used.
174
+ """
175
+ require_enabled()
176
+ check_reference(repository, path)
177
+ requested = ref or "HEAD"
178
+ if ref is not None:
179
+ check_ref(ref)
180
+ with locked_skill_mirror(repository) as mirror:
181
+ commit = mirrored_commit(mirror, repository, requested)
182
+ try:
183
+ text = committed_file(mirror, commit, path)
184
+ except LocalRepositoryError as error:
185
+ raise ExternalSkillError(
186
+ f"{path} is not a file in {repository} at {commit[:12]}."
187
+ ) from error
188
+ try:
189
+ entries = tree_entries(mirror, commit, skill_folder(path))
190
+ except SnapshotError as error:
191
+ raise ExternalSkillError(str(error)) from error
192
+ return ExternalSkill(
193
+ repository=repository,
194
+ path=path,
195
+ commit_sha=commit,
196
+ text=text,
197
+ file_count=len(entries),
198
+ size_bytes=sum(entry.size for entry in entries),
199
+ )
200
+
201
+
202
+ def skill_snapshot(repository: str, path: str, commit_sha: str) -> SourceSnapshot:
203
+ """The skill folder at a commit, as a snapshot a run can unpack.
204
+
205
+ Built and cached if needed. Raises SnapshotError or ExternalSkillError.
206
+ """
207
+ require_enabled()
208
+ check_reference(repository, path)
209
+ if not re.fullmatch(COMMIT_SHA_PATTERN, commit_sha):
210
+ raise SnapshotError("A full commit SHA is required.")
211
+ name = hashlib.sha256(f"{repository.lower()}\0{path}".encode()).hexdigest()[:32]
212
+ target = skill_snapshots_root() / f"{commit_sha}-{name}.tar.gz"
213
+ if target.exists():
214
+ target.touch()
215
+ else:
216
+ target.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
217
+ with locked_skill_mirror(repository) as mirror:
218
+ mirrored_commit(mirror, repository, commit_sha)
219
+ write_atomically(
220
+ mirror, commit_sha, target, skill_folder(path), SKILL_LIMITS
221
+ )
222
+ return SourceSnapshot(
223
+ path=target, sha256=file_sha256(target), size_bytes=target.stat().st_size
224
+ )
@@ -0,0 +1,27 @@
1
+ """Resolve GitHub scope from server-managed repository identity."""
2
+
3
+ from secondshift.connections.repository_ref import RepositoryRef
4
+ from secondshift.github.identity import github_remote_name, valid_name
5
+ from secondshift.repositories.models import Repository
6
+
7
+
8
+ def github_repository_scope(repository: Repository) -> str | None:
9
+ if repository.source_type == Repository.SourceType.LOCAL:
10
+ return github_remote_name(repository.clone_url)
11
+ name = repository.full_name
12
+ return name if valid_name(name) else None
13
+
14
+
15
+ def repository_ref(repository: Repository) -> RepositoryRef:
16
+ """What external tools may know about the repository a job works on."""
17
+ from_github = repository.source_type == Repository.SourceType.GITHUB
18
+ return RepositoryRef(
19
+ workspace_id=repository.workspace_id,
20
+ github_name=github_repository_scope(repository),
21
+ github_id=(
22
+ int(repository.external_id)
23
+ if from_github and repository.external_id
24
+ else None
25
+ ),
26
+ source=repository.github_connection if from_github else None,
27
+ )
@@ -0,0 +1,57 @@
1
+ """Reach a repository on GitHub through the connection it was added with.
2
+
3
+ Each operation gets one token and checks the repository's identity once, then
4
+ uses that token for every call and git command it makes.
5
+ """
6
+
7
+ from dataclasses import dataclass
8
+
9
+ from secondshift.connections.github import connection_source
10
+ from secondshift.connections.models import Connection
11
+ from secondshift.github import credentials, repos
12
+ from secondshift.github.errors import GitHubError
13
+ from secondshift.github.identity import clone_url
14
+ from secondshift.repositories.models import Repository
15
+
16
+
17
+ @dataclass(frozen=True)
18
+ class GitHubAccess:
19
+ """A checked repository and a token for it. Never stored or logged."""
20
+
21
+ repository_id: int
22
+ full_name: str
23
+ token: str
24
+ metadata: dict
25
+
26
+ @property
27
+ def clone_url(self) -> str:
28
+ return clone_url(self.full_name)
29
+
30
+
31
+ def connection_access(
32
+ connection: Connection | None,
33
+ repository_id: int,
34
+ permissions: dict[str, str] = credentials.READ,
35
+ ) -> GitHubAccess:
36
+ """Access to a repository the connection reaches, before it has a row."""
37
+ token = credentials.token_for(
38
+ connection_source(connection), repository_id, permissions
39
+ )
40
+ metadata = repos.repository(token, repository_id)
41
+ return GitHubAccess(repository_id, metadata["full_name"], token, metadata)
42
+
43
+
44
+ def github_access(
45
+ repository: Repository, permissions: dict[str, str] = credentials.READ
46
+ ) -> GitHubAccess:
47
+ """Access to an added repository. Fails if it was removed, moved or renamed."""
48
+ if repository.removed_at is not None:
49
+ raise GitHubError("This repository has been removed.")
50
+ connection = repository.github_connection
51
+ if connection is not None and connection.workspace_id != repository.workspace_id:
52
+ raise GitHubError("The GitHub connection is disconnected.")
53
+ access = connection_access(connection, int(repository.external_id), permissions)
54
+ # Do not silently move a repository's identity or its existing approvals.
55
+ if repository.full_name != access.full_name:
56
+ raise GitHubError("Repository was renamed. Remove and import it again.")
57
+ return access
@@ -0,0 +1,279 @@
1
+ """Check what a GitHub source connection can still do, feature by feature.
2
+
3
+ The result is kept as the connection's last check, so the dashboard shows
4
+ each gap with a link to fix it on GitHub. Tokens are never part of it.
5
+ """
6
+
7
+ from dataclasses import asdict, dataclass
8
+ from datetime import datetime, timedelta
9
+
10
+ from django.utils import timezone
11
+
12
+ from secondshift.config.constants import GITHUB_BASE_URL
13
+ from secondshift.connections.checks import record_check
14
+ from secondshift.connections.dao import connected_github_sources
15
+ from secondshift.connections.github import can_check, connection_source
16
+ from secondshift.connections.models import Connection
17
+ from secondshift.github import app, credentials, permissions
18
+ from secondshift.github.errors import GitHubError
19
+ from secondshift.github.tokens import inspect_token
20
+ from secondshift.repositories.dao import connection_repositories
21
+ from secondshift.repositories.github_access import (
22
+ GitHubAccess,
23
+ github_access,
24
+ )
25
+ from secondshift.repositories.models import Repository
26
+
27
+ # A grant can be withdrawn on GitHub at any time, so each source is checked daily.
28
+ CHECK_INTERVAL = timedelta(days=1)
29
+
30
+ GITHUB_TOKENS_URL = f"{GITHUB_BASE_URL}/settings/personal-access-tokens"
31
+
32
+ PERMISSION_LABELS = {
33
+ "contents": "Contents",
34
+ "pull_requests": "Pull requests",
35
+ "actions": "Actions",
36
+ "statuses": "Commit statuses",
37
+ "issues": "Issues",
38
+ }
39
+
40
+
41
+ @dataclass(frozen=True)
42
+ class FeatureCheck:
43
+ feature: str
44
+ label: str
45
+ # None when it cannot be checked without using it.
46
+ ok: bool | None
47
+ message: str
48
+
49
+
50
+ @dataclass(frozen=True)
51
+ class SourceCheck:
52
+ ok: bool
53
+ message: str
54
+ features: list[FeatureCheck]
55
+ fix_url: str = ""
56
+
57
+
58
+ def names(permission_names: list[str]) -> str:
59
+ return ", ".join(PERMISSION_LABELS[name] for name in permission_names)
60
+
61
+
62
+ def features(
63
+ reads: dict[str, bool] | None, writes: dict[str, bool] | None
64
+ ) -> list[FeatureCheck]:
65
+ """One line per feature. reads is None when no repository could be probed."""
66
+ if reads is None:
67
+ unknown = "Add a repository to check this."
68
+ return [
69
+ FeatureCheck("sync", "Sync", None, unknown),
70
+ FeatureCheck("tools", "GitHub tools", None, unknown),
71
+ FeatureCheck("draft_pull_requests", "Draft pull requests", None, unknown),
72
+ FeatureCheck("issues", "Issues", None, unknown),
73
+ ]
74
+ missing_tools = [name for name in permissions.TOOLS if not reads[name]]
75
+ sync = (
76
+ FeatureCheck("sync", "Sync", True, "Can read code.")
77
+ if reads["contents"]
78
+ else FeatureCheck(
79
+ "sync", "Sync", False, "Needs read access to Contents. Sync fails."
80
+ )
81
+ )
82
+ tools = (
83
+ FeatureCheck(
84
+ "tools",
85
+ "GitHub tools",
86
+ True,
87
+ "Can read pull requests, Actions runs and commit statuses.",
88
+ )
89
+ if not missing_tools
90
+ else FeatureCheck(
91
+ "tools",
92
+ "GitHub tools",
93
+ False,
94
+ f"Needs read access to {names(missing_tools)}.",
95
+ )
96
+ )
97
+ drafts = write_feature(
98
+ writes,
99
+ "draft_pull_requests",
100
+ "Draft pull requests",
101
+ permissions.DRAFT_PULL_REQUESTS,
102
+ "Can push a branch and open a draft pull request.",
103
+ "a draft pull request opens",
104
+ )
105
+ issues = write_feature(
106
+ writes,
107
+ "issues",
108
+ "Issues",
109
+ permissions.ISSUES,
110
+ "Can open issues.",
111
+ "an issue opens",
112
+ )
113
+ return [sync, tools, drafts, issues]
114
+
115
+
116
+ def write_feature(
117
+ writes: dict[str, bool] | None,
118
+ feature: str,
119
+ label: str,
120
+ needed: tuple[str, ...],
121
+ ready: str,
122
+ first_use: str,
123
+ ) -> FeatureCheck:
124
+ """A feature that writes. A token's write access is only known on use."""
125
+ if writes is None:
126
+ return FeatureCheck(
127
+ feature,
128
+ label,
129
+ None,
130
+ f"A token's write access is checked when {first_use}.",
131
+ )
132
+ missing = [name for name in needed if not writes[name]]
133
+ if missing:
134
+ return FeatureCheck(
135
+ feature, label, False, f"Needs write access to {names(missing)}."
136
+ )
137
+ return FeatureCheck(feature, label, True, ready)
138
+
139
+
140
+ def reach(repositories: list[Repository]) -> tuple[GitHubAccess | None, list[str]]:
141
+ """The first repository reached, and the names of those that were not."""
142
+ first: GitHubAccess | None = None
143
+ unreachable: list[str] = []
144
+ for repository in repositories:
145
+ try:
146
+ access = github_access(repository)
147
+ except GitHubError:
148
+ unreachable.append(repository.full_name)
149
+ continue
150
+ first = first or access
151
+ return first, unreachable
152
+
153
+
154
+ def summary(
155
+ repositories: list[Repository], unreachable: list[str], checked: list[FeatureCheck]
156
+ ) -> tuple[bool, str]:
157
+ if unreachable:
158
+ return False, (
159
+ f"Cannot reach {', '.join(unreachable)}. "
160
+ "Give this connection access to them on GitHub."
161
+ )
162
+ if checked[0].ok is False:
163
+ return False, "Cannot read code, so sync fails."
164
+ if not repositories:
165
+ return True, "Connected. No repositories added yet."
166
+ count = len(repositories)
167
+ return (
168
+ True,
169
+ f"Reaches {'its repository' if count == 1 else f'all {count} repositories'}.",
170
+ )
171
+
172
+
173
+ def check_token(
174
+ connection: Connection,
175
+ source: credentials.PersonalToken,
176
+ repositories: list[Repository],
177
+ ) -> SourceCheck:
178
+ owner = inspect_token(source.token)
179
+ if connection.expires_at != owner.expires_at:
180
+ connection.expires_at = owner.expires_at
181
+ connection.save(update_fields=["expires_at", "updated_at"])
182
+ first, unreachable = reach(repositories)
183
+ reads = (
184
+ permissions.token_reads(
185
+ source.token,
186
+ first.full_name,
187
+ str(first.metadata.get("default_branch") or "HEAD"),
188
+ permissions.SYNC + permissions.TOOLS,
189
+ )
190
+ if first
191
+ else None
192
+ )
193
+ checked = features(reads, None)
194
+ ok, message = summary(repositories, unreachable, checked)
195
+ return SourceCheck(ok, message, checked, GITHUB_TOKENS_URL)
196
+
197
+
198
+ def check_installation(
199
+ source: credentials.AppInstallation, repositories: list[Repository]
200
+ ) -> SourceCheck:
201
+ try:
202
+ details = app.installation(source.installation_id)
203
+ except GitHubError:
204
+ return SourceCheck(
205
+ False,
206
+ "GitHub could not find this App installation. It may have been removed.",
207
+ [],
208
+ )
209
+ granted = details.permissions
210
+ # An App's grants hold for every repository, so no repository is probed.
211
+ unreachable = reach(repositories)[1]
212
+ checked = features(
213
+ permissions.granted_reads(granted, permissions.SYNC + permissions.TOOLS),
214
+ permissions.granted_writes(
215
+ granted, permissions.DRAFT_PULL_REQUESTS + permissions.ISSUES
216
+ ),
217
+ )
218
+ ok, message = summary(repositories, unreachable, checked)
219
+ return SourceCheck(ok, message, checked, details.settings_url)
220
+
221
+
222
+ def check_source(connection: Connection) -> SourceCheck:
223
+ """Check the credential, every added repository, and each feature's access."""
224
+ repositories = connection_repositories(connection)
225
+ try:
226
+ source = connection_source(connection)
227
+ if isinstance(source, credentials.AppInstallation) and not app.configured():
228
+ # Without the App's key, no installation token can be made.
229
+ raise GitHubError(
230
+ "This server has no GitHub App configured, so this connection "
231
+ "cannot sync. Ask the server's operator to configure the App, "
232
+ "or connect a personal access token instead."
233
+ )
234
+ if isinstance(source, credentials.PersonalToken):
235
+ result = check_token(connection, source, repositories)
236
+ else:
237
+ result = check_installation(source, repositories)
238
+ except GitHubError as error:
239
+ # These messages never contain a token.
240
+ token = connection.auth_strategy == Connection.AuthStrategy.API_KEY
241
+ result = SourceCheck(False, str(error), [], GITHUB_TOKENS_URL if token else "")
242
+ record_check(
243
+ connection,
244
+ result.ok,
245
+ result.message,
246
+ features=[asdict(feature) for feature in result.features],
247
+ fix_url=result.fix_url,
248
+ )
249
+ return result
250
+
251
+
252
+ def last_checked(connection: Connection) -> datetime | None:
253
+ check = connection.last_check or {}
254
+ try:
255
+ return datetime.fromisoformat(check["checked_at"])
256
+ except (KeyError, TypeError, ValueError):
257
+ return None
258
+
259
+
260
+ def check_due_sources() -> int:
261
+ """Check each GitHub source not checked within a day. Returns how many."""
262
+ due = timezone.now() - CHECK_INTERVAL
263
+ checked = 0
264
+ for connection in connected_github_sources():
265
+ when = last_checked(connection)
266
+ if not can_check(connection) or (when is not None and when > due):
267
+ continue
268
+ check_source(connection)
269
+ checked += 1
270
+ return checked
271
+
272
+
273
+ def check_after_failure(connection: Connection | None) -> None:
274
+ """A sync failed, so show why on the connection, unless it already shows a failure."""
275
+ if not can_check(connection):
276
+ return
277
+ if connection.failed_last_check:
278
+ return
279
+ check_source(connection)