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,50 @@
|
|
|
1
|
+
"""The control plane's Git mirror of each GitHub repository.
|
|
2
|
+
|
|
3
|
+
A mirror is a cache. It is keyed by repository ID, so two workspaces never
|
|
4
|
+
share one, and deleting it only costs a full fetch the next time.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import fcntl
|
|
8
|
+
import subprocess
|
|
9
|
+
from collections.abc import Iterator
|
|
10
|
+
from contextlib import contextmanager
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from django.conf import settings
|
|
14
|
+
|
|
15
|
+
# The commit the repository last synced. It keeps that commit's objects in the
|
|
16
|
+
# mirror. Older commits are kept only until Git's own cleanup removes them.
|
|
17
|
+
SYNCED_REF = "refs/secondshift/synced"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def mirror_path(repository_id: int) -> Path:
|
|
21
|
+
return settings.SOURCE_CACHE_ROOT / "mirrors" / f"{repository_id}.git"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@contextmanager
|
|
25
|
+
def locked_mirror(repository_id: int) -> Iterator[Path]:
|
|
26
|
+
"""The repository's bare mirror, created if missing, held under a lock."""
|
|
27
|
+
path = mirror_path(repository_id)
|
|
28
|
+
path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
29
|
+
locks = settings.SOURCE_CACHE_ROOT / "locks"
|
|
30
|
+
locks.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
31
|
+
with (locks / f"{repository_id}.lock").open("a+b") as lock:
|
|
32
|
+
fcntl.flock(lock, fcntl.LOCK_EX)
|
|
33
|
+
if not path.exists():
|
|
34
|
+
subprocess.run(
|
|
35
|
+
["git", "init", "--quiet", "--bare", str(path)],
|
|
36
|
+
check=True,
|
|
37
|
+
capture_output=True,
|
|
38
|
+
timeout=30,
|
|
39
|
+
)
|
|
40
|
+
yield path
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def has_commit(mirror: Path, commit_sha: str) -> bool:
|
|
44
|
+
completed = subprocess.run(
|
|
45
|
+
["git", "-C", str(mirror), "cat-file", "-e", f"{commit_sha}^{{commit}}"],
|
|
46
|
+
capture_output=True,
|
|
47
|
+
check=False,
|
|
48
|
+
timeout=30,
|
|
49
|
+
)
|
|
50
|
+
return completed.returncode == 0
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
from django.core.exceptions import ValidationError
|
|
4
|
+
from django.db import models
|
|
5
|
+
|
|
6
|
+
from secondshift.connections.models import Connection
|
|
7
|
+
from secondshift.workspaces.models import Workspace
|
|
8
|
+
|
|
9
|
+
SYNC_ERROR_MAX_CHARS = 500
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Repository(models.Model):
|
|
13
|
+
class SourceType(models.TextChoices):
|
|
14
|
+
GITHUB = "github", "GitHub"
|
|
15
|
+
LOCAL = "local", "Local"
|
|
16
|
+
|
|
17
|
+
workspace = models.ForeignKey(
|
|
18
|
+
Workspace,
|
|
19
|
+
on_delete=models.CASCADE,
|
|
20
|
+
related_name="repositories",
|
|
21
|
+
)
|
|
22
|
+
source_type = models.CharField(
|
|
23
|
+
max_length=16,
|
|
24
|
+
choices=SourceType.choices,
|
|
25
|
+
default=SourceType.GITHUB,
|
|
26
|
+
)
|
|
27
|
+
local_path = models.CharField(max_length=1000, blank=True)
|
|
28
|
+
github_connection = models.ForeignKey(
|
|
29
|
+
Connection,
|
|
30
|
+
on_delete=models.PROTECT,
|
|
31
|
+
related_name="repositories",
|
|
32
|
+
null=True,
|
|
33
|
+
blank=True,
|
|
34
|
+
)
|
|
35
|
+
external_id = models.CharField(max_length=64, blank=True)
|
|
36
|
+
full_name = models.CharField(max_length=255)
|
|
37
|
+
clone_url = models.URLField(blank=True)
|
|
38
|
+
default_branch = models.CharField(max_length=255, default="main")
|
|
39
|
+
enabled = models.BooleanField(default=False)
|
|
40
|
+
# Sync new commits on the branch without pressing Resync.
|
|
41
|
+
auto_sync = models.BooleanField(default=True)
|
|
42
|
+
# Tasks may push a branch and open a draft pull request. Owners decide.
|
|
43
|
+
allow_pull_requests = models.BooleanField(default=False)
|
|
44
|
+
# Run CI on that branch at once. Off by default, because the patch is
|
|
45
|
+
# untrusted until a person reviews it.
|
|
46
|
+
pull_request_ci = models.BooleanField(default=False)
|
|
47
|
+
# Jobs may open the issues their agent proposes. Owners decide.
|
|
48
|
+
allow_issues = models.BooleanField(default=False)
|
|
49
|
+
config_path = models.CharField(max_length=255, default=".secondshift.yaml")
|
|
50
|
+
# The commit runs use. It moves only when a sync succeeds.
|
|
51
|
+
config_sha = models.CharField(max_length=64, blank=True)
|
|
52
|
+
# The newest commit a sync read, even if its configuration was invalid.
|
|
53
|
+
checked_sha = models.CharField(max_length=64, blank=True)
|
|
54
|
+
last_synced_at = models.DateTimeField(null=True, blank=True)
|
|
55
|
+
last_sync_error = models.CharField(max_length=SYNC_ERROR_MAX_CHARS, blank=True)
|
|
56
|
+
removed_at = models.DateTimeField(null=True, blank=True)
|
|
57
|
+
created_at = models.DateTimeField(auto_now_add=True)
|
|
58
|
+
updated_at = models.DateTimeField(auto_now=True)
|
|
59
|
+
|
|
60
|
+
class Meta:
|
|
61
|
+
constraints = [
|
|
62
|
+
models.UniqueConstraint(
|
|
63
|
+
fields=["workspace", "full_name"],
|
|
64
|
+
name="unique_workspace_repository",
|
|
65
|
+
),
|
|
66
|
+
models.UniqueConstraint(
|
|
67
|
+
fields=["workspace", "local_path"],
|
|
68
|
+
condition=~models.Q(local_path=""),
|
|
69
|
+
name="unique_workspace_local_repository",
|
|
70
|
+
),
|
|
71
|
+
]
|
|
72
|
+
ordering = ["full_name"]
|
|
73
|
+
verbose_name_plural = "repositories"
|
|
74
|
+
|
|
75
|
+
def __str__(self) -> str:
|
|
76
|
+
return self.display_name
|
|
77
|
+
|
|
78
|
+
@property
|
|
79
|
+
def accepts_runs(self) -> bool:
|
|
80
|
+
return self.enabled and self.removed_at is None
|
|
81
|
+
|
|
82
|
+
@property
|
|
83
|
+
def receives_pull_requests(self) -> bool:
|
|
84
|
+
return self.source_type == self.SourceType.GITHUB and self.allow_pull_requests
|
|
85
|
+
|
|
86
|
+
@property
|
|
87
|
+
def receives_issues(self) -> bool:
|
|
88
|
+
return self.source_type == self.SourceType.GITHUB and self.allow_issues
|
|
89
|
+
|
|
90
|
+
@property
|
|
91
|
+
def display_name(self) -> str:
|
|
92
|
+
if self.source_type == self.SourceType.LOCAL and self.local_path:
|
|
93
|
+
return Path(self.local_path).name
|
|
94
|
+
return self.full_name
|
|
95
|
+
|
|
96
|
+
def clean(self) -> None:
|
|
97
|
+
if self.source_type == self.SourceType.LOCAL and not self.local_path:
|
|
98
|
+
raise ValidationError(
|
|
99
|
+
{"local_path": "Local repositories require a filesystem path."}
|
|
100
|
+
)
|
|
101
|
+
if self.source_type != self.SourceType.LOCAL and self.local_path:
|
|
102
|
+
raise ValidationError(
|
|
103
|
+
{"local_path": "Only local repositories may have a local path."}
|
|
104
|
+
)
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
"""Publish a run's changes to GitHub as a draft pull request.
|
|
2
|
+
|
|
3
|
+
This is the only code that asks GitHub for write access. Each token is
|
|
4
|
+
limited to one repository and to contents and pull requests, and is used for
|
|
5
|
+
one push and one request. It never reaches the sandbox or the model.
|
|
6
|
+
|
|
7
|
+
The commit is built in the control plane's mirror from the pinned commit and
|
|
8
|
+
the run's stored patch, so publishing again never needs the agent or its
|
|
9
|
+
sandbox. The author is the account that pushes: the App's bot, or the person
|
|
10
|
+
who owns the token. With the same author, message and date, a retry makes the
|
|
11
|
+
same commit and pushes the same branch again instead of making a new one.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import os
|
|
15
|
+
import subprocess
|
|
16
|
+
import tempfile
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
from datetime import datetime
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
from secondshift.connections.models import Connection
|
|
22
|
+
from secondshift.github import credentials, pulls
|
|
23
|
+
from secondshift.github.authors import GitAuthor, app_author, token_author
|
|
24
|
+
from secondshift.github.errors import GitHubError
|
|
25
|
+
from secondshift.github.git import authenticated_git
|
|
26
|
+
from secondshift.repositories.github_access import github_access
|
|
27
|
+
from secondshift.repositories.github_sync import fetch_github_commit
|
|
28
|
+
from secondshift.repositories.mirrors import has_commit, locked_mirror
|
|
29
|
+
from secondshift.repositories.models import Repository
|
|
30
|
+
|
|
31
|
+
APP_PUSH_HINT = (
|
|
32
|
+
"Changes to workflow files are refused, and so is a branch someone else changed."
|
|
33
|
+
)
|
|
34
|
+
TOKEN_PUSH_HINT = (
|
|
35
|
+
"Give the token Contents and Pull requests write access. A branch someone "
|
|
36
|
+
"else changed is refused too, and so are workflow files unless the token "
|
|
37
|
+
"has Workflows write access."
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class PublishError(Exception):
|
|
42
|
+
"""A publishing step failed. The message is safe to show on the run."""
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass(frozen=True)
|
|
46
|
+
class DraftPullRequest:
|
|
47
|
+
branch: str
|
|
48
|
+
base: str
|
|
49
|
+
title: str
|
|
50
|
+
body: str
|
|
51
|
+
message: str
|
|
52
|
+
committed_at: datetime
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass(frozen=True)
|
|
56
|
+
class PublishedPullRequest:
|
|
57
|
+
number: int
|
|
58
|
+
url: str
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def publish_pull_request(
|
|
62
|
+
repository: Repository, commit_sha: str, patch: Path, draft: DraftPullRequest
|
|
63
|
+
) -> PublishedPullRequest:
|
|
64
|
+
if repository.source_type != Repository.SourceType.GITHUB:
|
|
65
|
+
raise PublishError("Only GitHub repositories can receive pull requests.")
|
|
66
|
+
with_token = (
|
|
67
|
+
repository.github_connection is not None
|
|
68
|
+
and repository.github_connection.auth_strategy
|
|
69
|
+
== Connection.AuthStrategy.API_KEY
|
|
70
|
+
)
|
|
71
|
+
try:
|
|
72
|
+
# One token for the fetch, the push and the pull request.
|
|
73
|
+
access = github_access(repository, credentials.WRITE)
|
|
74
|
+
except GitHubError as error:
|
|
75
|
+
if with_token:
|
|
76
|
+
# Token errors already say what to fix, such as an expired token.
|
|
77
|
+
raise PublishError(str(error)) from error
|
|
78
|
+
raise PublishError(
|
|
79
|
+
"GitHub refused write access. Give the GitHub App Contents and Pull "
|
|
80
|
+
"requests write permission, then retry."
|
|
81
|
+
) from error
|
|
82
|
+
try:
|
|
83
|
+
author = (token_author if with_token else app_author)(access.token)
|
|
84
|
+
except GitHubError as error:
|
|
85
|
+
raise PublishError(
|
|
86
|
+
"GitHub did not say which account publishes the changes."
|
|
87
|
+
) from error
|
|
88
|
+
with locked_mirror(repository.pk) as mirror:
|
|
89
|
+
if not has_commit(mirror, commit_sha):
|
|
90
|
+
try:
|
|
91
|
+
fetch_github_commit(access, commit_sha, mirror)
|
|
92
|
+
except GitHubError as error:
|
|
93
|
+
# These messages never contain the token.
|
|
94
|
+
raise PublishError(str(error)) from error
|
|
95
|
+
commit = commit_patch(mirror, commit_sha, patch, draft, author)
|
|
96
|
+
try:
|
|
97
|
+
authenticated_git(
|
|
98
|
+
mirror,
|
|
99
|
+
access.token,
|
|
100
|
+
"push",
|
|
101
|
+
"--quiet",
|
|
102
|
+
"--",
|
|
103
|
+
access.clone_url,
|
|
104
|
+
f"{commit}:refs/heads/{draft.branch}",
|
|
105
|
+
)
|
|
106
|
+
except GitHubError as error:
|
|
107
|
+
raise PublishError(
|
|
108
|
+
f"GitHub refused the push to {draft.branch}. "
|
|
109
|
+
+ (TOKEN_PUSH_HINT if with_token else APP_PUSH_HINT)
|
|
110
|
+
) from error
|
|
111
|
+
return open_draft(access.full_name, access.token, draft)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def commit_patch(
|
|
115
|
+
mirror: Path,
|
|
116
|
+
commit_sha: str,
|
|
117
|
+
patch: Path,
|
|
118
|
+
draft: DraftPullRequest,
|
|
119
|
+
author: GitAuthor,
|
|
120
|
+
) -> str:
|
|
121
|
+
"""Apply the patch to the pinned commit in a scratch index, and commit it."""
|
|
122
|
+
with tempfile.TemporaryDirectory(prefix="secondshift-publish-") as folder:
|
|
123
|
+
index = str(Path(folder) / "index")
|
|
124
|
+
git(mirror, index, draft, author, "read-tree", commit_sha)
|
|
125
|
+
try:
|
|
126
|
+
git(
|
|
127
|
+
mirror,
|
|
128
|
+
index,
|
|
129
|
+
draft,
|
|
130
|
+
author,
|
|
131
|
+
"apply",
|
|
132
|
+
"--cached",
|
|
133
|
+
"--binary",
|
|
134
|
+
str(patch),
|
|
135
|
+
)
|
|
136
|
+
except PublishError as error:
|
|
137
|
+
raise PublishError(
|
|
138
|
+
"The changes no longer apply to the run's commit."
|
|
139
|
+
) from error
|
|
140
|
+
tree = git(mirror, index, draft, author, "write-tree")
|
|
141
|
+
return git(
|
|
142
|
+
mirror,
|
|
143
|
+
index,
|
|
144
|
+
draft,
|
|
145
|
+
author,
|
|
146
|
+
"commit-tree",
|
|
147
|
+
tree,
|
|
148
|
+
"-p",
|
|
149
|
+
commit_sha,
|
|
150
|
+
"-m",
|
|
151
|
+
draft.message,
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def git(
|
|
156
|
+
mirror: Path, index: str, draft: DraftPullRequest, author: GitAuthor, *args: str
|
|
157
|
+
) -> str:
|
|
158
|
+
when = draft.committed_at.isoformat()
|
|
159
|
+
environment = {
|
|
160
|
+
"PATH": os.environ.get("PATH", "/usr/bin:/bin"),
|
|
161
|
+
"HOME": str(mirror),
|
|
162
|
+
"LC_ALL": "C",
|
|
163
|
+
"GIT_CONFIG_NOSYSTEM": "1",
|
|
164
|
+
"GIT_CONFIG_GLOBAL": os.devnull,
|
|
165
|
+
"GIT_INDEX_FILE": index,
|
|
166
|
+
"GIT_AUTHOR_NAME": author.name,
|
|
167
|
+
"GIT_AUTHOR_EMAIL": author.email,
|
|
168
|
+
"GIT_AUTHOR_DATE": when,
|
|
169
|
+
"GIT_COMMITTER_NAME": author.name,
|
|
170
|
+
"GIT_COMMITTER_EMAIL": author.email,
|
|
171
|
+
"GIT_COMMITTER_DATE": when,
|
|
172
|
+
}
|
|
173
|
+
try:
|
|
174
|
+
result = subprocess.run(
|
|
175
|
+
["git", "-C", str(mirror), "-c", "core.hooksPath=/dev/null", *args],
|
|
176
|
+
env=environment,
|
|
177
|
+
capture_output=True,
|
|
178
|
+
text=True,
|
|
179
|
+
timeout=120,
|
|
180
|
+
check=False,
|
|
181
|
+
)
|
|
182
|
+
except (OSError, subprocess.TimeoutExpired) as error:
|
|
183
|
+
raise PublishError("The commit could not be built.") from error
|
|
184
|
+
if result.returncode:
|
|
185
|
+
raise PublishError("The commit could not be built.")
|
|
186
|
+
return result.stdout.strip()
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def open_draft(
|
|
190
|
+
full_name: str, token: str, draft: DraftPullRequest
|
|
191
|
+
) -> PublishedPullRequest:
|
|
192
|
+
try:
|
|
193
|
+
opened = pulls.open_draft(
|
|
194
|
+
token, full_name, draft.title, draft.branch, draft.base, draft.body
|
|
195
|
+
)
|
|
196
|
+
except GitHubError as error:
|
|
197
|
+
# These messages never contain the token.
|
|
198
|
+
raise PublishError(str(error)) from error
|
|
199
|
+
return PublishedPullRequest(number=opened["number"], url=opened["html_url"])
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
from datetime import datetime
|
|
2
|
+
|
|
3
|
+
from ninja import Schema
|
|
4
|
+
from pydantic import Field
|
|
5
|
+
|
|
6
|
+
from secondshift.repositories.status import ValidationStatus
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class RepositorySchema(Schema):
|
|
10
|
+
id: int
|
|
11
|
+
full_name: str
|
|
12
|
+
display_name: str
|
|
13
|
+
default_branch: str
|
|
14
|
+
enabled: bool
|
|
15
|
+
job_count: int
|
|
16
|
+
last_synced_at: datetime | None
|
|
17
|
+
source_type: str
|
|
18
|
+
local_path: str | None
|
|
19
|
+
revision: str
|
|
20
|
+
# The GitHub source connection it syncs through. None for local folders.
|
|
21
|
+
github_connection_id: int | None
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class LocalRepositoryCreateSchema(Schema):
|
|
25
|
+
path: str = Field(min_length=1, max_length=1000)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class LocalRepositoryDirectorySchema(Schema):
|
|
29
|
+
name: str
|
|
30
|
+
path: str
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class LocalRepositoryDirectoryListSchema(Schema):
|
|
34
|
+
path: str
|
|
35
|
+
parent: str | None
|
|
36
|
+
directories: list[LocalRepositoryDirectorySchema]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class RepositoryEnabledSchema(Schema):
|
|
40
|
+
enabled: bool
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class RepositoryPullRequestsSchema(Schema):
|
|
44
|
+
allow: bool
|
|
45
|
+
run_ci: bool = False
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class RepositoryIssuesSchema(Schema):
|
|
49
|
+
allow: bool
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class RepositoryValidationSchema(Schema):
|
|
53
|
+
status: ValidationStatus
|
|
54
|
+
message: str
|
|
55
|
+
current_revision: str | None
|
|
56
|
+
synced_revision: str
|
|
57
|
+
needs_sync: bool
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class RepositoryJobSchema(Schema):
|
|
61
|
+
id: int
|
|
62
|
+
name: str
|
|
63
|
+
skill_path: str
|
|
64
|
+
cron: str
|
|
65
|
+
timezone: str
|
|
66
|
+
enabled: bool
|
|
67
|
+
grant_status: str
|
|
68
|
+
removed_at: datetime | None = None
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class RepositoryDetailSchema(RepositorySchema):
|
|
72
|
+
config_path: str
|
|
73
|
+
# The repository's choice, and whether this server syncs in the background.
|
|
74
|
+
auto_sync: bool
|
|
75
|
+
server_syncs: bool
|
|
76
|
+
# GitHub repositories only. See RepositoryPullRequestsSchema.
|
|
77
|
+
can_open_pull_requests: bool
|
|
78
|
+
allow_pull_requests: bool
|
|
79
|
+
pull_request_ci: bool
|
|
80
|
+
# GitHub repositories only, like pull requests.
|
|
81
|
+
allow_issues: bool
|
|
82
|
+
validation: RepositoryValidationSchema
|
|
83
|
+
jobs: list[RepositoryJobSchema]
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class GitHubRepositoryCreateSchema(Schema):
|
|
87
|
+
connection_id: int
|
|
88
|
+
repository_id: int = Field(gt=0)
|
|
89
|
+
branch: str = Field(default="", max_length=255)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class GitHubAvailableRepositorySchema(Schema):
|
|
93
|
+
connection_id: int
|
|
94
|
+
repository_id: int
|
|
95
|
+
full_name: str
|
|
96
|
+
default_branch: str
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class GitHubAppStatusSchema(Schema):
|
|
100
|
+
configured: bool
|
|
101
|
+
error: str = ""
|
|
102
|
+
repositories: list[GitHubAvailableRepositorySchema]
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
from django.http import HttpRequest
|
|
2
|
+
|
|
3
|
+
from secondshift.github import repos
|
|
4
|
+
from secondshift.github.errors import GitHubError
|
|
5
|
+
from secondshift.jobs.autosync import server_syncs
|
|
6
|
+
from secondshift.jobs.dao import repository_jobs_with_grants
|
|
7
|
+
from secondshift.jobs.repository_sync import (
|
|
8
|
+
RepositoryConfigError,
|
|
9
|
+
inspect_local_repository,
|
|
10
|
+
)
|
|
11
|
+
from secondshift.repositories.github_access import github_access
|
|
12
|
+
from secondshift.repositories.local import (
|
|
13
|
+
LocalRepositoryError,
|
|
14
|
+
)
|
|
15
|
+
from secondshift.repositories.models import Repository
|
|
16
|
+
from secondshift.repositories.schemas import (
|
|
17
|
+
RepositoryDetailSchema,
|
|
18
|
+
RepositoryJobSchema,
|
|
19
|
+
RepositorySchema,
|
|
20
|
+
RepositoryValidationSchema,
|
|
21
|
+
)
|
|
22
|
+
from secondshift.repositories.status import ValidationStatus, source_status
|
|
23
|
+
from secondshift.workspaces.access import (
|
|
24
|
+
Action,
|
|
25
|
+
allows,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def visible_local_path(request: HttpRequest, repository: Repository) -> str | None:
|
|
30
|
+
if repository.source_type == Repository.SourceType.LOCAL and allows(
|
|
31
|
+
request, Action.VIEW_LOCAL_PATHS
|
|
32
|
+
):
|
|
33
|
+
return repository.local_path
|
|
34
|
+
return None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def serialize_repository(
|
|
38
|
+
request: HttpRequest,
|
|
39
|
+
repository: Repository,
|
|
40
|
+
) -> RepositorySchema:
|
|
41
|
+
return RepositorySchema(
|
|
42
|
+
id=repository.pk,
|
|
43
|
+
full_name=repository.full_name,
|
|
44
|
+
display_name=repository.display_name,
|
|
45
|
+
default_branch=repository.default_branch,
|
|
46
|
+
enabled=repository.enabled,
|
|
47
|
+
# The list endpoint prefetches jobs, so this reads them from memory.
|
|
48
|
+
job_count=len(repository.jobs.all()),
|
|
49
|
+
last_synced_at=repository.last_synced_at,
|
|
50
|
+
source_type=repository.source_type,
|
|
51
|
+
local_path=visible_local_path(request, repository),
|
|
52
|
+
revision=repository.config_sha,
|
|
53
|
+
github_connection_id=repository.github_connection_id,
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def validation_schema(
|
|
58
|
+
repository: Repository, head: str | None, problem: str
|
|
59
|
+
) -> RepositoryValidationSchema:
|
|
60
|
+
"""`head` is None when the branch could not be read."""
|
|
61
|
+
status = source_status(repository, head, problem)
|
|
62
|
+
return RepositoryValidationSchema(
|
|
63
|
+
status=status.status,
|
|
64
|
+
message=status.message,
|
|
65
|
+
current_revision=head,
|
|
66
|
+
synced_revision=repository.config_sha,
|
|
67
|
+
needs_sync=status.needs_sync,
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def unreachable(repository: Repository, error: Exception) -> RepositoryValidationSchema:
|
|
72
|
+
return RepositoryValidationSchema(
|
|
73
|
+
status=ValidationStatus.ERROR,
|
|
74
|
+
message=str(error),
|
|
75
|
+
current_revision=None,
|
|
76
|
+
synced_revision=repository.config_sha,
|
|
77
|
+
needs_sync=False,
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def validate_repository(repository: Repository) -> RepositoryValidationSchema:
|
|
82
|
+
"""Read the branch head, then report the state the last syncs saved."""
|
|
83
|
+
if repository.source_type != Repository.SourceType.LOCAL:
|
|
84
|
+
try:
|
|
85
|
+
access = github_access(repository)
|
|
86
|
+
head = repos.branch_head(
|
|
87
|
+
access.token, access.repository_id, repository.default_branch
|
|
88
|
+
)
|
|
89
|
+
except GitHubError as error:
|
|
90
|
+
return unreachable(repository, error)
|
|
91
|
+
return validation_schema(repository, head, repository.last_sync_error)
|
|
92
|
+
try:
|
|
93
|
+
metadata = inspect_local_repository(
|
|
94
|
+
repository.local_path, repository.default_branch
|
|
95
|
+
)
|
|
96
|
+
except RepositoryConfigError as error:
|
|
97
|
+
# The branch is readable, but its configuration is broken right now.
|
|
98
|
+
return validation_schema(repository, None, str(error))
|
|
99
|
+
except LocalRepositoryError as error:
|
|
100
|
+
return unreachable(repository, error)
|
|
101
|
+
return validation_schema(repository, metadata.revision, repository.last_sync_error)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def serialize_repository_detail(
|
|
105
|
+
request: HttpRequest,
|
|
106
|
+
repository: Repository,
|
|
107
|
+
) -> RepositoryDetailSchema:
|
|
108
|
+
summary = serialize_repository(request, repository)
|
|
109
|
+
jobs = repository_jobs_with_grants(repository)
|
|
110
|
+
return RepositoryDetailSchema(
|
|
111
|
+
**summary.model_dump(),
|
|
112
|
+
config_path=repository.config_path,
|
|
113
|
+
auto_sync=repository.auto_sync,
|
|
114
|
+
server_syncs=server_syncs(),
|
|
115
|
+
can_open_pull_requests=repository.source_type == Repository.SourceType.GITHUB,
|
|
116
|
+
allow_pull_requests=repository.allow_pull_requests,
|
|
117
|
+
pull_request_ci=repository.pull_request_ci,
|
|
118
|
+
allow_issues=repository.allow_issues,
|
|
119
|
+
validation=validate_repository(repository),
|
|
120
|
+
jobs=[
|
|
121
|
+
RepositoryJobSchema(
|
|
122
|
+
id=job.pk,
|
|
123
|
+
name=job.name,
|
|
124
|
+
skill_path=job.skill_path,
|
|
125
|
+
cron=job.cron,
|
|
126
|
+
timezone=job.timezone,
|
|
127
|
+
enabled=job.enabled,
|
|
128
|
+
grant_status=job.capability_grant_status(),
|
|
129
|
+
removed_at=job.removed_at,
|
|
130
|
+
)
|
|
131
|
+
for job in jobs
|
|
132
|
+
],
|
|
133
|
+
)
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""Resolve a submitted skill at a committed revision without syncing jobs."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from django.conf import settings
|
|
7
|
+
|
|
8
|
+
from secondshift.config.constants import COMMIT_SHA_PATTERN
|
|
9
|
+
from secondshift.github.git import authenticated_git
|
|
10
|
+
from secondshift.repositories.github_access import github_access
|
|
11
|
+
from secondshift.repositories.local import committed_file, git
|
|
12
|
+
from secondshift.repositories.mirrors import locked_mirror
|
|
13
|
+
from secondshift.repositories.models import Repository
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class SkillSourceError(ValueError):
|
|
17
|
+
pass
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def skill_commit(
|
|
21
|
+
repository: Repository, skill_path: str | None, revision: str | None
|
|
22
|
+
) -> str:
|
|
23
|
+
"""Use the followed branch by default; explicit branches and full SHAs work."""
|
|
24
|
+
if (
|
|
25
|
+
repository.source_type == Repository.SourceType.LOCAL
|
|
26
|
+
and not settings.LOCAL_REPOSITORIES_ENABLED
|
|
27
|
+
):
|
|
28
|
+
raise SkillSourceError("Local repositories are not enabled on this server.")
|
|
29
|
+
requested = revision or repository.default_branch
|
|
30
|
+
is_commit = bool(re.fullmatch(COMMIT_SHA_PATTERN, requested))
|
|
31
|
+
if not is_commit:
|
|
32
|
+
# Only branch names are accepted, never arbitrary Git revision expressions.
|
|
33
|
+
git(
|
|
34
|
+
Path(repository.local_path) if repository.local_path else Path.cwd(),
|
|
35
|
+
"check-ref-format",
|
|
36
|
+
f"refs/heads/{requested}",
|
|
37
|
+
)
|
|
38
|
+
if repository.source_type == Repository.SourceType.LOCAL:
|
|
39
|
+
root = Path(repository.local_path)
|
|
40
|
+
ref = requested if is_commit else f"refs/heads/{requested}"
|
|
41
|
+
commit = git(root, "rev-parse", "--verify", f"{ref}^{{commit}}")
|
|
42
|
+
if skill_path is not None:
|
|
43
|
+
committed_file(root, commit, skill_path)
|
|
44
|
+
return commit
|
|
45
|
+
access = github_access(repository)
|
|
46
|
+
token = access.token
|
|
47
|
+
with locked_mirror(repository.pk) as mirror:
|
|
48
|
+
authenticated_git(
|
|
49
|
+
mirror,
|
|
50
|
+
token,
|
|
51
|
+
"fetch",
|
|
52
|
+
"--no-tags",
|
|
53
|
+
"--",
|
|
54
|
+
access.clone_url,
|
|
55
|
+
requested if is_commit else f"refs/heads/{requested}",
|
|
56
|
+
)
|
|
57
|
+
commit = authenticated_git(
|
|
58
|
+
mirror, token, "rev-parse", "--verify", "FETCH_HEAD^{commit}"
|
|
59
|
+
)
|
|
60
|
+
if not re.fullmatch(COMMIT_SHA_PATTERN, commit):
|
|
61
|
+
raise SkillSourceError("The repository did not resolve to a full commit.")
|
|
62
|
+
if skill_path is not None:
|
|
63
|
+
committed_file(mirror, commit, skill_path)
|
|
64
|
+
return commit
|