cleaner-crew 0.1.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 (49) hide show
  1. cleaner_crew-0.1.0/.github/workflows/python-publish.yml +70 -0
  2. cleaner_crew-0.1.0/.gitignore +4 -0
  3. cleaner_crew-0.1.0/PKG-INFO +148 -0
  4. cleaner_crew-0.1.0/README.md +137 -0
  5. cleaner_crew-0.1.0/pyproject.toml +28 -0
  6. cleaner_crew-0.1.0/src/cleaner_crew/__init__.py +4 -0
  7. cleaner_crew-0.1.0/src/cleaner_crew/__main__.py +3 -0
  8. cleaner_crew-0.1.0/src/cleaner_crew/adapters/__init__.py +32 -0
  9. cleaner_crew-0.1.0/src/cleaner_crew/adapters/base.py +109 -0
  10. cleaner_crew-0.1.0/src/cleaner_crew/adapters/github.py +97 -0
  11. cleaner_crew-0.1.0/src/cleaner_crew/adapters/gitlab.py +77 -0
  12. cleaner_crew-0.1.0/src/cleaner_crew/adapters/jira.py +140 -0
  13. cleaner_crew-0.1.0/src/cleaner_crew/adapters/linear.py +147 -0
  14. cleaner_crew-0.1.0/src/cleaner_crew/claude.py +130 -0
  15. cleaner_crew-0.1.0/src/cleaner_crew/cli.py +166 -0
  16. cleaner_crew-0.1.0/src/cleaner_crew/config.py +124 -0
  17. cleaner_crew-0.1.0/src/cleaner_crew/detect.py +72 -0
  18. cleaner_crew-0.1.0/src/cleaner_crew/gitutil.py +114 -0
  19. cleaner_crew-0.1.0/src/cleaner_crew/hooks/__init__.py +0 -0
  20. cleaner_crew-0.1.0/src/cleaner_crew/hooks/guard.py +111 -0
  21. cleaner_crew-0.1.0/src/cleaner_crew/installer.py +320 -0
  22. cleaner_crew-0.1.0/src/cleaner_crew/models.py +145 -0
  23. cleaner_crew-0.1.0/src/cleaner_crew/mutation.py +150 -0
  24. cleaner_crew-0.1.0/src/cleaner_crew/orchestrator.py +362 -0
  25. cleaner_crew-0.1.0/src/cleaner_crew/policy.py +268 -0
  26. cleaner_crew-0.1.0/src/cleaner_crew/schemas.py +60 -0
  27. cleaner_crew-0.1.0/src/cleaner_crew/templates/__init__.py +0 -0
  28. cleaner_crew-0.1.0/src/cleaner_crew/templates/agents/__init__.py +0 -0
  29. cleaner_crew-0.1.0/src/cleaner_crew/templates/agents/hooded.md +24 -0
  30. cleaner_crew-0.1.0/src/cleaner_crew/templates/agents/inspector.md +23 -0
  31. cleaner_crew-0.1.0/src/cleaner_crew/templates/agents/janitor.md +23 -0
  32. cleaner_crew-0.1.0/src/cleaner_crew/templates/agents/manager.md +37 -0
  33. cleaner_crew-0.1.0/src/cleaner_crew/templates/agents/scout.md +29 -0
  34. cleaner_crew-0.1.0/src/cleaner_crew/templates/ci/__init__.py +0 -0
  35. cleaner_crew-0.1.0/src/cleaner_crew/templates/ci/github-verify.yml +30 -0
  36. cleaner_crew-0.1.0/src/cleaner_crew/templates/ci/github.yml +49 -0
  37. cleaner_crew-0.1.0/src/cleaner_crew/templates/ci/gitlab.yml +38 -0
  38. cleaner_crew-0.1.0/src/cleaner_crew/templates/policy.yml +71 -0
  39. cleaner_crew-0.1.0/src/cleaner_crew/templates/systemd/__init__.py +0 -0
  40. cleaner_crew-0.1.0/src/cleaner_crew/templates/systemd/cleaner-crew.service +9 -0
  41. cleaner_crew-0.1.0/src/cleaner_crew/templates/systemd/cleaner-crew.timer +10 -0
  42. cleaner_crew-0.1.0/src/cleaner_crew/trust.py +62 -0
  43. cleaner_crew-0.1.0/src/cleaner_crew/verify.py +54 -0
  44. cleaner_crew-0.1.0/tests/test_guard.py +73 -0
  45. cleaner_crew-0.1.0/tests/test_models_and_git.py +58 -0
  46. cleaner_crew-0.1.0/tests/test_orchestrator.py +249 -0
  47. cleaner_crew-0.1.0/tests/test_policy.py +96 -0
  48. cleaner_crew-0.1.0/tests/test_trust_mutation_verify.py +169 -0
  49. cleaner_crew-0.1.0/uv.lock +306 -0
@@ -0,0 +1,70 @@
1
+ # This workflow will upload a Python Package to PyPI when a release is created
2
+ # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python#publishing-to-package-registries
3
+
4
+ # This workflow uses actions that are not certified by GitHub.
5
+ # They are provided by a third-party and are governed by
6
+ # separate terms of service, privacy policy, and support
7
+ # documentation.
8
+
9
+ name: Upload Python Package
10
+
11
+ on:
12
+ release:
13
+ types: [published]
14
+
15
+ permissions:
16
+ contents: read
17
+
18
+ jobs:
19
+ release-build:
20
+ runs-on: ubuntu-latest
21
+
22
+ steps:
23
+ - uses: actions/checkout@v4
24
+
25
+ - uses: actions/setup-python@v5
26
+ with:
27
+ python-version: "3.x"
28
+
29
+ - name: Build release distributions
30
+ run: |
31
+ # NOTE: put your own distribution build steps here.
32
+ python -m pip install build
33
+ python -m build
34
+
35
+ - name: Upload distributions
36
+ uses: actions/upload-artifact@v4
37
+ with:
38
+ name: release-dists
39
+ path: dist/
40
+
41
+ pypi-publish:
42
+ runs-on: ubuntu-latest
43
+ needs:
44
+ - release-build
45
+ permissions:
46
+ # IMPORTANT: this permission is mandatory for trusted publishing
47
+ id-token: write
48
+
49
+ # Dedicated environments with protections for publishing are strongly recommended.
50
+ # For more information, see: https://docs.github.com/en/actions/deployment/targeting-different-environments/using-environments-for-deployment#deployment-protection-rules
51
+ environment:
52
+ name: pypi
53
+ # OPTIONAL: uncomment and update to include your PyPI project URL in the deployment status:
54
+ # url: https://pypi.org/p/YOURPROJECT
55
+ #
56
+ # ALTERNATIVE: if your GitHub Release name is the PyPI project version string
57
+ # ALTERNATIVE: exactly, uncomment the following line instead:
58
+ # url: https://pypi.org/project/YOURPROJECT/${{ github.event.release.name }}
59
+
60
+ steps:
61
+ - name: Retrieve release distributions
62
+ uses: actions/download-artifact@v4
63
+ with:
64
+ name: release-dists
65
+ path: dist/
66
+
67
+ - name: Publish release distributions to PyPI
68
+ uses: pypa/gh-action-pypi-publish@release/v1
69
+ with:
70
+ packages-dir: dist/
@@ -0,0 +1,4 @@
1
+ .venv/
2
+ __pycache__/
3
+ *.egg-info/
4
+ .pytest_cache/
@@ -0,0 +1,148 @@
1
+ Metadata-Version: 2.5
2
+ Name: cleaner-crew
3
+ Version: 0.1.0
4
+ Summary: Autonomous crew of Claude Code agents that picks up small, safe tasks and opens MRs.
5
+ Requires-Python: >=3.11
6
+ Requires-Dist: httpx>=0.27
7
+ Requires-Dist: pyyaml>=6
8
+ Requires-Dist: rich>=13
9
+ Requires-Dist: typer>=0.12
10
+ Description-Content-Type: text/markdown
11
+
12
+ # Cleaner Crew 🧹
13
+
14
+ A crew of Claude Code agents that picks up small, safe tasks from your tracker
15
+ (Linear, Jira), fixes them in isolation, and opens merge requests on GitHub or GitLab.
16
+ It only ships a change when a deterministic policy gate agrees, it never merges, and it
17
+ earns more autonomy per category only as humans accept its work.
18
+
19
+ ```
20
+ scout ──► proposed tickets (cleaner-crew:proposed)
21
+ │ a human adds the `cleaner-crew` label to the ones worth doing
22
+ candidate tickets (cleaner-crew)
23
+ │
24
+ manager ──► claim ──► triage + plan
25
+ │
26
+ ├─► inspector write a failing test that reproduces the bug (bugfix)
27
+ ├─► janitor implement the plan (cannot edit tests)
28
+ ├─► inspector cover the change with tests (can only edit tests)
29
+ ├─► tests + lint + diff + mutation testing, run by the orchestrator
30
+ └─► hooded read-only security review, can veto
31
+ │
32
+ policy gate + manager verdict + trust level ──► MR │ draft MR │ shadow │ escalate │ reject
33
+ │
34
+ target repo CI: cleaner-crew-verify (required check) + human approval ──► merge
35
+ ```
36
+
37
+ ## Install into a repository
38
+
39
+ ```sh
40
+ cd path/to/your/cloned/repo
41
+ uvx cleaner-crew init # or: uv tool install cleaner-crew && cleaner-crew init
42
+ ```
43
+
44
+ `init` walks you through:
45
+
46
+ 1. **Code host.** It detects GitHub/GitLab from `origin`, checks the token (or an existing
47
+ `gh` login) and verifies that it has push and MR permissions.
48
+ 2. **Task manager.** You choose Linear or Jira, paste a token if one is missing, pick the
49
+ team/project, and map the todo / in progress / in review statuses.
50
+ 3. **Tests.** It detects the stack, confirms the test and lint commands, and runs the suite
51
+ once. If the baseline is red, the crew is installed disabled.
52
+ 4. **Files.** It writes `.cleaner-crew/config.yml`, `policy.yml` and `.claude/agents/cleaner-crew-*.md`.
53
+ 5. **Runner.** Choose a scheduled CI job (GitHub Actions / GitLab schedule), a local
54
+ daemon (loop or systemd user timer), or both.
55
+ 6. **Protection.** It adds the `cleaner-crew-verify` CI check, checks branch protection on
56
+ the default branch and tells you exactly what to turn on.
57
+
58
+ Secrets go to `.cleaner-crew/secrets.env` (gitignored, mode 600) or your CI secrets.
59
+ `config.yml` holds only the variable names.
60
+
61
+ ## Commands
62
+
63
+ | command | what it does |
64
+ |---|---|
65
+ | `cleaner-crew init` | install and connect |
66
+ | `cleaner-crew doctor` | re-check connections, CLI, test baseline, kill switch |
67
+ | `cleaner-crew run --dry-run` | scout and plan only; nothing is claimed, filed or pushed |
68
+ | `cleaner-crew run` | one run: up to `max_tasks_per_run` tickets |
69
+ | `cleaner-crew trust` | each category's earned trust level and acceptance rate |
70
+ | `cleaner-crew verify --base main` | CI check re-applying the base branch's policy to a crew branch |
71
+ | `cleaner-crew daemon` | run in a loop on this machine |
72
+ | `cleaner-crew stop` / `resume` | kill switch (commit `.cleaner-crew/STOP` to stop CI too) |
73
+
74
+ ## How it earns trust
75
+
76
+ **Humans choose the work.** The scout only *proposes* (`cleaner-crew:proposed`). Adding
77
+ the `cleaner-crew` label takes about ten seconds and is far cheaper than reviewing a
78
+ pointless MR. At most `max_open_proposals` proposals are open at once.
79
+
80
+ **Autonomy per category is measured, not assumed.** An MR counts as *accepted* if it was
81
+ merged with no human commits pushed to its branch (crew commits carry `Cleaner-Crew-*`
82
+ git trailers). Over the last `trust.window` closed MRs per category:
83
+
84
+ | record | level | behaviour |
85
+ |---|---|---|
86
+ | fewer than `min_samples` | draft | MRs are opened as drafts |
87
+ | acceptance ≥ `promote_at` (80%) | ready | MRs are opened ready for review |
88
+ | acceptance < `demote_below` (50%) | shadow | the plan is posted on the ticket; nothing changes |
89
+ | otherwise | draft | |
90
+
91
+ The earned level is capped by each category's `max_level` in `policy.yml`. After fixing
92
+ whatever caused a demotion, set `trust_since` to today's date to start a fresh record.
93
+ History is read from the code host, so CI and local runners agree.
94
+
95
+ **Tests must actually test the change.** After tests pass, the crew mutates the changed
96
+ lines (`==`→`!=`, `<`→`<=`, `n`→`n+1`, `and`→`or`, ...) and re-runs the tests. If
97
+ fewer than `mutation.min_score` of the mutants are caught, the MR becomes a draft, and
98
+ the surviving mutants are listed in the MR.
99
+
100
+ ## Protecting the target repo
101
+
102
+ The crew never merges. The repository's own protections are the real safety net:
103
+
104
+ - **Branch protection** on the default branch: require at least one approving review,
105
+ dismiss stale approvals, and require `cleaner-crew-verify` plus your test workflow.
106
+ - **`cleaner-crew-verify`** re-checks every `cleaner-crew/*` MR against the policy *from
107
+ the base branch*. It checks forbidden paths (including the crew's own config), size
108
+ limits, required tests, category enabled, and crew trailers present. On GitHub it runs
109
+ as `pull_request_target`, so an MR can't edit the check that judges it, and it never
110
+ executes the MR's code.
111
+ - **CODEOWNERS** for `/.cleaner-crew/`, `/.claude/` and CI config.
112
+
113
+ `cleaner-crew doctor` reports whether the default branch is adequately protected.
114
+
115
+ ## Safety model
116
+
117
+ - **The policy gate is code, not a prompt.** `policy.yml` sets size limits, forbidden
118
+ paths, required tests and security approval. The manager's verdict can only be made
119
+ stricter by the policy.
120
+ - **Separate contexts.** Each role is a separate `claude -p` process. The inspector and
121
+ hooded agent see the plan and diff, never the janitor's reasoning.
122
+ - **Separation of duties.** Janitors cannot edit tests, and inspectors can only edit tests.
123
+ Scout, manager and hooded agents are read-only.
124
+ - **Three layers of tool restriction:** `--allowedTools` per role, the agent's `tools:`
125
+ frontmatter, and a PreToolUse guard hook (`cleaner_crew.hooks.guard`). The guard blocks
126
+ the network, git history commands, secrets files, anything outside the task worktree,
127
+ and the crew's own config.
128
+ - **Agents hold no credentials.** Tracker and code host tokens are stripped from agent
129
+ environments. Only the orchestrator claims tickets, commits, pushes and opens MRs.
130
+ - **Ticket text is untrusted.** It is wrapped as data. The hooded agent checks the diff
131
+ against the plan and flags suspected prompt injection, which blocks the change.
132
+ - **Limits:** max open crew MRs, a per-task cost cap, a green baseline requirement, and a
133
+ kill switch.
134
+ - **Audit trail:** every agent transcript, the test log and the outcome are kept under
135
+ `.cleaner-crew/runs/` (uploaded as a CI artifact).
136
+
137
+ ## Extending
138
+
139
+ Add a tracker or code host by implementing `TaskSource` or `CodeHost` in
140
+ `src/cleaner_crew/adapters/base.py` and registering it in `adapters/__init__.py`.
141
+ Customise agent behaviour by editing `.claude/agents/cleaner-crew-*.md` in the target repo.
142
+
143
+ ## Development
144
+
145
+ ```sh
146
+ uv sync
147
+ uv run pytest
148
+ ```
@@ -0,0 +1,137 @@
1
+ # Cleaner Crew 🧹
2
+
3
+ A crew of Claude Code agents that picks up small, safe tasks from your tracker
4
+ (Linear, Jira), fixes them in isolation, and opens merge requests on GitHub or GitLab.
5
+ It only ships a change when a deterministic policy gate agrees, it never merges, and it
6
+ earns more autonomy per category only as humans accept its work.
7
+
8
+ ```
9
+ scout ──► proposed tickets (cleaner-crew:proposed)
10
+ │ a human adds the `cleaner-crew` label to the ones worth doing
11
+ candidate tickets (cleaner-crew)
12
+ │
13
+ manager ──► claim ──► triage + plan
14
+ │
15
+ ├─► inspector write a failing test that reproduces the bug (bugfix)
16
+ ├─► janitor implement the plan (cannot edit tests)
17
+ ├─► inspector cover the change with tests (can only edit tests)
18
+ ├─► tests + lint + diff + mutation testing, run by the orchestrator
19
+ └─► hooded read-only security review, can veto
20
+ │
21
+ policy gate + manager verdict + trust level ──► MR │ draft MR │ shadow │ escalate │ reject
22
+ │
23
+ target repo CI: cleaner-crew-verify (required check) + human approval ──► merge
24
+ ```
25
+
26
+ ## Install into a repository
27
+
28
+ ```sh
29
+ cd path/to/your/cloned/repo
30
+ uvx cleaner-crew init # or: uv tool install cleaner-crew && cleaner-crew init
31
+ ```
32
+
33
+ `init` walks you through:
34
+
35
+ 1. **Code host.** It detects GitHub/GitLab from `origin`, checks the token (or an existing
36
+ `gh` login) and verifies that it has push and MR permissions.
37
+ 2. **Task manager.** You choose Linear or Jira, paste a token if one is missing, pick the
38
+ team/project, and map the todo / in progress / in review statuses.
39
+ 3. **Tests.** It detects the stack, confirms the test and lint commands, and runs the suite
40
+ once. If the baseline is red, the crew is installed disabled.
41
+ 4. **Files.** It writes `.cleaner-crew/config.yml`, `policy.yml` and `.claude/agents/cleaner-crew-*.md`.
42
+ 5. **Runner.** Choose a scheduled CI job (GitHub Actions / GitLab schedule), a local
43
+ daemon (loop or systemd user timer), or both.
44
+ 6. **Protection.** It adds the `cleaner-crew-verify` CI check, checks branch protection on
45
+ the default branch and tells you exactly what to turn on.
46
+
47
+ Secrets go to `.cleaner-crew/secrets.env` (gitignored, mode 600) or your CI secrets.
48
+ `config.yml` holds only the variable names.
49
+
50
+ ## Commands
51
+
52
+ | command | what it does |
53
+ |---|---|
54
+ | `cleaner-crew init` | install and connect |
55
+ | `cleaner-crew doctor` | re-check connections, CLI, test baseline, kill switch |
56
+ | `cleaner-crew run --dry-run` | scout and plan only; nothing is claimed, filed or pushed |
57
+ | `cleaner-crew run` | one run: up to `max_tasks_per_run` tickets |
58
+ | `cleaner-crew trust` | each category's earned trust level and acceptance rate |
59
+ | `cleaner-crew verify --base main` | CI check re-applying the base branch's policy to a crew branch |
60
+ | `cleaner-crew daemon` | run in a loop on this machine |
61
+ | `cleaner-crew stop` / `resume` | kill switch (commit `.cleaner-crew/STOP` to stop CI too) |
62
+
63
+ ## How it earns trust
64
+
65
+ **Humans choose the work.** The scout only *proposes* (`cleaner-crew:proposed`). Adding
66
+ the `cleaner-crew` label takes about ten seconds and is far cheaper than reviewing a
67
+ pointless MR. At most `max_open_proposals` proposals are open at once.
68
+
69
+ **Autonomy per category is measured, not assumed.** An MR counts as *accepted* if it was
70
+ merged with no human commits pushed to its branch (crew commits carry `Cleaner-Crew-*`
71
+ git trailers). Over the last `trust.window` closed MRs per category:
72
+
73
+ | record | level | behaviour |
74
+ |---|---|---|
75
+ | fewer than `min_samples` | draft | MRs are opened as drafts |
76
+ | acceptance ≥ `promote_at` (80%) | ready | MRs are opened ready for review |
77
+ | acceptance < `demote_below` (50%) | shadow | the plan is posted on the ticket; nothing changes |
78
+ | otherwise | draft | |
79
+
80
+ The earned level is capped by each category's `max_level` in `policy.yml`. After fixing
81
+ whatever caused a demotion, set `trust_since` to today's date to start a fresh record.
82
+ History is read from the code host, so CI and local runners agree.
83
+
84
+ **Tests must actually test the change.** After tests pass, the crew mutates the changed
85
+ lines (`==`→`!=`, `<`→`<=`, `n`→`n+1`, `and`→`or`, ...) and re-runs the tests. If
86
+ fewer than `mutation.min_score` of the mutants are caught, the MR becomes a draft, and
87
+ the surviving mutants are listed in the MR.
88
+
89
+ ## Protecting the target repo
90
+
91
+ The crew never merges. The repository's own protections are the real safety net:
92
+
93
+ - **Branch protection** on the default branch: require at least one approving review,
94
+ dismiss stale approvals, and require `cleaner-crew-verify` plus your test workflow.
95
+ - **`cleaner-crew-verify`** re-checks every `cleaner-crew/*` MR against the policy *from
96
+ the base branch*. It checks forbidden paths (including the crew's own config), size
97
+ limits, required tests, category enabled, and crew trailers present. On GitHub it runs
98
+ as `pull_request_target`, so an MR can't edit the check that judges it, and it never
99
+ executes the MR's code.
100
+ - **CODEOWNERS** for `/.cleaner-crew/`, `/.claude/` and CI config.
101
+
102
+ `cleaner-crew doctor` reports whether the default branch is adequately protected.
103
+
104
+ ## Safety model
105
+
106
+ - **The policy gate is code, not a prompt.** `policy.yml` sets size limits, forbidden
107
+ paths, required tests and security approval. The manager's verdict can only be made
108
+ stricter by the policy.
109
+ - **Separate contexts.** Each role is a separate `claude -p` process. The inspector and
110
+ hooded agent see the plan and diff, never the janitor's reasoning.
111
+ - **Separation of duties.** Janitors cannot edit tests, and inspectors can only edit tests.
112
+ Scout, manager and hooded agents are read-only.
113
+ - **Three layers of tool restriction:** `--allowedTools` per role, the agent's `tools:`
114
+ frontmatter, and a PreToolUse guard hook (`cleaner_crew.hooks.guard`). The guard blocks
115
+ the network, git history commands, secrets files, anything outside the task worktree,
116
+ and the crew's own config.
117
+ - **Agents hold no credentials.** Tracker and code host tokens are stripped from agent
118
+ environments. Only the orchestrator claims tickets, commits, pushes and opens MRs.
119
+ - **Ticket text is untrusted.** It is wrapped as data. The hooded agent checks the diff
120
+ against the plan and flags suspected prompt injection, which blocks the change.
121
+ - **Limits:** max open crew MRs, a per-task cost cap, a green baseline requirement, and a
122
+ kill switch.
123
+ - **Audit trail:** every agent transcript, the test log and the outcome are kept under
124
+ `.cleaner-crew/runs/` (uploaded as a CI artifact).
125
+
126
+ ## Extending
127
+
128
+ Add a tracker or code host by implementing `TaskSource` or `CodeHost` in
129
+ `src/cleaner_crew/adapters/base.py` and registering it in `adapters/__init__.py`.
130
+ Customise agent behaviour by editing `.claude/agents/cleaner-crew-*.md` in the target repo.
131
+
132
+ ## Development
133
+
134
+ ```sh
135
+ uv sync
136
+ uv run pytest
137
+ ```
@@ -0,0 +1,28 @@
1
+ [project]
2
+ name = "cleaner-crew"
3
+ version = "0.1.0"
4
+ description = "Autonomous crew of Claude Code agents that picks up small, safe tasks and opens MRs."
5
+ readme = "README.md"
6
+ requires-python = ">=3.11"
7
+ dependencies = [
8
+ "typer>=0.12",
9
+ "rich>=13",
10
+ "pyyaml>=6",
11
+ "httpx>=0.27",
12
+ ]
13
+
14
+ [project.scripts]
15
+ cleaner-crew = "cleaner_crew.cli:app"
16
+
17
+ [dependency-groups]
18
+ dev = ["pytest>=8", "respx>=0.21"]
19
+
20
+ [build-system]
21
+ requires = ["hatchling"]
22
+ build-backend = "hatchling.build"
23
+
24
+ [tool.hatch.build.targets.wheel]
25
+ packages = ["src/cleaner_crew"]
26
+
27
+ [tool.pytest.ini_options]
28
+ testpaths = ["tests"]
@@ -0,0 +1,4 @@
1
+ __version__ = "0.1.0"
2
+
3
+ CREW_DIR = ".cleaner-crew"
4
+ BRANCH_PREFIX = "cleaner-crew/"
@@ -0,0 +1,3 @@
1
+ from .cli import app
2
+
3
+ app()
@@ -0,0 +1,32 @@
1
+ from __future__ import annotations
2
+
3
+ from ..config import Config, env
4
+ from .base import CodeHost, TaskSource
5
+
6
+
7
+ def make_tracker(cfg: Config) -> TaskSource:
8
+ t = cfg.tracker
9
+ if t.kind == "linear":
10
+ from .linear import Linear
11
+ return Linear(t, env(t.token_env))
12
+ if t.kind == "jira":
13
+ from .jira import Jira
14
+ return Jira(t, env(t.email_env), env(t.token_env))
15
+ raise ValueError(f"unknown tracker kind: {t.kind}")
16
+
17
+
18
+ def make_code_host(cfg: Config) -> CodeHost:
19
+ c = cfg.code_host
20
+ if c.kind == "github":
21
+ from .github import GitHub, github_token
22
+ tok = github_token(c.token_env)
23
+ if not tok:
24
+ raise RuntimeError(f"no GitHub token: set {c.token_env} or run `gh auth login`")
25
+ return GitHub(c.repo, tok, c.api_url)
26
+ if c.kind == "gitlab":
27
+ from .gitlab import GitLab
28
+ return GitLab(c.repo, env(c.token_env), c.api_url)
29
+ raise ValueError(f"unknown code host kind: {c.kind}")
30
+
31
+
32
+ __all__ = ["CodeHost", "TaskSource", "make_code_host", "make_tracker"]
@@ -0,0 +1,109 @@
1
+ """Adapter interfaces. Add a new tracker or code host by implementing one of these."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from abc import ABC, abstractmethod
6
+
7
+ from ..config import TrackerConfig
8
+ from ..models import ConnectionReport, Finding, MrRecord, Task
9
+
10
+
11
+ class TaskSource(ABC):
12
+ """Jira, Linear, ... The tracker is the source of truth for what the crew works on."""
13
+
14
+ cfg: TrackerConfig
15
+
16
+ @abstractmethod
17
+ def check(self) -> ConnectionReport:
18
+ """Verify credentials and that the configured project exists."""
19
+
20
+ @abstractmethod
21
+ def list_projects(self) -> list[tuple[str, str]]:
22
+ """(key, name) pairs, for the installer."""
23
+
24
+ @abstractmethod
25
+ def list_statuses(self) -> list[str]:
26
+ """Workflow status names of the configured project, for the installer."""
27
+
28
+ @abstractmethod
29
+ def fetch_candidates(self, limit: int = 20) -> list[Task]:
30
+ """Open tickets a human labelled as candidates, not yet claimed or settled by the crew."""
31
+
32
+ @abstractmethod
33
+ def find_issues(self, labels: list[str], open_only: bool, limit: int = 100) -> list[Task]:
34
+ """Tickets carrying any of `labels`."""
35
+
36
+ @abstractmethod
37
+ def create_finding(self, finding: Finding) -> Task:
38
+ """File a scout finding as a *proposed* ticket. Only a human makes it a candidate."""
39
+
40
+ @abstractmethod
41
+ def claim(self, task: Task) -> None:
42
+ """Mark in progress so no other run (CI or daemon) picks it up."""
43
+
44
+ @abstractmethod
45
+ def comment(self, task: Task, body: str) -> None: ...
46
+
47
+ @abstractmethod
48
+ def mark_in_review(self, task: Task, mr_url: str) -> None: ...
49
+
50
+ @abstractmethod
51
+ def release(self, task: Task, label: str, comment: str) -> None:
52
+ """Drop the claim, return the ticket to todo, add `label` and comment."""
53
+
54
+ def reject(self, task: Task, reason: str) -> None:
55
+ self.release(task, self.cfg.rejected_label, f"Cleaner crew: not shipping this.\n\n{reason}")
56
+
57
+ def escalate(self, task: Task, reason: str) -> None:
58
+ self.release(task, self.cfg.escalated_label,
59
+ f"Cleaner crew: this needs a human.\n\n{reason}")
60
+
61
+ def shadow(self, task: Task, plan: str) -> None:
62
+ self.release(task, self.cfg.shadow_label,
63
+ "Cleaner crew (shadow mode): this is what I would have done. "
64
+ f"Nothing was changed.\n\n{plan}")
65
+
66
+ def blocked_labels(self) -> set[str]:
67
+ c = self.cfg
68
+ return {c.in_progress_label, c.rejected_label, c.escalated_label, c.shadow_label}
69
+
70
+ def all_crew_labels(self) -> list[str]:
71
+ c = self.cfg
72
+ return [c.candidate_label, c.proposed_label, *sorted(self.blocked_labels())]
73
+
74
+ def find_by_fingerprint(self, fp: str) -> Task | None:
75
+ """Any ticket the crew ever filed or touched, open or closed, so nothing is re-filed."""
76
+ issues = self.find_issues(self.all_crew_labels(), open_only=False, limit=250)
77
+ return next((t for t in issues if t.fingerprint == fp), None)
78
+
79
+ def count_open_proposals(self) -> int:
80
+ issues = self.find_issues([self.cfg.proposed_label], open_only=True)
81
+ return sum(1 for t in issues if self.cfg.candidate_label not in t.labels)
82
+
83
+
84
+ class CodeHost(ABC):
85
+ """GitHub, GitLab, ..."""
86
+
87
+ @abstractmethod
88
+ def check(self) -> ConnectionReport:
89
+ """Verify credentials and push/MR permissions on the repository."""
90
+
91
+ @abstractmethod
92
+ def count_open_mrs(self, branch_prefix: str) -> int: ...
93
+
94
+ @abstractmethod
95
+ def open_mr(self, branch: str, base: str, title: str, body: str, draft: bool,
96
+ labels: list[str]) -> str:
97
+ """Open a merge/pull request and return its URL."""
98
+
99
+ @abstractmethod
100
+ def crew_mr_history(self, branch_prefix: str, limit: int = 100) -> list[MrRecord]:
101
+ """Recently closed crew MRs with their category, merge state and human changes."""
102
+
103
+ @abstractmethod
104
+ def branch_protection(self, branch: str) -> tuple[bool | None, str]:
105
+ """(adequately protected?, detail). None when it can't be determined.
106
+
107
+ Adequate means humans must approve before merge and, where the host exposes it,
108
+ the cleaner-crew-verify check is required.
109
+ """
@@ -0,0 +1,97 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import subprocess
5
+
6
+ import httpx
7
+
8
+ from ..models import ConnectionReport, MrRecord, category_from_labels, is_crew_commit
9
+ from .base import CodeHost
10
+
11
+
12
+ def github_token(token_env: str) -> str:
13
+ tok = os.environ.get(token_env, "")
14
+ if tok:
15
+ return tok
16
+ # Fall back to an existing gh CLI login for local use.
17
+ try:
18
+ return subprocess.run(["gh", "auth", "token"], capture_output=True, text=True,
19
+ timeout=10).stdout.strip()
20
+ except (FileNotFoundError, subprocess.TimeoutExpired):
21
+ return ""
22
+
23
+
24
+ class GitHub(CodeHost):
25
+ def __init__(self, repo: str, token: str, api_url: str = "https://api.github.com"):
26
+ self.repo = repo
27
+ self.http = httpx.Client(
28
+ base_url=api_url,
29
+ headers={"Authorization": f"Bearer {token}",
30
+ "Accept": "application/vnd.github+json",
31
+ "X-GitHub-Api-Version": "2022-11-28"},
32
+ timeout=30,
33
+ )
34
+
35
+ def check(self) -> ConnectionReport:
36
+ r = self.http.get("/user")
37
+ if r.status_code != 200:
38
+ return ConnectionReport(False, "github", detail=f"auth failed ({r.status_code})")
39
+ login = r.json()["login"]
40
+ r = self.http.get(f"/repos/{self.repo}")
41
+ if r.status_code != 200:
42
+ return ConnectionReport(False, "github", login,
43
+ f"repo {self.repo} not accessible ({r.status_code})")
44
+ perms = r.json().get("permissions", {})
45
+ missing = [] if perms.get("push") else ["push access (contents: write, pull_requests: write)"]
46
+ return ConnectionReport(not missing, "github", login, f"repo {self.repo}", missing)
47
+
48
+ def count_open_mrs(self, branch_prefix: str) -> int:
49
+ r = self.http.get(f"/repos/{self.repo}/pulls", params={"state": "open", "per_page": 100})
50
+ r.raise_for_status()
51
+ return sum(1 for pr in r.json() if pr["head"]["ref"].startswith(branch_prefix))
52
+
53
+ def open_mr(self, branch: str, base: str, title: str, body: str, draft: bool,
54
+ labels: list[str]) -> str:
55
+ r = self.http.post(f"/repos/{self.repo}/pulls", json={
56
+ "title": title, "head": branch, "base": base, "body": body, "draft": draft})
57
+ r.raise_for_status()
58
+ pr = r.json()
59
+ self.http.post(f"/repos/{self.repo}/issues/{pr['number']}/labels",
60
+ json={"labels": labels}).raise_for_status()
61
+ return pr["html_url"]
62
+
63
+ def crew_mr_history(self, branch_prefix: str, limit: int = 100) -> list[MrRecord]:
64
+ r = self.http.get(f"/repos/{self.repo}/pulls", params={
65
+ "state": "closed", "sort": "updated", "direction": "desc", "per_page": 100})
66
+ r.raise_for_status()
67
+ out = []
68
+ for pr in r.json():
69
+ if not pr["head"]["ref"].startswith(branch_prefix) or len(out) >= limit:
70
+ continue
71
+ cat = category_from_labels([l["name"] for l in pr.get("labels", [])])
72
+ if cat is None:
73
+ continue
74
+ commits = self.http.get(f"/repos/{self.repo}/pulls/{pr['number']}/commits",
75
+ params={"per_page": 100})
76
+ commits.raise_for_status()
77
+ human = any(not is_crew_commit(c["commit"]["message"]) for c in commits.json())
78
+ out.append(MrRecord(cat, merged=pr.get("merged_at") is not None,
79
+ changed_by_human=human, closed_at=pr["closed_at"]))
80
+ return out
81
+
82
+ def branch_protection(self, branch: str) -> tuple[bool | None, str]:
83
+ r = self.http.get(f"/repos/{self.repo}/branches/{branch}")
84
+ if r.status_code != 200:
85
+ return None, f"cannot read branch {branch} ({r.status_code})"
86
+ if not r.json().get("protected"):
87
+ return False, f"{branch} is not protected"
88
+ r = self.http.get(f"/repos/{self.repo}/branches/{branch}/protection")
89
+ if r.status_code != 200:
90
+ return True, f"{branch} is protected (details need admin rights)"
91
+ p = r.json()
92
+ checks = (p.get("required_status_checks") or {}).get("contexts", [])
93
+ reviews = (p.get("required_pull_request_reviews") or {}).get(
94
+ "required_approving_review_count", 0)
95
+ detail = f"{reviews} required review(s); required checks: {', '.join(checks) or 'none'}"
96
+ return bool(reviews) and any("cleaner-crew" in c for c in checks), detail
97
+