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,84 @@
1
+ """The only code that changes a run's status.
2
+
3
+ A run goes from queued to running, then succeeds, fails or is canceled. A
4
+ queued run can also fail or be canceled before it starts. A finished run
5
+ never changes again.
6
+ """
7
+
8
+ from collections.abc import Collection, Mapping
9
+
10
+ from django.db import transaction
11
+ from django.utils import timezone
12
+
13
+ from secondshift.config.constants import RunSandbox
14
+ from secondshift.gateway.dao import revoke_run_tokens
15
+ from secondshift.runs.dao import lock_run_in
16
+ from secondshift.runs.models import Run, RunFailure
17
+
18
+ # The statuses each status may move to. A finished status has no entry.
19
+ NEXT_STATUSES: dict[str, frozenset[str]] = {
20
+ Run.Status.QUEUED: frozenset(
21
+ {Run.Status.RUNNING, Run.Status.FAILED, Run.Status.CANCELED}
22
+ ),
23
+ Run.Status.RUNNING: frozenset(
24
+ {Run.Status.SUCCEEDED, Run.Status.FAILED, Run.Status.CANCELED}
25
+ ),
26
+ }
27
+
28
+
29
+ class TransitionRefused(Exception):
30
+ pass
31
+
32
+
33
+ def can_move(current: str, new: str) -> bool:
34
+ return new in NEXT_STATUSES.get(current, frozenset())
35
+
36
+
37
+ def mark_running(run_id: int, workflow_id: str) -> bool:
38
+ """Move a queued run to running. False when the run is not queued."""
39
+ # Status writes stay in this module, so it is the only place they happen.
40
+ return bool(
41
+ Run.objects.filter(pk=run_id, status=Run.Status.QUEUED).update(
42
+ status=Run.Status.RUNNING,
43
+ started_at=timezone.now(),
44
+ sandbox_runtime=RunSandbox.PENDING,
45
+ dbos_workflow_id=workflow_id,
46
+ )
47
+ )
48
+
49
+
50
+ @transaction.atomic
51
+ def finish_run(
52
+ run_id: int,
53
+ status: str,
54
+ summary: str,
55
+ *,
56
+ only_from: Collection[str] = Run.ACTIVE_STATUSES,
57
+ fields: Mapping[str, object] | None = None,
58
+ failure: RunFailure = RunFailure.OTHER,
59
+ reason: str = "",
60
+ ) -> Run | None:
61
+ """Give a run its final status, summary and finish time, and revoke its tokens.
62
+
63
+ Returns None and changes nothing when the run is not in `only_from`,
64
+ usually because it already finished. `fields` are saved with the status.
65
+ A failed run also records why. The reason defaults to the summary.
66
+ """
67
+ if status in Run.ACTIVE_STATUSES:
68
+ raise TransitionRefused(f"{status} is not a final status.")
69
+ run = lock_run_in(run_id, only_from)
70
+ if run is None:
71
+ return None
72
+ if not can_move(run.status, status):
73
+ raise TransitionRefused(f"A {run.status} run cannot become {status}.")
74
+ extra = dict(fields or {})
75
+ if status == Run.Status.FAILED:
76
+ extra |= {"failure": failure, "failure_reason": reason or summary}
77
+ run.status = status
78
+ run.summary = summary
79
+ run.finished_at = timezone.now()
80
+ for name, value in extra.items():
81
+ setattr(run, name, value)
82
+ run.save(update_fields=["status", "summary", "finished_at", *extra])
83
+ revoke_run_tokens(run_id)
84
+ return run
@@ -0,0 +1 @@
1
+ {% load accounts %}{% password_reset_link %}
@@ -0,0 +1,24 @@
1
+ {% extends "admin/base.html" %}
2
+ {% load static %}
3
+
4
+ {% block title %}{% if subtitle %}{{ subtitle }} | {% endif %}{{ title }} | {{ site_title }}{% endblock %}
5
+
6
+ {% block extrahead %}
7
+ <link rel="icon" type="image/svg+xml" href="{% static 'favicon.svg' %}">
8
+ <link rel="preconnect" href="https://fonts.googleapis.com">
9
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
10
+ <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Geist:wght@400;500;600;700&family=Geist+Mono:wght@400;500&display=swap">
11
+ {% endblock %}
12
+
13
+ {% block extrastyle %}<link rel="stylesheet" href="{% static 'core/admin.css' %}">{% endblock %}
14
+
15
+ {% block branding %}
16
+ <div id="site-name">
17
+ <a href="{% url 'admin:index' %}" class="ss-brand">
18
+ <img src="{% static 'secondshift-mark.svg' %}" alt="" width="32" height="32">
19
+ <span>{{ site_header }}</span>
20
+ </a>
21
+ </div>
22
+ {% endblock %}
23
+
24
+ {% block nav-global %}{% endblock %}
@@ -0,0 +1,9 @@
1
+ {% load i18n %}
2
+ <li>
3
+ <a class="account-provider" title="{{ attrs.name }}" href="{{ attrs.href }}">
4
+ {% if attrs.provider_id == "github" %}
5
+ <svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true"><path fill="currentColor" d="M12,2A10,10 0 0,0 2,12C2,16.42 4.87,20.17 8.84,21.5C9.34,21.58 9.5,21.27 9.5,21C9.5,20.77 9.5,20.14 9.5,19.31C6.73,19.91 6.14,17.97 6.14,17.97C5.68,16.81 5.03,16.5 5.03,16.5C4.12,15.88 5.1,15.9 5.1,15.9C6.1,15.97 6.63,16.93 6.63,16.93C7.5,18.45 8.97,18 9.54,17.76C9.63,17.11 9.89,16.67 10.17,16.42C7.95,16.17 5.62,15.31 5.62,11.5C5.62,10.39 6,9.5 6.65,8.79C6.55,8.54 6.2,7.5 6.75,6.15C6.75,6.15 7.59,5.88 9.5,7.17C10.29,6.95 11.15,6.84 12,6.84C12.85,6.84 13.71,6.95 14.5,7.17C16.41,5.88 17.25,6.15 17.25,6.15C17.8,7.5 17.45,8.54 17.35,8.79C18,9.5 18.38,10.39 18.38,11.5C18.38,15.32 16.04,16.16 13.81,16.41C14.17,16.72 14.5,17.33 14.5,18.26C14.5,19.6 14.5,20.68 14.5,21C14.5,21.27 14.66,21.59 15.17,21.5C19.14,20.16 22,16.42 22,12A10,10 0 0,0 12,2Z"/></svg>
6
+ {% endif %}
7
+ <span>{% blocktrans with provider=attrs.name %}Continue with {{ provider }}{% endblocktrans %}</span>
8
+ </a>
9
+ </li>
@@ -0,0 +1,67 @@
1
+ {% load i18n static %}
2
+ <!DOCTYPE html>
3
+ <html lang="en">
4
+ <head>
5
+ <meta charset="utf-8">
6
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
7
+ <title>{% block head_title %}{% endblock head_title %} · SecondShift</title>
8
+ <link rel="icon" type="image/svg+xml" href="{% static 'favicon.svg' %}">
9
+ <link rel="preconnect" href="https://fonts.googleapis.com">
10
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
11
+ <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500&family=IBM+Plex+Sans:wght@400;500;600&display=swap">
12
+ <link rel="stylesheet" href="{% static 'core/account.css' %}">
13
+ {% block extra_head %}{% endblock extra_head %}
14
+ </head>
15
+ <body>
16
+ {% block body %}
17
+ <main class="account-page">
18
+ <section class="account-card">
19
+ <a href="/" class="account-brand">
20
+ <img src="{% static 'secondshift-mark.svg' %}" alt="" width="32" height="32">
21
+ <span>SecondShift</span>
22
+ </a>
23
+ <span class="account-eyebrow">{% trans "Your agents, safely on duty" %}</span>
24
+ {% if messages %}
25
+ <ul class="account-messages">
26
+ {% for message in messages %}<li>{{ message }}</li>{% endfor %}
27
+ </ul>
28
+ {% endif %}
29
+ {% if pending_invite and not user.is_authenticated %}
30
+ <p class="account-invite">
31
+ {% blocktrans with workspace=pending_invite.workspace.name role=pending_invite.get_role_display %}Sign in or sign up to join <strong>{{ workspace }}</strong> as {{ role }}.{% endblocktrans %}
32
+ </p>
33
+ {% endif %}
34
+ {% block content %}{% endblock content %}
35
+ {% if user.is_authenticated %}
36
+ <a href="/" class="account-back">{% trans "Back to SecondShift" %}</a>
37
+ {% endif %}
38
+ </section>
39
+ <aside class="account-atmosphere" aria-label="{% trans "Example scheduled reviews" %}">
40
+ <ol class="shift-schedule">
41
+ <li class="shift-preview">
42
+ <time>Mon 09:00</time>
43
+ <div><strong>Weekly release recap</strong><p>Merged features, fixes, and what comes next.</p></div>
44
+ </li>
45
+ <li class="shift-preview">
46
+ <time>Daily 08:00</time>
47
+ <div><strong>CI failure review</strong><p>Recurring failures and suggested fixes.</p></div>
48
+ </li>
49
+ <li class="shift-preview">
50
+ <time>Mon 09:15</time>
51
+ <div><strong>Repository health review</strong><p>Defects, test gaps, and docs that drifted.</p></div>
52
+ </li>
53
+ <li class="shift-preview">
54
+ <time>Mon 09:30</time>
55
+ <div><strong>Alert coverage review</strong><p>Removed alerts, paused rules, and changed thresholds.</p></div>
56
+ </li>
57
+ <li class="shift-preview">
58
+ <time>Fri 16:00</time>
59
+ <div><strong>Contributor recap</strong><p>The people behind the work and reviews.</p></div>
60
+ </li>
61
+ </ol>
62
+ </aside>
63
+ </main>
64
+ {% endblock body %}
65
+ {% block extra_body %}{% endblock extra_body %}
66
+ </body>
67
+ </html>
@@ -0,0 +1,9 @@
1
+ {% load i18n allauth socialaccount %}
2
+ {% get_providers as socialaccount_providers %}
3
+ {% if socialaccount_providers %}
4
+ {% if not SOCIALACCOUNT_ONLY %}
5
+ <hr>
6
+ {% endif %}
7
+ {% include "socialaccount/snippets/provider_list.html" with process="login" %}
8
+ {% include "socialaccount/snippets/login_extra.html" %}
9
+ {% endif %}
@@ -0,0 +1,10 @@
1
+ {% extends "allauth/layouts/base.html" %}
2
+ {% load i18n %}
3
+ {% block head_title %}{% trans "Invite link" %}{% endblock head_title %}
4
+ {% block content %}
5
+ <h1>{% trans "This invite link no longer works" %}</h1>
6
+ <p>{{ message }}</p>
7
+ {% if not user.is_authenticated %}
8
+ <p><a href="{% url 'account_login' %}">{% trans "Sign in" %}</a></p>
9
+ {% endif %}
10
+ {% endblock content %}
File without changes
@@ -0,0 +1,19 @@
1
+ from django.contrib import admin
2
+
3
+ from secondshift.triggers.models import JobTrigger, TriggerEvent
4
+
5
+
6
+ @admin.register(JobTrigger)
7
+ class JobTriggerAdmin(admin.ModelAdmin):
8
+ list_display = ("job", "kind", "enabled", "secret_rotated_at")
9
+ list_filter = ("kind", "enabled")
10
+ # The secret is never shown. Rotate it from the dashboard.
11
+ exclude = ("secret",)
12
+ readonly_fields = ("job", "kind", "public_id", "secret_rotated_at")
13
+
14
+
15
+ @admin.register(TriggerEvent)
16
+ class TriggerEventAdmin(admin.ModelAdmin):
17
+ list_display = ("trigger", "outcome", "reason", "run", "received_at")
18
+ list_filter = ("outcome",)
19
+ readonly_fields = ("trigger", "outcome", "reason", "run", "received_at")
@@ -0,0 +1,116 @@
1
+ import logging
2
+
3
+ from django.conf import settings
4
+ from django.http import HttpRequest, HttpResponse
5
+ from ninja import Router, Status
6
+ from ninja.errors import HttpError
7
+ from ninja.security import django_auth
8
+
9
+ from secondshift.config.constants import TriggerKind
10
+ from secondshift.jobs.access import job_for_request
11
+ from secondshift.triggers.dao import trigger_for_url
12
+ from secondshift.triggers.schemas import (
13
+ TriggerChangeSchema,
14
+ TriggerEnabledSchema,
15
+ TriggerLimitSchema,
16
+ TriggerReceivedSchema,
17
+ TriggerSchema,
18
+ )
19
+ from secondshift.triggers.serialization import (
20
+ serialize_change,
21
+ serialize_job_triggers,
22
+ serialize_trigger,
23
+ )
24
+ from secondshift.triggers.services import (
25
+ TriggerError,
26
+ receive,
27
+ rotate_secret,
28
+ set_runs_per_hour,
29
+ set_trigger_enabled,
30
+ )
31
+ from secondshift.workspaces.access import Action, require
32
+
33
+ logger = logging.getLogger(__name__)
34
+
35
+ router = Router(auth=django_auth, tags=["triggers"])
36
+
37
+
38
+ @router.get("/jobs/{job_id}/triggers", response=list[TriggerSchema])
39
+ def job_triggers(request: HttpRequest, job_id: int) -> list[TriggerSchema]:
40
+ return serialize_job_triggers(job_for_request(request, job_id))
41
+
42
+
43
+ @router.post("/jobs/{job_id}/triggers/{kind}/enabled", response=TriggerChangeSchema)
44
+ def set_enabled(
45
+ request: HttpRequest, job_id: int, kind: TriggerKind, payload: TriggerEnabledSchema
46
+ ) -> TriggerChangeSchema:
47
+ job = job_for_request(request, job_id)
48
+ require(request, Action.MANAGE_TRIGGERS)
49
+ try:
50
+ change = set_trigger_enabled(job, kind, payload.enabled)
51
+ except TriggerError as error:
52
+ raise HttpError(409, str(error)) from error
53
+ return serialize_change(job, change)
54
+
55
+
56
+ @router.post("/jobs/{job_id}/triggers/{kind}/rotate", response=TriggerChangeSchema)
57
+ def rotate(request: HttpRequest, job_id: int, kind: TriggerKind) -> TriggerChangeSchema:
58
+ job = job_for_request(request, job_id)
59
+ require(request, Action.MANAGE_TRIGGERS)
60
+ try:
61
+ change = rotate_secret(job, kind)
62
+ except TriggerError as error:
63
+ raise HttpError(409, str(error)) from error
64
+ return serialize_change(job, change)
65
+
66
+
67
+ @router.post("/jobs/{job_id}/triggers/{kind}/limit", response=TriggerSchema)
68
+ def set_limit(
69
+ request: HttpRequest, job_id: int, kind: TriggerKind, payload: TriggerLimitSchema
70
+ ) -> TriggerSchema:
71
+ job = job_for_request(request, job_id)
72
+ require(request, Action.MANAGE_TRIGGERS)
73
+ try:
74
+ trigger = set_runs_per_hour(job, kind, payload.runs_per_hour)
75
+ except TriggerError as error:
76
+ raise HttpError(409, str(error)) from error
77
+ return serialize_trigger(job, kind, trigger)
78
+
79
+
80
+ @router.post(
81
+ "/triggers/{public_id}",
82
+ auth=None,
83
+ response={
84
+ frozenset({200, 202, 401, 409, 413, 429, 503}): TriggerReceivedSchema,
85
+ },
86
+ include_in_schema=False,
87
+ )
88
+ def receive_trigger(
89
+ request: HttpRequest, response: HttpResponse, public_id: str
90
+ ) -> Status[TriggerReceivedSchema]:
91
+ """The public URL a webhook calls. Its secret is the only sign-in."""
92
+ trigger = trigger_for_url(public_id)
93
+ if trigger is None:
94
+ raise HttpError(404, "Not found.")
95
+ length = int(request.headers.get("Content-Length") or 0)
96
+ if length > settings.TRIGGER_MAX_BODY_BYTES:
97
+ return Status(
98
+ 413, TriggerReceivedSchema(detail="The request body is too large.")
99
+ )
100
+ body = request.body
101
+ if len(body) > settings.TRIGGER_MAX_BODY_BYTES:
102
+ return Status(
103
+ 413, TriggerReceivedSchema(detail="The request body is too large.")
104
+ )
105
+ received = receive(trigger, request.headers, body)
106
+ if received.status != 202:
107
+ logger.info("trigger %s did not start a run: %s", trigger.pk, received.detail)
108
+ if received.retry_after is not None:
109
+ response["Retry-After"] = str(received.retry_after)
110
+ return Status(
111
+ received.status,
112
+ TriggerReceivedSchema(
113
+ detail=received.detail,
114
+ run_id=received.run.pk if received.run else None,
115
+ ),
116
+ )
@@ -0,0 +1,6 @@
1
+ from django.apps import AppConfig
2
+
3
+
4
+ class TriggersConfig(AppConfig):
5
+ default_auto_field = "django.db.models.BigAutoField"
6
+ name = "secondshift.triggers"
@@ -0,0 +1,66 @@
1
+ """Queries and writes on job triggers and the requests they receive."""
2
+
3
+ from datetime import datetime
4
+
5
+ from django.db import transaction
6
+
7
+ from secondshift.jobs.models import Job
8
+ from secondshift.runs.models import Run
9
+ from secondshift.triggers.models import JobTrigger, TriggerEvent
10
+
11
+
12
+ def trigger_for_url(public_id: str) -> JobTrigger | None:
13
+ """The trigger behind a public webhook URL, with its job and repository."""
14
+ return (
15
+ JobTrigger.objects.select_related("job__repository")
16
+ .filter(public_id=public_id)
17
+ .first()
18
+ )
19
+
20
+
21
+ def job_trigger(job: Job, kind: str) -> JobTrigger | None:
22
+ """The job's trigger of this kind, if it was ever set up."""
23
+ return JobTrigger.objects.filter(job=job, kind=kind).first()
24
+
25
+
26
+ def create_trigger(
27
+ job: Job, kind: str, secret: str, secret_rotated_at: datetime
28
+ ) -> JobTrigger:
29
+ """Set up an enabled trigger with its first secret."""
30
+ return JobTrigger.objects.create(
31
+ job=job,
32
+ kind=kind,
33
+ secret=secret,
34
+ enabled=True,
35
+ secret_rotated_at=secret_rotated_at,
36
+ )
37
+
38
+
39
+ def add_event(
40
+ trigger: JobTrigger, outcome: str, reason: str, run: Run | None, keep: int
41
+ ) -> None:
42
+ """Record a request, keeping only the newest `keep` events of its outcome."""
43
+ with transaction.atomic():
44
+ TriggerEvent.objects.create(
45
+ trigger=trigger, outcome=outcome, reason=reason, run=run
46
+ )
47
+ stale = (
48
+ trigger.events.filter(outcome=outcome)
49
+ .order_by("-received_at", "-pk")
50
+ .values_list("pk", flat=True)[keep:]
51
+ )
52
+ TriggerEvent.objects.filter(pk__in=list(stale)).delete()
53
+
54
+
55
+ def has_recent_event(
56
+ trigger: JobTrigger, outcome: str, reason: str, since: datetime
57
+ ) -> bool:
58
+ """Whether the trigger recorded this outcome and reason since then."""
59
+ return trigger.events.filter(
60
+ outcome=outcome, reason=reason, received_at__gte=since
61
+ ).exists()
62
+
63
+
64
+ def recent_events(trigger: JobTrigger, limit: int) -> list[TriggerEvent]:
65
+ """The trigger's newest requests, newest first."""
66
+ return list(trigger.events.all()[:limit])
@@ -0,0 +1,48 @@
1
+ """Read a webhook body against the job's webhook settings.
2
+
3
+ The settings name fields in the sender's own format, such as Grafana's
4
+ "status" or "groupKey". Nothing here knows any sender.
5
+ """
6
+
7
+ import hashlib
8
+ import json
9
+ from typing import Any
10
+
11
+ MISSING = object()
12
+
13
+
14
+ def parse_body(body: bytes) -> Any:
15
+ try:
16
+ return json.loads(body)
17
+ except (UnicodeDecodeError, ValueError):
18
+ return None
19
+
20
+
21
+ def field_value(data: Any, path: str) -> Any:
22
+ """The value at a dotted path, or MISSING."""
23
+ for part in path.split("."):
24
+ if not isinstance(data, dict) or part not in data:
25
+ return MISSING
26
+ data = data[part]
27
+ return data
28
+
29
+
30
+ def skip_reason(data: Any, skip_when: dict[str, Any]) -> str | None:
31
+ """Why the body matches skip_when, or None. Every field must match."""
32
+ if not skip_when:
33
+ return None
34
+ if all(field_value(data, path) == value for path, value in skip_when.items()):
35
+ matched = ", ".join(f"{path} is {value}" for path, value in skip_when.items())
36
+ return f"Skipped by the job's settings: {matched}."
37
+ return None
38
+
39
+
40
+ def repeat_key(data: Any, path: str | None) -> str | None:
41
+ """A hash of the repeat_key field's value. None when it is not set or absent."""
42
+ if not path:
43
+ return None
44
+ value = field_value(data, path)
45
+ if value is MISSING or value is None:
46
+ return None
47
+ canonical = json.dumps(value, sort_keys=True, separators=(",", ":"))
48
+ return hashlib.sha256(f"{path}={canonical}".encode()).hexdigest()
@@ -0,0 +1,55 @@
1
+ # Generated by Django 6.0.9 on 2026-11-01 17:26
2
+
3
+ import django.db.models.deletion
4
+ import encrypted_model_fields.fields
5
+ import secondshift.triggers.models
6
+ from django.db import migrations, models
7
+
8
+
9
+ class Migration(migrations.Migration):
10
+
11
+ initial = True
12
+
13
+ dependencies = [
14
+ ('jobs', '0005_job_triggers'),
15
+ ('runs', '0002_run_started_by'),
16
+ ]
17
+
18
+ operations = [
19
+ migrations.CreateModel(
20
+ name='JobTrigger',
21
+ fields=[
22
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
23
+ ('kind', models.CharField(choices=[('webhook', 'Webhook')], max_length=16)),
24
+ ('public_id', models.CharField(default=secondshift.triggers.models.new_public_id, max_length=64, unique=True)),
25
+ ('secret', encrypted_model_fields.fields.EncryptedTextField()),
26
+ ('enabled', models.BooleanField(default=False)),
27
+ ('secret_rotated_at', models.DateTimeField()),
28
+ ('created_at', models.DateTimeField(auto_now_add=True)),
29
+ ('updated_at', models.DateTimeField(auto_now=True)),
30
+ ('job', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='job_triggers', to='jobs.job')),
31
+ ],
32
+ ),
33
+ migrations.CreateModel(
34
+ name='TriggerEvent',
35
+ fields=[
36
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
37
+ ('outcome', models.CharField(choices=[('started', 'Started a run'), ('refused', 'Refused')], max_length=16)),
38
+ ('reason', models.CharField(blank=True, max_length=500)),
39
+ ('received_at', models.DateTimeField(auto_now_add=True)),
40
+ ('run', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='trigger_events', to='runs.run')),
41
+ ('trigger', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='events', to='triggers.jobtrigger')),
42
+ ],
43
+ options={
44
+ 'ordering': ['-received_at', '-pk'],
45
+ },
46
+ ),
47
+ migrations.AddConstraint(
48
+ model_name='jobtrigger',
49
+ constraint=models.UniqueConstraint(fields=('job', 'kind'), name='unique_job_trigger_kind'),
50
+ ),
51
+ migrations.AddIndex(
52
+ model_name='triggerevent',
53
+ index=models.Index(fields=['trigger', 'received_at'], name='trigger_event_received_idx'),
54
+ ),
55
+ ]
@@ -0,0 +1,23 @@
1
+ # Generated by Django 6.0.9 on 2026-11-01 18:06
2
+
3
+ from django.db import migrations, models
4
+
5
+
6
+ class Migration(migrations.Migration):
7
+
8
+ dependencies = [
9
+ ('triggers', '0001_initial'),
10
+ ]
11
+
12
+ operations = [
13
+ migrations.AddField(
14
+ model_name='jobtrigger',
15
+ name='max_runs_per_hour',
16
+ field=models.PositiveIntegerField(blank=True, null=True),
17
+ ),
18
+ migrations.AlterField(
19
+ model_name='triggerevent',
20
+ name='outcome',
21
+ field=models.CharField(choices=[('started', 'Started a run'), ('ignored', 'Ignored'), ('refused', 'Refused')], max_length=16),
22
+ ),
23
+ ]
File without changes
@@ -0,0 +1,83 @@
1
+ import secrets
2
+
3
+ from django.db import models
4
+ from encrypted_model_fields.fields import EncryptedTextField
5
+
6
+ from secondshift.config.constants import TriggerKind
7
+ from secondshift.jobs.models import Job
8
+ from secondshift.runs.models import Run
9
+
10
+
11
+ def new_public_id() -> str:
12
+ return secrets.token_urlsafe(24)
13
+
14
+
15
+ class JobTrigger(models.Model):
16
+ """One trigger kind for one job, as an owner or admin set it up.
17
+
18
+ The repository only names the kind. The URL, the secret and whether it is
19
+ on live here and are changed only in the dashboard.
20
+ """
21
+
22
+ job = models.ForeignKey(Job, on_delete=models.CASCADE, related_name="job_triggers")
23
+ kind = models.CharField(
24
+ max_length=16,
25
+ choices=[(item.value, item.value.title()) for item in TriggerKind],
26
+ )
27
+ # The last part of the trigger's URL. Random, so it reveals nothing.
28
+ public_id = models.CharField(max_length=64, unique=True, default=new_public_id)
29
+ # Encrypted, not hashed: checking an HMAC signature needs the secret itself.
30
+ secret = EncryptedTextField()
31
+ enabled = models.BooleanField(default=False)
32
+ # The owner's cap on runs this trigger starts per hour. Empty uses the
33
+ # server default.
34
+ max_runs_per_hour = models.PositiveIntegerField(null=True, blank=True)
35
+ secret_rotated_at = models.DateTimeField()
36
+ created_at = models.DateTimeField(auto_now_add=True)
37
+ updated_at = models.DateTimeField(auto_now=True)
38
+
39
+ class Meta:
40
+ constraints = [
41
+ models.UniqueConstraint(
42
+ fields=["job", "kind"], name="unique_job_trigger_kind"
43
+ )
44
+ ]
45
+
46
+ def __str__(self) -> str:
47
+ return f"{self.job}: {self.kind}"
48
+
49
+
50
+ class TriggerEvent(models.Model):
51
+ """One request a trigger received, and what SecondShift did with it."""
52
+
53
+ class Outcome(models.TextChoices):
54
+ STARTED = "started", "Started a run"
55
+ # Not a run, by the job's own settings: a skipped or repeated event.
56
+ IGNORED = "ignored", "Ignored"
57
+ REFUSED = "refused", "Refused"
58
+
59
+ trigger = models.ForeignKey(
60
+ JobTrigger, on_delete=models.CASCADE, related_name="events"
61
+ )
62
+ outcome = models.CharField(max_length=16, choices=Outcome.choices)
63
+ # Why it was ignored or refused, in words for the person who set it up.
64
+ reason = models.CharField(max_length=500, blank=True)
65
+ run = models.ForeignKey(
66
+ Run,
67
+ on_delete=models.SET_NULL,
68
+ null=True,
69
+ blank=True,
70
+ related_name="trigger_events",
71
+ )
72
+ received_at = models.DateTimeField(auto_now_add=True)
73
+
74
+ class Meta:
75
+ ordering = ["-received_at", "-pk"]
76
+ indexes = [
77
+ models.Index(
78
+ fields=["trigger", "received_at"], name="trigger_event_received_idx"
79
+ )
80
+ ]
81
+
82
+ def __str__(self) -> str:
83
+ return f"{self.trigger} at {self.received_at:%Y-%m-%d %H:%M}: {self.outcome}"