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,262 @@
|
|
|
1
|
+
"""What runs a workspace's jobs: one model key or one agent login."""
|
|
2
|
+
|
|
3
|
+
from collections import Counter
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from datetime import timedelta
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from django.conf import settings
|
|
9
|
+
from django.contrib.auth.models import User
|
|
10
|
+
from django.core.exceptions import ValidationError
|
|
11
|
+
from django.utils import timezone
|
|
12
|
+
|
|
13
|
+
from secondshift.config.constants import AgentBackend
|
|
14
|
+
from secondshift.connections.agents import (
|
|
15
|
+
product_name,
|
|
16
|
+
sign_in_refused,
|
|
17
|
+
usable_agent_connection,
|
|
18
|
+
)
|
|
19
|
+
from secondshift.connections.dao import (
|
|
20
|
+
connected_logins,
|
|
21
|
+
has_models_or_logins,
|
|
22
|
+
login_run_counts,
|
|
23
|
+
usable_models,
|
|
24
|
+
)
|
|
25
|
+
from secondshift.connections.models import AgentConnection, Connection
|
|
26
|
+
from secondshift.workspaces.models import Workspace
|
|
27
|
+
|
|
28
|
+
# Choice keys name one model key or one login, for example "model:3" or "agent:7".
|
|
29
|
+
MODEL_PREFIX = "model:"
|
|
30
|
+
AGENT_PREFIX = "agent:"
|
|
31
|
+
NOTHING_CHOSEN = "Choose a model API key or a subscription to run jobs."
|
|
32
|
+
# A login's runs per day count over this rolling window.
|
|
33
|
+
RUN_CAP_WINDOW = timedelta(hours=24)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(frozen=True)
|
|
37
|
+
class ExecutionSelection:
|
|
38
|
+
key: str
|
|
39
|
+
label: str
|
|
40
|
+
backend: AgentBackend
|
|
41
|
+
connection: AgentConnection | None = None
|
|
42
|
+
model: Connection | None = None
|
|
43
|
+
problem: str = ""
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
def ready(self) -> bool:
|
|
47
|
+
return not self.problem and (
|
|
48
|
+
self.connection is not None or self.model is not None
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def recent_login_runs(workspace_id: int) -> Counter[int]:
|
|
53
|
+
"""Runs per agent login in the cap window. Only ready plans used a login."""
|
|
54
|
+
return login_run_counts(workspace_id, timezone.now() - RUN_CAP_WINDOW)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def cli_enabled() -> bool:
|
|
58
|
+
return bool(settings.AGENT_IMAGE and settings.AGENT_BROKER_URL)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def model_problem(model: Connection) -> str:
|
|
62
|
+
"""Why runs cannot use this model key, or empty when they can."""
|
|
63
|
+
if model.status != Connection.Status.CONNECTED:
|
|
64
|
+
return f"The model provider {model.display_name} is disconnected. Reconnect it."
|
|
65
|
+
if not model.default_model.strip():
|
|
66
|
+
return f"The model provider {model.display_name} has no model selected."
|
|
67
|
+
if model.failed_last_check:
|
|
68
|
+
return f"The model provider {model.display_name} failed its last check."
|
|
69
|
+
return ""
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def workspace_model(workspace: Workspace) -> Connection | None:
|
|
73
|
+
"""The chosen model provider, if runs can use it."""
|
|
74
|
+
model = workspace.default_model_connection
|
|
75
|
+
return None if model is None or model_problem(model) else model
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def model_selection(connection: Connection) -> ExecutionSelection:
|
|
79
|
+
problem = model_problem(connection)
|
|
80
|
+
return ExecutionSelection(
|
|
81
|
+
key=f"{MODEL_PREFIX}{connection.pk}",
|
|
82
|
+
label=f"Model provider · {connection.display_name}"
|
|
83
|
+
+ ("" if problem else f" · {connection.default_model}"),
|
|
84
|
+
backend=AgentBackend.PYDANTIC,
|
|
85
|
+
model=None if problem else connection,
|
|
86
|
+
problem=problem,
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def subscription_problem(connection: AgentConnection, workspace: Workspace) -> str:
|
|
91
|
+
"""Why runs cannot use this agent login, or empty when they can."""
|
|
92
|
+
if not cli_enabled():
|
|
93
|
+
return "Agent subscriptions are not set up on this server."
|
|
94
|
+
if not connection.connected:
|
|
95
|
+
return (
|
|
96
|
+
f"The agent login {connection.display_name} is disconnected. Reconnect it."
|
|
97
|
+
)
|
|
98
|
+
if sign_in_refused(connection):
|
|
99
|
+
return f"The agent login {connection.display_name} was refused at its last check. Reconnect it."
|
|
100
|
+
if not usable_agent_connection(connection, workspace.pk):
|
|
101
|
+
return f"The agent login {connection.display_name} belongs to someone who is no longer a workspace owner."
|
|
102
|
+
cap = connection.max_runs_per_day
|
|
103
|
+
if cap is not None and recent_login_runs(workspace.pk)[connection.pk] >= cap:
|
|
104
|
+
return (
|
|
105
|
+
f"The agent login {connection.display_name} reached its limit of "
|
|
106
|
+
f"{cap} runs in 24 hours. Raise its limit or wait."
|
|
107
|
+
)
|
|
108
|
+
return ""
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def subscription_selection(
|
|
112
|
+
connection: AgentConnection, workspace: Workspace
|
|
113
|
+
) -> ExecutionSelection:
|
|
114
|
+
backend = AgentBackend(connection.backend)
|
|
115
|
+
product = product_name(backend)
|
|
116
|
+
return ExecutionSelection(
|
|
117
|
+
key=f"{AGENT_PREFIX}{connection.pk}",
|
|
118
|
+
label=f"{product} subscription · {connection.display_name}",
|
|
119
|
+
backend=backend,
|
|
120
|
+
connection=connection,
|
|
121
|
+
problem=subscription_problem(connection, workspace),
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def workspace_execution(workspace: Workspace) -> ExecutionSelection:
|
|
126
|
+
"""What runs the workspace's jobs. It changes only when an owner changes it."""
|
|
127
|
+
if workspace.default_agent_connection_id:
|
|
128
|
+
return subscription_selection(workspace.default_agent_connection, workspace)
|
|
129
|
+
if workspace.default_model_connection_id:
|
|
130
|
+
return model_selection(workspace.default_model_connection)
|
|
131
|
+
return ExecutionSelection(
|
|
132
|
+
key="",
|
|
133
|
+
label="Nothing chosen",
|
|
134
|
+
backend=AgentBackend.PYDANTIC,
|
|
135
|
+
problem=NOTHING_CHOSEN,
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def execution_choices(workspace: Workspace, user: User) -> list[ExecutionSelection]:
|
|
140
|
+
"""The workspace's model keys, then the user's own logins."""
|
|
141
|
+
models = usable_models(workspace)
|
|
142
|
+
logins = connected_logins(workspace, user)
|
|
143
|
+
return [
|
|
144
|
+
*(model_selection(model) for model in models),
|
|
145
|
+
*(subscription_selection(login, workspace) for login in logins),
|
|
146
|
+
]
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def execution_configuration(workspace: Workspace, user: User) -> dict[str, Any]:
|
|
150
|
+
selected = workspace_execution(workspace)
|
|
151
|
+
choices = [
|
|
152
|
+
{
|
|
153
|
+
"key": choice.key,
|
|
154
|
+
"label": choice.label,
|
|
155
|
+
"disabled": not choice.ready,
|
|
156
|
+
"problem": choice.problem,
|
|
157
|
+
}
|
|
158
|
+
for choice in execution_choices(workspace, user)
|
|
159
|
+
]
|
|
160
|
+
if selected.key and selected.key not in {choice["key"] for choice in choices}:
|
|
161
|
+
# Another owner's login or a disconnected provider. Shown, not selectable.
|
|
162
|
+
choices.append(
|
|
163
|
+
{
|
|
164
|
+
"key": selected.key,
|
|
165
|
+
"label": selected.label,
|
|
166
|
+
"disabled": True,
|
|
167
|
+
"problem": selected.problem,
|
|
168
|
+
}
|
|
169
|
+
)
|
|
170
|
+
return {
|
|
171
|
+
"enabled": cli_enabled(),
|
|
172
|
+
"choices": choices,
|
|
173
|
+
"selected": selected.key,
|
|
174
|
+
"label": selected.label,
|
|
175
|
+
"backend": selected.backend,
|
|
176
|
+
"ready": selected.ready,
|
|
177
|
+
"problem": selected.problem,
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def execution_status(workspace: Workspace) -> dict[str, Any]:
|
|
182
|
+
"""What runs the jobs, why runs would fail, and where to fix it."""
|
|
183
|
+
selected = workspace_execution(workspace)
|
|
184
|
+
nothing_chosen = not selected.key
|
|
185
|
+
return {
|
|
186
|
+
"label": selected.label,
|
|
187
|
+
"backend": selected.backend,
|
|
188
|
+
"problem": selected.problem,
|
|
189
|
+
"chosen": not nothing_chosen,
|
|
190
|
+
"needs_setup": nothing_chosen and not has_models_or_logins(workspace.pk),
|
|
191
|
+
# The chosen model provider, so the page can open it ready to fix.
|
|
192
|
+
"fix_connection_id": workspace.default_model_connection_id
|
|
193
|
+
if selected.problem and not nothing_chosen and selected.backend.uses_model_api
|
|
194
|
+
else None,
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def choose_execution(
|
|
199
|
+
workspace: Workspace,
|
|
200
|
+
model: Connection | None = None,
|
|
201
|
+
login: AgentConnection | None = None,
|
|
202
|
+
) -> None:
|
|
203
|
+
"""Point the workspace at one model key or one login. None of both clears it."""
|
|
204
|
+
workspace.default_model_connection = model
|
|
205
|
+
workspace.default_agent_connection = login
|
|
206
|
+
workspace.save(
|
|
207
|
+
update_fields=[
|
|
208
|
+
"default_model_connection",
|
|
209
|
+
"default_agent_connection",
|
|
210
|
+
"updated_at",
|
|
211
|
+
]
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def save_execution_preference(workspace: Workspace, key: str, user: User) -> None:
|
|
216
|
+
choice = next(
|
|
217
|
+
(
|
|
218
|
+
choice
|
|
219
|
+
for choice in execution_choices(workspace, user)
|
|
220
|
+
if choice.key == key and choice.ready
|
|
221
|
+
),
|
|
222
|
+
None,
|
|
223
|
+
)
|
|
224
|
+
if choice is None:
|
|
225
|
+
raise ValidationError(
|
|
226
|
+
"Choose a working model provider or one of your own working logins."
|
|
227
|
+
)
|
|
228
|
+
choose_execution(workspace, model=choice.model, login=choice.connection)
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def use_when_nothing_runs(
|
|
232
|
+
workspace: Workspace,
|
|
233
|
+
model: Connection | None = None,
|
|
234
|
+
login: AgentConnection | None = None,
|
|
235
|
+
) -> None:
|
|
236
|
+
"""A new provider or login runs the jobs when nothing chosen can.
|
|
237
|
+
|
|
238
|
+
A broken newcomer only fills an empty choice, so it never hides a working
|
|
239
|
+
fix behind another problem. Settings shows the result.
|
|
240
|
+
"""
|
|
241
|
+
current = workspace_execution(workspace)
|
|
242
|
+
if current.ready:
|
|
243
|
+
return
|
|
244
|
+
candidate = model_selection(model) if model else None
|
|
245
|
+
if current.key and candidate is not None and not candidate.ready:
|
|
246
|
+
return
|
|
247
|
+
choose_execution(workspace, model=model, login=login)
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def apply_model_default(connection: Connection, is_default: bool | None) -> None:
|
|
251
|
+
"""The model provider form's default switch.
|
|
252
|
+
|
|
253
|
+
None means the form did not say, so the provider is used only when nothing
|
|
254
|
+
chosen can run jobs.
|
|
255
|
+
"""
|
|
256
|
+
workspace = connection.workspace
|
|
257
|
+
if is_default:
|
|
258
|
+
choose_execution(workspace, model=connection)
|
|
259
|
+
elif is_default is None:
|
|
260
|
+
use_when_nothing_runs(workspace, model=connection)
|
|
261
|
+
elif workspace.default_model_connection_id == connection.pk:
|
|
262
|
+
choose_execution(workspace)
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
"""External tools by connection kind: MCP servers and HTTP APIs.
|
|
2
|
+
|
|
3
|
+
The planner, broker, and access review are the same for every kind. Each kind
|
|
4
|
+
supplies only what differs: what a job may reach, what is frozen into a run
|
|
5
|
+
plan, how a connection is checked against that plan, and how a call is made.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from typing import Any, Protocol
|
|
10
|
+
|
|
11
|
+
from asgiref.sync import async_to_sync
|
|
12
|
+
|
|
13
|
+
from secondshift.connections.access import Access, AccessReason
|
|
14
|
+
from secondshift.connections.errors import ExternalToolError
|
|
15
|
+
from secondshift.connections.http_api.client import (
|
|
16
|
+
build_request,
|
|
17
|
+
send,
|
|
18
|
+
validate_api_connection,
|
|
19
|
+
)
|
|
20
|
+
from secondshift.connections.mcp.client import (
|
|
21
|
+
borrows_source,
|
|
22
|
+
mcp_request,
|
|
23
|
+
validate_mcp_connection,
|
|
24
|
+
)
|
|
25
|
+
from secondshift.connections.mcp.oauth import fresh_connection
|
|
26
|
+
from secondshift.connections.mcp.providers import (
|
|
27
|
+
MCP_PROVIDERS,
|
|
28
|
+
mcp_provider,
|
|
29
|
+
)
|
|
30
|
+
from secondshift.connections.models import Connection
|
|
31
|
+
from secondshift.connections.outbound import host_label
|
|
32
|
+
from secondshift.connections.repository_ref import RepositoryRef
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(frozen=True)
|
|
36
|
+
class Authorization:
|
|
37
|
+
"""A connection checked against the run's frozen plan, ready to call."""
|
|
38
|
+
|
|
39
|
+
connection: Connection
|
|
40
|
+
scope: str
|
|
41
|
+
# The frozen tool definition: description and inputSchema at least.
|
|
42
|
+
definition: dict[str, Any]
|
|
43
|
+
# Start of the model-facing tool name, such as "github" or "api".
|
|
44
|
+
name_prefix: str
|
|
45
|
+
scope_label: str
|
|
46
|
+
# The run's repository. Connections that borrow its source need it.
|
|
47
|
+
repository: RepositoryRef | None = None
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class ExternalKind(Protocol):
|
|
51
|
+
def access(self, connection: Connection, repository: RepositoryRef) -> Access: ...
|
|
52
|
+
|
|
53
|
+
def freeze(
|
|
54
|
+
self, connection: Connection, scope: str, tools: set[str]
|
|
55
|
+
) -> dict[str, Any]: ...
|
|
56
|
+
|
|
57
|
+
def authorize(
|
|
58
|
+
self,
|
|
59
|
+
connection: Connection,
|
|
60
|
+
frozen: dict[str, Any],
|
|
61
|
+
repository: RepositoryRef,
|
|
62
|
+
tool: str,
|
|
63
|
+
) -> Authorization: ...
|
|
64
|
+
|
|
65
|
+
def call(
|
|
66
|
+
self, authorization: Authorization, tool: str, arguments: dict[str, Any]
|
|
67
|
+
) -> dict[str, Any]: ...
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def frozen_tools(connection: Connection, tools: set[str]) -> dict[str, Any]:
|
|
71
|
+
definitions = connection.tool_definitions
|
|
72
|
+
return {name: definitions.get(name) for name in sorted(tools)}
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def frozen_definition(
|
|
76
|
+
connection: Connection, frozen: dict[str, Any], tool: str, noun: str
|
|
77
|
+
) -> dict[str, Any]:
|
|
78
|
+
definition = frozen.get("tools", {}).get(tool)
|
|
79
|
+
if not definition or definition != connection.tool_definitions.get(tool):
|
|
80
|
+
raise ExternalToolError(f"The {noun} definition changed. Start a new run.")
|
|
81
|
+
return definition
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class MCPKind:
|
|
85
|
+
def access(self, connection: Connection, repository: RepositoryRef) -> Access:
|
|
86
|
+
provider = MCP_PROVIDERS.get(connection.provider)
|
|
87
|
+
if provider is None:
|
|
88
|
+
return Access(
|
|
89
|
+
False,
|
|
90
|
+
None,
|
|
91
|
+
"This MCP provider is not supported.",
|
|
92
|
+
AccessReason.MCP_PROVIDER_UNSUPPORTED,
|
|
93
|
+
)
|
|
94
|
+
scope = provider.scope(connection.settings, repository)
|
|
95
|
+
if not scope:
|
|
96
|
+
return Access(
|
|
97
|
+
False,
|
|
98
|
+
None,
|
|
99
|
+
provider.missing_scope_message,
|
|
100
|
+
provider.missing_scope_reason,
|
|
101
|
+
)
|
|
102
|
+
if borrows_source(connection) and not provider.source_reachable(repository):
|
|
103
|
+
return Access(
|
|
104
|
+
False,
|
|
105
|
+
None,
|
|
106
|
+
provider.missing_source_message,
|
|
107
|
+
AccessReason.MCP_SOURCE_MISSING,
|
|
108
|
+
)
|
|
109
|
+
return Access(
|
|
110
|
+
True,
|
|
111
|
+
scope,
|
|
112
|
+
f"Allow these tools to {provider.access_verb} "
|
|
113
|
+
f"{provider.describe_scope(scope)}.",
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
def freeze(
|
|
117
|
+
self, connection: Connection, scope: str, tools: set[str]
|
|
118
|
+
) -> dict[str, Any]:
|
|
119
|
+
"""The approved scope and tool definitions. Never credentials."""
|
|
120
|
+
return {
|
|
121
|
+
"provider": connection.provider,
|
|
122
|
+
"scope": scope,
|
|
123
|
+
"tools": frozen_tools(connection, tools),
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
def authorize(
|
|
127
|
+
self,
|
|
128
|
+
connection: Connection,
|
|
129
|
+
frozen: dict[str, Any],
|
|
130
|
+
repository: RepositoryRef,
|
|
131
|
+
tool: str,
|
|
132
|
+
) -> Authorization:
|
|
133
|
+
# Signed-in connections renew their token here, before the checks below.
|
|
134
|
+
fresh_connection(connection)
|
|
135
|
+
provider = validate_mcp_connection(connection)
|
|
136
|
+
if (
|
|
137
|
+
frozen.get("provider") != provider.key
|
|
138
|
+
or not frozen.get("scope")
|
|
139
|
+
or frozen["scope"] != provider.scope(connection.settings, repository)
|
|
140
|
+
):
|
|
141
|
+
raise ExternalToolError(
|
|
142
|
+
f"The job {provider.scope_label} scope changed. Start a new run."
|
|
143
|
+
)
|
|
144
|
+
return Authorization(
|
|
145
|
+
connection=connection,
|
|
146
|
+
scope=frozen["scope"],
|
|
147
|
+
definition=frozen_definition(connection, frozen, tool, "MCP tool"),
|
|
148
|
+
name_prefix=provider.key,
|
|
149
|
+
scope_label=provider.scope_label,
|
|
150
|
+
repository=repository,
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
def call(
|
|
154
|
+
self, authorization: Authorization, tool: str, arguments: dict[str, Any]
|
|
155
|
+
) -> dict[str, Any]:
|
|
156
|
+
connection = authorization.connection
|
|
157
|
+
provider = mcp_provider(connection.provider)
|
|
158
|
+
scoped = provider.scope_arguments(authorization.scope, tool, arguments)
|
|
159
|
+
token = ""
|
|
160
|
+
if borrows_source(connection):
|
|
161
|
+
if provider.source_token is None or authorization.repository is None:
|
|
162
|
+
raise ExternalToolError("This MCP connection has no repository.")
|
|
163
|
+
# Minted for this call and this repository only. Never stored.
|
|
164
|
+
token = provider.source_token(authorization.repository)
|
|
165
|
+
return async_to_sync(mcp_request)(connection, tool, scoped, token)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
class APIKind:
|
|
169
|
+
def access(self, connection: Connection, repository: RepositoryRef) -> Access:
|
|
170
|
+
del repository
|
|
171
|
+
base_url = connection.endpoint_url
|
|
172
|
+
if not base_url:
|
|
173
|
+
return Access(
|
|
174
|
+
False,
|
|
175
|
+
None,
|
|
176
|
+
"This API connection has no base URL.",
|
|
177
|
+
AccessReason.API_BASE_URL_UNKNOWN,
|
|
178
|
+
)
|
|
179
|
+
host = host_label(base_url)
|
|
180
|
+
return Access(
|
|
181
|
+
True,
|
|
182
|
+
host,
|
|
183
|
+
f"Allow these operations to use anything the credential can reach on {host}.",
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
def freeze(
|
|
187
|
+
self, connection: Connection, scope: str, tools: set[str]
|
|
188
|
+
) -> dict[str, Any]:
|
|
189
|
+
return {
|
|
190
|
+
"base_url": connection.endpoint_url,
|
|
191
|
+
"scope": scope,
|
|
192
|
+
"tools": frozen_tools(connection, tools),
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
def authorize(
|
|
196
|
+
self,
|
|
197
|
+
connection: Connection,
|
|
198
|
+
frozen: dict[str, Any],
|
|
199
|
+
repository: RepositoryRef,
|
|
200
|
+
tool: str,
|
|
201
|
+
) -> Authorization:
|
|
202
|
+
del repository
|
|
203
|
+
validate_api_connection(connection)
|
|
204
|
+
if not frozen.get("base_url") or frozen["base_url"] != connection.endpoint_url:
|
|
205
|
+
raise ExternalToolError("The API base URL changed. Start a new run.")
|
|
206
|
+
return Authorization(
|
|
207
|
+
connection=connection,
|
|
208
|
+
scope=frozen["scope"],
|
|
209
|
+
definition=frozen_definition(connection, frozen, tool, "API operation"),
|
|
210
|
+
name_prefix="api",
|
|
211
|
+
scope_label="API",
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
def call(
|
|
215
|
+
self, authorization: Authorization, tool: str, arguments: dict[str, Any]
|
|
216
|
+
) -> dict[str, Any]:
|
|
217
|
+
del tool
|
|
218
|
+
request = build_request(
|
|
219
|
+
authorization.connection.endpoint_url, authorization.definition, arguments
|
|
220
|
+
)
|
|
221
|
+
return async_to_sync(send)(authorization.connection, request)
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
EXTERNAL_KINDS: dict[str, ExternalKind] = {
|
|
225
|
+
Connection.Kind.MCP: MCPKind(),
|
|
226
|
+
Connection.Kind.API: APIKind(),
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def external_kind(kind: str) -> ExternalKind:
|
|
231
|
+
try:
|
|
232
|
+
return EXTERNAL_KINDS[kind]
|
|
233
|
+
except KeyError:
|
|
234
|
+
raise ExternalToolError("This external tool is not supported.") from None
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def external_access(connection: Connection, repository: RepositoryRef) -> Access | None:
|
|
238
|
+
"""None for connections that are not external tools, such as webhooks."""
|
|
239
|
+
adapter = EXTERNAL_KINDS.get(connection.kind)
|
|
240
|
+
return adapter.access(connection, repository) if adapter else None
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def changing_tools(connection: Connection, tools: list[str]) -> list[str]:
|
|
244
|
+
"""Requested tools that may change data, for the access review.
|
|
245
|
+
|
|
246
|
+
Built-in MCP providers only offer allowlisted read tools. Custom servers
|
|
247
|
+
and HTTP APIs rely on the server's own read-only labels and HTTP methods.
|
|
248
|
+
"""
|
|
249
|
+
if connection.kind == Connection.Kind.MCP:
|
|
250
|
+
provider = MCP_PROVIDERS.get(connection.provider)
|
|
251
|
+
if provider is None or not provider.owner_selects_tools:
|
|
252
|
+
return []
|
|
253
|
+
elif connection.kind != Connection.Kind.API:
|
|
254
|
+
return []
|
|
255
|
+
offered = connection.offered_tools
|
|
256
|
+
return [
|
|
257
|
+
name for name in tools if name in offered and not offered[name].get("readOnly")
|
|
258
|
+
]
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""GitHub source connections: how a workspace reaches its repositories."""
|
|
2
|
+
|
|
3
|
+
from secondshift.connections.dao import save_github_source
|
|
4
|
+
from secondshift.connections.models import Connection
|
|
5
|
+
from secondshift.github import credentials
|
|
6
|
+
from secondshift.github.app import configured
|
|
7
|
+
from secondshift.github.errors import GitHubError
|
|
8
|
+
from secondshift.github.installations import Installation
|
|
9
|
+
from secondshift.github.tokens import inspect_token
|
|
10
|
+
from secondshift.workspaces.models import Workspace
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def link_installations(
|
|
14
|
+
workspace: Workspace, installations: list[Installation]
|
|
15
|
+
) -> list[Connection]:
|
|
16
|
+
"""Add or reconnect one source connection per App installation."""
|
|
17
|
+
return [
|
|
18
|
+
save_github_source(
|
|
19
|
+
workspace,
|
|
20
|
+
installation.installation_id,
|
|
21
|
+
{
|
|
22
|
+
"kind": Connection.Kind.SOURCE,
|
|
23
|
+
"auth_strategy": Connection.AuthStrategy.MANAGED,
|
|
24
|
+
"display_name": f"GitHub · {installation.account}",
|
|
25
|
+
"status": Connection.Status.CONNECTED,
|
|
26
|
+
},
|
|
27
|
+
)
|
|
28
|
+
for installation in installations
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
# Keeps token connections apart from installations, whose IDs are numbers.
|
|
33
|
+
TOKEN_ACCOUNT_PREFIX = "token:"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def connect_token_source(
|
|
37
|
+
workspace: Workspace, token: str, replacing: Connection | None = None
|
|
38
|
+
) -> Connection:
|
|
39
|
+
"""Add a personal access token as a source, or replace the owner's last one.
|
|
40
|
+
|
|
41
|
+
GitHub checks the token first. A token for the same GitHub account
|
|
42
|
+
replaces the stored one, so its repositories keep working. When
|
|
43
|
+
replacing a given connection, the token must belong to its account.
|
|
44
|
+
"""
|
|
45
|
+
owner = inspect_token(token.strip())
|
|
46
|
+
account_id = f"{TOKEN_ACCOUNT_PREFIX}{owner.login}"
|
|
47
|
+
if replacing is not None and replacing.external_account_id != account_id:
|
|
48
|
+
expected = replacing.external_account_id.removeprefix(TOKEN_ACCOUNT_PREFIX)
|
|
49
|
+
raise GitHubError(
|
|
50
|
+
f"This token belongs to {owner.login}. Use a token from {expected}."
|
|
51
|
+
)
|
|
52
|
+
return save_github_source(
|
|
53
|
+
workspace,
|
|
54
|
+
account_id,
|
|
55
|
+
{
|
|
56
|
+
"kind": Connection.Kind.SOURCE,
|
|
57
|
+
"auth_strategy": Connection.AuthStrategy.API_KEY,
|
|
58
|
+
"display_name": f"GitHub · {owner.login}",
|
|
59
|
+
"access_token": token.strip(),
|
|
60
|
+
"expires_at": owner.expires_at,
|
|
61
|
+
"status": Connection.Status.CONNECTED,
|
|
62
|
+
},
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def is_github_source(connection: Connection) -> bool:
|
|
67
|
+
return (
|
|
68
|
+
connection.provider == Connection.Provider.GITHUB
|
|
69
|
+
and connection.kind == Connection.Kind.SOURCE
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def shows_attribution(connection: Connection | None) -> bool:
|
|
74
|
+
"""Whether what SecondShift publishes through this connection names it.
|
|
75
|
+
|
|
76
|
+
On by default for the App, whose bot already shows SecondShift's work.
|
|
77
|
+
Off by default for a personal token, which posts as a person who may
|
|
78
|
+
not want to say which tools they use.
|
|
79
|
+
"""
|
|
80
|
+
if connection is None or not is_github_source(connection):
|
|
81
|
+
return True
|
|
82
|
+
if connection.attribution is not None:
|
|
83
|
+
return connection.attribution
|
|
84
|
+
return connection.auth_strategy != Connection.AuthStrategy.API_KEY
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def set_attribution(connection: Connection, attribution: bool) -> Connection:
|
|
88
|
+
connection.attribution = attribution
|
|
89
|
+
connection.save(update_fields=["attribution", "updated_at"])
|
|
90
|
+
return connection
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def usable_source(connection: Connection | None) -> bool:
|
|
94
|
+
if not (
|
|
95
|
+
connection
|
|
96
|
+
and connection.provider == Connection.Provider.GITHUB
|
|
97
|
+
and connection.kind == Connection.Kind.SOURCE
|
|
98
|
+
and connection.status == Connection.Status.CONNECTED
|
|
99
|
+
):
|
|
100
|
+
return False
|
|
101
|
+
if connection.auth_strategy == Connection.AuthStrategy.MANAGED:
|
|
102
|
+
return True
|
|
103
|
+
return connection.auth_strategy == Connection.AuthStrategy.API_KEY and bool(
|
|
104
|
+
connection.access_token
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def can_check(connection: Connection | None) -> bool:
|
|
109
|
+
"""App sources need the App on this server. Token sources need only themselves."""
|
|
110
|
+
return usable_source(connection) and (
|
|
111
|
+
connection.auth_strategy != Connection.AuthStrategy.MANAGED or configured()
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def connection_source(connection: Connection | None) -> credentials.Source:
|
|
116
|
+
"""The credential behind a source connection. Never returned to a client."""
|
|
117
|
+
if not usable_source(connection):
|
|
118
|
+
raise GitHubError("The GitHub connection is disconnected.")
|
|
119
|
+
if connection.auth_strategy == Connection.AuthStrategy.MANAGED:
|
|
120
|
+
return credentials.AppInstallation(connection.external_account_id)
|
|
121
|
+
if connection.current_status == Connection.Status.EXPIRED:
|
|
122
|
+
raise GitHubError("The GitHub token expired. Enter a new one.")
|
|
123
|
+
return credentials.PersonalToken(connection.access_token)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""HTTP API connections: operations from an OpenAPI spec, called by SecondShift."""
|