cheaphelp 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. cheaphelp/__init__.py +10 -0
  2. cheaphelp/__main__.py +14 -0
  3. cheaphelp/_internal/__init__.py +0 -0
  4. cheaphelp/_internal/cleanup.py +111 -0
  5. cheaphelp/_internal/cli.py +372 -0
  6. cheaphelp/_internal/commands.py +1025 -0
  7. cheaphelp/_internal/config.py +326 -0
  8. cheaphelp/_internal/conventions.py +21 -0
  9. cheaphelp/_internal/debug.py +107 -0
  10. cheaphelp/_internal/env.py +103 -0
  11. cheaphelp/_internal/fixer.py +133 -0
  12. cheaphelp/_internal/github.py +372 -0
  13. cheaphelp/_internal/gitutil.py +216 -0
  14. cheaphelp/_internal/lock.py +83 -0
  15. cheaphelp/_internal/opencode.py +655 -0
  16. cheaphelp/_internal/orchestrator.py +801 -0
  17. cheaphelp/_internal/planner.py +187 -0
  18. cheaphelp/_internal/pr_state.py +48 -0
  19. cheaphelp/_internal/registry.py +147 -0
  20. cheaphelp/_internal/responder.py +225 -0
  21. cheaphelp/_internal/reviewer.py +322 -0
  22. cheaphelp/_internal/rework.py +371 -0
  23. cheaphelp/_internal/spend.py +107 -0
  24. cheaphelp/_internal/systemd.py +302 -0
  25. cheaphelp/_internal/tasks.py +263 -0
  26. cheaphelp/_internal/templates/__init__.py +24 -0
  27. cheaphelp/_internal/templates/fixer.md +50 -0
  28. cheaphelp/_internal/templates/planner.md +64 -0
  29. cheaphelp/_internal/templates/responder.md +154 -0
  30. cheaphelp/_internal/templates/reviewer.md +53 -0
  31. cheaphelp/_internal/templates/rework.md +61 -0
  32. cheaphelp/_internal/templates/worker.md +49 -0
  33. cheaphelp/_internal/worker.py +147 -0
  34. cheaphelp/py.typed +0 -0
  35. cheaphelp-1.0.0.dist-info/METADATA +112 -0
  36. cheaphelp-1.0.0.dist-info/RECORD +39 -0
  37. cheaphelp-1.0.0.dist-info/WHEEL +4 -0
  38. cheaphelp-1.0.0.dist-info/entry_points.txt +5 -0
  39. cheaphelp-1.0.0.dist-info/licenses/LICENSE +21 -0
cheaphelp/__init__.py ADDED
@@ -0,0 +1,10 @@
1
+ """cheaphelp package.
2
+
3
+ An AI software-engineer that triages GitHub issues, plans, implements, and reviews changes on your repos using cheap OpenRouter models via the opencode harness.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from cheaphelp._internal.cli import get_parser, main
9
+
10
+ __all__: list[str] = ["get_parser", "main"]
cheaphelp/__main__.py ADDED
@@ -0,0 +1,14 @@
1
+ """Entry-point module, in case you use `python -m cheaphelp`.
2
+
3
+ Why does this file exist, and why `__main__`? For more info, read:
4
+
5
+ - https://www.python.org/dev/peps/pep-0338/
6
+ - https://docs.python.org/3/using/cmdline.html#cmdoption-m
7
+ """
8
+
9
+ import sys
10
+
11
+ from cheaphelp._internal.cli import main
12
+
13
+ if __name__ == "__main__":
14
+ sys.exit(main(sys.argv[1:]))
File without changes
@@ -0,0 +1,111 @@
1
+ """Workspace maintenance: prune build clones the harness no longer needs.
2
+
3
+ The orchestrator keeps two kinds of clone under ``<workspace>/clones/``:
4
+
5
+ - a shared read-only clone per repo, ``<owner>__<repo>`` (reused every tick), and
6
+ - a build clone per issue, ``<owner>__<repo>__issue-<n>`` (used while the issue is
7
+ being implemented).
8
+
9
+ Build clones are only needed while an issue is *live* (open). Once it closes they
10
+ are dead weight — and they are large (a full working tree each). This module
11
+ removes build clones for closed issues, and any clone belonging to a repo that is
12
+ no longer registered. Per-issue **state** under ``state/`` is intentionally left
13
+ untouched so it stays available for inspection and debugging.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import shutil
19
+ from collections.abc import Callable, Iterable
20
+ from pathlib import Path
21
+
22
+ from cheaphelp._internal.config import Workspace
23
+ from cheaphelp._internal.lock import RunLock
24
+ from cheaphelp._internal.registry import RepoEntry
25
+
26
+ Logger = Callable[[str], None]
27
+
28
+
29
+ def iter_issue_work_clones(workspace: Workspace, owner: str, name: str) -> dict[int, Path]:
30
+ """Map issue number -> build-clone path for one repo's per-issue clones.
31
+
32
+ The repo's shared clone (``<owner>__<repo>``, no ``__issue-`` suffix) is never
33
+ included. Matching by the known ``<owner>__<repo>__issue-`` prefix avoids the
34
+ ambiguity of splitting on ``__`` (repo names may contain underscores).
35
+ """
36
+ prefix = f"{owner}__{name}__issue-"
37
+ clones: dict[int, Path] = {}
38
+ if not workspace.clones_dir.exists():
39
+ return clones
40
+ for path in workspace.clones_dir.iterdir():
41
+ if path.is_dir() and path.name.startswith(prefix):
42
+ suffix = path.name[len(prefix) :]
43
+ if suffix.isdigit():
44
+ clones[int(suffix)] = path
45
+ return clones
46
+
47
+
48
+ def prune_repo_work_clones(
49
+ workspace: Workspace,
50
+ repo: RepoEntry,
51
+ live_numbers: set[int],
52
+ *,
53
+ dry_run: bool = False,
54
+ log: Logger | None = None,
55
+ ) -> list[int]:
56
+ """Remove build clones for this repo's issues that are no longer live.
57
+
58
+ ``live_numbers`` is the set of currently-open issue numbers; any build clone for
59
+ an issue outside it is removed. Returns the issue numbers whose clones were
60
+ removed (or, in dry-run, would be).
61
+ """
62
+ log = log or (lambda _m: None)
63
+ removed: list[int] = []
64
+ for number, path in sorted(iter_issue_work_clones(workspace, repo.owner, repo.name).items()):
65
+ if number in live_numbers:
66
+ continue
67
+ if dry_run:
68
+ log(f" · would remove build clone for {repo.slug}#{number}")
69
+ removed.append(number)
70
+ continue
71
+ # Guard against a concurrent tick still using this issue's clone. Closed
72
+ # issues are never actionable, so this should never contend in practice.
73
+ with RunLock(workspace.issue_lock_path(repo.owner, repo.name, number)) as lock:
74
+ if not lock.acquired:
75
+ continue
76
+ shutil.rmtree(path, ignore_errors=True)
77
+ log(f" · removed build clone for {repo.slug}#{number}")
78
+ removed.append(number)
79
+ return removed
80
+
81
+
82
+ def prune_orphan_clones(
83
+ workspace: Workspace,
84
+ repos: Iterable[RepoEntry],
85
+ *,
86
+ dry_run: bool = False,
87
+ log: Logger | None = None,
88
+ ) -> list[str]:
89
+ """Remove clone dirs (shared or per-issue) for repos no longer registered.
90
+
91
+ Returns the clone directory names that were removed (or would be, in dry-run).
92
+ """
93
+ log = log or (lambda _m: None)
94
+ if not workspace.clones_dir.exists():
95
+ return []
96
+ known = {f"{r.owner}__{r.name}" for r in repos}
97
+ removed: list[str] = []
98
+ for path in workspace.clones_dir.iterdir():
99
+ if not path.is_dir():
100
+ continue
101
+ # A clone belongs to a registered repo if its name equals "<owner>__<repo>"
102
+ # or starts with "<owner>__<repo>__issue-".
103
+ if any(path.name == k or path.name.startswith(f"{k}__issue-") for k in known):
104
+ continue
105
+ if dry_run:
106
+ log(f" · would remove orphaned clone {path.name}")
107
+ else:
108
+ shutil.rmtree(path, ignore_errors=True)
109
+ log(f" · removed orphaned clone {path.name}")
110
+ removed.append(path.name)
111
+ return removed
@@ -0,0 +1,372 @@
1
+ # Why does this file exist, and why not put this in `__main__`?
2
+ #
3
+ # You might be tempted to import things from `__main__` later,
4
+ # but that will cause problems: the code will get executed twice:
5
+ #
6
+ # - When you run `python -m cheaphelp` python will execute
7
+ # `__main__.py` as a script. That means there won't be any
8
+ # `cheaphelp.__main__` in `sys.modules`.
9
+ # - When you import `__main__` it will get executed again (as a module) because
10
+ # there's no `cheaphelp.__main__` in `sys.modules`.
11
+
12
+ from __future__ import annotations
13
+
14
+ import argparse
15
+ import sys
16
+ from typing import Any
17
+
18
+ from cheaphelp._internal import commands, debug
19
+
20
+
21
+ class _DebugInfo(argparse.Action):
22
+ def __init__(self, nargs: int | str | None = 0, **kwargs: Any) -> None:
23
+ super().__init__(nargs=nargs, **kwargs)
24
+
25
+ def __call__(self, *args: Any, **kwargs: Any) -> None: # noqa: ARG002
26
+ debug._print_debug_info()
27
+ sys.exit(0)
28
+
29
+
30
+ def get_parser() -> argparse.ArgumentParser:
31
+ """Return the CLI argument parser.
32
+
33
+ Returns:
34
+ An argparse parser.
35
+ """
36
+ parser = argparse.ArgumentParser(
37
+ prog="cheaphelp",
38
+ description="An AI software-engineer for your GitHub repositories, powered by "
39
+ "cheap OpenRouter models via the opencode harness.",
40
+ )
41
+ parser.add_argument("-V", "--version", action="version", version=f"%(prog)s {debug._get_version()}")
42
+ parser.add_argument("--debug-info", action=_DebugInfo, help="Print debug information.")
43
+ parser.add_argument(
44
+ "--home",
45
+ metavar="DIR",
46
+ help="Workspace directory (default: $CHEAPHELP_HOME or ~/.cheaphelp).",
47
+ )
48
+
49
+ subparsers = parser.add_subparsers(dest="command", metavar="<command>")
50
+
51
+ # init
52
+ p_init = subparsers.add_parser("init", help="Create or update the workspace.")
53
+ p_init.add_argument("--github-token", help="GitHub personal access token.")
54
+ p_init.add_argument("--openrouter-key", help="OpenRouter API key.")
55
+ p_init.add_argument("--no-prompt", action="store_true", help="Never prompt for secrets.")
56
+ p_init.set_defaults(func=commands.cmd_init)
57
+
58
+ # repo
59
+ p_repo = subparsers.add_parser("repo", help="Manage registered repositories.")
60
+ repo_sub = p_repo.add_subparsers(dest="repo_command", metavar="<action>")
61
+ p_add = repo_sub.add_parser("add", help="Register a repository (owner/name).")
62
+ p_add.add_argument("slug", help="Repository as owner/name or a GitHub URL.")
63
+ p_add.add_argument(
64
+ "--checks",
65
+ default="",
66
+ help="Quality-gate shell command run in the clone before a PR is opened "
67
+ '(e.g. "ruff check . && pytest"). A failing gate sends the issue back to planning.',
68
+ )
69
+ p_add.add_argument(
70
+ "--autofix",
71
+ default="",
72
+ help="Shell command run in the clone before the gate to auto-fix trivial "
73
+ 'issues (e.g. "ruff check --fix . ; ruff format ."). Changes are committed.',
74
+ )
75
+ p_add.add_argument(
76
+ "--max-diff-files",
77
+ type=int,
78
+ default=None,
79
+ metavar="N",
80
+ help="Maximum files allowed in a single PR for this repo (0 = unlimited, default 30).",
81
+ )
82
+ p_add.add_argument(
83
+ "--max-diff-lines",
84
+ type=int,
85
+ default=None,
86
+ metavar="N",
87
+ help="Maximum lines (added+removed) allowed in a single PR (0 = unlimited, default 1000).",
88
+ )
89
+ p_add.set_defaults(func=commands.cmd_repo_add)
90
+ p_list = repo_sub.add_parser("list", help="List registered repositories.")
91
+ p_list.add_argument(
92
+ "--json",
93
+ action="store_true",
94
+ help="Output the list of registered repositories as a JSON array.",
95
+ )
96
+ p_list.set_defaults(func=commands.cmd_repo_list)
97
+ p_rm = repo_sub.add_parser("remove", help="Unregister a repository.")
98
+ p_rm.add_argument("slug")
99
+ p_rm.set_defaults(func=commands.cmd_repo_remove)
100
+ p_set = repo_sub.add_parser(
101
+ "set",
102
+ help="Update checks/autofix on an already-registered repository.",
103
+ )
104
+ p_set.add_argument("slug", help="Repository as owner/name or a GitHub URL.")
105
+ p_set.add_argument(
106
+ "--checks",
107
+ default=None,
108
+ help="Replace the quality-gate shell command. Pass an empty string to disable the gate.",
109
+ )
110
+ p_set.add_argument(
111
+ "--autofix",
112
+ default=None,
113
+ help="Replace the auto-fix shell command. Pass an empty string to disable auto-fix.",
114
+ )
115
+ p_set.add_argument(
116
+ "--max-diff-files",
117
+ type=int,
118
+ default=None,
119
+ metavar="N",
120
+ help="Maximum files allowed in a single PR for this repo (0 = unlimited, default 30).",
121
+ )
122
+ p_set.add_argument(
123
+ "--max-diff-lines",
124
+ type=int,
125
+ default=None,
126
+ metavar="N",
127
+ help="Maximum lines (added+removed) allowed in a single PR (0 = unlimited, default 1000).",
128
+ )
129
+ p_set.set_defaults(func=commands.cmd_repo_set)
130
+ p_update = repo_sub.add_parser(
131
+ "update",
132
+ help="Update checks/autofix/max-diff-* on an already-registered repository (alias of `set`).",
133
+ )
134
+ p_update.add_argument("slug", help="Repository as owner/name or a GitHub URL.")
135
+ p_update.add_argument(
136
+ "--checks",
137
+ default=None,
138
+ help="Replace the quality-gate shell command. Pass an empty string to disable the gate.",
139
+ )
140
+ p_update.add_argument(
141
+ "--autofix",
142
+ default=None,
143
+ help="Replace the auto-fix shell command. Pass an empty string to disable auto-fix.",
144
+ )
145
+ p_update.add_argument(
146
+ "--max-diff-files",
147
+ type=int,
148
+ default=None,
149
+ metavar="N",
150
+ help="Maximum files allowed in a single PR for this repo (0 = unlimited, default 30).",
151
+ )
152
+ p_update.add_argument(
153
+ "--max-diff-lines",
154
+ type=int,
155
+ default=None,
156
+ metavar="N",
157
+ help="Maximum lines (added+removed) allowed in a single PR (0 = unlimited, default 1000).",
158
+ )
159
+ p_update.set_defaults(func=commands.cmd_repo_set)
160
+ p_en = repo_sub.add_parser("enable", help="Enable processing for a repository.")
161
+ p_en.add_argument("slug")
162
+ p_en.set_defaults(func=lambda a: commands.cmd_repo_toggle(a, enabled=True))
163
+ p_dis = repo_sub.add_parser("disable", help="Disable processing for a repository.")
164
+ p_dis.add_argument("slug")
165
+ p_dis.set_defaults(func=lambda a: commands.cmd_repo_toggle(a, enabled=False))
166
+
167
+ # run
168
+ p_run = subparsers.add_parser("run", help="Run the orchestrator. Default: one tick.")
169
+ p_run.add_argument(
170
+ "--dry-run",
171
+ action="store_true",
172
+ help="Report what would happen without acting (applies to every tick in a multi-tick run).",
173
+ )
174
+ p_run.add_argument(
175
+ "-n",
176
+ "--num-ticks",
177
+ type=int,
178
+ default=1,
179
+ metavar="N",
180
+ help="Run exactly N ticks, sleeping --sleep seconds between each (default 1). "
181
+ "Mutually exclusive with --continuous.",
182
+ )
183
+ p_run.add_argument(
184
+ "--continuous",
185
+ action="store_true",
186
+ help="Run until a tick produces no work, capped at --max-ticks. Mutually exclusive with --num-ticks.",
187
+ )
188
+ p_run.add_argument(
189
+ "--max-ticks",
190
+ type=int,
191
+ default=20,
192
+ metavar="N",
193
+ help="Hard cap on total ticks when --continuous is used (default 20). Ignored when --num-ticks is set.",
194
+ )
195
+ p_run.add_argument(
196
+ "--sleep",
197
+ type=float,
198
+ default=30.0,
199
+ metavar="N",
200
+ help="Seconds to sleep between ticks in multi-tick mode (default 30).",
201
+ )
202
+ p_run.add_argument(
203
+ "--max-issues",
204
+ type=int,
205
+ default=0,
206
+ metavar="N",
207
+ help=(
208
+ "Cap the number of issues processed per repo in this tick "
209
+ "(0 = unlimited, default 0). Overrides max_issues_per_tick from "
210
+ "config.json when > 0."
211
+ ),
212
+ )
213
+ p_run.set_defaults(func=commands.cmd_run)
214
+
215
+ # systemd
216
+ p_sys = subparsers.add_parser("systemd", help="Manage the systemd user timer.")
217
+ sys_sub = p_sys.add_subparsers(dest="systemd_command", metavar="<action>")
218
+ p_sys_install = sys_sub.add_parser("install", help="Install and start the timer.")
219
+ p_sys_install.add_argument(
220
+ "--interval",
221
+ default="10m",
222
+ help="Timer firing interval, e.g. 30s, 10m, 2h (default: 10m).",
223
+ )
224
+ p_sys_install.add_argument(
225
+ "--continuous",
226
+ action=argparse.BooleanOptionalAction,
227
+ default=True,
228
+ help=(
229
+ "Each timer firing runs `cheaphelp run --continuous`, draining the "
230
+ "backlog (repeated ticks until one is idle, capped at --max-ticks) "
231
+ "instead of a single tick (default: enabled). Use --no-continuous "
232
+ "for one tick per firing."
233
+ ),
234
+ )
235
+ p_sys_install.add_argument(
236
+ "--max-ticks",
237
+ type=int,
238
+ default=20,
239
+ metavar="N",
240
+ help="Cap on ticks per timer firing in continuous mode (default 20).",
241
+ )
242
+ p_sys_install.add_argument(
243
+ "--sleep",
244
+ type=float,
245
+ default=30.0,
246
+ metavar="N",
247
+ help="Seconds to sleep between ticks in continuous mode (default 30).",
248
+ )
249
+ p_sys_install.add_argument(
250
+ "--linger",
251
+ action="store_true",
252
+ help=(
253
+ "After installing the timer, run `loginctl enable-linger $USER` so the "
254
+ "user timer keeps firing after logout. Suppresses the lingering tip. "
255
+ "Failures are reported as warnings; install is not aborted."
256
+ ),
257
+ )
258
+ p_sys_install.set_defaults(func=commands.cmd_systemd_install)
259
+ sys_sub.add_parser("uninstall", help="Stop and remove the timer.").set_defaults(
260
+ func=commands.cmd_systemd_uninstall,
261
+ )
262
+ sys_sub.add_parser("status", help="Show timer status.").set_defaults(
263
+ func=commands.cmd_systemd_status,
264
+ )
265
+
266
+ # agents
267
+ p_agents = subparsers.add_parser("agents", help="Manage agent prompts and opencode config.")
268
+ agents_sub = p_agents.add_subparsers(dest="agents_command", metavar="<action>")
269
+ p_sync = agents_sub.add_parser("sync", help="Regenerate opencode.json from prompts + config.")
270
+ p_sync.add_argument("--force", action="store_true", help="Overwrite workspace prompts.")
271
+ p_sync.set_defaults(func=commands.cmd_agents_sync)
272
+
273
+ # config
274
+ p_config = subparsers.add_parser("config", help="View or change configuration settings.")
275
+ config_sub = p_config.add_subparsers(dest="config_command", metavar="<action>")
276
+ p_cfg_show = config_sub.add_parser("show", help="Print the effective configuration.")
277
+ p_cfg_show.set_defaults(func=commands.cmd_config_show)
278
+ p_cfg_get = config_sub.add_parser("get", help="Look up a single config value by dotted path.")
279
+ p_cfg_get.add_argument("key", help="Dotted path to a config key (e.g. models.worker).")
280
+ p_cfg_get.set_defaults(func=commands.cmd_config_get)
281
+ p_cfg_set = config_sub.add_parser("set", help="Set a config value by dotted path.")
282
+ p_cfg_set.add_argument("key", help="Dotted path to a config key (e.g. agent_timeout).")
283
+ p_cfg_set.add_argument("value", help="New value for the config key.")
284
+ p_cfg_set.set_defaults(func=commands.cmd_config_set)
285
+
286
+ # doctor
287
+ subparsers.add_parser("doctor", help="Check workspace, tokens and opencode.").set_defaults(
288
+ func=commands.cmd_doctor,
289
+ )
290
+
291
+ # status
292
+ p_status = subparsers.add_parser(
293
+ "status",
294
+ help="List open issues for each enabled repo and their pipeline stage.",
295
+ )
296
+ p_status.add_argument(
297
+ "--costs",
298
+ action="store_true",
299
+ help="Also show the cumulative cost per issue (read from <issue_dir>/cost.json).",
300
+ )
301
+ p_status.set_defaults(func=commands.cmd_status)
302
+
303
+ # clean
304
+ p_clean = subparsers.add_parser(
305
+ "clean",
306
+ help="Remove build clones for closed issues and unregistered repos (keeps state).",
307
+ )
308
+ p_clean.add_argument(
309
+ "--dry-run",
310
+ action="store_true",
311
+ help="Report what would be removed without deleting anything.",
312
+ )
313
+ p_clean.set_defaults(func=commands.cmd_clean)
314
+
315
+ # retry
316
+ p_retry = subparsers.add_parser(
317
+ "retry",
318
+ help="Un-stick an issue labeled 'needs-human' and run one orchestrator tick.",
319
+ )
320
+ p_retry.add_argument("slug", help="Repository as owner/name or a GitHub URL.")
321
+ p_retry.add_argument("number", type=int, help="Issue number to retry.")
322
+ p_retry.add_argument(
323
+ "--dry-run",
324
+ action="store_true",
325
+ help="Report the planned actions without making API or filesystem changes.",
326
+ )
327
+ p_retry.add_argument(
328
+ "-y",
329
+ "--yes",
330
+ action="store_true",
331
+ help="Skip the confirmation prompt.",
332
+ )
333
+ p_retry.set_defaults(func=commands.cmd_retry)
334
+
335
+ # logs
336
+ p_logs = subparsers.add_parser("logs", help="Show recent run activity, or follow it live.")
337
+ p_logs.add_argument(
338
+ "--follow",
339
+ "-f",
340
+ action="store_true",
341
+ help="Stream new log lines as they are appended (Ctrl-C to stop).",
342
+ )
343
+ p_logs.add_argument(
344
+ "--issue",
345
+ type=int,
346
+ metavar="N",
347
+ help="Show only lines that reference issue #N.",
348
+ )
349
+ p_logs.set_defaults(func=commands.cmd_logs)
350
+
351
+ return parser
352
+
353
+
354
+ def main(args: list[str] | None = None) -> int:
355
+ """Run the main program.
356
+
357
+ This function is executed when you type `cheaphelp` or `python -m cheaphelp`.
358
+
359
+ Parameters:
360
+ args: Arguments passed from the command line.
361
+
362
+ Returns:
363
+ An exit code.
364
+ """
365
+ parser = get_parser()
366
+ opts = parser.parse_args(args=args)
367
+
368
+ func = getattr(opts, "func", None)
369
+ if func is None:
370
+ parser.print_help()
371
+ return 1
372
+ return func(opts)