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,59 @@
|
|
|
1
|
+
"""One way to get a GitHub token, whatever kind of credential stands behind it."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
from secondshift.github import app
|
|
6
|
+
|
|
7
|
+
READ = {"contents": "read"}
|
|
8
|
+
WRITE = {"contents": "write", "pull_requests": "write"}
|
|
9
|
+
ISSUES_WRITE = {"issues": "write"}
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass(frozen=True)
|
|
13
|
+
class AppInstallation:
|
|
14
|
+
"""A GitHub App installation. Each token is minted for one request."""
|
|
15
|
+
|
|
16
|
+
installation_id: str
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True)
|
|
20
|
+
class PersonalToken:
|
|
21
|
+
"""A fine-grained personal access token. It cannot be narrowed per request."""
|
|
22
|
+
|
|
23
|
+
token: str
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
Source = AppInstallation | PersonalToken
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def token_for(
|
|
30
|
+
source: Source, repository_id: int | None, permissions: dict[str, str]
|
|
31
|
+
) -> str:
|
|
32
|
+
"""A token for one repository, or for the whole source when repository_id is None.
|
|
33
|
+
|
|
34
|
+
App tokens carry only the permissions asked for and expire within an hour.
|
|
35
|
+
A personal token is returned as it is. GitHub refuses what it cannot do.
|
|
36
|
+
"""
|
|
37
|
+
if isinstance(source, PersonalToken):
|
|
38
|
+
return source.token
|
|
39
|
+
return app.installation_token(source.installation_id, repository_id, permissions)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def read_token_for(
|
|
43
|
+
source: Source, repository_id: int | None, wanted: tuple[str, ...]
|
|
44
|
+
) -> str:
|
|
45
|
+
"""A read-only token with the wanted permissions the source has.
|
|
46
|
+
|
|
47
|
+
An App token never asks for more than the installation granted, so a
|
|
48
|
+
missing permission fails only the calls that need it. A personal token
|
|
49
|
+
has whatever its owner gave it.
|
|
50
|
+
"""
|
|
51
|
+
if isinstance(source, PersonalToken):
|
|
52
|
+
return source.token
|
|
53
|
+
granted = app.installation_permissions(source.installation_id)
|
|
54
|
+
permissions = {
|
|
55
|
+
name: "read" for name in wanted if granted.get(name) in {"read", "write"}
|
|
56
|
+
}
|
|
57
|
+
return app.installation_token(
|
|
58
|
+
source.installation_id, repository_id, permissions or {"metadata": "read"}
|
|
59
|
+
)
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Run git against GitHub with a token that never leaves the process."""
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import os
|
|
5
|
+
import subprocess
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from secondshift.config.constants import GITHUB_BASE_URL
|
|
9
|
+
from secondshift.github.errors import GitHubError
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def authenticated_git(root: Path, token: str, *arguments: str) -> str:
|
|
13
|
+
"""Run one git command in root, sending the token only as a header."""
|
|
14
|
+
# Never put credentials in URLs, arguments, persisted config, or errors.
|
|
15
|
+
authorization = base64.b64encode(f"x-access-token:{token}".encode()).decode()
|
|
16
|
+
return run_git(
|
|
17
|
+
root,
|
|
18
|
+
arguments,
|
|
19
|
+
{
|
|
20
|
+
"GIT_CONFIG_COUNT": "1",
|
|
21
|
+
"GIT_CONFIG_KEY_0": f"http.{GITHUB_BASE_URL}/.extraheader",
|
|
22
|
+
"GIT_CONFIG_VALUE_0": f"AUTHORIZATION: basic {authorization}",
|
|
23
|
+
},
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def public_git(root: Path, *arguments: str) -> str:
|
|
28
|
+
"""Run one git command in root without any credentials."""
|
|
29
|
+
return run_git(root, arguments, {"GIT_CONFIG_COUNT": "0"})
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def run_git(root: Path, arguments: tuple[str, ...], config: dict[str, str]) -> str:
|
|
33
|
+
environment = {
|
|
34
|
+
**os.environ,
|
|
35
|
+
"GIT_TERMINAL_PROMPT": "0",
|
|
36
|
+
"GIT_CONFIG_NOSYSTEM": "1",
|
|
37
|
+
"GIT_CONFIG_GLOBAL": os.devnull,
|
|
38
|
+
# No helper may answer a password prompt with stored credentials.
|
|
39
|
+
"GIT_ASKPASS": "",
|
|
40
|
+
"SSH_ASKPASS": "",
|
|
41
|
+
**config,
|
|
42
|
+
}
|
|
43
|
+
try:
|
|
44
|
+
result = subprocess.run(
|
|
45
|
+
["git", "-C", str(root), *arguments],
|
|
46
|
+
env=environment,
|
|
47
|
+
capture_output=True,
|
|
48
|
+
text=True,
|
|
49
|
+
timeout=120,
|
|
50
|
+
check=False,
|
|
51
|
+
)
|
|
52
|
+
except subprocess.TimeoutExpired as error:
|
|
53
|
+
raise GitHubError("GitHub repository fetch timed out.") from error
|
|
54
|
+
if result.returncode:
|
|
55
|
+
raise GitHubError("Could not fetch the GitHub repository and branch.")
|
|
56
|
+
return result.stdout.strip()
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Repository names from GitHub remotes."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from urllib.parse import urlsplit
|
|
5
|
+
|
|
6
|
+
from secondshift.config.constants import (
|
|
7
|
+
GITHUB_BASE_URL,
|
|
8
|
+
GITHUB_HOST,
|
|
9
|
+
GITHUB_REPOSITORY_PATTERN,
|
|
10
|
+
GITHUB_SSH_PREFIX,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def github_remote_name(remote: str) -> str | None:
|
|
15
|
+
"""The owner/name a GitHub remote points at, or None for anything else."""
|
|
16
|
+
if remote.startswith(GITHUB_SSH_PREFIX):
|
|
17
|
+
path = remote.removeprefix(GITHUB_SSH_PREFIX)
|
|
18
|
+
else:
|
|
19
|
+
try:
|
|
20
|
+
parts = urlsplit(remote)
|
|
21
|
+
if parts.hostname != GITHUB_HOST or parts.scheme not in {"https", "ssh"}:
|
|
22
|
+
return None
|
|
23
|
+
path = parts.path.removeprefix("/")
|
|
24
|
+
except ValueError:
|
|
25
|
+
return None
|
|
26
|
+
name = path.removesuffix("/").removesuffix(".git")
|
|
27
|
+
return name if valid_name(name) else None
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def valid_name(full_name: str) -> bool:
|
|
31
|
+
return bool(re.fullmatch(GITHUB_REPOSITORY_PATTERN, full_name))
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def clone_url(full_name: str) -> str:
|
|
35
|
+
return f"{GITHUB_BASE_URL}/{full_name}.git"
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""Find the App installations a person can use, through their own sign-in.
|
|
2
|
+
|
|
3
|
+
The person's token proves which installations they may link. It is used for
|
|
4
|
+
two requests and never stored.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from urllib.parse import urlencode
|
|
9
|
+
|
|
10
|
+
from django.conf import settings
|
|
11
|
+
|
|
12
|
+
from secondshift.config.constants import (
|
|
13
|
+
GITHUB_API_URL,
|
|
14
|
+
GITHUB_BASE_URL,
|
|
15
|
+
GITHUB_OAUTH_URL,
|
|
16
|
+
)
|
|
17
|
+
from secondshift.github.errors import GitHubError
|
|
18
|
+
from secondshift.github.rest import paginated, request_json
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(frozen=True)
|
|
22
|
+
class Installation:
|
|
23
|
+
installation_id: str
|
|
24
|
+
account: str
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def authorization_url(state: str, redirect_uri: str) -> str:
|
|
28
|
+
return f"{GITHUB_OAUTH_URL}/authorize?" + urlencode(
|
|
29
|
+
{
|
|
30
|
+
"client_id": settings.GITHUB_APP_CLIENT_ID,
|
|
31
|
+
"state": state,
|
|
32
|
+
"redirect_uri": redirect_uri,
|
|
33
|
+
}
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def installation_url(state: str) -> str:
|
|
38
|
+
return (
|
|
39
|
+
f"{GITHUB_BASE_URL}/apps/{settings.GITHUB_APP_SLUG}/installations/new?"
|
|
40
|
+
+ urlencode({"state": state})
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def user_token(code: str, redirect_uri: str) -> str:
|
|
45
|
+
data = request_json(
|
|
46
|
+
"POST",
|
|
47
|
+
f"{GITHUB_OAUTH_URL}/access_token",
|
|
48
|
+
json={
|
|
49
|
+
"client_id": settings.GITHUB_APP_CLIENT_ID,
|
|
50
|
+
"client_secret": settings.GITHUB_APP_CLIENT_SECRET,
|
|
51
|
+
"code": code,
|
|
52
|
+
"redirect_uri": redirect_uri,
|
|
53
|
+
},
|
|
54
|
+
)
|
|
55
|
+
token = data.get("access_token")
|
|
56
|
+
if not token:
|
|
57
|
+
raise GitHubError("GitHub did not authorize this user.")
|
|
58
|
+
return token
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def user_installations(token: str) -> list[Installation]:
|
|
62
|
+
"""This App's installations that the person can reach. Other apps are skipped."""
|
|
63
|
+
listed = paginated(
|
|
64
|
+
f"{GITHUB_API_URL}/user/installations",
|
|
65
|
+
token,
|
|
66
|
+
"installations",
|
|
67
|
+
"You can reach too many GitHub App installations to list.",
|
|
68
|
+
)
|
|
69
|
+
return [
|
|
70
|
+
Installation(str(item["id"]), item["account"]["login"])
|
|
71
|
+
for item in listed
|
|
72
|
+
if isinstance(item, dict)
|
|
73
|
+
and str(item.get("app_id")) == str(settings.GITHUB_APP_ID)
|
|
74
|
+
]
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Open issues through the REST API."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from secondshift.config.constants import GITHUB_API_URL
|
|
7
|
+
from secondshift.github.errors import GitHubError
|
|
8
|
+
from secondshift.github.rest import parsed, reason, send
|
|
9
|
+
|
|
10
|
+
# GitHub refuses a longer title or body.
|
|
11
|
+
MAX_TITLE_CHARS = 256
|
|
12
|
+
MAX_BODY_CHARS = 65_000
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True)
|
|
16
|
+
class Issue:
|
|
17
|
+
number: int
|
|
18
|
+
url: str
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def same_title(left: str, right: str) -> bool:
|
|
22
|
+
return " ".join(left.split()).casefold() == " ".join(right.split()).casefold()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def open_issue_titled(token: str, full_name: str, title: str) -> Issue | None:
|
|
26
|
+
"""An open issue with this exact title, so a weekly job does not repeat it.
|
|
27
|
+
|
|
28
|
+
Search is a little behind new issues, so this catches older duplicates,
|
|
29
|
+
not one opened seconds ago.
|
|
30
|
+
"""
|
|
31
|
+
words = title.replace('"', " ").strip()
|
|
32
|
+
response = send(
|
|
33
|
+
"GET",
|
|
34
|
+
f"{GITHUB_API_URL}/search/issues",
|
|
35
|
+
token,
|
|
36
|
+
params={
|
|
37
|
+
"q": f'repo:{full_name} is:issue is:open in:title "{words}"',
|
|
38
|
+
"per_page": 20,
|
|
39
|
+
},
|
|
40
|
+
)
|
|
41
|
+
if response.status_code != 200:
|
|
42
|
+
raise GitHubError(f"GitHub could not search issues: {reason(response)}")
|
|
43
|
+
payload = parsed(response)
|
|
44
|
+
items = payload.get("items") if isinstance(payload, dict) else None
|
|
45
|
+
for item in items if isinstance(items, list) else []:
|
|
46
|
+
if isinstance(item, dict) and same_title(str(item.get("title", "")), title):
|
|
47
|
+
return checked(item)
|
|
48
|
+
return None
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def open_issue(
|
|
52
|
+
token: str, full_name: str, title: str, body: str, labels: list[str]
|
|
53
|
+
) -> Issue:
|
|
54
|
+
payload: dict[str, Any] = {
|
|
55
|
+
"title": title[:MAX_TITLE_CHARS],
|
|
56
|
+
"body": body[:MAX_BODY_CHARS],
|
|
57
|
+
}
|
|
58
|
+
if labels:
|
|
59
|
+
payload["labels"] = labels
|
|
60
|
+
created = send(
|
|
61
|
+
"POST", f"{GITHUB_API_URL}/repos/{full_name}/issues", token, json=payload
|
|
62
|
+
)
|
|
63
|
+
if created.status_code != 201:
|
|
64
|
+
raise GitHubError(f"GitHub did not open the issue: {reason(created)}")
|
|
65
|
+
return checked(parsed(created))
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def checked(payload: Any) -> Issue:
|
|
69
|
+
if (
|
|
70
|
+
not isinstance(payload, dict)
|
|
71
|
+
or not isinstance(payload.get("html_url"), str)
|
|
72
|
+
or not isinstance(payload.get("number"), int)
|
|
73
|
+
):
|
|
74
|
+
raise GitHubError("GitHub returned an invalid issue.")
|
|
75
|
+
return Issue(payload["number"], payload["html_url"])
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""What a GitHub credential may do, by feature.
|
|
2
|
+
|
|
3
|
+
An App installation lists its permissions. A personal token does not, so each
|
|
4
|
+
read permission is probed with one cheap request. Write access cannot be
|
|
5
|
+
probed without writing, so it is never reported for a token.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from urllib.parse import quote
|
|
9
|
+
|
|
10
|
+
from secondshift.config.constants import GITHUB_API_URL
|
|
11
|
+
from secondshift.github.rest import send
|
|
12
|
+
|
|
13
|
+
# The feature each permission serves, as GitHub names them.
|
|
14
|
+
SYNC = ("contents",)
|
|
15
|
+
TOOLS = ("pull_requests", "actions", "statuses")
|
|
16
|
+
DRAFT_PULL_REQUESTS = ("contents", "pull_requests")
|
|
17
|
+
ISSUES = ("issues",)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def probe_paths(branch: str) -> dict[str, str]:
|
|
21
|
+
"""A read request per permission. Each fails without that permission."""
|
|
22
|
+
return {
|
|
23
|
+
"contents": "commits?per_page=1",
|
|
24
|
+
"pull_requests": "pulls?per_page=1",
|
|
25
|
+
"actions": "actions/runs?per_page=1",
|
|
26
|
+
"statuses": f"commits/{quote(branch, safe='')}/status",
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def token_reads(
|
|
31
|
+
token: str, full_name: str, branch: str, names: tuple[str, ...]
|
|
32
|
+
) -> dict[str, bool]:
|
|
33
|
+
"""Whether the token can read each named permission on this repository."""
|
|
34
|
+
paths = probe_paths(branch)
|
|
35
|
+
return {
|
|
36
|
+
name: send(
|
|
37
|
+
"GET", f"{GITHUB_API_URL}/repos/{full_name}/{paths[name]}", token
|
|
38
|
+
).status_code
|
|
39
|
+
== 200
|
|
40
|
+
for name in names
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def granted_reads(granted: dict[str, str], names: tuple[str, ...]) -> dict[str, bool]:
|
|
45
|
+
return {name: granted.get(name) in {"read", "write"} for name in names}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def granted_writes(granted: dict[str, str], names: tuple[str, ...]) -> dict[str, bool]:
|
|
49
|
+
return {name: granted.get(name) == "write" for name in names}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Open draft pull requests through the REST API."""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from secondshift.config.constants import GITHUB_API_URL
|
|
6
|
+
from secondshift.github.errors import GitHubError
|
|
7
|
+
from secondshift.github.rest import parsed, reason, send
|
|
8
|
+
|
|
9
|
+
# GitHub refuses a longer description.
|
|
10
|
+
MAX_BODY_CHARS = 60_000
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def open_draft(
|
|
14
|
+
token: str, full_name: str, title: str, head: str, base: str, body: str
|
|
15
|
+
) -> dict[str, Any]:
|
|
16
|
+
"""Open the draft, or find the one an earlier attempt opened."""
|
|
17
|
+
url = f"{GITHUB_API_URL}/repos/{full_name}/pulls"
|
|
18
|
+
created = send(
|
|
19
|
+
"POST",
|
|
20
|
+
url,
|
|
21
|
+
token,
|
|
22
|
+
json={
|
|
23
|
+
"title": title,
|
|
24
|
+
"head": head,
|
|
25
|
+
"base": base,
|
|
26
|
+
"body": body[:MAX_BODY_CHARS],
|
|
27
|
+
"draft": True,
|
|
28
|
+
},
|
|
29
|
+
)
|
|
30
|
+
if created.status_code == 201:
|
|
31
|
+
return checked(parsed(created))
|
|
32
|
+
owner = full_name.split("/", 1)[0]
|
|
33
|
+
existing = send(
|
|
34
|
+
"GET", url, token, params={"head": f"{owner}:{head}", "state": "all"}
|
|
35
|
+
)
|
|
36
|
+
found = parsed(existing) if existing.status_code == 200 else []
|
|
37
|
+
if isinstance(found, list) and found:
|
|
38
|
+
return checked(found[0])
|
|
39
|
+
raise GitHubError(f"GitHub did not open the pull request: {reason(created)}")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def checked(payload: object) -> dict[str, Any]:
|
|
43
|
+
if (
|
|
44
|
+
not isinstance(payload, dict)
|
|
45
|
+
or not isinstance(payload.get("html_url"), str)
|
|
46
|
+
or not isinstance(payload.get("number"), int)
|
|
47
|
+
):
|
|
48
|
+
raise GitHubError("GitHub returned an invalid pull request.")
|
|
49
|
+
return payload
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Repository lookups that work with any token."""
|
|
2
|
+
|
|
3
|
+
from urllib.parse import quote
|
|
4
|
+
|
|
5
|
+
from secondshift.config.constants import GITHUB_API_URL
|
|
6
|
+
from secondshift.github.errors import GitHubError
|
|
7
|
+
from secondshift.github.identity import valid_name
|
|
8
|
+
from secondshift.github.rest import paginated, request_json
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def repository(token: str, repository_id: int) -> dict:
|
|
12
|
+
"""The repository with this ID. Its name may change, its ID never does."""
|
|
13
|
+
data = request_json("GET", f"{GITHUB_API_URL}/repositories/{repository_id}", token)
|
|
14
|
+
if data.get("id") != repository_id:
|
|
15
|
+
raise GitHubError("GitHub repository identity changed.")
|
|
16
|
+
if not valid_name(str(data.get("full_name", ""))):
|
|
17
|
+
raise GitHubError("Invalid GitHub repository name.")
|
|
18
|
+
return data
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def branch_head(token: str, repository_id: int, branch: str) -> str:
|
|
22
|
+
data = request_json(
|
|
23
|
+
"GET",
|
|
24
|
+
f"{GITHUB_API_URL}/repositories/{repository_id}/commits/{quote(branch, safe='')}",
|
|
25
|
+
token,
|
|
26
|
+
)
|
|
27
|
+
return data["sha"]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def branches(token: str, full_name: str) -> list[str]:
|
|
31
|
+
batch = paginated(
|
|
32
|
+
f"{GITHUB_API_URL}/repos/{full_name}/branches",
|
|
33
|
+
token,
|
|
34
|
+
None,
|
|
35
|
+
"This repository has too many branches to list.",
|
|
36
|
+
)
|
|
37
|
+
if any(
|
|
38
|
+
not isinstance(item, dict) or not isinstance(item.get("name"), str)
|
|
39
|
+
for item in batch
|
|
40
|
+
):
|
|
41
|
+
raise GitHubError("GitHub returned an invalid branch listing.")
|
|
42
|
+
return [item["name"] for item in batch]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def token_repositories(token: str) -> list[dict]:
|
|
46
|
+
"""The repositories a personal access token was given."""
|
|
47
|
+
return paginated(
|
|
48
|
+
f"{GITHUB_API_URL}/user/repos",
|
|
49
|
+
token,
|
|
50
|
+
None,
|
|
51
|
+
"This token reaches too many repositories to list.",
|
|
52
|
+
)
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""The one way SecondShift calls GitHub's REST API.
|
|
2
|
+
|
|
3
|
+
Errors raised here never include the token, the request, or GitHub's reply.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
|
|
10
|
+
from secondshift.config.constants import GITHUB_API_VERSION
|
|
11
|
+
from secondshift.github.errors import GitHubError
|
|
12
|
+
|
|
13
|
+
PAGE_SIZE = 100
|
|
14
|
+
MAX_PAGES = 100
|
|
15
|
+
TIMEOUT_SECONDS = 30
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def headers(token: str = "") -> dict[str, str]:
|
|
19
|
+
return {
|
|
20
|
+
"Accept": "application/json",
|
|
21
|
+
**({"Authorization": f"Bearer {token}"} if token else {}),
|
|
22
|
+
"X-GitHub-Api-Version": GITHUB_API_VERSION,
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def client() -> httpx.Client:
|
|
27
|
+
return httpx.Client(timeout=TIMEOUT_SECONDS)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def send(method: str, url: str, token: str = "", **kwargs: Any) -> httpx.Response:
|
|
31
|
+
"""The raw response, for callers that read GitHub's status codes."""
|
|
32
|
+
try:
|
|
33
|
+
with client() as session:
|
|
34
|
+
return session.request(method, url, headers=headers(token), **kwargs)
|
|
35
|
+
except (httpx.HTTPError, httpx.InvalidURL) as error:
|
|
36
|
+
raise GitHubError("GitHub could not be reached.") from error
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def request_payload(method: str, url: str, token: str = "", **kwargs: Any) -> object:
|
|
40
|
+
try:
|
|
41
|
+
response = send(method, url, token, **kwargs)
|
|
42
|
+
response.raise_for_status()
|
|
43
|
+
return response.json()
|
|
44
|
+
except (httpx.HTTPError, ValueError, GitHubError) as error:
|
|
45
|
+
raise GitHubError(
|
|
46
|
+
"GitHub access failed. Check the installation and permissions."
|
|
47
|
+
) from error
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def request_json(method: str, url: str, token: str = "", **kwargs: Any) -> dict:
|
|
51
|
+
result = request_payload(method, url, token, **kwargs)
|
|
52
|
+
if not isinstance(result, dict) or "error" in result:
|
|
53
|
+
raise GitHubError("GitHub returned an invalid authorization response.")
|
|
54
|
+
return result
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def paginated(url: str, token: str, key: str | None, too_many: str) -> list[Any]:
|
|
58
|
+
"""Every item of a listing, page by page. key names the list in each page."""
|
|
59
|
+
items: list[Any] = []
|
|
60
|
+
separator = "&" if "?" in url else "?"
|
|
61
|
+
for page in range(1, MAX_PAGES + 1):
|
|
62
|
+
payload = request_payload(
|
|
63
|
+
"GET", f"{url}{separator}per_page={PAGE_SIZE}&page={page}", token
|
|
64
|
+
)
|
|
65
|
+
batch = payload.get(key) if key and isinstance(payload, dict) else payload
|
|
66
|
+
if not isinstance(batch, list):
|
|
67
|
+
raise GitHubError("GitHub returned an invalid listing.")
|
|
68
|
+
items.extend(batch)
|
|
69
|
+
if len(batch) < PAGE_SIZE:
|
|
70
|
+
return items
|
|
71
|
+
raise GitHubError(too_many)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def parsed(response: httpx.Response) -> object:
|
|
75
|
+
try:
|
|
76
|
+
return response.json()
|
|
77
|
+
except ValueError:
|
|
78
|
+
return None
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def reason(response: httpx.Response) -> str:
|
|
82
|
+
"""GitHub's own short reason, which never contains the token."""
|
|
83
|
+
payload = parsed(response)
|
|
84
|
+
errors = payload.get("errors") if isinstance(payload, dict) else None
|
|
85
|
+
details = [
|
|
86
|
+
str(item.get("message"))
|
|
87
|
+
for item in errors or []
|
|
88
|
+
if isinstance(item, dict) and item.get("message")
|
|
89
|
+
]
|
|
90
|
+
message = details[0] if details else ""
|
|
91
|
+
if not message and isinstance(payload, dict):
|
|
92
|
+
message = str(payload.get("message", ""))
|
|
93
|
+
return (message or f"HTTP {response.status_code}")[:200]
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# This package must not depend on the Django apps, so every app can use it.
|
|
2
|
+
# This list replaces the root one, so the root ban on requests is repeated.
|
|
3
|
+
extend = "../../pyproject.toml"
|
|
4
|
+
|
|
5
|
+
[lint.flake8-tidy-imports.banned-api]
|
|
6
|
+
"requests".msg = "Use httpx, the project's one HTTP client."
|
|
7
|
+
"secondshift.connections".msg = "secondshift.github must not import Django apps."
|
|
8
|
+
"secondshift.core".msg = "secondshift.github must not import Django apps."
|
|
9
|
+
"secondshift.gateway".msg = "secondshift.github must not import Django apps."
|
|
10
|
+
"secondshift.jobs".msg = "secondshift.github must not import Django apps."
|
|
11
|
+
"secondshift.mcp_server".msg = "secondshift.github must not import Django apps."
|
|
12
|
+
"secondshift.repositories".msg = "secondshift.github must not import Django apps."
|
|
13
|
+
"secondshift.runner".msg = "secondshift.github must not import Django apps."
|
|
14
|
+
"secondshift.runs".msg = "secondshift.github must not import Django apps."
|
|
15
|
+
"secondshift.triggers".msg = "secondshift.github must not import Django apps."
|
|
16
|
+
"secondshift.workspaces".msg = "secondshift.github must not import Django apps."
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Check a personal access token before SecondShift stores it."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from datetime import UTC, datetime
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
|
|
9
|
+
from secondshift.config.constants import (
|
|
10
|
+
GITHUB_API_URL,
|
|
11
|
+
GITHUB_FINE_GRAINED_TOKEN_PATTERN,
|
|
12
|
+
GITHUB_TOKEN_HINT,
|
|
13
|
+
)
|
|
14
|
+
from secondshift.github.errors import GitHubError
|
|
15
|
+
from secondshift.github.rest import send
|
|
16
|
+
|
|
17
|
+
# GitHub sends a token's expiry with every response it answers with that token.
|
|
18
|
+
EXPIRY_HEADER = "github-authentication-token-expiration"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(frozen=True)
|
|
22
|
+
class TokenOwner:
|
|
23
|
+
login: str
|
|
24
|
+
expires_at: datetime | None
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def inspect_token(token: str) -> TokenOwner:
|
|
28
|
+
"""Whose token this is and when it expires. Refuses classic tokens."""
|
|
29
|
+
if not re.fullmatch(GITHUB_FINE_GRAINED_TOKEN_PATTERN, token):
|
|
30
|
+
raise GitHubError(GITHUB_TOKEN_HINT)
|
|
31
|
+
response = send("GET", f"{GITHUB_API_URL}/user", token)
|
|
32
|
+
if response.status_code == 401:
|
|
33
|
+
raise GitHubError("GitHub refused this token. It may have expired.")
|
|
34
|
+
try:
|
|
35
|
+
response.raise_for_status()
|
|
36
|
+
login = response.json()["login"]
|
|
37
|
+
except (httpx.HTTPError, ValueError, KeyError, TypeError) as error:
|
|
38
|
+
raise GitHubError("GitHub could not check this token.") from error
|
|
39
|
+
if not isinstance(login, str) or not login:
|
|
40
|
+
raise GitHubError("GitHub could not check this token.")
|
|
41
|
+
return TokenOwner(login, expiry(response.headers.get(EXPIRY_HEADER, "")))
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def expiry(value: str) -> datetime | None:
|
|
45
|
+
"""Parse "2026-12-01 08:00:00 UTC" or "... -0800". None for no expiry."""
|
|
46
|
+
value = value.strip().replace(" UTC", " +0000")
|
|
47
|
+
if not value:
|
|
48
|
+
return None
|
|
49
|
+
try:
|
|
50
|
+
return datetime.strptime(value, "%Y-%m-%d %H:%M:%S %z").astimezone(UTC)
|
|
51
|
+
except ValueError:
|
|
52
|
+
return None
|
|
File without changes
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
from django.http import Http404, HttpRequest
|
|
2
|
+
|
|
3
|
+
from secondshift.jobs.dao import find_active_job
|
|
4
|
+
from secondshift.jobs.models import Job
|
|
5
|
+
from secondshift.workspaces.access import current_workspace
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def job_for_request(request: HttpRequest, job_id: int) -> Job:
|
|
9
|
+
job = find_active_job(current_workspace(request), job_id)
|
|
10
|
+
if job is None:
|
|
11
|
+
raise Http404
|
|
12
|
+
return job
|