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,224 @@
|
|
|
1
|
+
"""Skills from another public GitHub repository, pinned to one commit.
|
|
2
|
+
|
|
3
|
+
A skill is a SKILL.md and the files in its folder. SecondShift fetches them
|
|
4
|
+
without credentials, so only public repositories work. A run gets the folder
|
|
5
|
+
at the pinned commit, mounted read-only at /skill. Nothing in the skill's
|
|
6
|
+
repository can change how the sandbox runs or what it may reach.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import fcntl
|
|
10
|
+
import hashlib
|
|
11
|
+
import os
|
|
12
|
+
import posixpath
|
|
13
|
+
import re
|
|
14
|
+
import subprocess
|
|
15
|
+
from collections.abc import Iterator
|
|
16
|
+
from contextlib import contextmanager
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
from pathlib import Path, PurePosixPath
|
|
19
|
+
|
|
20
|
+
from django.conf import settings
|
|
21
|
+
|
|
22
|
+
from secondshift.config.constants import COMMIT_SHA_PATTERN, GITHUB_REPOSITORY_PATTERN
|
|
23
|
+
from secondshift.github.errors import GitHubError
|
|
24
|
+
from secondshift.github.git import public_git
|
|
25
|
+
from secondshift.repositories.local import LocalRepositoryError, committed_file
|
|
26
|
+
from secondshift.repositories.mirrors import has_commit
|
|
27
|
+
from secondshift.repositories.snapshots import (
|
|
28
|
+
SKILL_LIMITS,
|
|
29
|
+
SnapshotError,
|
|
30
|
+
tree_entries,
|
|
31
|
+
)
|
|
32
|
+
from secondshift.repositories.sources import (
|
|
33
|
+
SourceSnapshot,
|
|
34
|
+
file_sha256,
|
|
35
|
+
skill_mirrors_root,
|
|
36
|
+
skill_snapshots_root,
|
|
37
|
+
write_atomically,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
SKILL_FILE = "SKILL.md"
|
|
41
|
+
# Branch and tag names a person may give. Git checks them again.
|
|
42
|
+
REF_NAME = re.compile(r"[A-Za-z0-9._][A-Za-z0-9._/-]{0,199}")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class ExternalSkillError(ValueError):
|
|
46
|
+
"""Worded for the person who named the skill."""
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass(frozen=True)
|
|
50
|
+
class ExternalSkill:
|
|
51
|
+
repository: str
|
|
52
|
+
path: str
|
|
53
|
+
commit_sha: str
|
|
54
|
+
text: str
|
|
55
|
+
file_count: int
|
|
56
|
+
size_bytes: int
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def check_reference(repository: str, path: str) -> None:
|
|
60
|
+
if not re.fullmatch(GITHUB_REPOSITORY_PATTERN, repository) or any(
|
|
61
|
+
part in {".", ".."} for part in repository.split("/")
|
|
62
|
+
):
|
|
63
|
+
raise ExternalSkillError("Name the repository as owner/name.")
|
|
64
|
+
pure = PurePosixPath(path)
|
|
65
|
+
if (
|
|
66
|
+
pure.is_absolute()
|
|
67
|
+
or ".." in pure.parts
|
|
68
|
+
or "\0" in path
|
|
69
|
+
or posixpath.normpath(path) != path
|
|
70
|
+
):
|
|
71
|
+
raise ExternalSkillError("The skill path must be a path inside its repository.")
|
|
72
|
+
if pure.name != SKILL_FILE:
|
|
73
|
+
raise ExternalSkillError(f"The skill path must point to a {SKILL_FILE} file.")
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def check_ref(ref: str) -> None:
|
|
77
|
+
if re.fullmatch(COMMIT_SHA_PATTERN, ref):
|
|
78
|
+
return
|
|
79
|
+
if (
|
|
80
|
+
not REF_NAME.fullmatch(ref)
|
|
81
|
+
or ".." in ref
|
|
82
|
+
or "//" in ref
|
|
83
|
+
or ref.endswith(("/", ".lock", "."))
|
|
84
|
+
or "@{" in ref
|
|
85
|
+
):
|
|
86
|
+
raise ExternalSkillError("Give a branch, a tag or a full commit SHA.")
|
|
87
|
+
completed = subprocess.run(
|
|
88
|
+
["git", "check-ref-format", f"refs/heads/{ref}"],
|
|
89
|
+
capture_output=True,
|
|
90
|
+
check=False,
|
|
91
|
+
timeout=10,
|
|
92
|
+
)
|
|
93
|
+
if completed.returncode:
|
|
94
|
+
raise ExternalSkillError("Give a branch, a tag or a full commit SHA.")
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def skill_folder(path: str) -> str:
|
|
98
|
+
"""The folder whose files a run gets. Empty for a SKILL.md at the root."""
|
|
99
|
+
return posixpath.dirname(path)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def cache_key(repository: str) -> str:
|
|
103
|
+
# Repository names are case-insensitive on GitHub.
|
|
104
|
+
return hashlib.sha256(repository.lower().encode()).hexdigest()[:32]
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
@contextmanager
|
|
108
|
+
def locked_skill_mirror(repository: str) -> Iterator[Path]:
|
|
109
|
+
"""A bare mirror of the skill's repository, held under a lock."""
|
|
110
|
+
key = cache_key(repository)
|
|
111
|
+
path = skill_mirrors_root() / f"{key}.git"
|
|
112
|
+
path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
113
|
+
locks = settings.SOURCE_CACHE_ROOT / "locks"
|
|
114
|
+
locks.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
115
|
+
with (locks / f"skill-{key}.lock").open("a+b") as lock:
|
|
116
|
+
fcntl.flock(lock, fcntl.LOCK_EX)
|
|
117
|
+
if not path.exists():
|
|
118
|
+
subprocess.run(
|
|
119
|
+
["git", "init", "--quiet", "--bare", str(path)],
|
|
120
|
+
check=True,
|
|
121
|
+
capture_output=True,
|
|
122
|
+
timeout=30,
|
|
123
|
+
)
|
|
124
|
+
# Mark it used, so cache cleanup keeps it.
|
|
125
|
+
os.utime(path)
|
|
126
|
+
yield path
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def clone_url(repository: str) -> str:
|
|
130
|
+
return f"{settings.EXTERNAL_SKILLS_BASE_URL}/{repository}.git"
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def fetch(mirror: Path, repository: str, ref: str) -> str:
|
|
134
|
+
"""Fetch one branch, tag or commit and return its full commit SHA."""
|
|
135
|
+
try:
|
|
136
|
+
public_git(
|
|
137
|
+
mirror, "fetch", "--no-tags", "--depth=1", "--", clone_url(repository), ref
|
|
138
|
+
)
|
|
139
|
+
commit = public_git(mirror, "rev-parse", "--verify", "FETCH_HEAD^{commit}")
|
|
140
|
+
except GitHubError as error:
|
|
141
|
+
raise ExternalSkillError(
|
|
142
|
+
f"Could not fetch {ref} from {repository}. Only public GitHub "
|
|
143
|
+
"repositories work, and the branch, tag or commit must exist."
|
|
144
|
+
) from error
|
|
145
|
+
if not re.fullmatch(COMMIT_SHA_PATTERN, commit):
|
|
146
|
+
raise ExternalSkillError(f"{repository} did not resolve to a full commit.")
|
|
147
|
+
# Keep the commit's objects in the mirror until cache cleanup.
|
|
148
|
+
public_git(mirror, "update-ref", f"refs/secondshift/skills/{commit}", commit)
|
|
149
|
+
return commit
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def mirrored_commit(mirror: Path, repository: str, ref: str) -> str:
|
|
153
|
+
if re.fullmatch(COMMIT_SHA_PATTERN, ref) and has_commit(mirror, ref):
|
|
154
|
+
return ref
|
|
155
|
+
commit = fetch(mirror, repository, ref)
|
|
156
|
+
if re.fullmatch(COMMIT_SHA_PATTERN, ref) and commit != ref:
|
|
157
|
+
raise ExternalSkillError(f"{repository} did not return commit {ref[:12]}.")
|
|
158
|
+
return commit
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def require_enabled() -> None:
|
|
162
|
+
if not settings.EXTERNAL_SKILLS_ENABLED:
|
|
163
|
+
raise ExternalSkillError(
|
|
164
|
+
"This server does not run skills from other repositories."
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def resolve_external_skill(
|
|
169
|
+
repository: str, path: str, ref: str | None = None
|
|
170
|
+
) -> ExternalSkill:
|
|
171
|
+
"""Fetch the skill at a branch, tag or commit, and pin it to a commit.
|
|
172
|
+
|
|
173
|
+
Without a ref, the repository's default branch is used.
|
|
174
|
+
"""
|
|
175
|
+
require_enabled()
|
|
176
|
+
check_reference(repository, path)
|
|
177
|
+
requested = ref or "HEAD"
|
|
178
|
+
if ref is not None:
|
|
179
|
+
check_ref(ref)
|
|
180
|
+
with locked_skill_mirror(repository) as mirror:
|
|
181
|
+
commit = mirrored_commit(mirror, repository, requested)
|
|
182
|
+
try:
|
|
183
|
+
text = committed_file(mirror, commit, path)
|
|
184
|
+
except LocalRepositoryError as error:
|
|
185
|
+
raise ExternalSkillError(
|
|
186
|
+
f"{path} is not a file in {repository} at {commit[:12]}."
|
|
187
|
+
) from error
|
|
188
|
+
try:
|
|
189
|
+
entries = tree_entries(mirror, commit, skill_folder(path))
|
|
190
|
+
except SnapshotError as error:
|
|
191
|
+
raise ExternalSkillError(str(error)) from error
|
|
192
|
+
return ExternalSkill(
|
|
193
|
+
repository=repository,
|
|
194
|
+
path=path,
|
|
195
|
+
commit_sha=commit,
|
|
196
|
+
text=text,
|
|
197
|
+
file_count=len(entries),
|
|
198
|
+
size_bytes=sum(entry.size for entry in entries),
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def skill_snapshot(repository: str, path: str, commit_sha: str) -> SourceSnapshot:
|
|
203
|
+
"""The skill folder at a commit, as a snapshot a run can unpack.
|
|
204
|
+
|
|
205
|
+
Built and cached if needed. Raises SnapshotError or ExternalSkillError.
|
|
206
|
+
"""
|
|
207
|
+
require_enabled()
|
|
208
|
+
check_reference(repository, path)
|
|
209
|
+
if not re.fullmatch(COMMIT_SHA_PATTERN, commit_sha):
|
|
210
|
+
raise SnapshotError("A full commit SHA is required.")
|
|
211
|
+
name = hashlib.sha256(f"{repository.lower()}\0{path}".encode()).hexdigest()[:32]
|
|
212
|
+
target = skill_snapshots_root() / f"{commit_sha}-{name}.tar.gz"
|
|
213
|
+
if target.exists():
|
|
214
|
+
target.touch()
|
|
215
|
+
else:
|
|
216
|
+
target.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
217
|
+
with locked_skill_mirror(repository) as mirror:
|
|
218
|
+
mirrored_commit(mirror, repository, commit_sha)
|
|
219
|
+
write_atomically(
|
|
220
|
+
mirror, commit_sha, target, skill_folder(path), SKILL_LIMITS
|
|
221
|
+
)
|
|
222
|
+
return SourceSnapshot(
|
|
223
|
+
path=target, sha256=file_sha256(target), size_bytes=target.stat().st_size
|
|
224
|
+
)
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""Resolve GitHub scope from server-managed repository identity."""
|
|
2
|
+
|
|
3
|
+
from secondshift.connections.repository_ref import RepositoryRef
|
|
4
|
+
from secondshift.github.identity import github_remote_name, valid_name
|
|
5
|
+
from secondshift.repositories.models import Repository
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def github_repository_scope(repository: Repository) -> str | None:
|
|
9
|
+
if repository.source_type == Repository.SourceType.LOCAL:
|
|
10
|
+
return github_remote_name(repository.clone_url)
|
|
11
|
+
name = repository.full_name
|
|
12
|
+
return name if valid_name(name) else None
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def repository_ref(repository: Repository) -> RepositoryRef:
|
|
16
|
+
"""What external tools may know about the repository a job works on."""
|
|
17
|
+
from_github = repository.source_type == Repository.SourceType.GITHUB
|
|
18
|
+
return RepositoryRef(
|
|
19
|
+
workspace_id=repository.workspace_id,
|
|
20
|
+
github_name=github_repository_scope(repository),
|
|
21
|
+
github_id=(
|
|
22
|
+
int(repository.external_id)
|
|
23
|
+
if from_github and repository.external_id
|
|
24
|
+
else None
|
|
25
|
+
),
|
|
26
|
+
source=repository.github_connection if from_github else None,
|
|
27
|
+
)
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Reach a repository on GitHub through the connection it was added with.
|
|
2
|
+
|
|
3
|
+
Each operation gets one token and checks the repository's identity once, then
|
|
4
|
+
uses that token for every call and git command it makes.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
|
|
9
|
+
from secondshift.connections.github import connection_source
|
|
10
|
+
from secondshift.connections.models import Connection
|
|
11
|
+
from secondshift.github import credentials, repos
|
|
12
|
+
from secondshift.github.errors import GitHubError
|
|
13
|
+
from secondshift.github.identity import clone_url
|
|
14
|
+
from secondshift.repositories.models import Repository
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True)
|
|
18
|
+
class GitHubAccess:
|
|
19
|
+
"""A checked repository and a token for it. Never stored or logged."""
|
|
20
|
+
|
|
21
|
+
repository_id: int
|
|
22
|
+
full_name: str
|
|
23
|
+
token: str
|
|
24
|
+
metadata: dict
|
|
25
|
+
|
|
26
|
+
@property
|
|
27
|
+
def clone_url(self) -> str:
|
|
28
|
+
return clone_url(self.full_name)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def connection_access(
|
|
32
|
+
connection: Connection | None,
|
|
33
|
+
repository_id: int,
|
|
34
|
+
permissions: dict[str, str] = credentials.READ,
|
|
35
|
+
) -> GitHubAccess:
|
|
36
|
+
"""Access to a repository the connection reaches, before it has a row."""
|
|
37
|
+
token = credentials.token_for(
|
|
38
|
+
connection_source(connection), repository_id, permissions
|
|
39
|
+
)
|
|
40
|
+
metadata = repos.repository(token, repository_id)
|
|
41
|
+
return GitHubAccess(repository_id, metadata["full_name"], token, metadata)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def github_access(
|
|
45
|
+
repository: Repository, permissions: dict[str, str] = credentials.READ
|
|
46
|
+
) -> GitHubAccess:
|
|
47
|
+
"""Access to an added repository. Fails if it was removed, moved or renamed."""
|
|
48
|
+
if repository.removed_at is not None:
|
|
49
|
+
raise GitHubError("This repository has been removed.")
|
|
50
|
+
connection = repository.github_connection
|
|
51
|
+
if connection is not None and connection.workspace_id != repository.workspace_id:
|
|
52
|
+
raise GitHubError("The GitHub connection is disconnected.")
|
|
53
|
+
access = connection_access(connection, int(repository.external_id), permissions)
|
|
54
|
+
# Do not silently move a repository's identity or its existing approvals.
|
|
55
|
+
if repository.full_name != access.full_name:
|
|
56
|
+
raise GitHubError("Repository was renamed. Remove and import it again.")
|
|
57
|
+
return access
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
"""Check what a GitHub source connection can still do, feature by feature.
|
|
2
|
+
|
|
3
|
+
The result is kept as the connection's last check, so the dashboard shows
|
|
4
|
+
each gap with a link to fix it on GitHub. Tokens are never part of it.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from dataclasses import asdict, dataclass
|
|
8
|
+
from datetime import datetime, timedelta
|
|
9
|
+
|
|
10
|
+
from django.utils import timezone
|
|
11
|
+
|
|
12
|
+
from secondshift.config.constants import GITHUB_BASE_URL
|
|
13
|
+
from secondshift.connections.checks import record_check
|
|
14
|
+
from secondshift.connections.dao import connected_github_sources
|
|
15
|
+
from secondshift.connections.github import can_check, connection_source
|
|
16
|
+
from secondshift.connections.models import Connection
|
|
17
|
+
from secondshift.github import app, credentials, permissions
|
|
18
|
+
from secondshift.github.errors import GitHubError
|
|
19
|
+
from secondshift.github.tokens import inspect_token
|
|
20
|
+
from secondshift.repositories.dao import connection_repositories
|
|
21
|
+
from secondshift.repositories.github_access import (
|
|
22
|
+
GitHubAccess,
|
|
23
|
+
github_access,
|
|
24
|
+
)
|
|
25
|
+
from secondshift.repositories.models import Repository
|
|
26
|
+
|
|
27
|
+
# A grant can be withdrawn on GitHub at any time, so each source is checked daily.
|
|
28
|
+
CHECK_INTERVAL = timedelta(days=1)
|
|
29
|
+
|
|
30
|
+
GITHUB_TOKENS_URL = f"{GITHUB_BASE_URL}/settings/personal-access-tokens"
|
|
31
|
+
|
|
32
|
+
PERMISSION_LABELS = {
|
|
33
|
+
"contents": "Contents",
|
|
34
|
+
"pull_requests": "Pull requests",
|
|
35
|
+
"actions": "Actions",
|
|
36
|
+
"statuses": "Commit statuses",
|
|
37
|
+
"issues": "Issues",
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass(frozen=True)
|
|
42
|
+
class FeatureCheck:
|
|
43
|
+
feature: str
|
|
44
|
+
label: str
|
|
45
|
+
# None when it cannot be checked without using it.
|
|
46
|
+
ok: bool | None
|
|
47
|
+
message: str
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass(frozen=True)
|
|
51
|
+
class SourceCheck:
|
|
52
|
+
ok: bool
|
|
53
|
+
message: str
|
|
54
|
+
features: list[FeatureCheck]
|
|
55
|
+
fix_url: str = ""
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def names(permission_names: list[str]) -> str:
|
|
59
|
+
return ", ".join(PERMISSION_LABELS[name] for name in permission_names)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def features(
|
|
63
|
+
reads: dict[str, bool] | None, writes: dict[str, bool] | None
|
|
64
|
+
) -> list[FeatureCheck]:
|
|
65
|
+
"""One line per feature. reads is None when no repository could be probed."""
|
|
66
|
+
if reads is None:
|
|
67
|
+
unknown = "Add a repository to check this."
|
|
68
|
+
return [
|
|
69
|
+
FeatureCheck("sync", "Sync", None, unknown),
|
|
70
|
+
FeatureCheck("tools", "GitHub tools", None, unknown),
|
|
71
|
+
FeatureCheck("draft_pull_requests", "Draft pull requests", None, unknown),
|
|
72
|
+
FeatureCheck("issues", "Issues", None, unknown),
|
|
73
|
+
]
|
|
74
|
+
missing_tools = [name for name in permissions.TOOLS if not reads[name]]
|
|
75
|
+
sync = (
|
|
76
|
+
FeatureCheck("sync", "Sync", True, "Can read code.")
|
|
77
|
+
if reads["contents"]
|
|
78
|
+
else FeatureCheck(
|
|
79
|
+
"sync", "Sync", False, "Needs read access to Contents. Sync fails."
|
|
80
|
+
)
|
|
81
|
+
)
|
|
82
|
+
tools = (
|
|
83
|
+
FeatureCheck(
|
|
84
|
+
"tools",
|
|
85
|
+
"GitHub tools",
|
|
86
|
+
True,
|
|
87
|
+
"Can read pull requests, Actions runs and commit statuses.",
|
|
88
|
+
)
|
|
89
|
+
if not missing_tools
|
|
90
|
+
else FeatureCheck(
|
|
91
|
+
"tools",
|
|
92
|
+
"GitHub tools",
|
|
93
|
+
False,
|
|
94
|
+
f"Needs read access to {names(missing_tools)}.",
|
|
95
|
+
)
|
|
96
|
+
)
|
|
97
|
+
drafts = write_feature(
|
|
98
|
+
writes,
|
|
99
|
+
"draft_pull_requests",
|
|
100
|
+
"Draft pull requests",
|
|
101
|
+
permissions.DRAFT_PULL_REQUESTS,
|
|
102
|
+
"Can push a branch and open a draft pull request.",
|
|
103
|
+
"a draft pull request opens",
|
|
104
|
+
)
|
|
105
|
+
issues = write_feature(
|
|
106
|
+
writes,
|
|
107
|
+
"issues",
|
|
108
|
+
"Issues",
|
|
109
|
+
permissions.ISSUES,
|
|
110
|
+
"Can open issues.",
|
|
111
|
+
"an issue opens",
|
|
112
|
+
)
|
|
113
|
+
return [sync, tools, drafts, issues]
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def write_feature(
|
|
117
|
+
writes: dict[str, bool] | None,
|
|
118
|
+
feature: str,
|
|
119
|
+
label: str,
|
|
120
|
+
needed: tuple[str, ...],
|
|
121
|
+
ready: str,
|
|
122
|
+
first_use: str,
|
|
123
|
+
) -> FeatureCheck:
|
|
124
|
+
"""A feature that writes. A token's write access is only known on use."""
|
|
125
|
+
if writes is None:
|
|
126
|
+
return FeatureCheck(
|
|
127
|
+
feature,
|
|
128
|
+
label,
|
|
129
|
+
None,
|
|
130
|
+
f"A token's write access is checked when {first_use}.",
|
|
131
|
+
)
|
|
132
|
+
missing = [name for name in needed if not writes[name]]
|
|
133
|
+
if missing:
|
|
134
|
+
return FeatureCheck(
|
|
135
|
+
feature, label, False, f"Needs write access to {names(missing)}."
|
|
136
|
+
)
|
|
137
|
+
return FeatureCheck(feature, label, True, ready)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def reach(repositories: list[Repository]) -> tuple[GitHubAccess | None, list[str]]:
|
|
141
|
+
"""The first repository reached, and the names of those that were not."""
|
|
142
|
+
first: GitHubAccess | None = None
|
|
143
|
+
unreachable: list[str] = []
|
|
144
|
+
for repository in repositories:
|
|
145
|
+
try:
|
|
146
|
+
access = github_access(repository)
|
|
147
|
+
except GitHubError:
|
|
148
|
+
unreachable.append(repository.full_name)
|
|
149
|
+
continue
|
|
150
|
+
first = first or access
|
|
151
|
+
return first, unreachable
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def summary(
|
|
155
|
+
repositories: list[Repository], unreachable: list[str], checked: list[FeatureCheck]
|
|
156
|
+
) -> tuple[bool, str]:
|
|
157
|
+
if unreachable:
|
|
158
|
+
return False, (
|
|
159
|
+
f"Cannot reach {', '.join(unreachable)}. "
|
|
160
|
+
"Give this connection access to them on GitHub."
|
|
161
|
+
)
|
|
162
|
+
if checked[0].ok is False:
|
|
163
|
+
return False, "Cannot read code, so sync fails."
|
|
164
|
+
if not repositories:
|
|
165
|
+
return True, "Connected. No repositories added yet."
|
|
166
|
+
count = len(repositories)
|
|
167
|
+
return (
|
|
168
|
+
True,
|
|
169
|
+
f"Reaches {'its repository' if count == 1 else f'all {count} repositories'}.",
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def check_token(
|
|
174
|
+
connection: Connection,
|
|
175
|
+
source: credentials.PersonalToken,
|
|
176
|
+
repositories: list[Repository],
|
|
177
|
+
) -> SourceCheck:
|
|
178
|
+
owner = inspect_token(source.token)
|
|
179
|
+
if connection.expires_at != owner.expires_at:
|
|
180
|
+
connection.expires_at = owner.expires_at
|
|
181
|
+
connection.save(update_fields=["expires_at", "updated_at"])
|
|
182
|
+
first, unreachable = reach(repositories)
|
|
183
|
+
reads = (
|
|
184
|
+
permissions.token_reads(
|
|
185
|
+
source.token,
|
|
186
|
+
first.full_name,
|
|
187
|
+
str(first.metadata.get("default_branch") or "HEAD"),
|
|
188
|
+
permissions.SYNC + permissions.TOOLS,
|
|
189
|
+
)
|
|
190
|
+
if first
|
|
191
|
+
else None
|
|
192
|
+
)
|
|
193
|
+
checked = features(reads, None)
|
|
194
|
+
ok, message = summary(repositories, unreachable, checked)
|
|
195
|
+
return SourceCheck(ok, message, checked, GITHUB_TOKENS_URL)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def check_installation(
|
|
199
|
+
source: credentials.AppInstallation, repositories: list[Repository]
|
|
200
|
+
) -> SourceCheck:
|
|
201
|
+
try:
|
|
202
|
+
details = app.installation(source.installation_id)
|
|
203
|
+
except GitHubError:
|
|
204
|
+
return SourceCheck(
|
|
205
|
+
False,
|
|
206
|
+
"GitHub could not find this App installation. It may have been removed.",
|
|
207
|
+
[],
|
|
208
|
+
)
|
|
209
|
+
granted = details.permissions
|
|
210
|
+
# An App's grants hold for every repository, so no repository is probed.
|
|
211
|
+
unreachable = reach(repositories)[1]
|
|
212
|
+
checked = features(
|
|
213
|
+
permissions.granted_reads(granted, permissions.SYNC + permissions.TOOLS),
|
|
214
|
+
permissions.granted_writes(
|
|
215
|
+
granted, permissions.DRAFT_PULL_REQUESTS + permissions.ISSUES
|
|
216
|
+
),
|
|
217
|
+
)
|
|
218
|
+
ok, message = summary(repositories, unreachable, checked)
|
|
219
|
+
return SourceCheck(ok, message, checked, details.settings_url)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def check_source(connection: Connection) -> SourceCheck:
|
|
223
|
+
"""Check the credential, every added repository, and each feature's access."""
|
|
224
|
+
repositories = connection_repositories(connection)
|
|
225
|
+
try:
|
|
226
|
+
source = connection_source(connection)
|
|
227
|
+
if isinstance(source, credentials.AppInstallation) and not app.configured():
|
|
228
|
+
# Without the App's key, no installation token can be made.
|
|
229
|
+
raise GitHubError(
|
|
230
|
+
"This server has no GitHub App configured, so this connection "
|
|
231
|
+
"cannot sync. Ask the server's operator to configure the App, "
|
|
232
|
+
"or connect a personal access token instead."
|
|
233
|
+
)
|
|
234
|
+
if isinstance(source, credentials.PersonalToken):
|
|
235
|
+
result = check_token(connection, source, repositories)
|
|
236
|
+
else:
|
|
237
|
+
result = check_installation(source, repositories)
|
|
238
|
+
except GitHubError as error:
|
|
239
|
+
# These messages never contain a token.
|
|
240
|
+
token = connection.auth_strategy == Connection.AuthStrategy.API_KEY
|
|
241
|
+
result = SourceCheck(False, str(error), [], GITHUB_TOKENS_URL if token else "")
|
|
242
|
+
record_check(
|
|
243
|
+
connection,
|
|
244
|
+
result.ok,
|
|
245
|
+
result.message,
|
|
246
|
+
features=[asdict(feature) for feature in result.features],
|
|
247
|
+
fix_url=result.fix_url,
|
|
248
|
+
)
|
|
249
|
+
return result
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def last_checked(connection: Connection) -> datetime | None:
|
|
253
|
+
check = connection.last_check or {}
|
|
254
|
+
try:
|
|
255
|
+
return datetime.fromisoformat(check["checked_at"])
|
|
256
|
+
except (KeyError, TypeError, ValueError):
|
|
257
|
+
return None
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def check_due_sources() -> int:
|
|
261
|
+
"""Check each GitHub source not checked within a day. Returns how many."""
|
|
262
|
+
due = timezone.now() - CHECK_INTERVAL
|
|
263
|
+
checked = 0
|
|
264
|
+
for connection in connected_github_sources():
|
|
265
|
+
when = last_checked(connection)
|
|
266
|
+
if not can_check(connection) or (when is not None and when > due):
|
|
267
|
+
continue
|
|
268
|
+
check_source(connection)
|
|
269
|
+
checked += 1
|
|
270
|
+
return checked
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def check_after_failure(connection: Connection | None) -> None:
|
|
274
|
+
"""A sync failed, so show why on the connection, unless it already shows a failure."""
|
|
275
|
+
if not can_check(connection):
|
|
276
|
+
return
|
|
277
|
+
if connection.failed_last_check:
|
|
278
|
+
return
|
|
279
|
+
check_source(connection)
|