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.
- secondshift/__init__.py +1 -0
- secondshift/__main__.py +3 -0
- secondshift/cli.py +17 -0
- secondshift/config/__init__.py +0 -0
- secondshift/config/api.py +23 -0
- secondshift/config/app.py +62 -0
- secondshift/config/constants.py +279 -0
- secondshift/config/container_env.py +118 -0
- secondshift/config/first_run.py +40 -0
- secondshift/config/formatting.py +5 -0
- secondshift/config/gunicorn.py +31 -0
- secondshift/config/logging.py +40 -0
- secondshift/config/settings/__init__.py +0 -0
- secondshift/config/settings/base.py +524 -0
- secondshift/config/settings/dev.py +14 -0
- secondshift/config/settings/local.py +23 -0
- secondshift/config/settings/production.py +113 -0
- secondshift/config/settings/runner.py +140 -0
- secondshift/config/settings/test.py +47 -0
- secondshift/config/static_storage.py +45 -0
- secondshift/config/urls.py +42 -0
- secondshift/connections/__init__.py +0 -0
- secondshift/connections/access.py +27 -0
- secondshift/connections/admin.py +25 -0
- secondshift/connections/agents.py +224 -0
- secondshift/connections/api.py +747 -0
- secondshift/connections/apps.py +6 -0
- secondshift/connections/catalog.py +96 -0
- secondshift/connections/checks.py +22 -0
- secondshift/connections/dao.py +305 -0
- secondshift/connections/errors.py +13 -0
- secondshift/connections/execution.py +262 -0
- secondshift/connections/external.py +258 -0
- secondshift/connections/github.py +123 -0
- secondshift/connections/http_api/__init__.py +1 -0
- secondshift/connections/http_api/client.py +161 -0
- secondshift/connections/http_api/connections.py +187 -0
- secondshift/connections/http_api/spec.py +382 -0
- secondshift/connections/mcp/__init__.py +1 -0
- secondshift/connections/mcp/base.py +139 -0
- secondshift/connections/mcp/client.py +353 -0
- secondshift/connections/mcp/custom.py +86 -0
- secondshift/connections/mcp/github.py +256 -0
- secondshift/connections/mcp/oauth.py +352 -0
- secondshift/connections/mcp/providers.py +15 -0
- secondshift/connections/mcp/schemas.py +77 -0
- secondshift/connections/mcp/sentry.py +135 -0
- secondshift/connections/mcp/signin.py +206 -0
- secondshift/connections/migration_helpers.py +28 -0
- secondshift/connections/migrations/0001_initial.py +101 -0
- secondshift/connections/migrations/0002_alter_connection_kind.py +18 -0
- secondshift/connections/migrations/0003_require_approval.py +23 -0
- secondshift/connections/migrations/0004_new_connections_skip_approval.py +18 -0
- secondshift/connections/migrations/0005_agentconnection.py +36 -0
- secondshift/connections/migrations/0006_remove_is_default.py +23 -0
- secondshift/connections/migrations/0007_agent_last_check.py +18 -0
- secondshift/connections/migrations/0008_agentcredentialcleanup.py +25 -0
- secondshift/connections/migrations/0009_release_0_1_0b11.py +52 -0
- secondshift/connections/migrations/0010_connection_attribution.py +18 -0
- secondshift/connections/migrations/__init__.py +0 -0
- secondshift/connections/model_providers.py +441 -0
- secondshift/connections/models.py +246 -0
- secondshift/connections/outbound.py +317 -0
- secondshift/connections/repository_ref.py +19 -0
- secondshift/connections/schemas.py +313 -0
- secondshift/connections/serialization.py +100 -0
- secondshift/connections/services.py +94 -0
- secondshift/connections/tools.py +38 -0
- secondshift/connections/webhooks.py +107 -0
- secondshift/core/__init__.py +0 -0
- secondshift/core/adapters.py +38 -0
- secondshift/core/admin.py +5 -0
- secondshift/core/api.py +104 -0
- secondshift/core/apps.py +20 -0
- secondshift/core/launcher.py +311 -0
- secondshift/core/local_agents.py +90 -0
- secondshift/core/local_output.py +120 -0
- secondshift/core/management/__init__.py +0 -0
- secondshift/core/management/commands/__init__.py +0 -0
- secondshift/core/management/commands/bootstrap_dev.py +33 -0
- secondshift/core/management/commands/dev_reset_db.py +204 -0
- secondshift/core/management/commands/login.py +23 -0
- secondshift/core/management/commands/logs.py +58 -0
- secondshift/core/management/commands/status.py +46 -0
- secondshift/core/management/commands/up.py +244 -0
- secondshift/core/middleware.py +21 -0
- secondshift/core/migrations/__init__.py +0 -0
- secondshift/core/schemas.py +71 -0
- secondshift/core/signin.py +58 -0
- secondshift/core/static/core/account.css +149 -0
- secondshift/core/static/core/admin.css +135 -0
- secondshift/core/templatetags/__init__.py +0 -0
- secondshift/core/templatetags/accounts.py +20 -0
- secondshift/core/terminal.py +27 -0
- secondshift/core/views.py +10 -0
- secondshift/gateway/__init__.py +0 -0
- secondshift/gateway/apps.py +6 -0
- secondshift/gateway/dao.py +137 -0
- secondshift/gateway/metering.py +171 -0
- secondshift/gateway/migrations/0001_initial.py +54 -0
- secondshift/gateway/migrations/0002_modelusage_gateway_usage_created_at_idx.py +19 -0
- secondshift/gateway/migrations/__init__.py +0 -0
- secondshift/gateway/models.py +61 -0
- secondshift/gateway/pricing.py +39 -0
- secondshift/gateway/proxy.py +369 -0
- secondshift/gateway/tokens.py +68 -0
- secondshift/gateway/urls.py +17 -0
- secondshift/gateway/views.py +11 -0
- secondshift/github/__init__.py +6 -0
- secondshift/github/app.py +109 -0
- secondshift/github/authors.py +56 -0
- secondshift/github/credentials.py +59 -0
- secondshift/github/errors.py +2 -0
- secondshift/github/git.py +56 -0
- secondshift/github/identity.py +35 -0
- secondshift/github/installations.py +74 -0
- secondshift/github/issues.py +75 -0
- secondshift/github/permissions.py +49 -0
- secondshift/github/pulls.py +49 -0
- secondshift/github/repos.py +52 -0
- secondshift/github/rest.py +93 -0
- secondshift/github/ruff.toml +16 -0
- secondshift/github/tokens.py +52 -0
- secondshift/jobs/__init__.py +0 -0
- secondshift/jobs/access.py +12 -0
- secondshift/jobs/admin.py +83 -0
- secondshift/jobs/api.py +184 -0
- secondshift/jobs/apps.py +6 -0
- secondshift/jobs/automatic_grants.py +115 -0
- secondshift/jobs/autosync.py +78 -0
- secondshift/jobs/capabilities.py +43 -0
- secondshift/jobs/dao.py +258 -0
- secondshift/jobs/drafts.py +112 -0
- secondshift/jobs/grants.py +116 -0
- secondshift/jobs/limits.py +26 -0
- secondshift/jobs/management/__init__.py +0 -0
- secondshift/jobs/management/commands/__init__.py +0 -0
- secondshift/jobs/manifest.py +364 -0
- secondshift/jobs/migrations/0001_initial.py +87 -0
- secondshift/jobs/migrations/0002_job_history.py +18 -0
- secondshift/jobs/migrations/0003_job_skill_text.py +23 -0
- secondshift/jobs/migrations/0004_jobgrant_tool_rules.py +18 -0
- secondshift/jobs/migrations/0005_job_triggers.py +18 -0
- secondshift/jobs/migrations/0006_job_webhook.py +18 -0
- secondshift/jobs/migrations/0007_job_deliveries.py +23 -0
- secondshift/jobs/migrations/0008_job_removed_at.py +18 -0
- secondshift/jobs/migrations/0009_job_subagents.py +18 -0
- secondshift/jobs/migrations/0010_job_skill_source.py +18 -0
- secondshift/jobs/migrations/__init__.py +0 -0
- secondshift/jobs/models.py +199 -0
- secondshift/jobs/needs.py +154 -0
- secondshift/jobs/plan.py +176 -0
- secondshift/jobs/planning.py +386 -0
- secondshift/jobs/readiness.py +46 -0
- secondshift/jobs/repository_sync.py +199 -0
- secondshift/jobs/scheduling.py +26 -0
- secondshift/jobs/schemas.py +207 -0
- secondshift/jobs/serialization.py +218 -0
- secondshift/jobs/skill_needs.py +189 -0
- secondshift/jobs/sync.py +252 -0
- secondshift/jobs/tool_policy.py +82 -0
- secondshift/jobs/tool_rules.py +200 -0
- secondshift/mcp_server/__init__.py +6 -0
- secondshift/mcp_server/api.py +79 -0
- secondshift/mcp_server/apps.py +7 -0
- secondshift/mcp_server/audit.py +22 -0
- secondshift/mcp_server/core.py +12 -0
- secondshift/mcp_server/dao.py +23 -0
- secondshift/mcp_server/migrations/0001_initial.py +33 -0
- secondshift/mcp_server/migrations/__init__.py +0 -0
- secondshift/mcp_server/models.py +40 -0
- secondshift/mcp_server/schemas.py +204 -0
- secondshift/mcp_server/tools/__init__.py +9 -0
- secondshift/mcp_server/tools/jobs.py +106 -0
- secondshift/mcp_server/tools/manifests.py +127 -0
- secondshift/mcp_server/tools/repositories.py +42 -0
- secondshift/mcp_server/tools/runs.py +86 -0
- secondshift/mcp_server/tools/tasks.py +103 -0
- secondshift/mcp_server/tools_registry.py +81 -0
- secondshift/repositories/__init__.py +0 -0
- secondshift/repositories/access.py +13 -0
- secondshift/repositories/admin.py +19 -0
- secondshift/repositories/api.py +296 -0
- secondshift/repositories/apps.py +6 -0
- secondshift/repositories/dao.py +114 -0
- secondshift/repositories/external_skills.py +224 -0
- secondshift/repositories/github.py +27 -0
- secondshift/repositories/github_access.py +57 -0
- secondshift/repositories/github_check.py +279 -0
- secondshift/repositories/github_sync.py +181 -0
- secondshift/repositories/github_views.py +90 -0
- secondshift/repositories/local.py +198 -0
- secondshift/repositories/management/__init__.py +0 -0
- secondshift/repositories/management/commands/__init__.py +0 -0
- secondshift/repositories/management/commands/prune_source_cache.py +15 -0
- secondshift/repositories/migrations/0001_initial.py +45 -0
- secondshift/repositories/migrations/0002_repository_last_sync_error.py +18 -0
- secondshift/repositories/migrations/0003_repository_checked_sha.py +18 -0
- secondshift/repositories/migrations/0004_pull_request_settings.py +23 -0
- secondshift/repositories/migrations/0005_remove_repository_installation_id.py +17 -0
- secondshift/repositories/migrations/0006_allow_issues.py +18 -0
- secondshift/repositories/migrations/__init__.py +0 -0
- secondshift/repositories/mirrors.py +50 -0
- secondshift/repositories/models.py +104 -0
- secondshift/repositories/pull_requests.py +199 -0
- secondshift/repositories/schemas.py +102 -0
- secondshift/repositories/serialization.py +133 -0
- secondshift/repositories/skill_source.py +64 -0
- secondshift/repositories/snapshots.py +238 -0
- secondshift/repositories/sources.py +256 -0
- secondshift/repositories/status.py +67 -0
- secondshift/runner/README.md +65 -0
- secondshift/runner/__init__.py +1 -0
- secondshift/runner/agent_credentials.py +89 -0
- secondshift/runner/agent_image/Dockerfile +16 -0
- secondshift/runner/agent_image/agent.py +132 -0
- secondshift/runner/agent_image/claude.py +254 -0
- secondshift/runner/agent_image/codex.py +84 -0
- secondshift/runner/agent_image/outcomes.py +120 -0
- secondshift/runner/agent_image/subagent_hook.py +71 -0
- secondshift/runner/agent_protocol.py +41 -0
- secondshift/runner/agents.py +254 -0
- secondshift/runner/api.py +123 -0
- secondshift/runner/apps.py +6 -0
- secondshift/runner/client.py +168 -0
- secondshift/runner/collection.py +276 -0
- secondshift/runner/dao.py +64 -0
- secondshift/runner/dev.py +19 -0
- secondshift/runner/images.py +57 -0
- secondshift/runner/migrations/0001_initial.py +48 -0
- secondshift/runner/migrations/0002_session_source_sha256.py +18 -0
- secondshift/runner/migrations/0003_session_collect_changes.py +18 -0
- secondshift/runner/migrations/0004_session_history_sha256.py +18 -0
- secondshift/runner/migrations/0005_require_source_sha256.py +18 -0
- secondshift/runner/migrations/0006_session_skill_sha256.py +18 -0
- secondshift/runner/migrations/__init__.py +0 -0
- secondshift/runner/models.py +53 -0
- secondshift/runner/protocol.py +97 -0
- secondshift/runner/schemas.py +36 -0
- secondshift/runner/services.py +465 -0
- secondshift/runner/sources.py +130 -0
- secondshift/runner/urls.py +7 -0
- secondshift/runs/__init__.py +0 -0
- secondshift/runs/access.py +13 -0
- secondshift/runs/admin.py +89 -0
- secondshift/runs/api.py +211 -0
- secondshift/runs/apps.py +11 -0
- secondshift/runs/artifacts.py +261 -0
- secondshift/runs/dao.py +640 -0
- secondshift/runs/deliveries.py +80 -0
- secondshift/runs/execution/__init__.py +0 -0
- secondshift/runs/execution/access_needs.py +89 -0
- secondshift/runs/execution/agent.py +91 -0
- secondshift/runs/execution/agent_broker.py +198 -0
- secondshift/runs/execution/budgets.py +177 -0
- secondshift/runs/execution/cli_agent.py +167 -0
- secondshift/runs/execution/consent.py +58 -0
- secondshift/runs/execution/deadlines.py +47 -0
- secondshift/runs/execution/external_tools.py +84 -0
- secondshift/runs/execution/issues.py +99 -0
- secondshift/runs/execution/model_gateway.py +90 -0
- secondshift/runs/execution/payload.py +63 -0
- secondshift/runs/execution/result.py +59 -0
- secondshift/runs/execution/run.py +345 -0
- secondshift/runs/execution/subagents.py +246 -0
- secondshift/runs/execution/tool_broker.py +123 -0
- secondshift/runs/execution/workspace_tools.py +248 -0
- secondshift/runs/history.py +182 -0
- secondshift/runs/inputs.py +49 -0
- secondshift/runs/issue_delivery.py +144 -0
- secondshift/runs/management/__init__.py +0 -0
- secondshift/runs/management/commands/__init__.py +0 -0
- secondshift/runs/management/commands/prune_runs.py +12 -0
- secondshift/runs/management/commands/run_dbos_worker.py +129 -0
- secondshift/runs/migration_helpers.py +313 -0
- secondshift/runs/migrations/0001_initial.py +69 -0
- secondshift/runs/migrations/0002_run_started_by.py +18 -0
- secondshift/runs/migrations/0003_agentlease.py +25 -0
- secondshift/runs/migrations/0004_task_assignment.py +39 -0
- secondshift/runs/migrations/0005_backfill_tasks.py +19 -0
- secondshift/runs/migrations/0006_standalone_tasks.py +50 -0
- secondshift/runs/migrations/0007_run_delivery.py +35 -0
- secondshift/runs/migrations/0008_run_outputs.py +18 -0
- secondshift/runs/migrations/0009_pull_request_delivery.py +28 -0
- secondshift/runs/migrations/0010_run_issue.py +32 -0
- secondshift/runs/migrations/0011_issues_delivery.py +23 -0
- secondshift/runs/migrations/0012_alter_rundelivery_status.py +18 -0
- secondshift/runs/migrations/0013_release_0_1_0b11.py +113 -0
- secondshift/runs/migrations/0014_run_access_need.py +30 -0
- secondshift/runs/migrations/__init__.py +0 -0
- secondshift/runs/models.py +340 -0
- secondshift/runs/orchestration/__init__.py +0 -0
- secondshift/runs/orchestration/admission.py +15 -0
- secondshift/runs/orchestration/dbos_runtime.py +43 -0
- secondshift/runs/orchestration/dispatch.py +130 -0
- secondshift/runs/orchestration/periodic.py +39 -0
- secondshift/runs/orchestration/reconciliation.py +98 -0
- secondshift/runs/orchestration/schedules.py +51 -0
- secondshift/runs/orchestration/workflows.py +480 -0
- secondshift/runs/outputs.py +52 -0
- secondshift/runs/publishing.py +162 -0
- secondshift/runs/queue.py +26 -0
- secondshift/runs/reporting/__init__.py +0 -0
- secondshift/runs/reporting/collection.py +26 -0
- secondshift/runs/reporting/connection_usage.py +45 -0
- secondshift/runs/reporting/failures.py +150 -0
- secondshift/runs/reporting/health.py +158 -0
- secondshift/runs/reporting/notifications.py +258 -0
- secondshift/runs/reporting/queries.py +60 -0
- secondshift/runs/retention.py +35 -0
- secondshift/runs/schemas.py +192 -0
- secondshift/runs/serialization.py +245 -0
- secondshift/runs/services.py +176 -0
- secondshift/runs/signals.py +25 -0
- secondshift/runs/submissions.py +236 -0
- secondshift/runs/transitions.py +84 -0
- secondshift/templates/account/password_reset_help_text.html +1 -0
- secondshift/templates/admin/base_site.html +24 -0
- secondshift/templates/allauth/elements/provider.html +9 -0
- secondshift/templates/allauth/layouts/base.html +67 -0
- secondshift/templates/socialaccount/snippets/login.html +9 -0
- secondshift/templates/workspaces/invite_unusable.html +10 -0
- secondshift/triggers/__init__.py +0 -0
- secondshift/triggers/admin.py +19 -0
- secondshift/triggers/api.py +116 -0
- secondshift/triggers/apps.py +6 -0
- secondshift/triggers/dao.py +66 -0
- secondshift/triggers/filters.py +48 -0
- secondshift/triggers/migrations/0001_initial.py +55 -0
- secondshift/triggers/migrations/0002_ignored_events_and_hourly_limit.py +23 -0
- secondshift/triggers/migrations/__init__.py +0 -0
- secondshift/triggers/models.py +83 -0
- secondshift/triggers/schemas.py +58 -0
- secondshift/triggers/serialization.py +91 -0
- secondshift/triggers/services.py +246 -0
- secondshift/triggers/verification.py +84 -0
- secondshift/web/assets/index-Bwy0hvk_.js +2419 -0
- secondshift/web/assets/index-D27gB9_L.css +1 -0
- secondshift/web/favicon.svg +19 -0
- secondshift/web/index.html +16 -0
- secondshift/web/secondshift-mark.svg +16 -0
- secondshift/workspaces/__init__.py +0 -0
- secondshift/workspaces/access.py +181 -0
- secondshift/workspaces/admin.py +37 -0
- secondshift/workspaces/api.py +264 -0
- secondshift/workspaces/apps.py +6 -0
- secondshift/workspaces/context_processors.py +11 -0
- secondshift/workspaces/dao.py +281 -0
- secondshift/workspaces/invites.py +150 -0
- secondshift/workspaces/limits.py +197 -0
- secondshift/workspaces/management/__init__.py +0 -0
- secondshift/workspaces/management/commands/__init__.py +0 -0
- secondshift/workspaces/management/commands/create_workspace.py +39 -0
- secondshift/workspaces/migrations/0001_initial.py +67 -0
- secondshift/workspaces/migrations/0002_api_token.py +34 -0
- secondshift/workspaces/migrations/0003_execution_default.py +29 -0
- secondshift/workspaces/migrations/0004_copy_default_model_key.py +26 -0
- secondshift/workspaces/migrations/__init__.py +0 -0
- secondshift/workspaces/models.py +169 -0
- secondshift/workspaces/owners.py +58 -0
- secondshift/workspaces/schemas.py +149 -0
- secondshift/workspaces/serialization.py +138 -0
- secondshift/workspaces/server_settings.py +140 -0
- secondshift/workspaces/services.py +94 -0
- secondshift/workspaces/tokens.py +108 -0
- secondshift/workspaces/views.py +32 -0
- secondshift-0.1.0b12.dist-info/METADATA +270 -0
- secondshift-0.1.0b12.dist-info/RECORD +371 -0
- secondshift-0.1.0b12.dist-info/WHEEL +4 -0
- secondshift-0.1.0b12.dist-info/entry_points.txt +2 -0
- 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,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}"
|