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,161 @@
|
|
|
1
|
+
"""Call one approved operation. The model supplies values, never the request shape."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import re
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Any
|
|
7
|
+
from urllib.parse import quote, urlencode
|
|
8
|
+
|
|
9
|
+
import anyio
|
|
10
|
+
from django.conf import settings
|
|
11
|
+
|
|
12
|
+
from secondshift.connections.http_api.spec import (
|
|
13
|
+
BODY,
|
|
14
|
+
APIArgumentError,
|
|
15
|
+
APIError,
|
|
16
|
+
APIRequestError,
|
|
17
|
+
)
|
|
18
|
+
from secondshift.connections.models import Connection
|
|
19
|
+
from secondshift.connections.outbound import RESPONSE_MAX_BYTES, outbound_client
|
|
20
|
+
|
|
21
|
+
BEARER = "bearer"
|
|
22
|
+
HEADER = "header"
|
|
23
|
+
AUTH_TYPES = (BEARER, HEADER)
|
|
24
|
+
HEADER_NAME_PATTERN = r"[A-Za-z0-9-]{1,64}"
|
|
25
|
+
# Headers an owner may not use for the credential: they change how the request
|
|
26
|
+
# is routed or framed.
|
|
27
|
+
RESERVED_HEADERS = {
|
|
28
|
+
"host",
|
|
29
|
+
"content-length",
|
|
30
|
+
"content-type",
|
|
31
|
+
"transfer-encoding",
|
|
32
|
+
"connection",
|
|
33
|
+
"cookie",
|
|
34
|
+
"accept",
|
|
35
|
+
"accept-encoding",
|
|
36
|
+
}
|
|
37
|
+
SCALAR_TYPES = (str, int, float, bool)
|
|
38
|
+
TEXT_MAX_CHARS = 64 * 1024
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass(frozen=True)
|
|
42
|
+
class APIRequest:
|
|
43
|
+
method: str
|
|
44
|
+
url: str
|
|
45
|
+
body: Any = None
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def auth_header(settings_: dict[str, str], token: str) -> dict[str, str]:
|
|
49
|
+
if settings_.get("auth") == HEADER:
|
|
50
|
+
name = settings_.get("header", "")
|
|
51
|
+
if (
|
|
52
|
+
not re.fullmatch(HEADER_NAME_PATTERN, name)
|
|
53
|
+
or name.lower() in RESERVED_HEADERS
|
|
54
|
+
):
|
|
55
|
+
raise APIError("The credential header name is not allowed.")
|
|
56
|
+
return {name: token}
|
|
57
|
+
return {"Authorization": f"Bearer {token}"}
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def path_value(value: Any) -> str:
|
|
61
|
+
"""One path segment. Slashes, dots-only segments, and URLs are refused."""
|
|
62
|
+
if isinstance(value, bool) or not isinstance(value, SCALAR_TYPES):
|
|
63
|
+
raise APIArgumentError("Path values must be text or numbers.")
|
|
64
|
+
text = str(value)
|
|
65
|
+
if not text or text in {".", ".."} or "/" in text or "\\" in text or "://" in text:
|
|
66
|
+
raise APIArgumentError("A path value is not allowed.")
|
|
67
|
+
return quote(text, safe="")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def query_values(value: Any) -> list[str]:
|
|
71
|
+
values = value if isinstance(value, list) else [value]
|
|
72
|
+
if any(item is None or not isinstance(item, SCALAR_TYPES) for item in values):
|
|
73
|
+
raise APIArgumentError("Query values must be text, numbers, or lists of them.")
|
|
74
|
+
return [
|
|
75
|
+
str(item).lower() if isinstance(item, bool) else str(item) for item in values
|
|
76
|
+
]
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def build_request(
|
|
80
|
+
base_url: str, operation: dict[str, Any], arguments: dict[str, Any]
|
|
81
|
+
) -> APIRequest:
|
|
82
|
+
declared = {item["name"]: item for item in operation["parameters"]}
|
|
83
|
+
unknown = set(arguments) - set(declared) - ({BODY} if operation["body"] else set())
|
|
84
|
+
if unknown:
|
|
85
|
+
raise APIArgumentError("The call has arguments the operation does not declare.")
|
|
86
|
+
path = operation["path"]
|
|
87
|
+
for name, item in declared.items():
|
|
88
|
+
if item["in"] != "path":
|
|
89
|
+
continue
|
|
90
|
+
if name not in arguments:
|
|
91
|
+
raise APIArgumentError("A required path value is missing.")
|
|
92
|
+
path = path.replace("{" + name + "}", path_value(arguments[name]))
|
|
93
|
+
query: list[tuple[str, str]] = []
|
|
94
|
+
for name, item in declared.items():
|
|
95
|
+
if item["in"] == "query" and arguments.get(name) is not None:
|
|
96
|
+
query += [(name, value) for value in query_values(arguments[name])]
|
|
97
|
+
url = base_url.rstrip("/") + path
|
|
98
|
+
if query:
|
|
99
|
+
url += "?" + urlencode(query)
|
|
100
|
+
return APIRequest(
|
|
101
|
+
method=operation["method"].upper(),
|
|
102
|
+
url=url,
|
|
103
|
+
body=arguments.get(BODY) if operation["body"] else None,
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def validate_api_connection(connection: Connection) -> None:
|
|
108
|
+
if (
|
|
109
|
+
connection.kind != Connection.Kind.API
|
|
110
|
+
or connection.provider != Connection.Provider.API
|
|
111
|
+
or connection.status != Connection.Status.CONNECTED
|
|
112
|
+
or not connection.access_token
|
|
113
|
+
or not connection.endpoint_url
|
|
114
|
+
or connection.token_expired
|
|
115
|
+
):
|
|
116
|
+
raise APIError("This API connection is unavailable.")
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
async def send(connection: Connection, request: APIRequest) -> dict[str, Any]:
|
|
120
|
+
"""Status and body. No token, exception text, or redirect target leaks."""
|
|
121
|
+
headers = {
|
|
122
|
+
**auth_header(connection.settings, connection.access_token),
|
|
123
|
+
"Accept": "application/json, text/plain;q=0.5",
|
|
124
|
+
}
|
|
125
|
+
try:
|
|
126
|
+
with anyio.fail_after(settings.OUTBOUND_TIMEOUT_SECONDS):
|
|
127
|
+
async with outbound_client(headers) as client:
|
|
128
|
+
response = await client.request(
|
|
129
|
+
request.method,
|
|
130
|
+
request.url,
|
|
131
|
+
json=request.body if request.body is not None else None,
|
|
132
|
+
)
|
|
133
|
+
raw = await response.aread()
|
|
134
|
+
except Exception:
|
|
135
|
+
raise APIRequestError(
|
|
136
|
+
"The API request failed. Check the service and token."
|
|
137
|
+
) from None
|
|
138
|
+
if 300 <= response.status_code < 400:
|
|
139
|
+
return {
|
|
140
|
+
"status": response.status_code,
|
|
141
|
+
"ok": False,
|
|
142
|
+
"body": "The API redirected. SecondShift does not follow redirects.",
|
|
143
|
+
}
|
|
144
|
+
content_type = response.headers.get("content-type", "").split(";")[0].strip()
|
|
145
|
+
text = raw.decode("utf-8", errors="replace").replace(
|
|
146
|
+
connection.access_token, "[redacted]"
|
|
147
|
+
)
|
|
148
|
+
body: Any
|
|
149
|
+
if content_type == "application/json" or content_type.endswith("+json"):
|
|
150
|
+
try:
|
|
151
|
+
body = json.loads(text)
|
|
152
|
+
except ValueError:
|
|
153
|
+
body = text[:TEXT_MAX_CHARS]
|
|
154
|
+
elif content_type.startswith("text/") or not content_type:
|
|
155
|
+
body = text[:TEXT_MAX_CHARS]
|
|
156
|
+
else:
|
|
157
|
+
body = f"Unsupported content type: {content_type[:80]}"
|
|
158
|
+
result = {"status": response.status_code, "ok": response.is_success, "body": body}
|
|
159
|
+
if len(json.dumps(result).encode()) > RESPONSE_MAX_BYTES:
|
|
160
|
+
raise APIError("The API response exceeded the size limit.")
|
|
161
|
+
return result
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
"""Create, edit, and re-check HTTP API connections."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from django.db import IntegrityError, transaction
|
|
8
|
+
|
|
9
|
+
from secondshift.connections.dao import binding_taken
|
|
10
|
+
from secondshift.connections.http_api.client import (
|
|
11
|
+
AUTH_TYPES,
|
|
12
|
+
BEARER,
|
|
13
|
+
HEADER,
|
|
14
|
+
HEADER_NAME_PATTERN,
|
|
15
|
+
RESERVED_HEADERS,
|
|
16
|
+
)
|
|
17
|
+
from secondshift.connections.http_api.spec import APIError, Spec, load_spec
|
|
18
|
+
from secondshift.connections.models import Connection
|
|
19
|
+
from secondshift.connections.outbound import (
|
|
20
|
+
OutboundUrlError,
|
|
21
|
+
host_label,
|
|
22
|
+
validate_outbound_url,
|
|
23
|
+
)
|
|
24
|
+
from secondshift.connections.services import ConnectionConflict
|
|
25
|
+
from secondshift.connections.tools import UnknownTools, store_tools
|
|
26
|
+
from secondshift.workspaces.models import Workspace
|
|
27
|
+
|
|
28
|
+
TOKEN_PATTERN = r"\S{1,2000}"
|
|
29
|
+
# Connection.endpoint_url is a URLField with Django's default limit.
|
|
30
|
+
BASE_URL_MAX_LENGTH = 200
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(frozen=True)
|
|
34
|
+
class SpecSummary:
|
|
35
|
+
title: str
|
|
36
|
+
servers: tuple[str, ...]
|
|
37
|
+
operations: int
|
|
38
|
+
read_only: int
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def summarize(spec_url: str, spec_text: str) -> SpecSummary:
|
|
42
|
+
spec = load_spec(spec_url, spec_text)
|
|
43
|
+
return SpecSummary(
|
|
44
|
+
title=spec.title,
|
|
45
|
+
servers=spec.servers,
|
|
46
|
+
operations=len(spec.operations),
|
|
47
|
+
read_only=sum(1 for item in spec.operations.values() if item.read_only),
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def clean_base_url(value: str) -> str:
|
|
52
|
+
"""Where requests and the credential go. Checked with DNS, like webhooks."""
|
|
53
|
+
value = value.strip().rstrip("/")
|
|
54
|
+
if not value or len(value) > BASE_URL_MAX_LENGTH:
|
|
55
|
+
raise APIError("Enter the API base URL.")
|
|
56
|
+
try:
|
|
57
|
+
value = validate_outbound_url(value, "base URL")
|
|
58
|
+
except OutboundUrlError as error:
|
|
59
|
+
raise APIError(str(error)) from None
|
|
60
|
+
if "?" in value or "#" in value:
|
|
61
|
+
raise APIError("The base URL cannot have a query or fragment.")
|
|
62
|
+
return value
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def clean_auth(auth: str, header: str) -> dict[str, str]:
|
|
66
|
+
if auth not in AUTH_TYPES:
|
|
67
|
+
raise APIError("Choose how to send the credential.")
|
|
68
|
+
if auth == BEARER:
|
|
69
|
+
return {"auth": BEARER}
|
|
70
|
+
if (
|
|
71
|
+
not re.fullmatch(HEADER_NAME_PATTERN, header)
|
|
72
|
+
or header.lower() in RESERVED_HEADERS
|
|
73
|
+
):
|
|
74
|
+
raise APIError("Enter a header name such as X-API-Key.")
|
|
75
|
+
return {"auth": HEADER, "header": header}
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def offered(spec: Spec) -> dict[str, Any]:
|
|
79
|
+
return {name: operation.definition() for name, operation in spec.operations.items()}
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def select_operations(
|
|
83
|
+
connection: Connection, operations: dict[str, Any], selected: list[str] | None
|
|
84
|
+
) -> None:
|
|
85
|
+
"""GET operations start selected. Anything else waits for the owner."""
|
|
86
|
+
try:
|
|
87
|
+
store_tools(connection, operations, selected)
|
|
88
|
+
except UnknownTools as error:
|
|
89
|
+
raise APIError("Choose operations from the spec.") from error
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def create_api_connection(
|
|
93
|
+
workspace: Workspace,
|
|
94
|
+
binding_key: str,
|
|
95
|
+
display_name: str,
|
|
96
|
+
spec_url: str,
|
|
97
|
+
spec_text: str,
|
|
98
|
+
base_url: str,
|
|
99
|
+
auth: str,
|
|
100
|
+
header: str,
|
|
101
|
+
token: str,
|
|
102
|
+
) -> Connection:
|
|
103
|
+
if not re.fullmatch(TOKEN_PATTERN, token):
|
|
104
|
+
raise APIError("Enter the credential without spaces.")
|
|
105
|
+
if binding_taken(workspace.pk, binding_key):
|
|
106
|
+
raise ConnectionConflict("This binding already has a connection.")
|
|
107
|
+
# Cheap checks first, before downloading the spec.
|
|
108
|
+
auth_settings = clean_auth(auth, header)
|
|
109
|
+
base_url = clean_base_url(base_url)
|
|
110
|
+
spec = load_spec(spec_url, spec_text)
|
|
111
|
+
connection = Connection(
|
|
112
|
+
workspace=workspace,
|
|
113
|
+
provider=Connection.Provider.API,
|
|
114
|
+
kind=Connection.Kind.API,
|
|
115
|
+
binding_key=binding_key,
|
|
116
|
+
display_name=display_name.strip() or spec.title or host_label(base_url),
|
|
117
|
+
endpoint_url=base_url,
|
|
118
|
+
auth_strategy=Connection.AuthStrategy.API_KEY,
|
|
119
|
+
access_token=token,
|
|
120
|
+
metadata={
|
|
121
|
+
"target": host_label(base_url),
|
|
122
|
+
"spec_url": spec_url,
|
|
123
|
+
"settings": auth_settings,
|
|
124
|
+
},
|
|
125
|
+
)
|
|
126
|
+
select_operations(connection, offered(spec), None)
|
|
127
|
+
connection.full_clean()
|
|
128
|
+
try:
|
|
129
|
+
with transaction.atomic():
|
|
130
|
+
connection.save()
|
|
131
|
+
except IntegrityError:
|
|
132
|
+
raise ConnectionConflict("This binding already has a connection.") from None
|
|
133
|
+
return connection
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def update_api_connection(
|
|
137
|
+
connection: Connection,
|
|
138
|
+
display_name: str | None,
|
|
139
|
+
token: str | None,
|
|
140
|
+
base_url: str | None,
|
|
141
|
+
operations: list[str] | None,
|
|
142
|
+
) -> Connection:
|
|
143
|
+
"""A blank token keeps the current one. A new base URL is an explicit choice."""
|
|
144
|
+
if connection.kind != Connection.Kind.API:
|
|
145
|
+
raise ConnectionConflict("Only API connections can be edited here.")
|
|
146
|
+
if token:
|
|
147
|
+
if not re.fullmatch(TOKEN_PATTERN, token):
|
|
148
|
+
raise APIError("Enter the credential without spaces.")
|
|
149
|
+
connection.access_token = token
|
|
150
|
+
elif (
|
|
151
|
+
connection.status != Connection.Status.CONNECTED or not connection.access_token
|
|
152
|
+
):
|
|
153
|
+
raise APIError("Enter a new credential to reconnect.")
|
|
154
|
+
if display_name is not None:
|
|
155
|
+
connection.display_name = display_name.strip() or connection.binding_key
|
|
156
|
+
if base_url is not None:
|
|
157
|
+
cleaned = clean_base_url(base_url)
|
|
158
|
+
connection.endpoint_url = cleaned
|
|
159
|
+
connection.metadata = {
|
|
160
|
+
**connection.metadata,
|
|
161
|
+
"target": host_label(cleaned),
|
|
162
|
+
}
|
|
163
|
+
if operations is not None:
|
|
164
|
+
select_operations(connection, connection.offered_tools, operations)
|
|
165
|
+
connection.status = Connection.Status.CONNECTED
|
|
166
|
+
connection.full_clean()
|
|
167
|
+
connection.save()
|
|
168
|
+
return connection
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def recheck_api_connection(connection: Connection) -> str:
|
|
172
|
+
"""Load the spec again. The saved base URL never changes here."""
|
|
173
|
+
spec_url = connection.metadata.get("spec_url", "")
|
|
174
|
+
if not spec_url:
|
|
175
|
+
return (
|
|
176
|
+
"This connection uses a pasted spec, so there is nothing to download again."
|
|
177
|
+
)
|
|
178
|
+
spec = load_spec(spec_url, "")
|
|
179
|
+
operations = offered(spec)
|
|
180
|
+
kept = [name for name in connection.available_tools if name in operations]
|
|
181
|
+
select_operations(connection, operations, kept)
|
|
182
|
+
connection.save(update_fields=["available_tools", "metadata", "updated_at"])
|
|
183
|
+
message = f"Found {len(operations)} operations. {len(kept)} enabled."
|
|
184
|
+
base_url = connection.endpoint_url
|
|
185
|
+
if spec.servers and base_url not in spec.servers:
|
|
186
|
+
message += f" The spec now lists a different server. Requests still go to {host_label(base_url)}."
|
|
187
|
+
return message
|