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.
Files changed (249) hide show
  1. syncmill-0.2.0/.github/cla-check.py +594 -0
  2. syncmill-0.2.0/.github/workflows/ci.yml +100 -0
  3. syncmill-0.2.0/.github/workflows/cla.yml +64 -0
  4. syncmill-0.2.0/.github/workflows/release.yml +185 -0
  5. syncmill-0.2.0/.gitignore +26 -0
  6. syncmill-0.2.0/.python-version +1 -0
  7. syncmill-0.2.0/CHANGELOG.md +1392 -0
  8. syncmill-0.2.0/CLA.md +135 -0
  9. syncmill-0.2.0/CLAUDE.md +382 -0
  10. syncmill-0.2.0/CONTRIBUTING.md +113 -0
  11. syncmill-0.2.0/LICENSE +191 -0
  12. syncmill-0.2.0/PKG-INFO +853 -0
  13. syncmill-0.2.0/README.md +816 -0
  14. syncmill-0.2.0/contracts/fixtures/envelope-additive.json +29 -0
  15. syncmill-0.2.0/contracts/fixtures/envelope-board.json +30 -0
  16. syncmill-0.2.0/contracts/fixtures/envelope-direct.json +27 -0
  17. syncmill-0.2.0/contracts/fixtures/envelope-traced.json +48 -0
  18. syncmill-0.2.0/contracts/fixtures/envelope-v2.json +21 -0
  19. syncmill-0.2.0/contracts/fixtures/preflight-request.json +7 -0
  20. syncmill-0.2.0/contracts/preflight-request.schema.json +19 -0
  21. syncmill-0.2.0/contracts/run-envelope.schema.json +154 -0
  22. syncmill-0.2.0/contracts/strict-preflight-manifest.schema.json +27 -0
  23. syncmill-0.2.0/docs/a2a-decision-memo.md +178 -0
  24. syncmill-0.2.0/docs/adr/README.md +14 -0
  25. syncmill-0.2.0/docs/adr/adr-0005-evidence-first-review.ko.md +69 -0
  26. syncmill-0.2.0/docs/adr/adr-0005-evidence-first-review.md +79 -0
  27. syncmill-0.2.0/docs/architecture/README.md +29 -0
  28. syncmill-0.2.0/docs/architecture/artifact-boundary.md +28 -0
  29. syncmill-0.2.0/docs/architecture/board.md +23 -0
  30. syncmill-0.2.0/docs/architecture/config.md +6 -0
  31. syncmill-0.2.0/docs/architecture/dashboard.md +14 -0
  32. syncmill-0.2.0/docs/architecture/extension-points.md +20 -0
  33. syncmill-0.2.0/docs/architecture/gateway.md +12 -0
  34. syncmill-0.2.0/docs/architecture/interactive-collaboration.md +36 -0
  35. syncmill-0.2.0/docs/architecture/memory.md +10 -0
  36. syncmill-0.2.0/docs/architecture/monitor.md +12 -0
  37. syncmill-0.2.0/docs/architecture/preflight.md +26 -0
  38. syncmill-0.2.0/docs/architecture/report.md +6 -0
  39. syncmill-0.2.0/docs/architecture/session.md +16 -0
  40. syncmill-0.2.0/docs/architecture/teamfile.md +11 -0
  41. syncmill-0.2.0/docs/architecture/teams.md +11 -0
  42. syncmill-0.2.0/docs/architecture/tracer.md +11 -0
  43. syncmill-0.2.0/docs/architecture/tui.md +104 -0
  44. syncmill-0.2.0/docs/architecture/worktrees.md +11 -0
  45. syncmill-0.2.0/docs/archive/multi-agent-cli-landscape-research.md +137 -0
  46. syncmill-0.2.0/docs/archive/research-summary.md +9 -0
  47. syncmill-0.2.0/docs/archive/syncmill-review.md +237 -0
  48. syncmill-0.2.0/docs/assets/tui/01-board.svg +221 -0
  49. syncmill-0.2.0/docs/assets/tui/02-runs-operations.svg +222 -0
  50. syncmill-0.2.0/docs/assets/tui/03-runs-artifacts-summary.svg +219 -0
  51. syncmill-0.2.0/docs/assets/tui/04-runs-artifacts-diff.svg +219 -0
  52. syncmill-0.2.0/docs/assets/tui/05-reports.svg +219 -0
  53. syncmill-0.2.0/docs/assets/tui/06-teams.svg +219 -0
  54. syncmill-0.2.0/docs/assets/tui/07-status.svg +217 -0
  55. syncmill-0.2.0/docs/assets/tui/08-sessions.svg +219 -0
  56. syncmill-0.2.0/docs/assets/tui/09-rooms-diff.svg +219 -0
  57. syncmill-0.2.0/docs/assets/tui/10-rooms-output.svg +219 -0
  58. syncmill-0.2.0/docs/assets/tui/11-rooms-details.svg +220 -0
  59. syncmill-0.2.0/docs/assets/tui/12-rooms-compare.svg +220 -0
  60. syncmill-0.2.0/docs/difficulty-model-routing-plan.md +861 -0
  61. syncmill-0.2.0/docs/ecosystem/adr-0001-strict-preflight-enforcement.md +32 -0
  62. syncmill-0.2.0/docs/ecosystem/adr-0002-gate-e-opt-in-approval.md +55 -0
  63. syncmill-0.2.0/docs/ecosystem/adr-0003-control-plan-preflight.md +43 -0
  64. syncmill-0.2.0/docs/ecosystem/adr-0004-millflow-control-evidence.md +60 -0
  65. syncmill-0.2.0/docs/ecosystem/gate-e-p4-operating-evidence.md +101 -0
  66. syncmill-0.2.0/docs/ecosystem/gate-e-p4-runbook.md +88 -0
  67. syncmill-0.2.0/docs/ecosystem/implementation-design.md +561 -0
  68. syncmill-0.2.0/docs/ecosystem/integration-plan.md +480 -0
  69. syncmill-0.2.0/docs/ecosystem/smoke-runbook.md +76 -0
  70. syncmill-0.2.0/docs/ecosystem/strict-p3.1-release-note.md +22 -0
  71. syncmill-0.2.0/docs/ecosystem-integration-plan.md +158 -0
  72. syncmill-0.2.0/docs/implementation-review-2026-09-07.ko.md +483 -0
  73. syncmill-0.2.0/docs/improvement-roadmap-2026-09-08.ko.md +77 -0
  74. syncmill-0.2.0/docs/memory-backend-design.md +703 -0
  75. syncmill-0.2.0/docs/notebooks/00-introduction.ipynb +254 -0
  76. syncmill-0.2.0/docs/notebooks/01-first-run.ipynb +2398 -0
  77. syncmill-0.2.0/docs/notebooks/02-strategies.ipynb +1240 -0
  78. syncmill-0.2.0/docs/notebooks/03-shared-memory.ipynb +820 -0
  79. syncmill-0.2.0/docs/notebooks/04-board-and-tui.ipynb +1140 -0
  80. syncmill-0.2.0/docs/notebooks/README.md +46 -0
  81. syncmill-0.2.0/docs/notebooks/_sandbox.py +503 -0
  82. syncmill-0.2.0/docs/review-backlog-2026-08.md +118 -0
  83. syncmill-0.2.0/docs/review-evidence/2026-10-16/issue-166/README.md +39 -0
  84. syncmill-0.2.0/docs/review-evidence/2026-10-16/issue-166/lint.log +2 -0
  85. syncmill-0.2.0/docs/review-evidence/2026-10-16/issue-166/review-fix-1/README.md +40 -0
  86. syncmill-0.2.0/docs/review-evidence/2026-10-16/issue-166/review-fix-1/lint.log +2 -0
  87. syncmill-0.2.0/docs/review-evidence/2026-10-16/issue-166/review-fix-1/tests.log +22 -0
  88. syncmill-0.2.0/docs/review-evidence/2026-10-16/issue-166/review-fix-1/validation.json +54 -0
  89. syncmill-0.2.0/docs/review-evidence/2026-10-16/issue-166/review-fix-2/README.md +27 -0
  90. syncmill-0.2.0/docs/review-evidence/2026-10-16/issue-166/review-fix-2/lint.log +2 -0
  91. syncmill-0.2.0/docs/review-evidence/2026-10-16/issue-166/review-fix-2/tests.log +8 -0
  92. syncmill-0.2.0/docs/review-evidence/2026-10-16/issue-166/review-fix-2/validation.json +67 -0
  93. syncmill-0.2.0/docs/review-evidence/2026-10-16/issue-166/review-fix-2/validation.reviewed.json +56 -0
  94. syncmill-0.2.0/docs/review-evidence/2026-10-16/issue-166/tests.log +22 -0
  95. syncmill-0.2.0/docs/review-evidence/2026-10-16/issue-166/validation.json +45 -0
  96. syncmill-0.2.0/docs/tmp-test-guide.ko.md +340 -0
  97. syncmill-0.2.0/docs/tui-dashboard-run-plan.md +105 -0
  98. syncmill-0.2.0/docs/tui-guide.ko.md +319 -0
  99. syncmill-0.2.0/docs/user-guide.ko.md +1504 -0
  100. syncmill-0.2.0/docs/user-guide.md +1743 -0
  101. syncmill-0.2.0/examples/explicit_retry_canary.py +164 -0
  102. syncmill-0.2.0/examples/strategy_selfbench/README.md +135 -0
  103. syncmill-0.2.0/examples/strategy_selfbench/run_selfbench.py +1046 -0
  104. syncmill-0.2.0/examples/strategy_selfbench/tasks.jsonl +8 -0
  105. syncmill-0.2.0/pyproject.toml +83 -0
  106. syncmill-0.2.0/scripts/release_preflight.py +562 -0
  107. syncmill-0.2.0/scripts/render_tui_guide.py +656 -0
  108. syncmill-0.2.0/src/syncmill/__init__.py +11 -0
  109. syncmill-0.2.0/src/syncmill/agents.py +2064 -0
  110. syncmill-0.2.0/src/syncmill/artifact_io.py +201 -0
  111. syncmill-0.2.0/src/syncmill/artifacts.py +1946 -0
  112. syncmill-0.2.0/src/syncmill/board.py +2060 -0
  113. syncmill-0.2.0/src/syncmill/breaker.py +327 -0
  114. syncmill-0.2.0/src/syncmill/cli.py +4986 -0
  115. syncmill-0.2.0/src/syncmill/command_errors.py +32 -0
  116. syncmill-0.2.0/src/syncmill/config.py +1118 -0
  117. syncmill-0.2.0/src/syncmill/dashboard.py +935 -0
  118. syncmill-0.2.0/src/syncmill/gates.py +179 -0
  119. syncmill-0.2.0/src/syncmill/gateway.py +234 -0
  120. syncmill-0.2.0/src/syncmill/git_env.py +99 -0
  121. syncmill-0.2.0/src/syncmill/isolated_session.py +431 -0
  122. syncmill-0.2.0/src/syncmill/matcher.py +127 -0
  123. syncmill-0.2.0/src/syncmill/memory.py +1067 -0
  124. syncmill-0.2.0/src/syncmill/memory_backend.py +1907 -0
  125. syncmill-0.2.0/src/syncmill/memory_migration.py +962 -0
  126. syncmill-0.2.0/src/syncmill/model_routing.py +482 -0
  127. syncmill-0.2.0/src/syncmill/models.py +244 -0
  128. syncmill-0.2.0/src/syncmill/monitor.py +775 -0
  129. syncmill-0.2.0/src/syncmill/phoenix_exporter.py +339 -0
  130. syncmill-0.2.0/src/syncmill/planning.py +512 -0
  131. syncmill-0.2.0/src/syncmill/platform_support.py +14 -0
  132. syncmill-0.2.0/src/syncmill/preflight.py +1129 -0
  133. syncmill-0.2.0/src/syncmill/project.py +1067 -0
  134. syncmill-0.2.0/src/syncmill/report.py +502 -0
  135. syncmill-0.2.0/src/syncmill/review_candidates.py +107 -0
  136. syncmill-0.2.0/src/syncmill/room_compare.py +2380 -0
  137. syncmill-0.2.0/src/syncmill/room_raw.py +484 -0
  138. syncmill-0.2.0/src/syncmill/room_viewer.py +604 -0
  139. syncmill-0.2.0/src/syncmill/rooms.py +4044 -0
  140. syncmill-0.2.0/src/syncmill/session.py +444 -0
  141. syncmill-0.2.0/src/syncmill/strategies/__init__.py +82 -0
  142. syncmill-0.2.0/src/syncmill/strategies/base.py +910 -0
  143. syncmill-0.2.0/src/syncmill/strategies/compete.py +233 -0
  144. syncmill-0.2.0/src/syncmill/strategies/council.py +429 -0
  145. syncmill-0.2.0/src/syncmill/strategies/decompose.py +570 -0
  146. syncmill-0.2.0/src/syncmill/strategies/pipeline.py +221 -0
  147. syncmill-0.2.0/src/syncmill/strategies/route.py +130 -0
  148. syncmill-0.2.0/src/syncmill/supervisor.py +1423 -0
  149. syncmill-0.2.0/src/syncmill/teamfile.py +571 -0
  150. syncmill-0.2.0/src/syncmill/telemetry_hook.py +231 -0
  151. syncmill-0.2.0/src/syncmill/terminal.py +245 -0
  152. syncmill-0.2.0/src/syncmill/tracer.py +548 -0
  153. syncmill-0.2.0/src/syncmill/tui.py +3537 -0
  154. syncmill-0.2.0/src/syncmill/tui_collaboration.py +499 -0
  155. syncmill-0.2.0/src/syncmill/tui_jobs.py +1407 -0
  156. syncmill-0.2.0/src/syncmill/tui_render.py +156 -0
  157. syncmill-0.2.0/src/syncmill/workflow.py +3746 -0
  158. syncmill-0.2.0/src/syncmill/worktree.py +1045 -0
  159. syncmill-0.2.0/syncmill.toml.example +333 -0
  160. syncmill-0.2.0/tests/conftest.py +946 -0
  161. syncmill-0.2.0/tests/fixtures/claude/array-error-event.json +15 -0
  162. syncmill-0.2.0/tests/fixtures/claude/array-rate-limit.json +18 -0
  163. syncmill-0.2.0/tests/fixtures/claude/array-success.json +29 -0
  164. syncmill-0.2.0/tests/fixtures/room_checkpoint/pre_188.json +13 -0
  165. syncmill-0.2.0/tests/fixtures/room_compare/pre_pr3.json +241 -0
  166. syncmill-0.2.0/tests/fixtures/toolgraph/README.md +19 -0
  167. syncmill-0.2.0/tests/fixtures/toolgraph/control-plan-v1.json +81 -0
  168. syncmill-0.2.0/tests/fixtures/toolgraph/control-plan.schema.json +121 -0
  169. syncmill-0.2.0/tests/fixtures/toolgraph/control-preflight-v1.json +29 -0
  170. syncmill-0.2.0/tests/fixtures/toolgraph/control-preflight.schema.json +75 -0
  171. syncmill-0.2.0/tests/fixtures/toolgraph/preflight-additive.json +17 -0
  172. syncmill-0.2.0/tests/fixtures/toolgraph/preflight-allow.json +15 -0
  173. syncmill-0.2.0/tests/fixtures/toolgraph/preflight-drift.json +15 -0
  174. syncmill-0.2.0/tests/fixtures/toolgraph/preflight-unknown-tool.json +15 -0
  175. syncmill-0.2.0/tests/fixtures/toolgraph/preflight-unresolved-identity.json +15 -0
  176. syncmill-0.2.0/tests/fixtures/toolgraph/preflight-v2.json +15 -0
  177. syncmill-0.2.0/tests/fixtures/toolgraph/preflight-warn.json +35 -0
  178. syncmill-0.2.0/tests/fixtures/toolgraph/preflight.schema.json +129 -0
  179. syncmill-0.2.0/tests/fixtures/tracegraph/README.md +14 -0
  180. syncmill-0.2.0/tests/fixtures/tracegraph/review-candidates.schema.json +53 -0
  181. syncmill-0.2.0/tests/fixtures/tracegraph/v1.json +13 -0
  182. syncmill-0.2.0/tests/memory_gc_worker.py +111 -0
  183. syncmill-0.2.0/tests/test_agents.py +2460 -0
  184. syncmill-0.2.0/tests/test_artifact_boundary.py +795 -0
  185. syncmill-0.2.0/tests/test_board.py +2153 -0
  186. syncmill-0.2.0/tests/test_breaker.py +430 -0
  187. syncmill-0.2.0/tests/test_cla_check.py +708 -0
  188. syncmill-0.2.0/tests/test_cli.py +5868 -0
  189. syncmill-0.2.0/tests/test_config.py +1122 -0
  190. syncmill-0.2.0/tests/test_console_logging.py +389 -0
  191. syncmill-0.2.0/tests/test_contracts.py +203 -0
  192. syncmill-0.2.0/tests/test_control_plan.py +467 -0
  193. syncmill-0.2.0/tests/test_dashboard.py +767 -0
  194. syncmill-0.2.0/tests/test_display_escaping.py +1015 -0
  195. syncmill-0.2.0/tests/test_docs_freshness.py +417 -0
  196. syncmill-0.2.0/tests/test_gates.py +218 -0
  197. syncmill-0.2.0/tests/test_gateway.py +411 -0
  198. syncmill-0.2.0/tests/test_implementation_hardening.py +291 -0
  199. syncmill-0.2.0/tests/test_isolated_session.py +529 -0
  200. syncmill-0.2.0/tests/test_isolated_session_tmux.py +151 -0
  201. syncmill-0.2.0/tests/test_matcher.py +167 -0
  202. syncmill-0.2.0/tests/test_memory.py +1429 -0
  203. syncmill-0.2.0/tests/test_memory_backend.py +2208 -0
  204. syncmill-0.2.0/tests/test_memory_backend_langgraph_integration.py +50 -0
  205. syncmill-0.2.0/tests/test_memory_backend_mem0_integration.py +49 -0
  206. syncmill-0.2.0/tests/test_memory_backend_memtomem_integration.py +807 -0
  207. syncmill-0.2.0/tests/test_memory_gc_process.py +239 -0
  208. syncmill-0.2.0/tests/test_memory_migration.py +997 -0
  209. syncmill-0.2.0/tests/test_memory_migration_integration.py +101 -0
  210. syncmill-0.2.0/tests/test_model_routing.py +628 -0
  211. syncmill-0.2.0/tests/test_models.py +73 -0
  212. syncmill-0.2.0/tests/test_monitor.py +895 -0
  213. syncmill-0.2.0/tests/test_notebooks.py +447 -0
  214. syncmill-0.2.0/tests/test_phoenix_exporter.py +179 -0
  215. syncmill-0.2.0/tests/test_phoenix_exporter_integration.py +116 -0
  216. syncmill-0.2.0/tests/test_preflight.py +589 -0
  217. syncmill-0.2.0/tests/test_project.py +1340 -0
  218. syncmill-0.2.0/tests/test_release_preflight.py +606 -0
  219. syncmill-0.2.0/tests/test_report.py +599 -0
  220. syncmill-0.2.0/tests/test_retry_causality.py +183 -0
  221. syncmill-0.2.0/tests/test_review_candidates.py +104 -0
  222. syncmill-0.2.0/tests/test_room_compare.py +2102 -0
  223. syncmill-0.2.0/tests/test_room_raw.py +780 -0
  224. syncmill-0.2.0/tests/test_room_viewer.py +1113 -0
  225. syncmill-0.2.0/tests/test_rooms.py +3288 -0
  226. syncmill-0.2.0/tests/test_round_conversation.py +610 -0
  227. syncmill-0.2.0/tests/test_round_provenance.py +364 -0
  228. syncmill-0.2.0/tests/test_session.py +495 -0
  229. syncmill-0.2.0/tests/test_strategy_selfbench_example.py +2226 -0
  230. syncmill-0.2.0/tests/test_supervisor.py +6367 -0
  231. syncmill-0.2.0/tests/test_teamfile.py +773 -0
  232. syncmill-0.2.0/tests/test_telemetry_hook.py +157 -0
  233. syncmill-0.2.0/tests/test_terminal.py +466 -0
  234. syncmill-0.2.0/tests/test_terminal_json.py +84 -0
  235. syncmill-0.2.0/tests/test_tmp_guide.py +120 -0
  236. syncmill-0.2.0/tests/test_tracer.py +459 -0
  237. syncmill-0.2.0/tests/test_tui.py +5026 -0
  238. syncmill-0.2.0/tests/test_tui_guide.py +266 -0
  239. syncmill-0.2.0/tests/test_tui_jobs.py +1506 -0
  240. syncmill-0.2.0/tests/test_tui_live_codex.py +246 -0
  241. syncmill-0.2.0/tests/test_tui_pty.py +248 -0
  242. syncmill-0.2.0/tests/test_tui_render.py +578 -0
  243. syncmill-0.2.0/tests/test_v2_workflow.py +2117 -0
  244. syncmill-0.2.0/tests/test_version.py +73 -0
  245. syncmill-0.2.0/tests/test_workflow.py +3402 -0
  246. syncmill-0.2.0/tests/test_workflow_connections.py +84 -0
  247. syncmill-0.2.0/tests/test_workflow_durable.py +720 -0
  248. syncmill-0.2.0/tests/test_worktree.py +869 -0
  249. 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