syncmill 0.2.0__tar.gz
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.
- syncmill-0.2.0/.github/cla-check.py +594 -0
- syncmill-0.2.0/.github/workflows/ci.yml +100 -0
- syncmill-0.2.0/.github/workflows/cla.yml +64 -0
- syncmill-0.2.0/.github/workflows/release.yml +185 -0
- syncmill-0.2.0/.gitignore +26 -0
- syncmill-0.2.0/.python-version +1 -0
- syncmill-0.2.0/CHANGELOG.md +1392 -0
- syncmill-0.2.0/CLA.md +135 -0
- syncmill-0.2.0/CLAUDE.md +382 -0
- syncmill-0.2.0/CONTRIBUTING.md +113 -0
- syncmill-0.2.0/LICENSE +191 -0
- syncmill-0.2.0/PKG-INFO +853 -0
- syncmill-0.2.0/README.md +816 -0
- syncmill-0.2.0/contracts/fixtures/envelope-additive.json +29 -0
- syncmill-0.2.0/contracts/fixtures/envelope-board.json +30 -0
- syncmill-0.2.0/contracts/fixtures/envelope-direct.json +27 -0
- syncmill-0.2.0/contracts/fixtures/envelope-traced.json +48 -0
- syncmill-0.2.0/contracts/fixtures/envelope-v2.json +21 -0
- syncmill-0.2.0/contracts/fixtures/preflight-request.json +7 -0
- syncmill-0.2.0/contracts/preflight-request.schema.json +19 -0
- syncmill-0.2.0/contracts/run-envelope.schema.json +154 -0
- syncmill-0.2.0/contracts/strict-preflight-manifest.schema.json +27 -0
- syncmill-0.2.0/docs/a2a-decision-memo.md +178 -0
- syncmill-0.2.0/docs/adr/README.md +14 -0
- syncmill-0.2.0/docs/adr/adr-0005-evidence-first-review.ko.md +69 -0
- syncmill-0.2.0/docs/adr/adr-0005-evidence-first-review.md +79 -0
- syncmill-0.2.0/docs/architecture/README.md +29 -0
- syncmill-0.2.0/docs/architecture/artifact-boundary.md +28 -0
- syncmill-0.2.0/docs/architecture/board.md +23 -0
- syncmill-0.2.0/docs/architecture/config.md +6 -0
- syncmill-0.2.0/docs/architecture/dashboard.md +14 -0
- syncmill-0.2.0/docs/architecture/extension-points.md +20 -0
- syncmill-0.2.0/docs/architecture/gateway.md +12 -0
- syncmill-0.2.0/docs/architecture/interactive-collaboration.md +36 -0
- syncmill-0.2.0/docs/architecture/memory.md +10 -0
- syncmill-0.2.0/docs/architecture/monitor.md +12 -0
- syncmill-0.2.0/docs/architecture/preflight.md +26 -0
- syncmill-0.2.0/docs/architecture/report.md +6 -0
- syncmill-0.2.0/docs/architecture/session.md +16 -0
- syncmill-0.2.0/docs/architecture/teamfile.md +11 -0
- syncmill-0.2.0/docs/architecture/teams.md +11 -0
- syncmill-0.2.0/docs/architecture/tracer.md +11 -0
- syncmill-0.2.0/docs/architecture/tui.md +104 -0
- syncmill-0.2.0/docs/architecture/worktrees.md +11 -0
- syncmill-0.2.0/docs/archive/multi-agent-cli-landscape-research.md +137 -0
- syncmill-0.2.0/docs/archive/research-summary.md +9 -0
- syncmill-0.2.0/docs/archive/syncmill-review.md +237 -0
- syncmill-0.2.0/docs/assets/tui/01-board.svg +221 -0
- syncmill-0.2.0/docs/assets/tui/02-runs-operations.svg +222 -0
- syncmill-0.2.0/docs/assets/tui/03-runs-artifacts-summary.svg +219 -0
- syncmill-0.2.0/docs/assets/tui/04-runs-artifacts-diff.svg +219 -0
- syncmill-0.2.0/docs/assets/tui/05-reports.svg +219 -0
- syncmill-0.2.0/docs/assets/tui/06-teams.svg +219 -0
- syncmill-0.2.0/docs/assets/tui/07-status.svg +217 -0
- syncmill-0.2.0/docs/assets/tui/08-sessions.svg +219 -0
- syncmill-0.2.0/docs/assets/tui/09-rooms-diff.svg +219 -0
- syncmill-0.2.0/docs/assets/tui/10-rooms-output.svg +219 -0
- syncmill-0.2.0/docs/assets/tui/11-rooms-details.svg +220 -0
- syncmill-0.2.0/docs/assets/tui/12-rooms-compare.svg +220 -0
- syncmill-0.2.0/docs/difficulty-model-routing-plan.md +861 -0
- syncmill-0.2.0/docs/ecosystem/adr-0001-strict-preflight-enforcement.md +32 -0
- syncmill-0.2.0/docs/ecosystem/adr-0002-gate-e-opt-in-approval.md +55 -0
- syncmill-0.2.0/docs/ecosystem/adr-0003-control-plan-preflight.md +43 -0
- syncmill-0.2.0/docs/ecosystem/adr-0004-millflow-control-evidence.md +60 -0
- syncmill-0.2.0/docs/ecosystem/gate-e-p4-operating-evidence.md +101 -0
- syncmill-0.2.0/docs/ecosystem/gate-e-p4-runbook.md +88 -0
- syncmill-0.2.0/docs/ecosystem/implementation-design.md +561 -0
- syncmill-0.2.0/docs/ecosystem/integration-plan.md +480 -0
- syncmill-0.2.0/docs/ecosystem/smoke-runbook.md +76 -0
- syncmill-0.2.0/docs/ecosystem/strict-p3.1-release-note.md +22 -0
- syncmill-0.2.0/docs/ecosystem-integration-plan.md +158 -0
- syncmill-0.2.0/docs/implementation-review-2026-09-07.ko.md +483 -0
- syncmill-0.2.0/docs/improvement-roadmap-2026-09-08.ko.md +77 -0
- syncmill-0.2.0/docs/memory-backend-design.md +703 -0
- syncmill-0.2.0/docs/notebooks/00-introduction.ipynb +254 -0
- syncmill-0.2.0/docs/notebooks/01-first-run.ipynb +2398 -0
- syncmill-0.2.0/docs/notebooks/02-strategies.ipynb +1240 -0
- syncmill-0.2.0/docs/notebooks/03-shared-memory.ipynb +820 -0
- syncmill-0.2.0/docs/notebooks/04-board-and-tui.ipynb +1140 -0
- syncmill-0.2.0/docs/notebooks/README.md +46 -0
- syncmill-0.2.0/docs/notebooks/_sandbox.py +503 -0
- syncmill-0.2.0/docs/review-backlog-2026-08.md +118 -0
- syncmill-0.2.0/docs/review-evidence/2026-10-16/issue-166/README.md +39 -0
- syncmill-0.2.0/docs/review-evidence/2026-10-16/issue-166/lint.log +2 -0
- syncmill-0.2.0/docs/review-evidence/2026-10-16/issue-166/review-fix-1/README.md +40 -0
- syncmill-0.2.0/docs/review-evidence/2026-10-16/issue-166/review-fix-1/lint.log +2 -0
- syncmill-0.2.0/docs/review-evidence/2026-10-16/issue-166/review-fix-1/tests.log +22 -0
- syncmill-0.2.0/docs/review-evidence/2026-10-16/issue-166/review-fix-1/validation.json +54 -0
- syncmill-0.2.0/docs/review-evidence/2026-10-16/issue-166/review-fix-2/README.md +27 -0
- syncmill-0.2.0/docs/review-evidence/2026-10-16/issue-166/review-fix-2/lint.log +2 -0
- syncmill-0.2.0/docs/review-evidence/2026-10-16/issue-166/review-fix-2/tests.log +8 -0
- syncmill-0.2.0/docs/review-evidence/2026-10-16/issue-166/review-fix-2/validation.json +67 -0
- syncmill-0.2.0/docs/review-evidence/2026-10-16/issue-166/review-fix-2/validation.reviewed.json +56 -0
- syncmill-0.2.0/docs/review-evidence/2026-10-16/issue-166/tests.log +22 -0
- syncmill-0.2.0/docs/review-evidence/2026-10-16/issue-166/validation.json +45 -0
- syncmill-0.2.0/docs/tmp-test-guide.ko.md +340 -0
- syncmill-0.2.0/docs/tui-dashboard-run-plan.md +105 -0
- syncmill-0.2.0/docs/tui-guide.ko.md +319 -0
- syncmill-0.2.0/docs/user-guide.ko.md +1504 -0
- syncmill-0.2.0/docs/user-guide.md +1743 -0
- syncmill-0.2.0/examples/explicit_retry_canary.py +164 -0
- syncmill-0.2.0/examples/strategy_selfbench/README.md +135 -0
- syncmill-0.2.0/examples/strategy_selfbench/run_selfbench.py +1046 -0
- syncmill-0.2.0/examples/strategy_selfbench/tasks.jsonl +8 -0
- syncmill-0.2.0/pyproject.toml +83 -0
- syncmill-0.2.0/scripts/release_preflight.py +562 -0
- syncmill-0.2.0/scripts/render_tui_guide.py +656 -0
- syncmill-0.2.0/src/syncmill/__init__.py +11 -0
- syncmill-0.2.0/src/syncmill/agents.py +2064 -0
- syncmill-0.2.0/src/syncmill/artifact_io.py +201 -0
- syncmill-0.2.0/src/syncmill/artifacts.py +1946 -0
- syncmill-0.2.0/src/syncmill/board.py +2060 -0
- syncmill-0.2.0/src/syncmill/breaker.py +327 -0
- syncmill-0.2.0/src/syncmill/cli.py +4986 -0
- syncmill-0.2.0/src/syncmill/command_errors.py +32 -0
- syncmill-0.2.0/src/syncmill/config.py +1118 -0
- syncmill-0.2.0/src/syncmill/dashboard.py +935 -0
- syncmill-0.2.0/src/syncmill/gates.py +179 -0
- syncmill-0.2.0/src/syncmill/gateway.py +234 -0
- syncmill-0.2.0/src/syncmill/git_env.py +99 -0
- syncmill-0.2.0/src/syncmill/isolated_session.py +431 -0
- syncmill-0.2.0/src/syncmill/matcher.py +127 -0
- syncmill-0.2.0/src/syncmill/memory.py +1067 -0
- syncmill-0.2.0/src/syncmill/memory_backend.py +1907 -0
- syncmill-0.2.0/src/syncmill/memory_migration.py +962 -0
- syncmill-0.2.0/src/syncmill/model_routing.py +482 -0
- syncmill-0.2.0/src/syncmill/models.py +244 -0
- syncmill-0.2.0/src/syncmill/monitor.py +775 -0
- syncmill-0.2.0/src/syncmill/phoenix_exporter.py +339 -0
- syncmill-0.2.0/src/syncmill/planning.py +512 -0
- syncmill-0.2.0/src/syncmill/platform_support.py +14 -0
- syncmill-0.2.0/src/syncmill/preflight.py +1129 -0
- syncmill-0.2.0/src/syncmill/project.py +1067 -0
- syncmill-0.2.0/src/syncmill/report.py +502 -0
- syncmill-0.2.0/src/syncmill/review_candidates.py +107 -0
- syncmill-0.2.0/src/syncmill/room_compare.py +2380 -0
- syncmill-0.2.0/src/syncmill/room_raw.py +484 -0
- syncmill-0.2.0/src/syncmill/room_viewer.py +604 -0
- syncmill-0.2.0/src/syncmill/rooms.py +4044 -0
- syncmill-0.2.0/src/syncmill/session.py +444 -0
- syncmill-0.2.0/src/syncmill/strategies/__init__.py +82 -0
- syncmill-0.2.0/src/syncmill/strategies/base.py +910 -0
- syncmill-0.2.0/src/syncmill/strategies/compete.py +233 -0
- syncmill-0.2.0/src/syncmill/strategies/council.py +429 -0
- syncmill-0.2.0/src/syncmill/strategies/decompose.py +570 -0
- syncmill-0.2.0/src/syncmill/strategies/pipeline.py +221 -0
- syncmill-0.2.0/src/syncmill/strategies/route.py +130 -0
- syncmill-0.2.0/src/syncmill/supervisor.py +1423 -0
- syncmill-0.2.0/src/syncmill/teamfile.py +571 -0
- syncmill-0.2.0/src/syncmill/telemetry_hook.py +231 -0
- syncmill-0.2.0/src/syncmill/terminal.py +245 -0
- syncmill-0.2.0/src/syncmill/tracer.py +548 -0
- syncmill-0.2.0/src/syncmill/tui.py +3537 -0
- syncmill-0.2.0/src/syncmill/tui_collaboration.py +499 -0
- syncmill-0.2.0/src/syncmill/tui_jobs.py +1407 -0
- syncmill-0.2.0/src/syncmill/tui_render.py +156 -0
- syncmill-0.2.0/src/syncmill/workflow.py +3746 -0
- syncmill-0.2.0/src/syncmill/worktree.py +1045 -0
- syncmill-0.2.0/syncmill.toml.example +333 -0
- syncmill-0.2.0/tests/conftest.py +946 -0
- syncmill-0.2.0/tests/fixtures/claude/array-error-event.json +15 -0
- syncmill-0.2.0/tests/fixtures/claude/array-rate-limit.json +18 -0
- syncmill-0.2.0/tests/fixtures/claude/array-success.json +29 -0
- syncmill-0.2.0/tests/fixtures/room_checkpoint/pre_188.json +13 -0
- syncmill-0.2.0/tests/fixtures/room_compare/pre_pr3.json +241 -0
- syncmill-0.2.0/tests/fixtures/toolgraph/README.md +19 -0
- syncmill-0.2.0/tests/fixtures/toolgraph/control-plan-v1.json +81 -0
- syncmill-0.2.0/tests/fixtures/toolgraph/control-plan.schema.json +121 -0
- syncmill-0.2.0/tests/fixtures/toolgraph/control-preflight-v1.json +29 -0
- syncmill-0.2.0/tests/fixtures/toolgraph/control-preflight.schema.json +75 -0
- syncmill-0.2.0/tests/fixtures/toolgraph/preflight-additive.json +17 -0
- syncmill-0.2.0/tests/fixtures/toolgraph/preflight-allow.json +15 -0
- syncmill-0.2.0/tests/fixtures/toolgraph/preflight-drift.json +15 -0
- syncmill-0.2.0/tests/fixtures/toolgraph/preflight-unknown-tool.json +15 -0
- syncmill-0.2.0/tests/fixtures/toolgraph/preflight-unresolved-identity.json +15 -0
- syncmill-0.2.0/tests/fixtures/toolgraph/preflight-v2.json +15 -0
- syncmill-0.2.0/tests/fixtures/toolgraph/preflight-warn.json +35 -0
- syncmill-0.2.0/tests/fixtures/toolgraph/preflight.schema.json +129 -0
- syncmill-0.2.0/tests/fixtures/tracegraph/README.md +14 -0
- syncmill-0.2.0/tests/fixtures/tracegraph/review-candidates.schema.json +53 -0
- syncmill-0.2.0/tests/fixtures/tracegraph/v1.json +13 -0
- syncmill-0.2.0/tests/memory_gc_worker.py +111 -0
- syncmill-0.2.0/tests/test_agents.py +2460 -0
- syncmill-0.2.0/tests/test_artifact_boundary.py +795 -0
- syncmill-0.2.0/tests/test_board.py +2153 -0
- syncmill-0.2.0/tests/test_breaker.py +430 -0
- syncmill-0.2.0/tests/test_cla_check.py +708 -0
- syncmill-0.2.0/tests/test_cli.py +5868 -0
- syncmill-0.2.0/tests/test_config.py +1122 -0
- syncmill-0.2.0/tests/test_console_logging.py +389 -0
- syncmill-0.2.0/tests/test_contracts.py +203 -0
- syncmill-0.2.0/tests/test_control_plan.py +467 -0
- syncmill-0.2.0/tests/test_dashboard.py +767 -0
- syncmill-0.2.0/tests/test_display_escaping.py +1015 -0
- syncmill-0.2.0/tests/test_docs_freshness.py +417 -0
- syncmill-0.2.0/tests/test_gates.py +218 -0
- syncmill-0.2.0/tests/test_gateway.py +411 -0
- syncmill-0.2.0/tests/test_implementation_hardening.py +291 -0
- syncmill-0.2.0/tests/test_isolated_session.py +529 -0
- syncmill-0.2.0/tests/test_isolated_session_tmux.py +151 -0
- syncmill-0.2.0/tests/test_matcher.py +167 -0
- syncmill-0.2.0/tests/test_memory.py +1429 -0
- syncmill-0.2.0/tests/test_memory_backend.py +2208 -0
- syncmill-0.2.0/tests/test_memory_backend_langgraph_integration.py +50 -0
- syncmill-0.2.0/tests/test_memory_backend_mem0_integration.py +49 -0
- syncmill-0.2.0/tests/test_memory_backend_memtomem_integration.py +807 -0
- syncmill-0.2.0/tests/test_memory_gc_process.py +239 -0
- syncmill-0.2.0/tests/test_memory_migration.py +997 -0
- syncmill-0.2.0/tests/test_memory_migration_integration.py +101 -0
- syncmill-0.2.0/tests/test_model_routing.py +628 -0
- syncmill-0.2.0/tests/test_models.py +73 -0
- syncmill-0.2.0/tests/test_monitor.py +895 -0
- syncmill-0.2.0/tests/test_notebooks.py +447 -0
- syncmill-0.2.0/tests/test_phoenix_exporter.py +179 -0
- syncmill-0.2.0/tests/test_phoenix_exporter_integration.py +116 -0
- syncmill-0.2.0/tests/test_preflight.py +589 -0
- syncmill-0.2.0/tests/test_project.py +1340 -0
- syncmill-0.2.0/tests/test_release_preflight.py +606 -0
- syncmill-0.2.0/tests/test_report.py +599 -0
- syncmill-0.2.0/tests/test_retry_causality.py +183 -0
- syncmill-0.2.0/tests/test_review_candidates.py +104 -0
- syncmill-0.2.0/tests/test_room_compare.py +2102 -0
- syncmill-0.2.0/tests/test_room_raw.py +780 -0
- syncmill-0.2.0/tests/test_room_viewer.py +1113 -0
- syncmill-0.2.0/tests/test_rooms.py +3288 -0
- syncmill-0.2.0/tests/test_round_conversation.py +610 -0
- syncmill-0.2.0/tests/test_round_provenance.py +364 -0
- syncmill-0.2.0/tests/test_session.py +495 -0
- syncmill-0.2.0/tests/test_strategy_selfbench_example.py +2226 -0
- syncmill-0.2.0/tests/test_supervisor.py +6367 -0
- syncmill-0.2.0/tests/test_teamfile.py +773 -0
- syncmill-0.2.0/tests/test_telemetry_hook.py +157 -0
- syncmill-0.2.0/tests/test_terminal.py +466 -0
- syncmill-0.2.0/tests/test_terminal_json.py +84 -0
- syncmill-0.2.0/tests/test_tmp_guide.py +120 -0
- syncmill-0.2.0/tests/test_tracer.py +459 -0
- syncmill-0.2.0/tests/test_tui.py +5026 -0
- syncmill-0.2.0/tests/test_tui_guide.py +266 -0
- syncmill-0.2.0/tests/test_tui_jobs.py +1506 -0
- syncmill-0.2.0/tests/test_tui_live_codex.py +246 -0
- syncmill-0.2.0/tests/test_tui_pty.py +248 -0
- syncmill-0.2.0/tests/test_tui_render.py +578 -0
- syncmill-0.2.0/tests/test_v2_workflow.py +2117 -0
- syncmill-0.2.0/tests/test_version.py +73 -0
- syncmill-0.2.0/tests/test_workflow.py +3402 -0
- syncmill-0.2.0/tests/test_workflow_connections.py +84 -0
- syncmill-0.2.0/tests/test_workflow_durable.py +720 -0
- syncmill-0.2.0/tests/test_worktree.py +869 -0
- syncmill-0.2.0/uv.lock +2808 -0
|
@@ -0,0 +1,594 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Small CLA gate for GitHub Actions.
|
|
3
|
+
|
|
4
|
+
This replaces contributor-assistant/github-action while preserving the
|
|
5
|
+
existing signatures/v1/cla.json storage format on the cla-signatures branch.
|
|
6
|
+
It intentionally never checks out or executes pull request code.
|
|
7
|
+
|
|
8
|
+
Adapted from memtomem/memtomem with ONE addition: a commit status on the pull
|
|
9
|
+
request head. Comments alone cannot clear the gate — a signing reply arrives as
|
|
10
|
+
`issue_comment`, whose workflow run belongs to the default branch, so the
|
|
11
|
+
`pull_request_target` run that already failed stays red until the next push.
|
|
12
|
+
Publishing a stable status context against `pr.head.sha` on BOTH event paths is
|
|
13
|
+
what lets a signature turn the pull request green, and is what branch protection
|
|
14
|
+
can require. The status is the gate, so a failure to publish it is fatal here
|
|
15
|
+
rather than warned about like the cosmetic comments.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import base64
|
|
21
|
+
import json
|
|
22
|
+
import os
|
|
23
|
+
import subprocess
|
|
24
|
+
import sys
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
from typing import Any
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
SIGNATURES_KEY = "signedContributors"
|
|
30
|
+
CLA_BOT_LOGINS = {"github-actions[bot]", "cla-assistant[bot]"}
|
|
31
|
+
MISSING_MARKER = "Before we can merge, please sign"
|
|
32
|
+
UNRESOLVED_MARKER = "GitHub cannot attribute to an account"
|
|
33
|
+
SIGNED_MARKER = "All contributors have signed the CLA."
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class GitHubApiError(RuntimeError):
|
|
37
|
+
def __init__(self, command: list[str], stderr: str) -> None:
|
|
38
|
+
super().__init__(stderr.strip() or "gh api failed")
|
|
39
|
+
self.command = command
|
|
40
|
+
self.stderr = stderr
|
|
41
|
+
|
|
42
|
+
@property
|
|
43
|
+
def is_not_found(self) -> bool:
|
|
44
|
+
return "HTTP 404" in self.stderr or "Not Found" in self.stderr
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
def is_conflict(self) -> bool:
|
|
48
|
+
return "HTTP 409" in self.stderr or "Conflict" in self.stderr
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def env_required(name: str) -> str:
|
|
52
|
+
value = os.environ.get(name)
|
|
53
|
+
if not value:
|
|
54
|
+
raise RuntimeError(f"{name} is required")
|
|
55
|
+
return value
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def gh_api(
|
|
59
|
+
endpoint: str,
|
|
60
|
+
*,
|
|
61
|
+
method: str = "GET",
|
|
62
|
+
token: str | None = None,
|
|
63
|
+
payload: dict[str, Any] | None = None,
|
|
64
|
+
) -> Any:
|
|
65
|
+
command = ["gh", "api", endpoint, "--method", method]
|
|
66
|
+
stdin = None
|
|
67
|
+
if payload is not None:
|
|
68
|
+
command.extend(["--input", "-"])
|
|
69
|
+
stdin = json.dumps(payload)
|
|
70
|
+
|
|
71
|
+
env = os.environ.copy()
|
|
72
|
+
if token:
|
|
73
|
+
env["GH_TOKEN"] = token
|
|
74
|
+
|
|
75
|
+
result = subprocess.run(
|
|
76
|
+
command,
|
|
77
|
+
input=stdin,
|
|
78
|
+
capture_output=True,
|
|
79
|
+
check=False,
|
|
80
|
+
env=env,
|
|
81
|
+
text=True,
|
|
82
|
+
)
|
|
83
|
+
if result.returncode != 0:
|
|
84
|
+
raise GitHubApiError(command, result.stderr)
|
|
85
|
+
if not result.stdout.strip():
|
|
86
|
+
return None
|
|
87
|
+
return json.loads(result.stdout)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def gh_paginated(endpoint: str, *, token: str) -> list[Any]:
|
|
91
|
+
items: list[Any] = []
|
|
92
|
+
page = 1
|
|
93
|
+
separator = "&" if "?" in endpoint else "?"
|
|
94
|
+
while True:
|
|
95
|
+
batch = gh_api(f"{endpoint}{separator}per_page=100&page={page}", token=token)
|
|
96
|
+
if not isinstance(batch, list):
|
|
97
|
+
raise RuntimeError(f"Expected a list response from {endpoint}")
|
|
98
|
+
items.extend(batch)
|
|
99
|
+
if len(batch) < 100:
|
|
100
|
+
return items
|
|
101
|
+
page += 1
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def load_event() -> dict[str, Any]:
|
|
105
|
+
with Path(env_required("GITHUB_EVENT_PATH")).open(encoding="utf-8") as handle:
|
|
106
|
+
return json.load(handle)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def comma_set(value: str) -> set[str]:
|
|
110
|
+
return {item.strip().lower() for item in value.split(",") if item.strip()}
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def load_signatures(repo: str, branch: str, path: str, token: str) -> tuple[dict[str, Any], str | None]:
|
|
114
|
+
try:
|
|
115
|
+
response = gh_api(f"repos/{repo}/contents/{path}?ref={branch}", token=token)
|
|
116
|
+
except GitHubApiError as exc:
|
|
117
|
+
if exc.is_not_found:
|
|
118
|
+
return {SIGNATURES_KEY: []}, None
|
|
119
|
+
raise
|
|
120
|
+
|
|
121
|
+
raw_content = base64.b64decode(response["content"]).decode("utf-8")
|
|
122
|
+
signatures = json.loads(raw_content) if raw_content.strip() else {SIGNATURES_KEY: []}
|
|
123
|
+
signatures.setdefault(SIGNATURES_KEY, [])
|
|
124
|
+
return signatures, response.get("sha")
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def write_signatures(
|
|
128
|
+
repo: str,
|
|
129
|
+
branch: str,
|
|
130
|
+
path: str,
|
|
131
|
+
token: str,
|
|
132
|
+
signatures: dict[str, Any],
|
|
133
|
+
sha: str | None,
|
|
134
|
+
message: str,
|
|
135
|
+
) -> None:
|
|
136
|
+
raw_content = json.dumps(signatures, indent=2, ensure_ascii=False) + "\n"
|
|
137
|
+
payload = {
|
|
138
|
+
"message": message,
|
|
139
|
+
"content": base64.b64encode(raw_content.encode("utf-8")).decode("ascii"),
|
|
140
|
+
"branch": branch,
|
|
141
|
+
}
|
|
142
|
+
if sha:
|
|
143
|
+
payload["sha"] = sha
|
|
144
|
+
gh_api(f"repos/{repo}/contents/{path}", method="PUT", token=token, payload=payload)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def record_signature_with_retry(
|
|
148
|
+
*,
|
|
149
|
+
repo: str,
|
|
150
|
+
branch: str,
|
|
151
|
+
path: str,
|
|
152
|
+
token: str,
|
|
153
|
+
login: str,
|
|
154
|
+
user_id: int,
|
|
155
|
+
comment_id: int,
|
|
156
|
+
created_at: str,
|
|
157
|
+
repo_id: int,
|
|
158
|
+
pull_request_no: int,
|
|
159
|
+
) -> tuple[dict[str, Any], bool]:
|
|
160
|
+
for attempt in range(2):
|
|
161
|
+
signatures, sha = load_signatures(repo, branch, path, token)
|
|
162
|
+
changed = record_signature(
|
|
163
|
+
signatures,
|
|
164
|
+
login=login,
|
|
165
|
+
user_id=user_id,
|
|
166
|
+
comment_id=comment_id,
|
|
167
|
+
created_at=created_at,
|
|
168
|
+
repo_id=repo_id,
|
|
169
|
+
pull_request_no=pull_request_no,
|
|
170
|
+
)
|
|
171
|
+
if not changed:
|
|
172
|
+
return signatures, False
|
|
173
|
+
try:
|
|
174
|
+
write_signatures(
|
|
175
|
+
repo,
|
|
176
|
+
branch,
|
|
177
|
+
path,
|
|
178
|
+
token,
|
|
179
|
+
signatures,
|
|
180
|
+
sha,
|
|
181
|
+
f"Add CLA signature for {login}",
|
|
182
|
+
)
|
|
183
|
+
return signatures, True
|
|
184
|
+
except GitHubApiError as exc:
|
|
185
|
+
if attempt == 0 and exc.is_conflict:
|
|
186
|
+
print("Signature store changed concurrently; retrying once.")
|
|
187
|
+
continue
|
|
188
|
+
raise
|
|
189
|
+
raise RuntimeError("unreachable")
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def signed_logins(signatures: dict[str, Any]) -> set[str]:
|
|
193
|
+
return {
|
|
194
|
+
contributor.get("name", "").lower()
|
|
195
|
+
for contributor in signatures.get(SIGNATURES_KEY, [])
|
|
196
|
+
if contributor.get("name")
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def record_signature(
|
|
201
|
+
signatures: dict[str, Any],
|
|
202
|
+
*,
|
|
203
|
+
login: str,
|
|
204
|
+
user_id: int,
|
|
205
|
+
comment_id: int,
|
|
206
|
+
created_at: str,
|
|
207
|
+
repo_id: int,
|
|
208
|
+
pull_request_no: int,
|
|
209
|
+
) -> bool:
|
|
210
|
+
if login.lower() in signed_logins(signatures):
|
|
211
|
+
return False
|
|
212
|
+
signatures[SIGNATURES_KEY].append(
|
|
213
|
+
{
|
|
214
|
+
"name": login,
|
|
215
|
+
"id": user_id,
|
|
216
|
+
"comment_id": comment_id,
|
|
217
|
+
"created_at": created_at,
|
|
218
|
+
"repoId": repo_id,
|
|
219
|
+
"pullRequestNo": pull_request_no,
|
|
220
|
+
}
|
|
221
|
+
)
|
|
222
|
+
return True
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def pr_number_from_event(event: dict[str, Any]) -> int | None:
|
|
226
|
+
if "pull_request" in event:
|
|
227
|
+
return int(event["pull_request"]["number"])
|
|
228
|
+
issue = event.get("issue", {})
|
|
229
|
+
if issue.get("pull_request"):
|
|
230
|
+
return int(issue["number"])
|
|
231
|
+
return None
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def comment_command(event: dict[str, Any], signature_text: str) -> str | None:
|
|
235
|
+
if "comment" not in event:
|
|
236
|
+
return None
|
|
237
|
+
body = event["comment"].get("body", "").strip()
|
|
238
|
+
# The workflow `if` already filters comments. This keeps direct script
|
|
239
|
+
# invocations safe, and quoted instructions do not equal the bare signature.
|
|
240
|
+
if body == signature_text:
|
|
241
|
+
return "sign"
|
|
242
|
+
if body.lower() == "recheck":
|
|
243
|
+
return "recheck"
|
|
244
|
+
return "ignore"
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def pr_contributors(
|
|
248
|
+
repo: str, pr: dict[str, Any], allowlist: set[str], token: str
|
|
249
|
+
) -> tuple[set[str], list[str]]:
|
|
250
|
+
"""Everyone who must have signed for THIS pull request head to be mergeable.
|
|
251
|
+
|
|
252
|
+
Enumeration is bound to immutable SHAs, never to the pull request's own
|
|
253
|
+
commit list. That list follows the head wherever a force-push moves it, so
|
|
254
|
+
reading it can answer for a head other than the one a verdict is published
|
|
255
|
+
against — including an A -> B -> A sequence, where a before/after head
|
|
256
|
+
comparison sees no change at all while the commits in between came from B.
|
|
257
|
+
Comparing base...head by SHA asks a question whose answer cannot move.
|
|
258
|
+
|
|
259
|
+
Returns the logins that must have signed, and the commits whose author
|
|
260
|
+
GitHub could not resolve to an account — those block rather than being
|
|
261
|
+
skipped, since no signature can ever be required for a commit nobody is
|
|
262
|
+
named as having written.
|
|
263
|
+
"""
|
|
264
|
+
contributors: set[str] = set()
|
|
265
|
+
unresolved: list[str] = []
|
|
266
|
+
|
|
267
|
+
def add_user(user: dict[str, Any] | None) -> bool:
|
|
268
|
+
"""Record a contributor. False when GitHub could not name one."""
|
|
269
|
+
login = (user or {}).get("login")
|
|
270
|
+
if not login:
|
|
271
|
+
return False
|
|
272
|
+
if login.lower() not in allowlist:
|
|
273
|
+
contributors.add(login)
|
|
274
|
+
return True
|
|
275
|
+
|
|
276
|
+
if not add_user(pr.get("user")):
|
|
277
|
+
unresolved.append("the pull request author")
|
|
278
|
+
|
|
279
|
+
for commit in compare_commits(repo, pr["base"]["sha"], pr["head"]["sha"], token=token):
|
|
280
|
+
if not add_user(commit.get("author")):
|
|
281
|
+
# GitHub returns a null author when a commit's email is not linked
|
|
282
|
+
# to any account. Skipping it would mean nobody has to sign for
|
|
283
|
+
# that commit — the gate would go green having established nothing
|
|
284
|
+
# about whoever wrote it, which is the one outcome it exists to
|
|
285
|
+
# prevent. Unsigned is recoverable; unattributed is not, so it
|
|
286
|
+
# blocks and says which commit to fix.
|
|
287
|
+
unresolved.append(str(commit.get("sha", "an unidentified commit"))[:12])
|
|
288
|
+
|
|
289
|
+
return contributors, unresolved
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def compare_commits(repo: str, base_sha: str, head_sha: str, *, token: str) -> list[Any]:
|
|
293
|
+
"""Every commit `base_sha..head_sha`, paginated and checked for completeness.
|
|
294
|
+
|
|
295
|
+
The endpoint caps `per_page` at 100 and reports the true size separately as
|
|
296
|
+
`total_commits`, so a large pull request needs both the loop and the final
|
|
297
|
+
count check: a short read would silently drop whoever authored the commits
|
|
298
|
+
it never saw, and an unseen contributor reads as a signed one.
|
|
299
|
+
"""
|
|
300
|
+
collected: list[Any] = []
|
|
301
|
+
total: int | None = None
|
|
302
|
+
page = 1
|
|
303
|
+
while True:
|
|
304
|
+
comparison = gh_api(
|
|
305
|
+
f"repos/{repo}/compare/{base_sha}...{head_sha}?per_page=100&page={page}",
|
|
306
|
+
token=token,
|
|
307
|
+
)
|
|
308
|
+
commits = comparison.get("commits")
|
|
309
|
+
if not isinstance(commits, list):
|
|
310
|
+
raise RuntimeError(f"compare {base_sha}...{head_sha} returned no commit list")
|
|
311
|
+
reported = comparison.get("total_commits")
|
|
312
|
+
if not isinstance(reported, int) or isinstance(reported, bool) or reported < 0:
|
|
313
|
+
raise RuntimeError(
|
|
314
|
+
f"compare {base_sha}...{head_sha} page {page} reported no usable "
|
|
315
|
+
"total_commits; cannot prove the contributor list is complete"
|
|
316
|
+
)
|
|
317
|
+
# The comparison is between two fixed SHAs, so its size cannot change
|
|
318
|
+
# while it is being read. A page that disagrees is describing a
|
|
319
|
+
# different comparison than the one that was started, and continuing
|
|
320
|
+
# would either stop short of the real total or never stop at all.
|
|
321
|
+
if total is None:
|
|
322
|
+
total = reported
|
|
323
|
+
elif reported != total:
|
|
324
|
+
raise RuntimeError(
|
|
325
|
+
f"compare {base_sha}...{head_sha} reported {total} commits and then "
|
|
326
|
+
f"{reported} on page {page}; cannot enumerate contributors"
|
|
327
|
+
)
|
|
328
|
+
collected.extend(commits)
|
|
329
|
+
if len(collected) >= total or not commits:
|
|
330
|
+
break
|
|
331
|
+
page += 1
|
|
332
|
+
if len(collected) != total:
|
|
333
|
+
raise RuntimeError(
|
|
334
|
+
f"compare {base_sha}...{head_sha} yielded {len(collected)} of {total} "
|
|
335
|
+
"commits; cannot enumerate contributors"
|
|
336
|
+
)
|
|
337
|
+
return collected
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def cla_bot_comments(repo: str, pr_number: int, token: str) -> list[dict[str, Any]]:
|
|
341
|
+
comments = gh_paginated(f"repos/{repo}/issues/{pr_number}/comments", token=token)
|
|
342
|
+
return [
|
|
343
|
+
comment
|
|
344
|
+
for comment in comments
|
|
345
|
+
if (comment.get("user") or {}).get("login") in CLA_BOT_LOGINS
|
|
346
|
+
]
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def post_comment_once(repo: str, pr_number: int, token: str, body: str, marker: str) -> None:
|
|
350
|
+
if any(marker in (comment.get("body") or "") for comment in cla_bot_comments(repo, pr_number, token)):
|
|
351
|
+
return
|
|
352
|
+
gh_api(
|
|
353
|
+
f"repos/{repo}/issues/{pr_number}/comments",
|
|
354
|
+
method="POST",
|
|
355
|
+
token=token,
|
|
356
|
+
payload={"body": body},
|
|
357
|
+
)
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def delete_stale_missing_comments(repo: str, pr_number: int, token: str) -> None:
|
|
361
|
+
"""Remove the comments that a now-passing check has made obsolete."""
|
|
362
|
+
for comment in cla_bot_comments(repo, pr_number, token):
|
|
363
|
+
body = comment.get("body") or ""
|
|
364
|
+
if MISSING_MARKER in body or UNRESOLVED_MARKER in body:
|
|
365
|
+
gh_api(
|
|
366
|
+
f"repos/{repo}/issues/comments/{comment['id']}",
|
|
367
|
+
method="DELETE",
|
|
368
|
+
token=token,
|
|
369
|
+
)
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def warn_comment_failure(action: str, exc: GitHubApiError) -> None:
|
|
373
|
+
print(
|
|
374
|
+
f"Warning: unable to {action}: {' '.join(exc.command)}",
|
|
375
|
+
file=sys.stderr,
|
|
376
|
+
)
|
|
377
|
+
print(exc.stderr, file=sys.stderr)
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def publish_status(
|
|
381
|
+
repo: str,
|
|
382
|
+
sha: str,
|
|
383
|
+
*,
|
|
384
|
+
state: str,
|
|
385
|
+
description: str,
|
|
386
|
+
context: str,
|
|
387
|
+
target_url: str,
|
|
388
|
+
token: str,
|
|
389
|
+
) -> None:
|
|
390
|
+
"""Set the CLA status on a pull request head.
|
|
391
|
+
|
|
392
|
+
Deliberately NOT wrapped in a warn-and-continue handler: this status is the
|
|
393
|
+
signal branch protection reads, so a failure to publish must fail the run
|
|
394
|
+
rather than report a gate that was never set.
|
|
395
|
+
|
|
396
|
+
KNOWN LIMITATION — a commit status is keyed by (commit, context) for the
|
|
397
|
+
whole repository, not by pull request. Two open pull requests whose heads
|
|
398
|
+
are the same commit therefore share one `cla` status, and the last run to
|
|
399
|
+
finish wins: an unsigned author's pull request can end up green because a
|
|
400
|
+
re-check on the other one re-published success for that SHA. No new code
|
|
401
|
+
reaches the repository this way, since the SHA has to match an already-
|
|
402
|
+
green one, but the signature obligation itself can be evaded. Closing it
|
|
403
|
+
means requiring the UNION of every open pull request at this head, which
|
|
404
|
+
the check-run API would not fix either — it is keyed by commit too.
|
|
405
|
+
"""
|
|
406
|
+
gh_api(
|
|
407
|
+
f"repos/{repo}/statuses/{sha}",
|
|
408
|
+
method="POST",
|
|
409
|
+
token=token,
|
|
410
|
+
payload={
|
|
411
|
+
"state": state,
|
|
412
|
+
"context": context,
|
|
413
|
+
"description": description[:140],
|
|
414
|
+
"target_url": target_url,
|
|
415
|
+
},
|
|
416
|
+
)
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def main() -> int:
|
|
420
|
+
event = load_event()
|
|
421
|
+
repo = env_required("GITHUB_REPOSITORY")
|
|
422
|
+
github_token = env_required("GITHUB_TOKEN")
|
|
423
|
+
write_token = os.environ.get("PERSONAL_ACCESS_TOKEN") or github_token
|
|
424
|
+
comment_token = write_token
|
|
425
|
+
signatures_branch = os.environ.get("SIGNATURES_BRANCH", "cla-signatures")
|
|
426
|
+
signatures_path = os.environ.get("SIGNATURES_PATH", "signatures/v1/cla.json")
|
|
427
|
+
signature_text = os.environ.get(
|
|
428
|
+
"CLA_SIGNATURE_TEXT",
|
|
429
|
+
"I have read the CLA Document and I hereby sign the CLA",
|
|
430
|
+
)
|
|
431
|
+
document_url = os.environ.get(
|
|
432
|
+
"CLA_DOCUMENT_URL",
|
|
433
|
+
f"https://github.com/{repo}/blob/main/CLA.md",
|
|
434
|
+
)
|
|
435
|
+
allowlist = comma_set(os.environ.get("CLA_ALLOWLIST", ""))
|
|
436
|
+
status_context = os.environ.get("CLA_STATUS_CONTEXT", "cla")
|
|
437
|
+
|
|
438
|
+
command = comment_command(event, signature_text)
|
|
439
|
+
if command == "ignore":
|
|
440
|
+
print("Ignoring non-CLA issue comment.")
|
|
441
|
+
return 0
|
|
442
|
+
|
|
443
|
+
pr_number = pr_number_from_event(event)
|
|
444
|
+
if pr_number is None:
|
|
445
|
+
print("Ignoring event without a pull request.")
|
|
446
|
+
return 0
|
|
447
|
+
|
|
448
|
+
pr = gh_api(f"repos/{repo}/pulls/{pr_number}", token=github_token)
|
|
449
|
+
repo_id = int(event["repository"]["id"])
|
|
450
|
+
signatures, _sha = load_signatures(repo, signatures_branch, signatures_path, write_token)
|
|
451
|
+
|
|
452
|
+
if command == "sign":
|
|
453
|
+
comment = event["comment"]
|
|
454
|
+
user = comment["user"]
|
|
455
|
+
signatures, changed = record_signature_with_retry(
|
|
456
|
+
repo=repo,
|
|
457
|
+
branch=signatures_branch,
|
|
458
|
+
path=signatures_path,
|
|
459
|
+
token=write_token,
|
|
460
|
+
login=user["login"],
|
|
461
|
+
user_id=int(user["id"]),
|
|
462
|
+
comment_id=int(comment["id"]),
|
|
463
|
+
created_at=comment["created_at"],
|
|
464
|
+
repo_id=repo_id,
|
|
465
|
+
pull_request_no=pr_number,
|
|
466
|
+
)
|
|
467
|
+
if changed:
|
|
468
|
+
print(f"Recorded CLA signature for {user['login']}.")
|
|
469
|
+
else:
|
|
470
|
+
print(f"CLA signature for {user['login']} is already recorded.")
|
|
471
|
+
|
|
472
|
+
# `pr_contributors` enumerates by immutable SHA, so the verdict below is
|
|
473
|
+
# about `head_sha` whatever the branch does meanwhile — that is what makes
|
|
474
|
+
# the status correct rather than merely current. The re-read is a courtesy
|
|
475
|
+
# on top: if the head has already moved on, the push that moved it starts
|
|
476
|
+
# its own `pull_request_target` run which evaluates the new head from
|
|
477
|
+
# scratch, so publishing here would only race that run with a stale answer.
|
|
478
|
+
head_sha = pr["head"]["sha"]
|
|
479
|
+
contributors, unresolved = pr_contributors(repo, pr, allowlist, github_token)
|
|
480
|
+
current = gh_api(f"repos/{repo}/pulls/{pr_number}", token=github_token)
|
|
481
|
+
if current["head"]["sha"] != head_sha:
|
|
482
|
+
print(
|
|
483
|
+
f"Pull request head moved from {head_sha} to {current['head']['sha']} "
|
|
484
|
+
"during the check; leaving the verdict to that push's own run."
|
|
485
|
+
)
|
|
486
|
+
return 0
|
|
487
|
+
if unresolved:
|
|
488
|
+
where = ", ".join(f"`{item}`" for item in unresolved)
|
|
489
|
+
try:
|
|
490
|
+
post_comment_once(
|
|
491
|
+
repo,
|
|
492
|
+
pr_number,
|
|
493
|
+
comment_token,
|
|
494
|
+
(
|
|
495
|
+
"This pull request contains work GitHub cannot attribute to an "
|
|
496
|
+
"account, so the CLA check cannot tell whose signature to "
|
|
497
|
+
f"require.\n\nUnattributed: {where}\n\nThis usually means a "
|
|
498
|
+
"commit was authored with an email address that is not added to "
|
|
499
|
+
"any GitHub account. Adding that address to your account — or "
|
|
500
|
+
"re-committing with one already on it — resolves this."
|
|
501
|
+
),
|
|
502
|
+
UNRESOLVED_MARKER,
|
|
503
|
+
)
|
|
504
|
+
except GitHubApiError as exc:
|
|
505
|
+
warn_comment_failure("post unattributed-commit CLA comment", exc)
|
|
506
|
+
publish_status(
|
|
507
|
+
repo,
|
|
508
|
+
head_sha,
|
|
509
|
+
state="failure",
|
|
510
|
+
description=f"Unattributed commits: {', '.join(unresolved)}",
|
|
511
|
+
context=status_context,
|
|
512
|
+
target_url=document_url,
|
|
513
|
+
token=write_token,
|
|
514
|
+
)
|
|
515
|
+
print(f"Unattributed commit(s): {', '.join(unresolved)}")
|
|
516
|
+
return 1
|
|
517
|
+
|
|
518
|
+
# Re-read the signature store immediately before deciding. The first read
|
|
519
|
+
# happens before enumeration, and a signing run for this same head can land
|
|
520
|
+
# in between — publishing failure from that stale snapshot would paint a
|
|
521
|
+
# signed pull request red with no push to correct it, since the signing
|
|
522
|
+
# run has already finished and will not run again.
|
|
523
|
+
try:
|
|
524
|
+
signatures, _ = load_signatures(repo, signatures_branch, signatures_path, write_token)
|
|
525
|
+
except GitHubApiError as exc:
|
|
526
|
+
warn_comment_failure("re-read CLA signatures before publishing", exc)
|
|
527
|
+
signed = signed_logins(signatures)
|
|
528
|
+
missing = sorted(login for login in contributors if login.lower() not in signed)
|
|
529
|
+
|
|
530
|
+
if missing:
|
|
531
|
+
names = ", ".join(f"`{name}`" for name in missing)
|
|
532
|
+
try:
|
|
533
|
+
post_comment_once(
|
|
534
|
+
repo,
|
|
535
|
+
pr_number,
|
|
536
|
+
comment_token,
|
|
537
|
+
(
|
|
538
|
+
f"Thank you for your contribution! Before we can merge, please sign "
|
|
539
|
+
f"the [Contributor License Agreement]({document_url}).\n\n"
|
|
540
|
+
f"Missing signature(s): {names}\n\n"
|
|
541
|
+
f"To sign, comment on this pull request with the statement below. "
|
|
542
|
+
f"You only need to sign once per GitHub account.\n\n"
|
|
543
|
+
f"> {signature_text}"
|
|
544
|
+
),
|
|
545
|
+
MISSING_MARKER,
|
|
546
|
+
)
|
|
547
|
+
except GitHubApiError as exc:
|
|
548
|
+
warn_comment_failure("post missing-signature CLA comment", exc)
|
|
549
|
+
publish_status(
|
|
550
|
+
repo,
|
|
551
|
+
head_sha,
|
|
552
|
+
state="failure",
|
|
553
|
+
description=f"Unsigned: {', '.join(missing)}",
|
|
554
|
+
context=status_context,
|
|
555
|
+
target_url=document_url,
|
|
556
|
+
token=write_token,
|
|
557
|
+
)
|
|
558
|
+
print(f"Missing CLA signature(s): {', '.join(missing)}")
|
|
559
|
+
return 1
|
|
560
|
+
|
|
561
|
+
try:
|
|
562
|
+
delete_stale_missing_comments(repo, pr_number, comment_token)
|
|
563
|
+
post_comment_once(
|
|
564
|
+
repo,
|
|
565
|
+
pr_number,
|
|
566
|
+
comment_token,
|
|
567
|
+
SIGNED_MARKER,
|
|
568
|
+
SIGNED_MARKER,
|
|
569
|
+
)
|
|
570
|
+
except GitHubApiError as exc:
|
|
571
|
+
warn_comment_failure("update CLA status comments", exc)
|
|
572
|
+
publish_status(
|
|
573
|
+
repo,
|
|
574
|
+
head_sha,
|
|
575
|
+
state="success",
|
|
576
|
+
description=SIGNED_MARKER,
|
|
577
|
+
context=status_context,
|
|
578
|
+
target_url=document_url,
|
|
579
|
+
token=write_token,
|
|
580
|
+
)
|
|
581
|
+
print(SIGNED_MARKER)
|
|
582
|
+
return 0
|
|
583
|
+
|
|
584
|
+
|
|
585
|
+
if __name__ == "__main__":
|
|
586
|
+
try:
|
|
587
|
+
raise SystemExit(main())
|
|
588
|
+
except GitHubApiError as exc:
|
|
589
|
+
print(f"GitHub API call failed: {' '.join(exc.command)}", file=sys.stderr)
|
|
590
|
+
print(exc.stderr, file=sys.stderr)
|
|
591
|
+
raise SystemExit(2)
|
|
592
|
+
except Exception as exc:
|
|
593
|
+
print(f"CLA check failed: {exc}", file=sys.stderr)
|
|
594
|
+
raise SystemExit(2)
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
pull_request:
|
|
7
|
+
|
|
8
|
+
permissions:
|
|
9
|
+
contents: read
|
|
10
|
+
|
|
11
|
+
# Pin the uv toolchain so CI and the release build resolve identically.
|
|
12
|
+
# Keep in lockstep with the same pin in release.yml (Dependabot cannot bump
|
|
13
|
+
# a `with:` input).
|
|
14
|
+
env:
|
|
15
|
+
UV_VERSION: "0.11.16"
|
|
16
|
+
|
|
17
|
+
jobs:
|
|
18
|
+
lint:
|
|
19
|
+
runs-on: ubuntu-latest
|
|
20
|
+
steps:
|
|
21
|
+
- uses: actions/checkout@v4
|
|
22
|
+
- uses: astral-sh/setup-uv@v6
|
|
23
|
+
with:
|
|
24
|
+
version: ${{ env.UV_VERSION }}
|
|
25
|
+
python-version: "3.12"
|
|
26
|
+
enable-cache: true
|
|
27
|
+
- run: uv sync --locked --group dev
|
|
28
|
+
- run: uv run ruff check .
|
|
29
|
+
|
|
30
|
+
test:
|
|
31
|
+
runs-on: ubuntu-latest
|
|
32
|
+
strategy:
|
|
33
|
+
fail-fast: false
|
|
34
|
+
matrix:
|
|
35
|
+
python-version: ["3.12", "3.13", "3.14"]
|
|
36
|
+
steps:
|
|
37
|
+
- uses: actions/checkout@v4
|
|
38
|
+
- uses: astral-sh/setup-uv@v6
|
|
39
|
+
with:
|
|
40
|
+
version: ${{ env.UV_VERSION }}
|
|
41
|
+
python-version: ${{ matrix.python-version }}
|
|
42
|
+
enable-cache: true
|
|
43
|
+
- run: uv sync --locked --group dev
|
|
44
|
+
- run: uv run pytest -q
|
|
45
|
+
|
|
46
|
+
posix-process:
|
|
47
|
+
runs-on: ubuntu-latest
|
|
48
|
+
steps:
|
|
49
|
+
- uses: actions/checkout@v4
|
|
50
|
+
- uses: astral-sh/setup-uv@v6
|
|
51
|
+
with:
|
|
52
|
+
version: ${{ env.UV_VERSION }}
|
|
53
|
+
python-version: "3.12"
|
|
54
|
+
enable-cache: true
|
|
55
|
+
- run: sudo apt-get update
|
|
56
|
+
- run: sudo apt-get install -y tmux
|
|
57
|
+
- run: uv sync --locked --group dev
|
|
58
|
+
- run: SYNCMILL_TEST_TUI_PROCESS=1 SYNCMILL_TEST_TUI_PTY=1 SYNCMILL_TEST_TMUX=1 uv run pytest -q tests/test_tui_jobs.py tests/test_tui_pty.py tests/test_isolated_session_tmux.py
|
|
59
|
+
|
|
60
|
+
# Real tmux (incl. the PTY attach/detach state machine) on both Linux and
|
|
61
|
+
# macOS — the room viewer's tmux surface cannot be covered hermetically.
|
|
62
|
+
tmux:
|
|
63
|
+
strategy:
|
|
64
|
+
fail-fast: false
|
|
65
|
+
matrix:
|
|
66
|
+
os: [ubuntu-latest, macos-latest]
|
|
67
|
+
runs-on: ${{ matrix.os }}
|
|
68
|
+
steps:
|
|
69
|
+
- uses: actions/checkout@v4
|
|
70
|
+
- uses: astral-sh/setup-uv@v6
|
|
71
|
+
with:
|
|
72
|
+
version: ${{ env.UV_VERSION }}
|
|
73
|
+
python-version: "3.12"
|
|
74
|
+
enable-cache: true
|
|
75
|
+
- if: runner.os == 'Linux'
|
|
76
|
+
run: sudo apt-get update && sudo apt-get install -y tmux
|
|
77
|
+
- if: runner.os == 'macOS'
|
|
78
|
+
run: brew install tmux
|
|
79
|
+
- run: uv sync --locked --group dev
|
|
80
|
+
- run: SYNCMILL_TEST_ROOM_VIEWER_TMUX=1 uv run pytest -q tests/test_room_viewer.py
|
|
81
|
+
|
|
82
|
+
memory-backend-real:
|
|
83
|
+
strategy:
|
|
84
|
+
fail-fast: false
|
|
85
|
+
matrix:
|
|
86
|
+
os: [ubuntu-latest, macos-latest]
|
|
87
|
+
runs-on: ${{ matrix.os }}
|
|
88
|
+
steps:
|
|
89
|
+
- uses: actions/checkout@v4
|
|
90
|
+
- uses: astral-sh/setup-uv@v6
|
|
91
|
+
with:
|
|
92
|
+
version: ${{ env.UV_VERSION }}
|
|
93
|
+
python-version: "3.12"
|
|
94
|
+
enable-cache: true
|
|
95
|
+
# Runner macOS system Python may omit SQLite extension loading.
|
|
96
|
+
- run: uv sync --locked --group dev --managed-python
|
|
97
|
+
- name: Verify real SQLite extension loading
|
|
98
|
+
run: uv run python -c "import sqlite3, sqlite_vec; db = sqlite3.connect(':memory:'); db.enable_load_extension(True); sqlite_vec.load(db); db.close()"
|
|
99
|
+
# Temporary stores, real writer/watcher/locks; no provider calls.
|
|
100
|
+
- run: SYNCMILL_TEST_MEMTOMEM_REAL=1 uv run pytest -q tests/test_memory_backend_memtomem_integration.py tests/test_memory_gc_process.py
|