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,181 @@
1
+ """Fetch GitHub source into the control plane's mirror. Tokens never leave it."""
2
+
3
+ from collections.abc import Iterator
4
+ from contextlib import contextmanager
5
+ from dataclasses import dataclass
6
+ from pathlib import Path
7
+
8
+ from secondshift.connections.dao import connected_github_sources
9
+ from secondshift.connections.github import connection_source, usable_source
10
+ from secondshift.connections.models import Connection
11
+ from secondshift.github import credentials, repos
12
+ from secondshift.github.app import configured, installation_repositories
13
+ from secondshift.github.errors import GitHubError
14
+ from secondshift.github.git import authenticated_git
15
+ from secondshift.github.identity import clone_url
16
+ from secondshift.repositories.dao import (
17
+ lock_or_create_github_repository,
18
+ lock_repository,
19
+ )
20
+ from secondshift.repositories.github_access import (
21
+ GitHubAccess,
22
+ connection_access,
23
+ github_access,
24
+ )
25
+ from secondshift.repositories.mirrors import SYNCED_REF, locked_mirror
26
+ from secondshift.repositories.models import Repository
27
+ from secondshift.workspaces.models import Workspace
28
+
29
+
30
+ @dataclass(frozen=True)
31
+ class FetchedBranch:
32
+ """The branch's newest commit, fetched into the mirror."""
33
+
34
+ mirror: Path
35
+ revision: str
36
+ token: str
37
+
38
+ def mark_synced(self) -> None:
39
+ """Keep this commit's objects in the mirror."""
40
+ authenticated_git(
41
+ self.mirror, self.token, "update-ref", SYNCED_REF, self.revision
42
+ )
43
+
44
+
45
+ @contextmanager
46
+ def fetched_branch(repository: Repository) -> Iterator[FetchedBranch]:
47
+ """Fetch the branch and hold the mirror's lock until the caller is done.
48
+
49
+ The fetch runs outside any database transaction.
50
+ """
51
+ access = github_access(repository)
52
+ with locked_mirror(repository.pk) as mirror:
53
+ authenticated_git(
54
+ mirror,
55
+ access.token,
56
+ "fetch",
57
+ "--no-tags",
58
+ "--",
59
+ access.clone_url,
60
+ f"refs/heads/{repository.default_branch}",
61
+ )
62
+ revision = authenticated_git(mirror, access.token, "rev-parse", "FETCH_HEAD")
63
+ yield FetchedBranch(mirror, revision, access.token)
64
+
65
+
66
+ def record_synced_revision(repository_id: int, revision: str) -> Repository:
67
+ """Save a good sync of this commit. Call inside a transaction."""
68
+ locked = lock_repository(repository_id)
69
+ locked.config_sha = revision
70
+ locked.checked_sha = revision
71
+ locked.last_sync_error = ""
72
+ locked.save(
73
+ update_fields=["config_sha", "checked_sha", "last_sync_error", "updated_at"]
74
+ )
75
+ return locked
76
+
77
+
78
+ def fetch_github_commit(access: GitHubAccess, commit_sha: str, mirror: Path) -> None:
79
+ """Fetch one commit by its SHA into the repository's mirror."""
80
+ try:
81
+ authenticated_git(
82
+ mirror,
83
+ access.token,
84
+ "fetch",
85
+ "--no-tags",
86
+ "--",
87
+ access.clone_url,
88
+ commit_sha,
89
+ )
90
+ except GitHubError as error:
91
+ raise GitHubError(
92
+ f"The commit {commit_sha[:12]} is no longer available from GitHub."
93
+ ) from error
94
+
95
+
96
+ class GitHubRepositoryExistsError(GitHubError):
97
+ pass
98
+
99
+
100
+ def source_repositories(connection: Connection) -> list[dict]:
101
+ """What a source connection can import, as GitHub lists it."""
102
+ source = connection_source(connection)
103
+ if isinstance(source, credentials.AppInstallation):
104
+ return installation_repositories(source.installation_id)
105
+ return repos.token_repositories(source.token)
106
+
107
+
108
+ def available_github_sources(
109
+ workspace: Workspace,
110
+ ) -> tuple[list[dict[str, object]], list[str]]:
111
+ available = []
112
+ errors = []
113
+ for connection in connected_github_sources(workspace.pk):
114
+ if not usable_source(connection) or (
115
+ connection.auth_strategy == Connection.AuthStrategy.MANAGED
116
+ and not configured()
117
+ ):
118
+ continue
119
+ try:
120
+ repositories = source_repositories(connection)
121
+ except GitHubError as error:
122
+ errors.append(f"{connection.display_name}: {error}")
123
+ continue
124
+ for item in repositories:
125
+ available.append(
126
+ {
127
+ "connection_id": connection.pk,
128
+ "repository_id": item["id"],
129
+ "full_name": item["full_name"],
130
+ "default_branch": item["default_branch"],
131
+ }
132
+ )
133
+ return available, errors
134
+
135
+
136
+ # A removed repository that is added again gets these back if its sync fails.
137
+ RESTORED_FIELDS = (
138
+ "removed_at",
139
+ "github_connection",
140
+ "default_branch",
141
+ )
142
+
143
+
144
+ def save_attached_repository(
145
+ workspace: Workspace,
146
+ connection: Connection,
147
+ repository_id: int,
148
+ branch: str,
149
+ metadata: dict,
150
+ ) -> tuple[Repository, dict | None]:
151
+ """The new or restored row, and a restored row's earlier values."""
152
+ repository, created = lock_or_create_github_repository(
153
+ workspace,
154
+ metadata["full_name"],
155
+ defaults={
156
+ "source_type": Repository.SourceType.GITHUB,
157
+ "github_connection": connection,
158
+ "external_id": str(repository_id),
159
+ "clone_url": clone_url(metadata["full_name"]),
160
+ "default_branch": branch or metadata["default_branch"],
161
+ },
162
+ )
163
+ if created:
164
+ return repository, None
165
+ if (
166
+ repository.removed_at is None
167
+ or repository.source_type != Repository.SourceType.GITHUB
168
+ or repository.external_id != str(repository_id)
169
+ ):
170
+ raise GitHubRepositoryExistsError("This repository is already connected.")
171
+ previous = {field: getattr(repository, field) for field in RESTORED_FIELDS}
172
+ repository.removed_at = None
173
+ repository.github_connection = connection
174
+ repository.default_branch = branch or metadata["default_branch"]
175
+ repository.save()
176
+ return repository, previous
177
+
178
+
179
+ def repository_branches(connection: Connection, repository_id: int) -> list[str]:
180
+ access = connection_access(connection, repository_id)
181
+ return repos.branches(access.token, access.full_name)
@@ -0,0 +1,90 @@
1
+ """Browser authorization verifies installations with the authorizing user's token."""
2
+
3
+ import secrets
4
+ import time
5
+
6
+ from django.conf import settings
7
+ from django.contrib.auth.decorators import login_required
8
+ from django.core.exceptions import PermissionDenied
9
+ from django.http import HttpRequest, HttpResponse, HttpResponseBadRequest
10
+ from django.shortcuts import redirect
11
+ from django.urls import reverse
12
+ from django.views.decorators.http import require_GET
13
+
14
+ from secondshift.connections.github import link_installations
15
+ from secondshift.github import installations
16
+ from secondshift.github.app import configured
17
+ from secondshift.github.errors import GitHubError
18
+ from secondshift.workspaces.access import AccessDenied, Action, require
19
+
20
+ STATE_KEY = "secondshift.github_installation"
21
+
22
+
23
+ @login_required
24
+ @require_GET
25
+ def github_start(request: HttpRequest) -> HttpResponse:
26
+ try:
27
+ workspace = require(request, Action.ADD_GITHUB_REPOSITORIES)
28
+ except AccessDenied as error:
29
+ raise PermissionDenied(str(error)) from error
30
+ if not configured():
31
+ return redirect("/repositories/new?source=github")
32
+ state = secrets.token_urlsafe(32)
33
+ request.session[STATE_KEY] = {
34
+ "state": state,
35
+ "workspace": workspace.pk,
36
+ "time": time.time(),
37
+ }
38
+ if request.GET.get("install"):
39
+ return redirect(installations.installation_url(state))
40
+ return redirect(installations.authorization_url(state, callback_url()))
41
+
42
+
43
+ @login_required
44
+ @require_GET
45
+ def github_callback(request: HttpRequest) -> HttpResponse:
46
+ try:
47
+ workspace = require(request, Action.ADD_GITHUB_REPOSITORIES)
48
+ except AccessDenied as error:
49
+ raise PermissionDenied(str(error)) from error
50
+ pending = request.session.get(STATE_KEY, {})
51
+ state = request.GET.get("state", "")
52
+ if (
53
+ not state
54
+ or not secrets.compare_digest(state, pending.get("state", ""))
55
+ or pending.get("workspace") != workspace.pk
56
+ or time.time() - pending.get("time", 0) > 600
57
+ ):
58
+ return HttpResponseBadRequest(
59
+ "GitHub authorization expired or does not match this workspace."
60
+ )
61
+ if request.GET.get("setup_action") == "request":
62
+ request.session.pop(STATE_KEY, None)
63
+ return HttpResponseBadRequest(
64
+ "Your organization must approve the GitHub App installation. Connect again after approval."
65
+ )
66
+ if request.GET.get("error"):
67
+ request.session.pop(STATE_KEY, None)
68
+ return HttpResponseBadRequest("GitHub authorization was cancelled.")
69
+ code = request.GET.get("code", "")
70
+ if not code:
71
+ # Installations that already exist still require user authorization.
72
+ return redirect(installations.authorization_url(state, callback_url()))
73
+ request.session.pop(STATE_KEY, None)
74
+ try:
75
+ token = installations.user_token(code, callback_url())
76
+ if not link_installations(workspace, installations.user_installations(token)):
77
+ state = secrets.token_urlsafe(32)
78
+ request.session[STATE_KEY] = {
79
+ "state": state,
80
+ "workspace": workspace.pk,
81
+ "time": time.time(),
82
+ }
83
+ return redirect(installations.installation_url(state))
84
+ except GitHubError as error:
85
+ return HttpResponseBadRequest(str(error))
86
+ return redirect("/repositories/new?source=github")
87
+
88
+
89
+ def callback_url() -> str:
90
+ return settings.FRONTEND_URL.rstrip("/") + reverse("github-callback")
@@ -0,0 +1,198 @@
1
+ import hashlib
2
+ import logging
3
+ import posixpath
4
+ import subprocess
5
+ from dataclasses import dataclass
6
+ from pathlib import Path
7
+
8
+ from secondshift.config.constants import GITHUB_BASE_URL, MANIFEST_FILE
9
+ from secondshift.repositories.dao import local_repository, save_local_repository
10
+ from secondshift.repositories.github import github_remote_name
11
+ from secondshift.repositories.models import SYNC_ERROR_MAX_CHARS, Repository
12
+ from secondshift.workspaces.models import Workspace
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+
17
+ class LocalRepositoryError(ValueError):
18
+ pass
19
+
20
+
21
+ @dataclass(frozen=True)
22
+ class LocalRepositoryLocation:
23
+ root: Path
24
+ # The branch SecondShift follows, and its newest commit.
25
+ branch: str
26
+ revision: str
27
+ repository_name: str
28
+ clone_url: str = ""
29
+
30
+
31
+ # Git file modes for a regular and an executable file. Symbolic links and
32
+ # submodules are refused so a path cannot point outside the repository.
33
+ FILE_MODES = {"100644", "100755"}
34
+
35
+
36
+ def git(root: Path, *arguments: str) -> str:
37
+ try:
38
+ completed = subprocess.run(
39
+ ["git", "-C", str(root), *arguments],
40
+ capture_output=True,
41
+ check=False,
42
+ text=True,
43
+ timeout=10,
44
+ )
45
+ except subprocess.TimeoutExpired as error:
46
+ raise LocalRepositoryError("Git took too long to answer.") from error
47
+ if completed.returncode:
48
+ # Git's own output can name folders on this machine, so it stays in
49
+ # the server log. Only owners may see local paths.
50
+ logger.info(
51
+ "git %s failed: %s",
52
+ arguments[0] if arguments else "",
53
+ completed.stderr.strip() or completed.stdout.strip(),
54
+ )
55
+ raise LocalRepositoryError("Git could not inspect the repository.")
56
+ return completed.stdout.strip()
57
+
58
+
59
+ def committed_file(root: Path, revision: str, path: str) -> str:
60
+ """A file's text at a commit. Only regular files inside the repository."""
61
+ normalized = posixpath.normpath(path)
62
+ if normalized.startswith(("../", "/")) or normalized in ("..", "."):
63
+ raise LocalRepositoryError(f"{path} must be a file inside the repository.")
64
+ try:
65
+ entry = git(root, "ls-tree", revision, "--", normalized)
66
+ except LocalRepositoryError as error:
67
+ raise LocalRepositoryError(f"Git could not read {path}.") from error
68
+ if not entry:
69
+ raise LocalRepositoryError(f"{path} is not committed on the branch.")
70
+ mode = entry.split(maxsplit=1)[0]
71
+ if mode not in FILE_MODES:
72
+ raise LocalRepositoryError(f"{path} must be a regular file, not a link.")
73
+ try:
74
+ return git(root, "show", f"{revision}:{normalized}")
75
+ except LocalRepositoryError as error:
76
+ raise LocalRepositoryError(f"Git could not read {path}.") from error
77
+
78
+
79
+ def current_branch(root: Path) -> str:
80
+ try:
81
+ return git(root, "symbolic-ref", "--short", "HEAD")
82
+ except LocalRepositoryError as error:
83
+ raise LocalRepositoryError(
84
+ "Check out the branch SecondShift should follow, then add the repository."
85
+ ) from error
86
+
87
+
88
+ def branch_revision(root: Path, branch: str) -> str:
89
+ """The newest commit on a branch."""
90
+ try:
91
+ return git(root, "rev-parse", "--verify", f"refs/heads/{branch}^{{commit}}")
92
+ except LocalRepositoryError as error:
93
+ raise LocalRepositoryError(f"The branch {branch} does not exist.") from error
94
+
95
+
96
+ def locate_local_repository(
97
+ raw_path: str, branch: str | None = None
98
+ ) -> LocalRepositoryLocation:
99
+ """Find the repository and the newest commit on a branch.
100
+
101
+ Without a branch it follows the one checked out now.
102
+ """
103
+ candidate = Path(raw_path).expanduser().resolve()
104
+ if not candidate.is_dir():
105
+ raise LocalRepositoryError("The selected path is not a directory.")
106
+
107
+ try:
108
+ root = Path(git(candidate, "rev-parse", "--show-toplevel")).resolve()
109
+ except LocalRepositoryError as error:
110
+ raise LocalRepositoryError(
111
+ "The selected path is not a Git repository."
112
+ ) from error
113
+
114
+ branch = branch or current_branch(root)
115
+ revision = branch_revision(root, branch)
116
+ try:
117
+ github_name = github_remote_name(git(root, "remote", "get-url", "origin"))
118
+ except LocalRepositoryError:
119
+ github_name = None
120
+ return LocalRepositoryLocation(
121
+ root=root,
122
+ branch=branch,
123
+ revision=revision,
124
+ repository_name=local_repository_name(root),
125
+ clone_url=f"{GITHUB_BASE_URL}/{github_name}" if github_name else "",
126
+ )
127
+
128
+
129
+ def local_repository_name(root: Path) -> str:
130
+ digest = hashlib.sha256(str(root).encode()).hexdigest()[:8]
131
+ return f"local/{root.name}-{digest}"
132
+
133
+
134
+ def save_local_location(
135
+ workspace: Workspace,
136
+ location: LocalRepositoryLocation,
137
+ config_error: str,
138
+ config_read: bool,
139
+ ) -> tuple[Repository, bool]:
140
+ """Save what was found in the folder.
141
+
142
+ Returns the repository and whether its GitHub origin changed.
143
+ """
144
+ previous = local_repository(workspace, str(location.root))
145
+ origin_changed = previous is not None and previous.clone_url != location.clone_url
146
+ synced = {"config_sha": location.revision} if config_read else {}
147
+ # Only a new or a removed repository is switched on. A resync keeps the
148
+ # owner's choice, as GitHub syncs do.
149
+ added = (
150
+ {"enabled": True, "removed_at": None}
151
+ if previous is None or previous.removed_at is not None
152
+ else {}
153
+ )
154
+ repository = save_local_repository(
155
+ workspace,
156
+ str(location.root),
157
+ {
158
+ "source_type": Repository.SourceType.LOCAL,
159
+ "full_name": location.repository_name,
160
+ "clone_url": location.clone_url,
161
+ "default_branch": location.branch,
162
+ "config_path": MANIFEST_FILE,
163
+ "checked_sha": location.revision,
164
+ "last_sync_error": config_error[:SYNC_ERROR_MAX_CHARS],
165
+ **added,
166
+ **synced,
167
+ },
168
+ )
169
+ return repository, origin_changed
170
+
171
+
172
+ def local_directories(raw_path: str) -> tuple[Path, Path | None, list[Path]]:
173
+ requested = raw_path if raw_path.strip() else ""
174
+ try:
175
+ candidate = (
176
+ Path(requested).expanduser().resolve()
177
+ if requested
178
+ else Path.home().resolve()
179
+ )
180
+ except (OSError, RuntimeError, ValueError) as error:
181
+ target = "selected path" if requested else "home directory"
182
+ raise LocalRepositoryError(f"Unable to open the {target}.") from error
183
+ try:
184
+ is_directory = candidate.is_dir()
185
+ except (OSError, RuntimeError, ValueError) as error:
186
+ raise LocalRepositoryError(f"Unable to access {candidate}.") from error
187
+ if not is_directory:
188
+ raise LocalRepositoryError(f"{candidate} is not a directory.")
189
+ try:
190
+ entries = list(candidate.iterdir())
191
+ directories = sorted(
192
+ (item for item in entries if item.is_dir()),
193
+ key=lambda item: item.name.lower(),
194
+ )
195
+ except (OSError, RuntimeError, ValueError) as error:
196
+ raise LocalRepositoryError(f"Unable to read {candidate}.") from error
197
+ parent = None if candidate.parent == candidate else candidate.parent
198
+ return candidate, parent, directories
File without changes
@@ -0,0 +1,15 @@
1
+ import djclick as click
2
+
3
+ from secondshift.repositories.sources import prune_source_cache
4
+
5
+
6
+ @click.command()
7
+ @click.option("--dry-run", is_flag=True, help="Show what would be removed.")
8
+ def command(dry_run: bool) -> None:
9
+ """Remove cached repository sources no repository needs, and trim to size."""
10
+ pruned = prune_source_cache(dry_run=dry_run)
11
+ verb = "Would remove" if dry_run else "Removed"
12
+ click.echo(
13
+ f"{verb} {pruned.snapshots} snapshot(s) and {pruned.mirrors} mirror(s), "
14
+ f"{pruned.bytes / (1024 * 1024):.1f} MiB"
15
+ )
@@ -0,0 +1,45 @@
1
+ # Generated by Django 6.0.9 on 2026-10-26 14:22
2
+
3
+ import django.db.models.deletion
4
+ from django.db import migrations, models
5
+
6
+
7
+ class Migration(migrations.Migration):
8
+
9
+ initial = True
10
+
11
+ dependencies = [
12
+ ('connections', '0001_initial'),
13
+ ('workspaces', '0001_initial'),
14
+ ]
15
+
16
+ operations = [
17
+ migrations.CreateModel(
18
+ name='Repository',
19
+ fields=[
20
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
21
+ ('source_type', models.CharField(choices=[('github', 'GitHub'), ('local', 'Local')], default='github', max_length=16)),
22
+ ('local_path', models.CharField(blank=True, max_length=1000)),
23
+ ('external_id', models.CharField(blank=True, max_length=64)),
24
+ ('full_name', models.CharField(max_length=255)),
25
+ ('clone_url', models.URLField(blank=True)),
26
+ ('default_branch', models.CharField(default='main', max_length=255)),
27
+ ('installation_id', models.CharField(blank=True, max_length=64)),
28
+ ('enabled', models.BooleanField(default=False)),
29
+ ('auto_sync', models.BooleanField(default=True)),
30
+ ('config_path', models.CharField(default='.secondshift.yaml', max_length=255)),
31
+ ('config_sha', models.CharField(blank=True, max_length=64)),
32
+ ('last_synced_at', models.DateTimeField(blank=True, null=True)),
33
+ ('removed_at', models.DateTimeField(blank=True, null=True)),
34
+ ('created_at', models.DateTimeField(auto_now_add=True)),
35
+ ('updated_at', models.DateTimeField(auto_now=True)),
36
+ ('github_connection', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='repositories', to='connections.connection')),
37
+ ('workspace', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='repositories', to='workspaces.workspace')),
38
+ ],
39
+ options={
40
+ 'verbose_name_plural': 'repositories',
41
+ 'ordering': ['full_name'],
42
+ 'constraints': [models.UniqueConstraint(fields=('workspace', 'full_name'), name='unique_workspace_repository'), models.UniqueConstraint(condition=models.Q(('local_path', ''), _negated=True), fields=('workspace', 'local_path'), name='unique_workspace_local_repository')],
43
+ },
44
+ ),
45
+ ]
@@ -0,0 +1,18 @@
1
+ # Generated by Django 6.0.9 on 2026-10-26 15:22
2
+
3
+ from django.db import migrations, models
4
+
5
+
6
+ class Migration(migrations.Migration):
7
+
8
+ dependencies = [
9
+ ('repositories', '0001_initial'),
10
+ ]
11
+
12
+ operations = [
13
+ migrations.AddField(
14
+ model_name='repository',
15
+ name='last_sync_error',
16
+ field=models.CharField(blank=True, max_length=500),
17
+ ),
18
+ ]
@@ -0,0 +1,18 @@
1
+ # Generated by Django 6.0.9 on 2026-11-03 13:46
2
+
3
+ from django.db import migrations, models
4
+
5
+
6
+ class Migration(migrations.Migration):
7
+
8
+ dependencies = [
9
+ ('repositories', '0002_repository_last_sync_error'),
10
+ ]
11
+
12
+ operations = [
13
+ migrations.AddField(
14
+ model_name='repository',
15
+ name='checked_sha',
16
+ field=models.CharField(blank=True, max_length=64),
17
+ ),
18
+ ]
@@ -0,0 +1,23 @@
1
+ # Generated by Django 6.0.9 on 2026-11-03 20:32
2
+
3
+ from django.db import migrations, models
4
+
5
+
6
+ class Migration(migrations.Migration):
7
+
8
+ dependencies = [
9
+ ('repositories', '0003_repository_checked_sha'),
10
+ ]
11
+
12
+ operations = [
13
+ migrations.AddField(
14
+ model_name='repository',
15
+ name='allow_pull_requests',
16
+ field=models.BooleanField(default=False),
17
+ ),
18
+ migrations.AddField(
19
+ model_name='repository',
20
+ name='pull_request_ci',
21
+ field=models.BooleanField(default=False),
22
+ ),
23
+ ]
@@ -0,0 +1,17 @@
1
+ # Generated by Django 6.0.9 on 2026-11-03 23:46
2
+
3
+ from django.db import migrations
4
+
5
+
6
+ class Migration(migrations.Migration):
7
+
8
+ dependencies = [
9
+ ('repositories', '0004_pull_request_settings'),
10
+ ]
11
+
12
+ operations = [
13
+ migrations.RemoveField(
14
+ model_name='repository',
15
+ name='installation_id',
16
+ ),
17
+ ]
@@ -0,0 +1,18 @@
1
+ # Generated by Django 6.0.9 on 2026-11-04 09:19
2
+
3
+ from django.db import migrations, models
4
+
5
+
6
+ class Migration(migrations.Migration):
7
+
8
+ dependencies = [
9
+ ('repositories', '0005_remove_repository_installation_id'),
10
+ ]
11
+
12
+ operations = [
13
+ migrations.AddField(
14
+ model_name='repository',
15
+ name='allow_issues',
16
+ field=models.BooleanField(default=False),
17
+ ),
18
+ ]
File without changes