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,50 @@
1
+ """The control plane's Git mirror of each GitHub repository.
2
+
3
+ A mirror is a cache. It is keyed by repository ID, so two workspaces never
4
+ share one, and deleting it only costs a full fetch the next time.
5
+ """
6
+
7
+ import fcntl
8
+ import subprocess
9
+ from collections.abc import Iterator
10
+ from contextlib import contextmanager
11
+ from pathlib import Path
12
+
13
+ from django.conf import settings
14
+
15
+ # The commit the repository last synced. It keeps that commit's objects in the
16
+ # mirror. Older commits are kept only until Git's own cleanup removes them.
17
+ SYNCED_REF = "refs/secondshift/synced"
18
+
19
+
20
+ def mirror_path(repository_id: int) -> Path:
21
+ return settings.SOURCE_CACHE_ROOT / "mirrors" / f"{repository_id}.git"
22
+
23
+
24
+ @contextmanager
25
+ def locked_mirror(repository_id: int) -> Iterator[Path]:
26
+ """The repository's bare mirror, created if missing, held under a lock."""
27
+ path = mirror_path(repository_id)
28
+ path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
29
+ locks = settings.SOURCE_CACHE_ROOT / "locks"
30
+ locks.mkdir(parents=True, exist_ok=True, mode=0o700)
31
+ with (locks / f"{repository_id}.lock").open("a+b") as lock:
32
+ fcntl.flock(lock, fcntl.LOCK_EX)
33
+ if not path.exists():
34
+ subprocess.run(
35
+ ["git", "init", "--quiet", "--bare", str(path)],
36
+ check=True,
37
+ capture_output=True,
38
+ timeout=30,
39
+ )
40
+ yield path
41
+
42
+
43
+ def has_commit(mirror: Path, commit_sha: str) -> bool:
44
+ completed = subprocess.run(
45
+ ["git", "-C", str(mirror), "cat-file", "-e", f"{commit_sha}^{{commit}}"],
46
+ capture_output=True,
47
+ check=False,
48
+ timeout=30,
49
+ )
50
+ return completed.returncode == 0
@@ -0,0 +1,104 @@
1
+ from pathlib import Path
2
+
3
+ from django.core.exceptions import ValidationError
4
+ from django.db import models
5
+
6
+ from secondshift.connections.models import Connection
7
+ from secondshift.workspaces.models import Workspace
8
+
9
+ SYNC_ERROR_MAX_CHARS = 500
10
+
11
+
12
+ class Repository(models.Model):
13
+ class SourceType(models.TextChoices):
14
+ GITHUB = "github", "GitHub"
15
+ LOCAL = "local", "Local"
16
+
17
+ workspace = models.ForeignKey(
18
+ Workspace,
19
+ on_delete=models.CASCADE,
20
+ related_name="repositories",
21
+ )
22
+ source_type = models.CharField(
23
+ max_length=16,
24
+ choices=SourceType.choices,
25
+ default=SourceType.GITHUB,
26
+ )
27
+ local_path = models.CharField(max_length=1000, blank=True)
28
+ github_connection = models.ForeignKey(
29
+ Connection,
30
+ on_delete=models.PROTECT,
31
+ related_name="repositories",
32
+ null=True,
33
+ blank=True,
34
+ )
35
+ external_id = models.CharField(max_length=64, blank=True)
36
+ full_name = models.CharField(max_length=255)
37
+ clone_url = models.URLField(blank=True)
38
+ default_branch = models.CharField(max_length=255, default="main")
39
+ enabled = models.BooleanField(default=False)
40
+ # Sync new commits on the branch without pressing Resync.
41
+ auto_sync = models.BooleanField(default=True)
42
+ # Tasks may push a branch and open a draft pull request. Owners decide.
43
+ allow_pull_requests = models.BooleanField(default=False)
44
+ # Run CI on that branch at once. Off by default, because the patch is
45
+ # untrusted until a person reviews it.
46
+ pull_request_ci = models.BooleanField(default=False)
47
+ # Jobs may open the issues their agent proposes. Owners decide.
48
+ allow_issues = models.BooleanField(default=False)
49
+ config_path = models.CharField(max_length=255, default=".secondshift.yaml")
50
+ # The commit runs use. It moves only when a sync succeeds.
51
+ config_sha = models.CharField(max_length=64, blank=True)
52
+ # The newest commit a sync read, even if its configuration was invalid.
53
+ checked_sha = models.CharField(max_length=64, blank=True)
54
+ last_synced_at = models.DateTimeField(null=True, blank=True)
55
+ last_sync_error = models.CharField(max_length=SYNC_ERROR_MAX_CHARS, blank=True)
56
+ removed_at = models.DateTimeField(null=True, blank=True)
57
+ created_at = models.DateTimeField(auto_now_add=True)
58
+ updated_at = models.DateTimeField(auto_now=True)
59
+
60
+ class Meta:
61
+ constraints = [
62
+ models.UniqueConstraint(
63
+ fields=["workspace", "full_name"],
64
+ name="unique_workspace_repository",
65
+ ),
66
+ models.UniqueConstraint(
67
+ fields=["workspace", "local_path"],
68
+ condition=~models.Q(local_path=""),
69
+ name="unique_workspace_local_repository",
70
+ ),
71
+ ]
72
+ ordering = ["full_name"]
73
+ verbose_name_plural = "repositories"
74
+
75
+ def __str__(self) -> str:
76
+ return self.display_name
77
+
78
+ @property
79
+ def accepts_runs(self) -> bool:
80
+ return self.enabled and self.removed_at is None
81
+
82
+ @property
83
+ def receives_pull_requests(self) -> bool:
84
+ return self.source_type == self.SourceType.GITHUB and self.allow_pull_requests
85
+
86
+ @property
87
+ def receives_issues(self) -> bool:
88
+ return self.source_type == self.SourceType.GITHUB and self.allow_issues
89
+
90
+ @property
91
+ def display_name(self) -> str:
92
+ if self.source_type == self.SourceType.LOCAL and self.local_path:
93
+ return Path(self.local_path).name
94
+ return self.full_name
95
+
96
+ def clean(self) -> None:
97
+ if self.source_type == self.SourceType.LOCAL and not self.local_path:
98
+ raise ValidationError(
99
+ {"local_path": "Local repositories require a filesystem path."}
100
+ )
101
+ if self.source_type != self.SourceType.LOCAL and self.local_path:
102
+ raise ValidationError(
103
+ {"local_path": "Only local repositories may have a local path."}
104
+ )
@@ -0,0 +1,199 @@
1
+ """Publish a run's changes to GitHub as a draft pull request.
2
+
3
+ This is the only code that asks GitHub for write access. Each token is
4
+ limited to one repository and to contents and pull requests, and is used for
5
+ one push and one request. It never reaches the sandbox or the model.
6
+
7
+ The commit is built in the control plane's mirror from the pinned commit and
8
+ the run's stored patch, so publishing again never needs the agent or its
9
+ sandbox. The author is the account that pushes: the App's bot, or the person
10
+ who owns the token. With the same author, message and date, a retry makes the
11
+ same commit and pushes the same branch again instead of making a new one.
12
+ """
13
+
14
+ import os
15
+ import subprocess
16
+ import tempfile
17
+ from dataclasses import dataclass
18
+ from datetime import datetime
19
+ from pathlib import Path
20
+
21
+ from secondshift.connections.models import Connection
22
+ from secondshift.github import credentials, pulls
23
+ from secondshift.github.authors import GitAuthor, app_author, token_author
24
+ from secondshift.github.errors import GitHubError
25
+ from secondshift.github.git import authenticated_git
26
+ from secondshift.repositories.github_access import github_access
27
+ from secondshift.repositories.github_sync import fetch_github_commit
28
+ from secondshift.repositories.mirrors import has_commit, locked_mirror
29
+ from secondshift.repositories.models import Repository
30
+
31
+ APP_PUSH_HINT = (
32
+ "Changes to workflow files are refused, and so is a branch someone else changed."
33
+ )
34
+ TOKEN_PUSH_HINT = (
35
+ "Give the token Contents and Pull requests write access. A branch someone "
36
+ "else changed is refused too, and so are workflow files unless the token "
37
+ "has Workflows write access."
38
+ )
39
+
40
+
41
+ class PublishError(Exception):
42
+ """A publishing step failed. The message is safe to show on the run."""
43
+
44
+
45
+ @dataclass(frozen=True)
46
+ class DraftPullRequest:
47
+ branch: str
48
+ base: str
49
+ title: str
50
+ body: str
51
+ message: str
52
+ committed_at: datetime
53
+
54
+
55
+ @dataclass(frozen=True)
56
+ class PublishedPullRequest:
57
+ number: int
58
+ url: str
59
+
60
+
61
+ def publish_pull_request(
62
+ repository: Repository, commit_sha: str, patch: Path, draft: DraftPullRequest
63
+ ) -> PublishedPullRequest:
64
+ if repository.source_type != Repository.SourceType.GITHUB:
65
+ raise PublishError("Only GitHub repositories can receive pull requests.")
66
+ with_token = (
67
+ repository.github_connection is not None
68
+ and repository.github_connection.auth_strategy
69
+ == Connection.AuthStrategy.API_KEY
70
+ )
71
+ try:
72
+ # One token for the fetch, the push and the pull request.
73
+ access = github_access(repository, credentials.WRITE)
74
+ except GitHubError as error:
75
+ if with_token:
76
+ # Token errors already say what to fix, such as an expired token.
77
+ raise PublishError(str(error)) from error
78
+ raise PublishError(
79
+ "GitHub refused write access. Give the GitHub App Contents and Pull "
80
+ "requests write permission, then retry."
81
+ ) from error
82
+ try:
83
+ author = (token_author if with_token else app_author)(access.token)
84
+ except GitHubError as error:
85
+ raise PublishError(
86
+ "GitHub did not say which account publishes the changes."
87
+ ) from error
88
+ with locked_mirror(repository.pk) as mirror:
89
+ if not has_commit(mirror, commit_sha):
90
+ try:
91
+ fetch_github_commit(access, commit_sha, mirror)
92
+ except GitHubError as error:
93
+ # These messages never contain the token.
94
+ raise PublishError(str(error)) from error
95
+ commit = commit_patch(mirror, commit_sha, patch, draft, author)
96
+ try:
97
+ authenticated_git(
98
+ mirror,
99
+ access.token,
100
+ "push",
101
+ "--quiet",
102
+ "--",
103
+ access.clone_url,
104
+ f"{commit}:refs/heads/{draft.branch}",
105
+ )
106
+ except GitHubError as error:
107
+ raise PublishError(
108
+ f"GitHub refused the push to {draft.branch}. "
109
+ + (TOKEN_PUSH_HINT if with_token else APP_PUSH_HINT)
110
+ ) from error
111
+ return open_draft(access.full_name, access.token, draft)
112
+
113
+
114
+ def commit_patch(
115
+ mirror: Path,
116
+ commit_sha: str,
117
+ patch: Path,
118
+ draft: DraftPullRequest,
119
+ author: GitAuthor,
120
+ ) -> str:
121
+ """Apply the patch to the pinned commit in a scratch index, and commit it."""
122
+ with tempfile.TemporaryDirectory(prefix="secondshift-publish-") as folder:
123
+ index = str(Path(folder) / "index")
124
+ git(mirror, index, draft, author, "read-tree", commit_sha)
125
+ try:
126
+ git(
127
+ mirror,
128
+ index,
129
+ draft,
130
+ author,
131
+ "apply",
132
+ "--cached",
133
+ "--binary",
134
+ str(patch),
135
+ )
136
+ except PublishError as error:
137
+ raise PublishError(
138
+ "The changes no longer apply to the run's commit."
139
+ ) from error
140
+ tree = git(mirror, index, draft, author, "write-tree")
141
+ return git(
142
+ mirror,
143
+ index,
144
+ draft,
145
+ author,
146
+ "commit-tree",
147
+ tree,
148
+ "-p",
149
+ commit_sha,
150
+ "-m",
151
+ draft.message,
152
+ )
153
+
154
+
155
+ def git(
156
+ mirror: Path, index: str, draft: DraftPullRequest, author: GitAuthor, *args: str
157
+ ) -> str:
158
+ when = draft.committed_at.isoformat()
159
+ environment = {
160
+ "PATH": os.environ.get("PATH", "/usr/bin:/bin"),
161
+ "HOME": str(mirror),
162
+ "LC_ALL": "C",
163
+ "GIT_CONFIG_NOSYSTEM": "1",
164
+ "GIT_CONFIG_GLOBAL": os.devnull,
165
+ "GIT_INDEX_FILE": index,
166
+ "GIT_AUTHOR_NAME": author.name,
167
+ "GIT_AUTHOR_EMAIL": author.email,
168
+ "GIT_AUTHOR_DATE": when,
169
+ "GIT_COMMITTER_NAME": author.name,
170
+ "GIT_COMMITTER_EMAIL": author.email,
171
+ "GIT_COMMITTER_DATE": when,
172
+ }
173
+ try:
174
+ result = subprocess.run(
175
+ ["git", "-C", str(mirror), "-c", "core.hooksPath=/dev/null", *args],
176
+ env=environment,
177
+ capture_output=True,
178
+ text=True,
179
+ timeout=120,
180
+ check=False,
181
+ )
182
+ except (OSError, subprocess.TimeoutExpired) as error:
183
+ raise PublishError("The commit could not be built.") from error
184
+ if result.returncode:
185
+ raise PublishError("The commit could not be built.")
186
+ return result.stdout.strip()
187
+
188
+
189
+ def open_draft(
190
+ full_name: str, token: str, draft: DraftPullRequest
191
+ ) -> PublishedPullRequest:
192
+ try:
193
+ opened = pulls.open_draft(
194
+ token, full_name, draft.title, draft.branch, draft.base, draft.body
195
+ )
196
+ except GitHubError as error:
197
+ # These messages never contain the token.
198
+ raise PublishError(str(error)) from error
199
+ return PublishedPullRequest(number=opened["number"], url=opened["html_url"])
@@ -0,0 +1,102 @@
1
+ from datetime import datetime
2
+
3
+ from ninja import Schema
4
+ from pydantic import Field
5
+
6
+ from secondshift.repositories.status import ValidationStatus
7
+
8
+
9
+ class RepositorySchema(Schema):
10
+ id: int
11
+ full_name: str
12
+ display_name: str
13
+ default_branch: str
14
+ enabled: bool
15
+ job_count: int
16
+ last_synced_at: datetime | None
17
+ source_type: str
18
+ local_path: str | None
19
+ revision: str
20
+ # The GitHub source connection it syncs through. None for local folders.
21
+ github_connection_id: int | None
22
+
23
+
24
+ class LocalRepositoryCreateSchema(Schema):
25
+ path: str = Field(min_length=1, max_length=1000)
26
+
27
+
28
+ class LocalRepositoryDirectorySchema(Schema):
29
+ name: str
30
+ path: str
31
+
32
+
33
+ class LocalRepositoryDirectoryListSchema(Schema):
34
+ path: str
35
+ parent: str | None
36
+ directories: list[LocalRepositoryDirectorySchema]
37
+
38
+
39
+ class RepositoryEnabledSchema(Schema):
40
+ enabled: bool
41
+
42
+
43
+ class RepositoryPullRequestsSchema(Schema):
44
+ allow: bool
45
+ run_ci: bool = False
46
+
47
+
48
+ class RepositoryIssuesSchema(Schema):
49
+ allow: bool
50
+
51
+
52
+ class RepositoryValidationSchema(Schema):
53
+ status: ValidationStatus
54
+ message: str
55
+ current_revision: str | None
56
+ synced_revision: str
57
+ needs_sync: bool
58
+
59
+
60
+ class RepositoryJobSchema(Schema):
61
+ id: int
62
+ name: str
63
+ skill_path: str
64
+ cron: str
65
+ timezone: str
66
+ enabled: bool
67
+ grant_status: str
68
+ removed_at: datetime | None = None
69
+
70
+
71
+ class RepositoryDetailSchema(RepositorySchema):
72
+ config_path: str
73
+ # The repository's choice, and whether this server syncs in the background.
74
+ auto_sync: bool
75
+ server_syncs: bool
76
+ # GitHub repositories only. See RepositoryPullRequestsSchema.
77
+ can_open_pull_requests: bool
78
+ allow_pull_requests: bool
79
+ pull_request_ci: bool
80
+ # GitHub repositories only, like pull requests.
81
+ allow_issues: bool
82
+ validation: RepositoryValidationSchema
83
+ jobs: list[RepositoryJobSchema]
84
+
85
+
86
+ class GitHubRepositoryCreateSchema(Schema):
87
+ connection_id: int
88
+ repository_id: int = Field(gt=0)
89
+ branch: str = Field(default="", max_length=255)
90
+
91
+
92
+ class GitHubAvailableRepositorySchema(Schema):
93
+ connection_id: int
94
+ repository_id: int
95
+ full_name: str
96
+ default_branch: str
97
+
98
+
99
+ class GitHubAppStatusSchema(Schema):
100
+ configured: bool
101
+ error: str = ""
102
+ repositories: list[GitHubAvailableRepositorySchema]
@@ -0,0 +1,133 @@
1
+ from django.http import HttpRequest
2
+
3
+ from secondshift.github import repos
4
+ from secondshift.github.errors import GitHubError
5
+ from secondshift.jobs.autosync import server_syncs
6
+ from secondshift.jobs.dao import repository_jobs_with_grants
7
+ from secondshift.jobs.repository_sync import (
8
+ RepositoryConfigError,
9
+ inspect_local_repository,
10
+ )
11
+ from secondshift.repositories.github_access import github_access
12
+ from secondshift.repositories.local import (
13
+ LocalRepositoryError,
14
+ )
15
+ from secondshift.repositories.models import Repository
16
+ from secondshift.repositories.schemas import (
17
+ RepositoryDetailSchema,
18
+ RepositoryJobSchema,
19
+ RepositorySchema,
20
+ RepositoryValidationSchema,
21
+ )
22
+ from secondshift.repositories.status import ValidationStatus, source_status
23
+ from secondshift.workspaces.access import (
24
+ Action,
25
+ allows,
26
+ )
27
+
28
+
29
+ def visible_local_path(request: HttpRequest, repository: Repository) -> str | None:
30
+ if repository.source_type == Repository.SourceType.LOCAL and allows(
31
+ request, Action.VIEW_LOCAL_PATHS
32
+ ):
33
+ return repository.local_path
34
+ return None
35
+
36
+
37
+ def serialize_repository(
38
+ request: HttpRequest,
39
+ repository: Repository,
40
+ ) -> RepositorySchema:
41
+ return RepositorySchema(
42
+ id=repository.pk,
43
+ full_name=repository.full_name,
44
+ display_name=repository.display_name,
45
+ default_branch=repository.default_branch,
46
+ enabled=repository.enabled,
47
+ # The list endpoint prefetches jobs, so this reads them from memory.
48
+ job_count=len(repository.jobs.all()),
49
+ last_synced_at=repository.last_synced_at,
50
+ source_type=repository.source_type,
51
+ local_path=visible_local_path(request, repository),
52
+ revision=repository.config_sha,
53
+ github_connection_id=repository.github_connection_id,
54
+ )
55
+
56
+
57
+ def validation_schema(
58
+ repository: Repository, head: str | None, problem: str
59
+ ) -> RepositoryValidationSchema:
60
+ """`head` is None when the branch could not be read."""
61
+ status = source_status(repository, head, problem)
62
+ return RepositoryValidationSchema(
63
+ status=status.status,
64
+ message=status.message,
65
+ current_revision=head,
66
+ synced_revision=repository.config_sha,
67
+ needs_sync=status.needs_sync,
68
+ )
69
+
70
+
71
+ def unreachable(repository: Repository, error: Exception) -> RepositoryValidationSchema:
72
+ return RepositoryValidationSchema(
73
+ status=ValidationStatus.ERROR,
74
+ message=str(error),
75
+ current_revision=None,
76
+ synced_revision=repository.config_sha,
77
+ needs_sync=False,
78
+ )
79
+
80
+
81
+ def validate_repository(repository: Repository) -> RepositoryValidationSchema:
82
+ """Read the branch head, then report the state the last syncs saved."""
83
+ if repository.source_type != Repository.SourceType.LOCAL:
84
+ try:
85
+ access = github_access(repository)
86
+ head = repos.branch_head(
87
+ access.token, access.repository_id, repository.default_branch
88
+ )
89
+ except GitHubError as error:
90
+ return unreachable(repository, error)
91
+ return validation_schema(repository, head, repository.last_sync_error)
92
+ try:
93
+ metadata = inspect_local_repository(
94
+ repository.local_path, repository.default_branch
95
+ )
96
+ except RepositoryConfigError as error:
97
+ # The branch is readable, but its configuration is broken right now.
98
+ return validation_schema(repository, None, str(error))
99
+ except LocalRepositoryError as error:
100
+ return unreachable(repository, error)
101
+ return validation_schema(repository, metadata.revision, repository.last_sync_error)
102
+
103
+
104
+ def serialize_repository_detail(
105
+ request: HttpRequest,
106
+ repository: Repository,
107
+ ) -> RepositoryDetailSchema:
108
+ summary = serialize_repository(request, repository)
109
+ jobs = repository_jobs_with_grants(repository)
110
+ return RepositoryDetailSchema(
111
+ **summary.model_dump(),
112
+ config_path=repository.config_path,
113
+ auto_sync=repository.auto_sync,
114
+ server_syncs=server_syncs(),
115
+ can_open_pull_requests=repository.source_type == Repository.SourceType.GITHUB,
116
+ allow_pull_requests=repository.allow_pull_requests,
117
+ pull_request_ci=repository.pull_request_ci,
118
+ allow_issues=repository.allow_issues,
119
+ validation=validate_repository(repository),
120
+ jobs=[
121
+ RepositoryJobSchema(
122
+ id=job.pk,
123
+ name=job.name,
124
+ skill_path=job.skill_path,
125
+ cron=job.cron,
126
+ timezone=job.timezone,
127
+ enabled=job.enabled,
128
+ grant_status=job.capability_grant_status(),
129
+ removed_at=job.removed_at,
130
+ )
131
+ for job in jobs
132
+ ],
133
+ )
@@ -0,0 +1,64 @@
1
+ """Resolve a submitted skill at a committed revision without syncing jobs."""
2
+
3
+ import re
4
+ from pathlib import Path
5
+
6
+ from django.conf import settings
7
+
8
+ from secondshift.config.constants import COMMIT_SHA_PATTERN
9
+ from secondshift.github.git import authenticated_git
10
+ from secondshift.repositories.github_access import github_access
11
+ from secondshift.repositories.local import committed_file, git
12
+ from secondshift.repositories.mirrors import locked_mirror
13
+ from secondshift.repositories.models import Repository
14
+
15
+
16
+ class SkillSourceError(ValueError):
17
+ pass
18
+
19
+
20
+ def skill_commit(
21
+ repository: Repository, skill_path: str | None, revision: str | None
22
+ ) -> str:
23
+ """Use the followed branch by default; explicit branches and full SHAs work."""
24
+ if (
25
+ repository.source_type == Repository.SourceType.LOCAL
26
+ and not settings.LOCAL_REPOSITORIES_ENABLED
27
+ ):
28
+ raise SkillSourceError("Local repositories are not enabled on this server.")
29
+ requested = revision or repository.default_branch
30
+ is_commit = bool(re.fullmatch(COMMIT_SHA_PATTERN, requested))
31
+ if not is_commit:
32
+ # Only branch names are accepted, never arbitrary Git revision expressions.
33
+ git(
34
+ Path(repository.local_path) if repository.local_path else Path.cwd(),
35
+ "check-ref-format",
36
+ f"refs/heads/{requested}",
37
+ )
38
+ if repository.source_type == Repository.SourceType.LOCAL:
39
+ root = Path(repository.local_path)
40
+ ref = requested if is_commit else f"refs/heads/{requested}"
41
+ commit = git(root, "rev-parse", "--verify", f"{ref}^{{commit}}")
42
+ if skill_path is not None:
43
+ committed_file(root, commit, skill_path)
44
+ return commit
45
+ access = github_access(repository)
46
+ token = access.token
47
+ with locked_mirror(repository.pk) as mirror:
48
+ authenticated_git(
49
+ mirror,
50
+ token,
51
+ "fetch",
52
+ "--no-tags",
53
+ "--",
54
+ access.clone_url,
55
+ requested if is_commit else f"refs/heads/{requested}",
56
+ )
57
+ commit = authenticated_git(
58
+ mirror, token, "rev-parse", "--verify", "FETCH_HEAD^{commit}"
59
+ )
60
+ if not re.fullmatch(COMMIT_SHA_PATTERN, commit):
61
+ raise SkillSourceError("The repository did not resolve to a full commit.")
62
+ if skill_path is not None:
63
+ committed_file(mirror, commit, skill_path)
64
+ return commit