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,59 @@
1
+ """One way to get a GitHub token, whatever kind of credential stands behind it."""
2
+
3
+ from dataclasses import dataclass
4
+
5
+ from secondshift.github import app
6
+
7
+ READ = {"contents": "read"}
8
+ WRITE = {"contents": "write", "pull_requests": "write"}
9
+ ISSUES_WRITE = {"issues": "write"}
10
+
11
+
12
+ @dataclass(frozen=True)
13
+ class AppInstallation:
14
+ """A GitHub App installation. Each token is minted for one request."""
15
+
16
+ installation_id: str
17
+
18
+
19
+ @dataclass(frozen=True)
20
+ class PersonalToken:
21
+ """A fine-grained personal access token. It cannot be narrowed per request."""
22
+
23
+ token: str
24
+
25
+
26
+ Source = AppInstallation | PersonalToken
27
+
28
+
29
+ def token_for(
30
+ source: Source, repository_id: int | None, permissions: dict[str, str]
31
+ ) -> str:
32
+ """A token for one repository, or for the whole source when repository_id is None.
33
+
34
+ App tokens carry only the permissions asked for and expire within an hour.
35
+ A personal token is returned as it is. GitHub refuses what it cannot do.
36
+ """
37
+ if isinstance(source, PersonalToken):
38
+ return source.token
39
+ return app.installation_token(source.installation_id, repository_id, permissions)
40
+
41
+
42
+ def read_token_for(
43
+ source: Source, repository_id: int | None, wanted: tuple[str, ...]
44
+ ) -> str:
45
+ """A read-only token with the wanted permissions the source has.
46
+
47
+ An App token never asks for more than the installation granted, so a
48
+ missing permission fails only the calls that need it. A personal token
49
+ has whatever its owner gave it.
50
+ """
51
+ if isinstance(source, PersonalToken):
52
+ return source.token
53
+ granted = app.installation_permissions(source.installation_id)
54
+ permissions = {
55
+ name: "read" for name in wanted if granted.get(name) in {"read", "write"}
56
+ }
57
+ return app.installation_token(
58
+ source.installation_id, repository_id, permissions or {"metadata": "read"}
59
+ )
@@ -0,0 +1,2 @@
1
+ class GitHubError(ValueError):
2
+ """A GitHub step failed. The message never contains a credential."""
@@ -0,0 +1,56 @@
1
+ """Run git against GitHub with a token that never leaves the process."""
2
+
3
+ import base64
4
+ import os
5
+ import subprocess
6
+ from pathlib import Path
7
+
8
+ from secondshift.config.constants import GITHUB_BASE_URL
9
+ from secondshift.github.errors import GitHubError
10
+
11
+
12
+ def authenticated_git(root: Path, token: str, *arguments: str) -> str:
13
+ """Run one git command in root, sending the token only as a header."""
14
+ # Never put credentials in URLs, arguments, persisted config, or errors.
15
+ authorization = base64.b64encode(f"x-access-token:{token}".encode()).decode()
16
+ return run_git(
17
+ root,
18
+ arguments,
19
+ {
20
+ "GIT_CONFIG_COUNT": "1",
21
+ "GIT_CONFIG_KEY_0": f"http.{GITHUB_BASE_URL}/.extraheader",
22
+ "GIT_CONFIG_VALUE_0": f"AUTHORIZATION: basic {authorization}",
23
+ },
24
+ )
25
+
26
+
27
+ def public_git(root: Path, *arguments: str) -> str:
28
+ """Run one git command in root without any credentials."""
29
+ return run_git(root, arguments, {"GIT_CONFIG_COUNT": "0"})
30
+
31
+
32
+ def run_git(root: Path, arguments: tuple[str, ...], config: dict[str, str]) -> str:
33
+ environment = {
34
+ **os.environ,
35
+ "GIT_TERMINAL_PROMPT": "0",
36
+ "GIT_CONFIG_NOSYSTEM": "1",
37
+ "GIT_CONFIG_GLOBAL": os.devnull,
38
+ # No helper may answer a password prompt with stored credentials.
39
+ "GIT_ASKPASS": "",
40
+ "SSH_ASKPASS": "",
41
+ **config,
42
+ }
43
+ try:
44
+ result = subprocess.run(
45
+ ["git", "-C", str(root), *arguments],
46
+ env=environment,
47
+ capture_output=True,
48
+ text=True,
49
+ timeout=120,
50
+ check=False,
51
+ )
52
+ except subprocess.TimeoutExpired as error:
53
+ raise GitHubError("GitHub repository fetch timed out.") from error
54
+ if result.returncode:
55
+ raise GitHubError("Could not fetch the GitHub repository and branch.")
56
+ return result.stdout.strip()
@@ -0,0 +1,35 @@
1
+ """Repository names from GitHub remotes."""
2
+
3
+ import re
4
+ from urllib.parse import urlsplit
5
+
6
+ from secondshift.config.constants import (
7
+ GITHUB_BASE_URL,
8
+ GITHUB_HOST,
9
+ GITHUB_REPOSITORY_PATTERN,
10
+ GITHUB_SSH_PREFIX,
11
+ )
12
+
13
+
14
+ def github_remote_name(remote: str) -> str | None:
15
+ """The owner/name a GitHub remote points at, or None for anything else."""
16
+ if remote.startswith(GITHUB_SSH_PREFIX):
17
+ path = remote.removeprefix(GITHUB_SSH_PREFIX)
18
+ else:
19
+ try:
20
+ parts = urlsplit(remote)
21
+ if parts.hostname != GITHUB_HOST or parts.scheme not in {"https", "ssh"}:
22
+ return None
23
+ path = parts.path.removeprefix("/")
24
+ except ValueError:
25
+ return None
26
+ name = path.removesuffix("/").removesuffix(".git")
27
+ return name if valid_name(name) else None
28
+
29
+
30
+ def valid_name(full_name: str) -> bool:
31
+ return bool(re.fullmatch(GITHUB_REPOSITORY_PATTERN, full_name))
32
+
33
+
34
+ def clone_url(full_name: str) -> str:
35
+ return f"{GITHUB_BASE_URL}/{full_name}.git"
@@ -0,0 +1,74 @@
1
+ """Find the App installations a person can use, through their own sign-in.
2
+
3
+ The person's token proves which installations they may link. It is used for
4
+ two requests and never stored.
5
+ """
6
+
7
+ from dataclasses import dataclass
8
+ from urllib.parse import urlencode
9
+
10
+ from django.conf import settings
11
+
12
+ from secondshift.config.constants import (
13
+ GITHUB_API_URL,
14
+ GITHUB_BASE_URL,
15
+ GITHUB_OAUTH_URL,
16
+ )
17
+ from secondshift.github.errors import GitHubError
18
+ from secondshift.github.rest import paginated, request_json
19
+
20
+
21
+ @dataclass(frozen=True)
22
+ class Installation:
23
+ installation_id: str
24
+ account: str
25
+
26
+
27
+ def authorization_url(state: str, redirect_uri: str) -> str:
28
+ return f"{GITHUB_OAUTH_URL}/authorize?" + urlencode(
29
+ {
30
+ "client_id": settings.GITHUB_APP_CLIENT_ID,
31
+ "state": state,
32
+ "redirect_uri": redirect_uri,
33
+ }
34
+ )
35
+
36
+
37
+ def installation_url(state: str) -> str:
38
+ return (
39
+ f"{GITHUB_BASE_URL}/apps/{settings.GITHUB_APP_SLUG}/installations/new?"
40
+ + urlencode({"state": state})
41
+ )
42
+
43
+
44
+ def user_token(code: str, redirect_uri: str) -> str:
45
+ data = request_json(
46
+ "POST",
47
+ f"{GITHUB_OAUTH_URL}/access_token",
48
+ json={
49
+ "client_id": settings.GITHUB_APP_CLIENT_ID,
50
+ "client_secret": settings.GITHUB_APP_CLIENT_SECRET,
51
+ "code": code,
52
+ "redirect_uri": redirect_uri,
53
+ },
54
+ )
55
+ token = data.get("access_token")
56
+ if not token:
57
+ raise GitHubError("GitHub did not authorize this user.")
58
+ return token
59
+
60
+
61
+ def user_installations(token: str) -> list[Installation]:
62
+ """This App's installations that the person can reach. Other apps are skipped."""
63
+ listed = paginated(
64
+ f"{GITHUB_API_URL}/user/installations",
65
+ token,
66
+ "installations",
67
+ "You can reach too many GitHub App installations to list.",
68
+ )
69
+ return [
70
+ Installation(str(item["id"]), item["account"]["login"])
71
+ for item in listed
72
+ if isinstance(item, dict)
73
+ and str(item.get("app_id")) == str(settings.GITHUB_APP_ID)
74
+ ]
@@ -0,0 +1,75 @@
1
+ """Open issues through the REST API."""
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Any
5
+
6
+ from secondshift.config.constants import GITHUB_API_URL
7
+ from secondshift.github.errors import GitHubError
8
+ from secondshift.github.rest import parsed, reason, send
9
+
10
+ # GitHub refuses a longer title or body.
11
+ MAX_TITLE_CHARS = 256
12
+ MAX_BODY_CHARS = 65_000
13
+
14
+
15
+ @dataclass(frozen=True)
16
+ class Issue:
17
+ number: int
18
+ url: str
19
+
20
+
21
+ def same_title(left: str, right: str) -> bool:
22
+ return " ".join(left.split()).casefold() == " ".join(right.split()).casefold()
23
+
24
+
25
+ def open_issue_titled(token: str, full_name: str, title: str) -> Issue | None:
26
+ """An open issue with this exact title, so a weekly job does not repeat it.
27
+
28
+ Search is a little behind new issues, so this catches older duplicates,
29
+ not one opened seconds ago.
30
+ """
31
+ words = title.replace('"', " ").strip()
32
+ response = send(
33
+ "GET",
34
+ f"{GITHUB_API_URL}/search/issues",
35
+ token,
36
+ params={
37
+ "q": f'repo:{full_name} is:issue is:open in:title "{words}"',
38
+ "per_page": 20,
39
+ },
40
+ )
41
+ if response.status_code != 200:
42
+ raise GitHubError(f"GitHub could not search issues: {reason(response)}")
43
+ payload = parsed(response)
44
+ items = payload.get("items") if isinstance(payload, dict) else None
45
+ for item in items if isinstance(items, list) else []:
46
+ if isinstance(item, dict) and same_title(str(item.get("title", "")), title):
47
+ return checked(item)
48
+ return None
49
+
50
+
51
+ def open_issue(
52
+ token: str, full_name: str, title: str, body: str, labels: list[str]
53
+ ) -> Issue:
54
+ payload: dict[str, Any] = {
55
+ "title": title[:MAX_TITLE_CHARS],
56
+ "body": body[:MAX_BODY_CHARS],
57
+ }
58
+ if labels:
59
+ payload["labels"] = labels
60
+ created = send(
61
+ "POST", f"{GITHUB_API_URL}/repos/{full_name}/issues", token, json=payload
62
+ )
63
+ if created.status_code != 201:
64
+ raise GitHubError(f"GitHub did not open the issue: {reason(created)}")
65
+ return checked(parsed(created))
66
+
67
+
68
+ def checked(payload: Any) -> Issue:
69
+ if (
70
+ not isinstance(payload, dict)
71
+ or not isinstance(payload.get("html_url"), str)
72
+ or not isinstance(payload.get("number"), int)
73
+ ):
74
+ raise GitHubError("GitHub returned an invalid issue.")
75
+ return Issue(payload["number"], payload["html_url"])
@@ -0,0 +1,49 @@
1
+ """What a GitHub credential may do, by feature.
2
+
3
+ An App installation lists its permissions. A personal token does not, so each
4
+ read permission is probed with one cheap request. Write access cannot be
5
+ probed without writing, so it is never reported for a token.
6
+ """
7
+
8
+ from urllib.parse import quote
9
+
10
+ from secondshift.config.constants import GITHUB_API_URL
11
+ from secondshift.github.rest import send
12
+
13
+ # The feature each permission serves, as GitHub names them.
14
+ SYNC = ("contents",)
15
+ TOOLS = ("pull_requests", "actions", "statuses")
16
+ DRAFT_PULL_REQUESTS = ("contents", "pull_requests")
17
+ ISSUES = ("issues",)
18
+
19
+
20
+ def probe_paths(branch: str) -> dict[str, str]:
21
+ """A read request per permission. Each fails without that permission."""
22
+ return {
23
+ "contents": "commits?per_page=1",
24
+ "pull_requests": "pulls?per_page=1",
25
+ "actions": "actions/runs?per_page=1",
26
+ "statuses": f"commits/{quote(branch, safe='')}/status",
27
+ }
28
+
29
+
30
+ def token_reads(
31
+ token: str, full_name: str, branch: str, names: tuple[str, ...]
32
+ ) -> dict[str, bool]:
33
+ """Whether the token can read each named permission on this repository."""
34
+ paths = probe_paths(branch)
35
+ return {
36
+ name: send(
37
+ "GET", f"{GITHUB_API_URL}/repos/{full_name}/{paths[name]}", token
38
+ ).status_code
39
+ == 200
40
+ for name in names
41
+ }
42
+
43
+
44
+ def granted_reads(granted: dict[str, str], names: tuple[str, ...]) -> dict[str, bool]:
45
+ return {name: granted.get(name) in {"read", "write"} for name in names}
46
+
47
+
48
+ def granted_writes(granted: dict[str, str], names: tuple[str, ...]) -> dict[str, bool]:
49
+ return {name: granted.get(name) == "write" for name in names}
@@ -0,0 +1,49 @@
1
+ """Open draft pull requests through the REST API."""
2
+
3
+ from typing import Any
4
+
5
+ from secondshift.config.constants import GITHUB_API_URL
6
+ from secondshift.github.errors import GitHubError
7
+ from secondshift.github.rest import parsed, reason, send
8
+
9
+ # GitHub refuses a longer description.
10
+ MAX_BODY_CHARS = 60_000
11
+
12
+
13
+ def open_draft(
14
+ token: str, full_name: str, title: str, head: str, base: str, body: str
15
+ ) -> dict[str, Any]:
16
+ """Open the draft, or find the one an earlier attempt opened."""
17
+ url = f"{GITHUB_API_URL}/repos/{full_name}/pulls"
18
+ created = send(
19
+ "POST",
20
+ url,
21
+ token,
22
+ json={
23
+ "title": title,
24
+ "head": head,
25
+ "base": base,
26
+ "body": body[:MAX_BODY_CHARS],
27
+ "draft": True,
28
+ },
29
+ )
30
+ if created.status_code == 201:
31
+ return checked(parsed(created))
32
+ owner = full_name.split("/", 1)[0]
33
+ existing = send(
34
+ "GET", url, token, params={"head": f"{owner}:{head}", "state": "all"}
35
+ )
36
+ found = parsed(existing) if existing.status_code == 200 else []
37
+ if isinstance(found, list) and found:
38
+ return checked(found[0])
39
+ raise GitHubError(f"GitHub did not open the pull request: {reason(created)}")
40
+
41
+
42
+ def checked(payload: object) -> dict[str, Any]:
43
+ if (
44
+ not isinstance(payload, dict)
45
+ or not isinstance(payload.get("html_url"), str)
46
+ or not isinstance(payload.get("number"), int)
47
+ ):
48
+ raise GitHubError("GitHub returned an invalid pull request.")
49
+ return payload
@@ -0,0 +1,52 @@
1
+ """Repository lookups that work with any token."""
2
+
3
+ from urllib.parse import quote
4
+
5
+ from secondshift.config.constants import GITHUB_API_URL
6
+ from secondshift.github.errors import GitHubError
7
+ from secondshift.github.identity import valid_name
8
+ from secondshift.github.rest import paginated, request_json
9
+
10
+
11
+ def repository(token: str, repository_id: int) -> dict:
12
+ """The repository with this ID. Its name may change, its ID never does."""
13
+ data = request_json("GET", f"{GITHUB_API_URL}/repositories/{repository_id}", token)
14
+ if data.get("id") != repository_id:
15
+ raise GitHubError("GitHub repository identity changed.")
16
+ if not valid_name(str(data.get("full_name", ""))):
17
+ raise GitHubError("Invalid GitHub repository name.")
18
+ return data
19
+
20
+
21
+ def branch_head(token: str, repository_id: int, branch: str) -> str:
22
+ data = request_json(
23
+ "GET",
24
+ f"{GITHUB_API_URL}/repositories/{repository_id}/commits/{quote(branch, safe='')}",
25
+ token,
26
+ )
27
+ return data["sha"]
28
+
29
+
30
+ def branches(token: str, full_name: str) -> list[str]:
31
+ batch = paginated(
32
+ f"{GITHUB_API_URL}/repos/{full_name}/branches",
33
+ token,
34
+ None,
35
+ "This repository has too many branches to list.",
36
+ )
37
+ if any(
38
+ not isinstance(item, dict) or not isinstance(item.get("name"), str)
39
+ for item in batch
40
+ ):
41
+ raise GitHubError("GitHub returned an invalid branch listing.")
42
+ return [item["name"] for item in batch]
43
+
44
+
45
+ def token_repositories(token: str) -> list[dict]:
46
+ """The repositories a personal access token was given."""
47
+ return paginated(
48
+ f"{GITHUB_API_URL}/user/repos",
49
+ token,
50
+ None,
51
+ "This token reaches too many repositories to list.",
52
+ )
@@ -0,0 +1,93 @@
1
+ """The one way SecondShift calls GitHub's REST API.
2
+
3
+ Errors raised here never include the token, the request, or GitHub's reply.
4
+ """
5
+
6
+ from typing import Any
7
+
8
+ import httpx
9
+
10
+ from secondshift.config.constants import GITHUB_API_VERSION
11
+ from secondshift.github.errors import GitHubError
12
+
13
+ PAGE_SIZE = 100
14
+ MAX_PAGES = 100
15
+ TIMEOUT_SECONDS = 30
16
+
17
+
18
+ def headers(token: str = "") -> dict[str, str]:
19
+ return {
20
+ "Accept": "application/json",
21
+ **({"Authorization": f"Bearer {token}"} if token else {}),
22
+ "X-GitHub-Api-Version": GITHUB_API_VERSION,
23
+ }
24
+
25
+
26
+ def client() -> httpx.Client:
27
+ return httpx.Client(timeout=TIMEOUT_SECONDS)
28
+
29
+
30
+ def send(method: str, url: str, token: str = "", **kwargs: Any) -> httpx.Response:
31
+ """The raw response, for callers that read GitHub's status codes."""
32
+ try:
33
+ with client() as session:
34
+ return session.request(method, url, headers=headers(token), **kwargs)
35
+ except (httpx.HTTPError, httpx.InvalidURL) as error:
36
+ raise GitHubError("GitHub could not be reached.") from error
37
+
38
+
39
+ def request_payload(method: str, url: str, token: str = "", **kwargs: Any) -> object:
40
+ try:
41
+ response = send(method, url, token, **kwargs)
42
+ response.raise_for_status()
43
+ return response.json()
44
+ except (httpx.HTTPError, ValueError, GitHubError) as error:
45
+ raise GitHubError(
46
+ "GitHub access failed. Check the installation and permissions."
47
+ ) from error
48
+
49
+
50
+ def request_json(method: str, url: str, token: str = "", **kwargs: Any) -> dict:
51
+ result = request_payload(method, url, token, **kwargs)
52
+ if not isinstance(result, dict) or "error" in result:
53
+ raise GitHubError("GitHub returned an invalid authorization response.")
54
+ return result
55
+
56
+
57
+ def paginated(url: str, token: str, key: str | None, too_many: str) -> list[Any]:
58
+ """Every item of a listing, page by page. key names the list in each page."""
59
+ items: list[Any] = []
60
+ separator = "&" if "?" in url else "?"
61
+ for page in range(1, MAX_PAGES + 1):
62
+ payload = request_payload(
63
+ "GET", f"{url}{separator}per_page={PAGE_SIZE}&page={page}", token
64
+ )
65
+ batch = payload.get(key) if key and isinstance(payload, dict) else payload
66
+ if not isinstance(batch, list):
67
+ raise GitHubError("GitHub returned an invalid listing.")
68
+ items.extend(batch)
69
+ if len(batch) < PAGE_SIZE:
70
+ return items
71
+ raise GitHubError(too_many)
72
+
73
+
74
+ def parsed(response: httpx.Response) -> object:
75
+ try:
76
+ return response.json()
77
+ except ValueError:
78
+ return None
79
+
80
+
81
+ def reason(response: httpx.Response) -> str:
82
+ """GitHub's own short reason, which never contains the token."""
83
+ payload = parsed(response)
84
+ errors = payload.get("errors") if isinstance(payload, dict) else None
85
+ details = [
86
+ str(item.get("message"))
87
+ for item in errors or []
88
+ if isinstance(item, dict) and item.get("message")
89
+ ]
90
+ message = details[0] if details else ""
91
+ if not message and isinstance(payload, dict):
92
+ message = str(payload.get("message", ""))
93
+ return (message or f"HTTP {response.status_code}")[:200]
@@ -0,0 +1,16 @@
1
+ # This package must not depend on the Django apps, so every app can use it.
2
+ # This list replaces the root one, so the root ban on requests is repeated.
3
+ extend = "../../pyproject.toml"
4
+
5
+ [lint.flake8-tidy-imports.banned-api]
6
+ "requests".msg = "Use httpx, the project's one HTTP client."
7
+ "secondshift.connections".msg = "secondshift.github must not import Django apps."
8
+ "secondshift.core".msg = "secondshift.github must not import Django apps."
9
+ "secondshift.gateway".msg = "secondshift.github must not import Django apps."
10
+ "secondshift.jobs".msg = "secondshift.github must not import Django apps."
11
+ "secondshift.mcp_server".msg = "secondshift.github must not import Django apps."
12
+ "secondshift.repositories".msg = "secondshift.github must not import Django apps."
13
+ "secondshift.runner".msg = "secondshift.github must not import Django apps."
14
+ "secondshift.runs".msg = "secondshift.github must not import Django apps."
15
+ "secondshift.triggers".msg = "secondshift.github must not import Django apps."
16
+ "secondshift.workspaces".msg = "secondshift.github must not import Django apps."
@@ -0,0 +1,52 @@
1
+ """Check a personal access token before SecondShift stores it."""
2
+
3
+ import re
4
+ from dataclasses import dataclass
5
+ from datetime import UTC, datetime
6
+
7
+ import httpx
8
+
9
+ from secondshift.config.constants import (
10
+ GITHUB_API_URL,
11
+ GITHUB_FINE_GRAINED_TOKEN_PATTERN,
12
+ GITHUB_TOKEN_HINT,
13
+ )
14
+ from secondshift.github.errors import GitHubError
15
+ from secondshift.github.rest import send
16
+
17
+ # GitHub sends a token's expiry with every response it answers with that token.
18
+ EXPIRY_HEADER = "github-authentication-token-expiration"
19
+
20
+
21
+ @dataclass(frozen=True)
22
+ class TokenOwner:
23
+ login: str
24
+ expires_at: datetime | None
25
+
26
+
27
+ def inspect_token(token: str) -> TokenOwner:
28
+ """Whose token this is and when it expires. Refuses classic tokens."""
29
+ if not re.fullmatch(GITHUB_FINE_GRAINED_TOKEN_PATTERN, token):
30
+ raise GitHubError(GITHUB_TOKEN_HINT)
31
+ response = send("GET", f"{GITHUB_API_URL}/user", token)
32
+ if response.status_code == 401:
33
+ raise GitHubError("GitHub refused this token. It may have expired.")
34
+ try:
35
+ response.raise_for_status()
36
+ login = response.json()["login"]
37
+ except (httpx.HTTPError, ValueError, KeyError, TypeError) as error:
38
+ raise GitHubError("GitHub could not check this token.") from error
39
+ if not isinstance(login, str) or not login:
40
+ raise GitHubError("GitHub could not check this token.")
41
+ return TokenOwner(login, expiry(response.headers.get(EXPIRY_HEADER, "")))
42
+
43
+
44
+ def expiry(value: str) -> datetime | None:
45
+ """Parse "2026-12-01 08:00:00 UTC" or "... -0800". None for no expiry."""
46
+ value = value.strip().replace(" UTC", " +0000")
47
+ if not value:
48
+ return None
49
+ try:
50
+ return datetime.strptime(value, "%Y-%m-%d %H:%M:%S %z").astimezone(UTC)
51
+ except ValueError:
52
+ return None
File without changes
@@ -0,0 +1,12 @@
1
+ from django.http import Http404, HttpRequest
2
+
3
+ from secondshift.jobs.dao import find_active_job
4
+ from secondshift.jobs.models import Job
5
+ from secondshift.workspaces.access import current_workspace
6
+
7
+
8
+ def job_for_request(request: HttpRequest, job_id: int) -> Job:
9
+ job = find_active_job(current_workspace(request), job_id)
10
+ if job is None:
11
+ raise Http404
12
+ return job