reviewloop 0.1.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.
- reviewloop-0.1.0.dist-info/METADATA +119 -0
- reviewloop-0.1.0.dist-info/RECORD +13 -0
- reviewloop-0.1.0.dist-info/WHEEL +4 -0
- reviewloop-0.1.0.dist-info/entry_points.txt +2 -0
- reviewloop-0.1.0.dist-info/licenses/LICENSE +21 -0
- reviewloop_cli/__init__.py +0 -0
- reviewloop_cli/cli.py +37 -0
- reviewloop_cli/init.py +228 -0
- reviewloop_cli/py.typed +0 -0
- reviewloop_cli/templates/SKILL.md +45 -0
- reviewloop_cli/templates/__init__.py +0 -0
- reviewloop_cli/templates/scripts/review-comments.sh +161 -0
- reviewloop_cli/templates/scripts/review-wait.sh +119 -0
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: reviewloop
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Autonomous review loop - automates the review-fix-push cycle
|
|
5
|
+
Project-URL: Homepage, https://github.com/fabianboth/autonomous-review-loop
|
|
6
|
+
Project-URL: Repository, https://github.com/fabianboth/autonomous-review-loop.git
|
|
7
|
+
Project-URL: Issues, https://github.com/fabianboth/autonomous-review-loop/issues
|
|
8
|
+
Author: Fabian Both
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: ai,automation,cli,code-review,developer-tools
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Environment :: Console
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
16
|
+
Classifier: Operating System :: OS Independent
|
|
17
|
+
Classifier: Programming Language :: Python :: 3
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Topic :: Software Development :: Quality Assurance
|
|
20
|
+
Classifier: Typing :: Typed
|
|
21
|
+
Requires-Python: >=3.13
|
|
22
|
+
Requires-Dist: readchar
|
|
23
|
+
Requires-Dist: rich
|
|
24
|
+
Requires-Dist: typer
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
|
|
27
|
+
# Autonomous Review Loop
|
|
28
|
+
|
|
29
|
+

|
|
30
|
+
|
|
31
|
+
A workflow tool that automates the review-fix-push cycle by delegating to your coding agent.
|
|
32
|
+
|
|
33
|
+
## Why
|
|
34
|
+
|
|
35
|
+
When you create a PR, code review (human or automated) generates comments you need to triage: reading each one, deciding what to fix vs. reject, discussing with your coding agent, then resolving threads one by one. The whole cycle is tedious and distracting.
|
|
36
|
+
|
|
37
|
+
The solution: delegate the entire review loop to your coding agent. It waits for CI to complete, fetches all comments, decides which are valid, fixes the issues, resolves threads, pushes, and repeats until clean. You only get pulled in when there's an actual decision that needs human judgment.
|
|
38
|
+
|
|
39
|
+
## Features
|
|
40
|
+
|
|
41
|
+
- **Batched decision-making** - Get aggregate review requests instead of triaging individual comments one by one
|
|
42
|
+
- **Parallel work** - Continue other tasks while the review loop runs autonomously in the background
|
|
43
|
+
- **Multi-pass resolution** - Iterates automatically until no unresolved comments remain
|
|
44
|
+
- **Agent-agnostic** - Works with any coding agent (Claude Code, Cursor, etc.) and any review tool
|
|
45
|
+
|
|
46
|
+
## Prerequisites
|
|
47
|
+
|
|
48
|
+
- [Claude Code](https://claude.com/code) - Anthropic's CLI for Claude
|
|
49
|
+
- [GitHub CLI](https://cli.github.com/) (`gh`) - Authenticated with your account
|
|
50
|
+
- [jq](https://jqlang.org/) - JSON processor
|
|
51
|
+
|
|
52
|
+
## Installation
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
uv tool install reviewloop
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Then initialize in your project:
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
reviewloop init
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## Usage
|
|
65
|
+
|
|
66
|
+
### With Claude Code Skill
|
|
67
|
+
|
|
68
|
+
From your project directory with an open PR, invoke the skill:
|
|
69
|
+
|
|
70
|
+
```text
|
|
71
|
+
/reviewloop
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
The agent will autonomously:
|
|
75
|
+
1. Wait for CI to complete
|
|
76
|
+
2. Fetch and analyze review comments
|
|
77
|
+
3. Fix valid issues, ask you about ambiguous ones
|
|
78
|
+
4. Resolve threads and push
|
|
79
|
+
5. Repeat until no comments remain
|
|
80
|
+
|
|
81
|
+
### With Standalone Scripts
|
|
82
|
+
|
|
83
|
+
If you initialized with `--mode script`, prompt your coding agent with the contents of `scripts/reviewloop/reviewPrompt.txt`.
|
|
84
|
+
|
|
85
|
+
## Troubleshooting
|
|
86
|
+
|
|
87
|
+
### "No PR found for current branch"
|
|
88
|
+
|
|
89
|
+
Ensure you're on a branch with an open PR:
|
|
90
|
+
|
|
91
|
+
```bash
|
|
92
|
+
gh pr view
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
### "gh CLI is not authenticated"
|
|
96
|
+
|
|
97
|
+
Run:
|
|
98
|
+
|
|
99
|
+
```bash
|
|
100
|
+
gh auth login
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
### Skill not appearing in Claude Code
|
|
104
|
+
|
|
105
|
+
1. Check the skill file exists: `.claude/skills/reviewloop/SKILL.md`
|
|
106
|
+
2. Verify the frontmatter is valid YAML
|
|
107
|
+
3. Restart Claude Code
|
|
108
|
+
|
|
109
|
+
### CI timeout
|
|
110
|
+
|
|
111
|
+
The default timeout is 10 minutes. If your CI takes longer, the script will inform you and you can re-run with a longer timeout:
|
|
112
|
+
|
|
113
|
+
```bash
|
|
114
|
+
scripts/reviewloop/review-wait.sh --timeout=1200
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
## Status
|
|
118
|
+
|
|
119
|
+
Currently in active development.
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
reviewloop_cli/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
+
reviewloop_cli/cli.py,sha256=SriV0i2otzsTOdCuNRsxlM3iGH6MT7RUaGMJS8XDHPY,898
|
|
3
|
+
reviewloop_cli/init.py,sha256=4nmUpSutt-TZTmiaPQvn0IF0ppsBn-Pl2PabjKFR9YQ,7573
|
|
4
|
+
reviewloop_cli/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
5
|
+
reviewloop_cli/templates/SKILL.md,sha256=zjcrJRefH8jX33GNC2kEjJGZ25tZZcVFbDNVrJ-K7Yw,1843
|
|
6
|
+
reviewloop_cli/templates/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
7
|
+
reviewloop_cli/templates/scripts/review-comments.sh,sha256=GpC7ohA1aj9hTS2lpllYZLOzdwDw5sx237wpJ2gtrpA,5440
|
|
8
|
+
reviewloop_cli/templates/scripts/review-wait.sh,sha256=Bb5Mhi3cCF7bdKqfZ5POVutfxly9eorGD44dKZsq9bo,3192
|
|
9
|
+
reviewloop-0.1.0.dist-info/METADATA,sha256=hN24KqjxL9C8gPUNiaulwoHTz81IMK6rNSC6HrVrL3Q,3621
|
|
10
|
+
reviewloop-0.1.0.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
|
|
11
|
+
reviewloop-0.1.0.dist-info/entry_points.txt,sha256=B_r1tUnWUf9C49-VdWUq2TEnELVs-s1tj3dxHy4_9LQ,55
|
|
12
|
+
reviewloop-0.1.0.dist-info/licenses/LICENSE,sha256=mJJa1KbX5RMg_a-5T-2XkeytdkS2g1WwxkoikmxG8nw,1068
|
|
13
|
+
reviewloop-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Fabian Both
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
File without changes
|
reviewloop_cli/cli.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
from importlib.metadata import version
|
|
2
|
+
from typing import Annotated
|
|
3
|
+
|
|
4
|
+
import typer
|
|
5
|
+
|
|
6
|
+
from reviewloop_cli.init import init as init_command
|
|
7
|
+
|
|
8
|
+
app = typer.Typer(
|
|
9
|
+
name="reviewloop",
|
|
10
|
+
help="Autonomous review loop - automates the review-fix-push cycle.",
|
|
11
|
+
add_completion=False,
|
|
12
|
+
invoke_without_command=True,
|
|
13
|
+
)
|
|
14
|
+
app.command(name="init", help="Initialize reviewloop in a project.")(init_command)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _version_callback(value: bool) -> None:
|
|
18
|
+
if value:
|
|
19
|
+
print(f"reviewloop v{version('reviewloop')}")
|
|
20
|
+
raise typer.Exit
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@app.callback()
|
|
24
|
+
def callback(
|
|
25
|
+
ctx: typer.Context,
|
|
26
|
+
_version: Annotated[
|
|
27
|
+
bool | None,
|
|
28
|
+
typer.Option("--version", callback=_version_callback, is_eager=True, help="Show version and exit."),
|
|
29
|
+
] = None,
|
|
30
|
+
) -> None:
|
|
31
|
+
if ctx.invoked_subcommand is None:
|
|
32
|
+
print(ctx.get_help())
|
|
33
|
+
raise typer.Exit
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def main() -> None:
|
|
37
|
+
app()
|
reviewloop_cli/init.py
ADDED
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import shutil
|
|
5
|
+
import stat
|
|
6
|
+
from enum import StrEnum
|
|
7
|
+
from importlib.resources import files
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Annotated
|
|
10
|
+
|
|
11
|
+
import readchar
|
|
12
|
+
import typer
|
|
13
|
+
from rich.console import Console
|
|
14
|
+
from rich.live import Live
|
|
15
|
+
from rich.panel import Panel
|
|
16
|
+
from rich.table import Table
|
|
17
|
+
|
|
18
|
+
console = Console()
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class InitMode(StrEnum):
|
|
22
|
+
CLAUDE_CODE = "claude-code"
|
|
23
|
+
SCRIPT = "script"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _templates_path() -> Path:
|
|
27
|
+
source = files("reviewloop_cli.templates")
|
|
28
|
+
path = Path(str(source))
|
|
29
|
+
if not path.is_dir():
|
|
30
|
+
print("Error: template files not found. The reviewloop package may be corrupted — try reinstalling.")
|
|
31
|
+
raise typer.Exit(code=1)
|
|
32
|
+
return path
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _strip_frontmatter(content: str) -> str:
|
|
36
|
+
lines = content.split("\n")
|
|
37
|
+
if not lines or lines[0].strip() != "---":
|
|
38
|
+
return content
|
|
39
|
+
for i, line in enumerate(lines[1:], start=1):
|
|
40
|
+
if line.strip() == "---":
|
|
41
|
+
return "\n".join(lines[i + 1 :]).lstrip("\n")
|
|
42
|
+
return content
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _get_target_files(mode: InitMode) -> list[str]:
|
|
46
|
+
if mode == InitMode.CLAUDE_CODE:
|
|
47
|
+
return [
|
|
48
|
+
".claude/skills/reviewloop/SKILL.md",
|
|
49
|
+
".claude/skills/reviewloop/scripts/review-wait.sh",
|
|
50
|
+
".claude/skills/reviewloop/scripts/review-comments.sh",
|
|
51
|
+
]
|
|
52
|
+
return [
|
|
53
|
+
"scripts/reviewloop/review-wait.sh",
|
|
54
|
+
"scripts/reviewloop/review-comments.sh",
|
|
55
|
+
"scripts/reviewloop/reviewPrompt.txt",
|
|
56
|
+
]
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _check_existing_files(target_dir: Path, mode: InitMode, force: bool) -> bool:
|
|
60
|
+
existing = [f for f in _get_target_files(mode) if (target_dir / f).exists()]
|
|
61
|
+
if not existing:
|
|
62
|
+
return True
|
|
63
|
+
print("The following files already exist:")
|
|
64
|
+
for f in existing:
|
|
65
|
+
print(f" - {f}")
|
|
66
|
+
if force:
|
|
67
|
+
print("Overwriting (--force).")
|
|
68
|
+
return True
|
|
69
|
+
return typer.confirm("Overwrite?", default=False)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _make_executable(path: Path) -> None:
|
|
73
|
+
if os.name == "nt":
|
|
74
|
+
return
|
|
75
|
+
current = path.stat().st_mode
|
|
76
|
+
path.chmod(current | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _copy_scripts(templates: Path, scripts_dir: Path) -> list[str]:
|
|
80
|
+
scripts_dir.mkdir(parents=True, exist_ok=True)
|
|
81
|
+
created: list[str] = []
|
|
82
|
+
for name in ("review-wait.sh", "review-comments.sh"):
|
|
83
|
+
src = templates / "scripts" / name
|
|
84
|
+
dst = scripts_dir / name
|
|
85
|
+
shutil.copy2(src, dst)
|
|
86
|
+
_make_executable(dst)
|
|
87
|
+
created.append(str(dst))
|
|
88
|
+
return created
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _init_claude_code(target_dir: Path, templates: Path) -> list[str]:
|
|
92
|
+
skill_dir = target_dir / ".claude" / "skills" / "reviewloop"
|
|
93
|
+
scripts_dir = skill_dir / "scripts"
|
|
94
|
+
skill_dir.mkdir(parents=True, exist_ok=True)
|
|
95
|
+
|
|
96
|
+
skill_src = templates / "SKILL.md"
|
|
97
|
+
skill_dst = skill_dir / "SKILL.md"
|
|
98
|
+
shutil.copy2(skill_src, skill_dst)
|
|
99
|
+
|
|
100
|
+
created = [str(skill_dst)]
|
|
101
|
+
created.extend(_copy_scripts(templates, scripts_dir))
|
|
102
|
+
return created
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _init_script(target_dir: Path, templates: Path) -> list[str]:
|
|
106
|
+
scripts_dir = target_dir / "scripts" / "reviewloop"
|
|
107
|
+
created = _copy_scripts(templates, scripts_dir)
|
|
108
|
+
|
|
109
|
+
skill_content = (templates / "SKILL.md").read_text(encoding="utf-8")
|
|
110
|
+
prompt_content = _strip_frontmatter(skill_content)
|
|
111
|
+
prompt_content = prompt_content.replace(".claude/skills/reviewloop/scripts/", "scripts/reviewloop/")
|
|
112
|
+
prompt_path = scripts_dir / "reviewPrompt.txt"
|
|
113
|
+
prompt_path.write_text(prompt_content, encoding="utf-8")
|
|
114
|
+
created.append(str(prompt_path))
|
|
115
|
+
|
|
116
|
+
return created
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _print_summary(created_files: list[str], target_dir: Path) -> None:
|
|
120
|
+
print(f"\nInitialized reviewloop in {target_dir}\n")
|
|
121
|
+
print("Created files:")
|
|
122
|
+
for f in created_files:
|
|
123
|
+
print(f" {f}")
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _get_key() -> str:
|
|
127
|
+
key = readchar.readkey()
|
|
128
|
+
if key in (readchar.key.UP, readchar.key.CTRL_P):
|
|
129
|
+
return "up"
|
|
130
|
+
if key in (readchar.key.DOWN, readchar.key.CTRL_N):
|
|
131
|
+
return "down"
|
|
132
|
+
if key == readchar.key.ENTER:
|
|
133
|
+
return "enter"
|
|
134
|
+
if key == readchar.key.ESC:
|
|
135
|
+
return "escape"
|
|
136
|
+
if key == readchar.key.CTRL_C:
|
|
137
|
+
raise KeyboardInterrupt
|
|
138
|
+
return key
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _select_with_arrows(options: dict[str, str], title: str, default: str | None = None) -> str:
|
|
142
|
+
keys = list(options.keys())
|
|
143
|
+
selected = keys.index(default) if default and default in keys else 0
|
|
144
|
+
|
|
145
|
+
def _build_panel() -> Panel:
|
|
146
|
+
table = Table.grid(padding=(0, 2))
|
|
147
|
+
table.add_column(width=3)
|
|
148
|
+
table.add_column()
|
|
149
|
+
for i, key in enumerate(keys):
|
|
150
|
+
marker = "[cyan]\u25b6[/cyan]" if i == selected else " "
|
|
151
|
+
label = f"[bold cyan]{key}[/bold cyan]" if i == selected else f"[dim]{key}[/dim]"
|
|
152
|
+
table.add_row(marker, f"{label} [dim]{options[key]}[/dim]")
|
|
153
|
+
table.add_row("", "")
|
|
154
|
+
table.add_row("", "[dim]\u2191/\u2193 navigate, Enter select, Esc cancel[/dim]")
|
|
155
|
+
return Panel(table, title=f"[bold]{title}[/bold]", border_style="cyan", padding=(1, 2))
|
|
156
|
+
|
|
157
|
+
console.print()
|
|
158
|
+
with Live(_build_panel(), console=console, transient=True, auto_refresh=False) as live:
|
|
159
|
+
while True:
|
|
160
|
+
try:
|
|
161
|
+
key = _get_key()
|
|
162
|
+
except KeyboardInterrupt:
|
|
163
|
+
console.print("[yellow]Cancelled[/yellow]")
|
|
164
|
+
raise typer.Exit(code=1) from None
|
|
165
|
+
if key == "up":
|
|
166
|
+
selected = (selected - 1) % len(keys)
|
|
167
|
+
elif key == "down":
|
|
168
|
+
selected = (selected + 1) % len(keys)
|
|
169
|
+
elif key == "enter":
|
|
170
|
+
return keys[selected]
|
|
171
|
+
elif key == "escape":
|
|
172
|
+
console.print("[yellow]Cancelled[/yellow]")
|
|
173
|
+
raise typer.Exit(code=1)
|
|
174
|
+
live.update(_build_panel(), refresh=True)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _prompt_mode() -> InitMode:
|
|
178
|
+
choices = {
|
|
179
|
+
"Claude Code": "installs as a Claude Code skill",
|
|
180
|
+
"Script based": "creates standalone scripts + prompt file",
|
|
181
|
+
}
|
|
182
|
+
selected = _select_with_arrows(choices, "Initialization mode", default="Claude Code")
|
|
183
|
+
if selected == "Script based":
|
|
184
|
+
return InitMode.SCRIPT
|
|
185
|
+
return InitMode.CLAUDE_CODE
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _resolve_target_dir(project_name: str | None, here: bool) -> Path:
|
|
189
|
+
if here:
|
|
190
|
+
return Path.cwd()
|
|
191
|
+
if project_name is None:
|
|
192
|
+
name: str = str(typer.prompt("Project folder name"))
|
|
193
|
+
else:
|
|
194
|
+
name = project_name
|
|
195
|
+
if name == ".":
|
|
196
|
+
return Path.cwd()
|
|
197
|
+
target = Path.cwd() / name
|
|
198
|
+
target.mkdir(parents=True, exist_ok=True)
|
|
199
|
+
return target
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def init(
|
|
203
|
+
project_name: Annotated[str | None, typer.Argument(help="Target directory name for initialization.")] = None,
|
|
204
|
+
mode: Annotated[InitMode | None, typer.Option("--mode", help="Initialization mode.")] = None,
|
|
205
|
+
here: Annotated[bool, typer.Option("--here", help="Initialize in the current directory.")] = False,
|
|
206
|
+
force: Annotated[bool, typer.Option("--force", help="Overwrite existing files without confirmation.")] = False,
|
|
207
|
+
) -> None:
|
|
208
|
+
if project_name is not None and here:
|
|
209
|
+
print("Error: cannot specify both a project name and --here.")
|
|
210
|
+
raise typer.Exit(code=1)
|
|
211
|
+
|
|
212
|
+
target_dir = _resolve_target_dir(project_name, here)
|
|
213
|
+
|
|
214
|
+
if mode is None:
|
|
215
|
+
mode = _prompt_mode()
|
|
216
|
+
|
|
217
|
+
if not _check_existing_files(target_dir, mode, force):
|
|
218
|
+
print("Aborted.")
|
|
219
|
+
raise typer.Exit(code=1)
|
|
220
|
+
|
|
221
|
+
templates = _templates_path()
|
|
222
|
+
|
|
223
|
+
if mode == InitMode.CLAUDE_CODE:
|
|
224
|
+
created = _init_claude_code(target_dir, templates)
|
|
225
|
+
else:
|
|
226
|
+
created = _init_script(target_dir, templates)
|
|
227
|
+
|
|
228
|
+
_print_summary(created, target_dir)
|
reviewloop_cli/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: reviewloop
|
|
3
|
+
description: Run the autonomous review loop to address PR comments, fix issues, and push changes iteratively until all comments are resolved.
|
|
4
|
+
disable-model-invocation: true
|
|
5
|
+
allowed-tools:
|
|
6
|
+
- Bash(.claude/skills/reviewloop/scripts/review-wait.sh*)
|
|
7
|
+
- Bash(.claude/skills/reviewloop/scripts/review-comments.sh*)
|
|
8
|
+
- Bash(gh *)
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
## Autonomous Review Loop
|
|
12
|
+
|
|
13
|
+
This is an automated review loop. Follow these steps:
|
|
14
|
+
|
|
15
|
+
### 1. Wait for CI to complete
|
|
16
|
+
|
|
17
|
+
Run `.claude/skills/reviewloop/scripts/review-wait.sh` to poll for CI completion.
|
|
18
|
+
|
|
19
|
+
- Waits 10s for CI to start (in case called right after push)
|
|
20
|
+
- Polls every 15s until CI completes
|
|
21
|
+
- Times out after 10 minutes (configurable with `--timeout=SECONDS`)
|
|
22
|
+
- Exits immediately if no CI is in progress
|
|
23
|
+
|
|
24
|
+
### 2. Fetch and address comments
|
|
25
|
+
|
|
26
|
+
Once CI completes:
|
|
27
|
+
|
|
28
|
+
1. Run `.claude/skills/reviewloop/scripts/review-comments.sh` to fetch comments into `.reviews/prComments.md`
|
|
29
|
+
2. Review all comments critically - not all may be valid or apply to our codebase
|
|
30
|
+
3. **ASK BACK** the user for decisions if needed before implementing fixes
|
|
31
|
+
4. Address valid issues
|
|
32
|
+
5. For inline threads: resolve via the `gh api graphql` mutation shown in the file (whether addressed or rejected)
|
|
33
|
+
6. For general review comments: react with thumbs-up via the mutation shown in the file to mark as processed (even if no action was needed)
|
|
34
|
+
|
|
35
|
+
### 3. Verify and commit
|
|
36
|
+
|
|
37
|
+
1. Run `.claude/skills/reviewloop/scripts/review-comments.sh` again to verify no unresolved comments remain
|
|
38
|
+
2. Run your project's linting and type-checking commands to ensure code quality
|
|
39
|
+
3. Commit and push changes
|
|
40
|
+
|
|
41
|
+
### 4. Loop
|
|
42
|
+
|
|
43
|
+
Go back to step 1 - wait for the next review triggered by the push.
|
|
44
|
+
|
|
45
|
+
**Exit condition**: When `.claude/skills/reviewloop/scripts/review-comments.sh` shows no comments. Give a brief high-level summary of what was addressed.
|
|
File without changes
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
set -euo pipefail
|
|
3
|
+
|
|
4
|
+
GRAPHQL_QUERY='query($owner: String!, $repo: String!, $pr: Int!) { repository(owner: $owner, name: $repo) { pullRequest(number: $pr) { reviews(first: 100) { nodes { id databaseId author { login } body reactions(first: 100) { nodes { user { login } content } } } } reviewThreads(first: 100) { nodes { id isResolved comments(first: 1) { nodes { author { login } path line body } } } } } } }'
|
|
5
|
+
|
|
6
|
+
die() {
|
|
7
|
+
echo "Error: $1" >&2
|
|
8
|
+
exit "${2:-1}"
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
check_dependencies() {
|
|
12
|
+
command -v gh &>/dev/null || die "gh CLI is not installed. Install from https://cli.github.com/"
|
|
13
|
+
command -v jq &>/dev/null || die "jq is not installed. Install from https://stedolan.github.io/jq/download/"
|
|
14
|
+
gh auth status &>/dev/null || die "gh CLI is not authenticated. Run 'gh auth login' first."
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
get_pr_number() {
|
|
18
|
+
local pr_number
|
|
19
|
+
pr_number=$(gh pr view --json number -q '.number' 2>/dev/null) || die "No PR found for current branch"
|
|
20
|
+
[[ -n "$pr_number" ]] || die "No PR found for current branch"
|
|
21
|
+
echo "$pr_number"
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
get_repo_info() {
|
|
25
|
+
gh repo view --json owner,name 2>&1 || die "Failed to get repository info"
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
get_current_user() {
|
|
29
|
+
gh api user -q '.login' 2>&1 || die "Failed to get current user"
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
run_graphql() {
|
|
33
|
+
local owner="$1" repo="$2" pr="$3"
|
|
34
|
+
gh api graphql -F "owner=$owner" -F "repo=$repo" -F "pr=$pr" -f "query=$GRAPHQL_QUERY" 2>&1 || die "GraphQL query failed"
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
print_thread() {
|
|
38
|
+
local thread="$1" is_first="$2"
|
|
39
|
+
|
|
40
|
+
[[ "$is_first" == "false" ]] && { echo ""; echo "---"; echo ""; }
|
|
41
|
+
|
|
42
|
+
echo "## $(echo "$thread" | jq -r '.comments.nodes[0].path // "unknown"'):$(echo "$thread" | jq -r '.comments.nodes[0].line // "?"')"
|
|
43
|
+
echo ""
|
|
44
|
+
echo "**Thread ID:** \`$(echo "$thread" | jq -r '.id')\`"
|
|
45
|
+
echo "**Author:** $(echo "$thread" | jq -r '.comments.nodes[0].author.login // "unknown"')"
|
|
46
|
+
echo ""
|
|
47
|
+
echo "$thread" | jq -r '.comments.nodes[0].body // ""'
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
print_review() {
|
|
51
|
+
local review="$1" is_first="$2"
|
|
52
|
+
|
|
53
|
+
[[ "$is_first" == "false" ]] && { echo ""; echo "---"; echo ""; }
|
|
54
|
+
|
|
55
|
+
echo "**Review ID:** \`$(echo "$review" | jq -r '.id')\`"
|
|
56
|
+
echo "**Author:** $(echo "$review" | jq -r '.author // "unknown"')"
|
|
57
|
+
echo ""
|
|
58
|
+
echo "$review" | jq -r '.body // ""'
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
main() {
|
|
62
|
+
check_dependencies
|
|
63
|
+
|
|
64
|
+
local repo_info owner repo pr_number current_user
|
|
65
|
+
repo_info=$(get_repo_info)
|
|
66
|
+
owner=$(echo "$repo_info" | jq -r '.owner.login')
|
|
67
|
+
repo=$(echo "$repo_info" | jq -r '.name')
|
|
68
|
+
pr_number=$(get_pr_number)
|
|
69
|
+
current_user=$(get_current_user)
|
|
70
|
+
|
|
71
|
+
local result pr_data
|
|
72
|
+
result=$(run_graphql "$owner" "$repo" "$pr_number")
|
|
73
|
+
pr_data=$(echo "$result" | jq '.data.repository.pullRequest')
|
|
74
|
+
|
|
75
|
+
local unresolved_threads review_bodies thread_count review_count
|
|
76
|
+
|
|
77
|
+
unresolved_threads=$(echo "$pr_data" | jq '[.reviewThreads.nodes[] | select(.isResolved == false and (.comments.nodes | length) > 0)]')
|
|
78
|
+
|
|
79
|
+
# Get reviews user hasn't reacted to, strip HTML comments
|
|
80
|
+
review_bodies=$(echo "$pr_data" | jq --arg user "$current_user" '
|
|
81
|
+
[.reviews.nodes[]
|
|
82
|
+
| select(.body != null and .body != "")
|
|
83
|
+
| select(([.reactions.nodes[] | select(.user.login == $user and .content == "THUMBS_UP")] | length) == 0)
|
|
84
|
+
| {id: .id, author: .author.login, body: (.body | gsub("<!--.*?-->"; "") | gsub("^\\s+|\\s+$"; ""))}
|
|
85
|
+
| select(.body != "")
|
|
86
|
+
]')
|
|
87
|
+
|
|
88
|
+
thread_count=$(echo "$unresolved_threads" | jq 'length')
|
|
89
|
+
review_count=$(echo "$review_bodies" | jq 'length')
|
|
90
|
+
|
|
91
|
+
mkdir -p .reviews
|
|
92
|
+
|
|
93
|
+
if [[ "$thread_count" -eq 0 ]] && [[ "$review_count" -eq 0 ]]; then
|
|
94
|
+
cat > .reviews/prComments.md << EOF
|
|
95
|
+
# Review Comments
|
|
96
|
+
|
|
97
|
+
**PR:** #${pr_number}
|
|
98
|
+
|
|
99
|
+
No unresolved comments.
|
|
100
|
+
EOF
|
|
101
|
+
echo "No unresolved comments found on this PR"
|
|
102
|
+
exit 0
|
|
103
|
+
fi
|
|
104
|
+
|
|
105
|
+
local resolve_cmd='gh api graphql -f query='\''mutation { resolveReviewThread(input: {threadId: "THREAD_ID"}) { thread { isResolved } } }'\'''
|
|
106
|
+
local react_cmd='gh api graphql -f query='\''mutation { addReaction(input: {subjectId: "REVIEW_ID", content: THUMBS_UP}) { reaction { content } } }'\'''
|
|
107
|
+
|
|
108
|
+
{
|
|
109
|
+
cat << EOF
|
|
110
|
+
# Review Comments
|
|
111
|
+
|
|
112
|
+
**PR:** #${pr_number}
|
|
113
|
+
**Inline threads (unresolved):** ${thread_count}
|
|
114
|
+
**Review comments (unreacted):** ${review_count}
|
|
115
|
+
|
|
116
|
+
To resolve an inline thread after addressing it:
|
|
117
|
+
\`\`\`bash
|
|
118
|
+
${resolve_cmd}
|
|
119
|
+
\`\`\`
|
|
120
|
+
|
|
121
|
+
To mark a review as addressed (adds reaction):
|
|
122
|
+
\`\`\`bash
|
|
123
|
+
${react_cmd}
|
|
124
|
+
\`\`\`
|
|
125
|
+
|
|
126
|
+
EOF
|
|
127
|
+
|
|
128
|
+
if [[ "$thread_count" -gt 0 ]]; then
|
|
129
|
+
echo "---"
|
|
130
|
+
echo ""
|
|
131
|
+
echo "# Inline Comments (Unresolved)"
|
|
132
|
+
echo ""
|
|
133
|
+
|
|
134
|
+
local is_first=true
|
|
135
|
+
while IFS= read -r thread; do
|
|
136
|
+
print_thread "$thread" "$is_first"
|
|
137
|
+
is_first=false
|
|
138
|
+
done < <(echo "$unresolved_threads" | jq -c '.[]')
|
|
139
|
+
fi
|
|
140
|
+
|
|
141
|
+
if [[ "$review_count" -gt 0 ]]; then
|
|
142
|
+
echo ""
|
|
143
|
+
echo "---"
|
|
144
|
+
echo ""
|
|
145
|
+
echo "# Review Comments (${review_count})"
|
|
146
|
+
echo ""
|
|
147
|
+
|
|
148
|
+
local is_first=true
|
|
149
|
+
while IFS= read -r review; do
|
|
150
|
+
print_review "$review" "$is_first"
|
|
151
|
+
is_first=false
|
|
152
|
+
done < <(echo "$review_bodies" | jq -c '.[]')
|
|
153
|
+
fi
|
|
154
|
+
} > .reviews/prComments.md
|
|
155
|
+
|
|
156
|
+
echo "Saved review comments to .reviews/prComments.md:"
|
|
157
|
+
echo " - ${thread_count} unresolved inline threads"
|
|
158
|
+
echo " - ${review_count} review comments"
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
main "$@"
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Usage: ./review-wait.sh [--timeout=600]
|
|
3
|
+
set -euo pipefail
|
|
4
|
+
|
|
5
|
+
INITIAL_DELAY=10
|
|
6
|
+
POLL_INTERVAL=15
|
|
7
|
+
DEFAULT_TIMEOUT=600
|
|
8
|
+
RUNNING_STATES=(pending in_progress queued waiting requested)
|
|
9
|
+
|
|
10
|
+
die() {
|
|
11
|
+
echo "Error: $1" >&2
|
|
12
|
+
exit "${2:-1}"
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
check_dependencies() {
|
|
16
|
+
command -v gh &>/dev/null || die "gh CLI is not installed. Install from https://cli.github.com/"
|
|
17
|
+
command -v jq &>/dev/null || die "jq is not installed. Install from https://stedolan.github.io/jq/download/"
|
|
18
|
+
gh auth status &>/dev/null || die "gh CLI is not authenticated. Run 'gh auth login' first."
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
get_pr_number() {
|
|
22
|
+
local pr_number
|
|
23
|
+
pr_number=$(gh pr view --json number -q '.number' 2>/dev/null) || die "No PR found for current branch"
|
|
24
|
+
[[ -n "$pr_number" ]] || die "No PR found for current branch"
|
|
25
|
+
echo "$pr_number"
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
# Returns aggregated state: if any check is running, returns that state; otherwise returns the last state
|
|
29
|
+
get_ci_state() {
|
|
30
|
+
local pr_number="$1"
|
|
31
|
+
local checks_json states
|
|
32
|
+
checks_json=$(gh pr checks "$pr_number" --json name,state 2>&1) || die "Failed to fetch PR checks: $checks_json"
|
|
33
|
+
|
|
34
|
+
# Get all check states
|
|
35
|
+
states=$(echo "$checks_json" | jq -r '[.[] | .state] | if length == 0 then "" else .[] end' 2>/dev/null)
|
|
36
|
+
|
|
37
|
+
[[ -z "$states" ]] && return
|
|
38
|
+
|
|
39
|
+
# If any state is a running state, return it (priority: running > completed)
|
|
40
|
+
local state
|
|
41
|
+
while IFS= read -r state; do
|
|
42
|
+
if is_running_state "$state"; then
|
|
43
|
+
echo "$state"
|
|
44
|
+
return
|
|
45
|
+
fi
|
|
46
|
+
done <<< "$states"
|
|
47
|
+
|
|
48
|
+
# No running states found, return the last state
|
|
49
|
+
echo "$states" | tail -n1
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
is_running_state() {
|
|
53
|
+
local state="${1,,}"
|
|
54
|
+
local s
|
|
55
|
+
for s in "${RUNNING_STATES[@]}"; do
|
|
56
|
+
[[ "$state" == "$s" ]] && return 0
|
|
57
|
+
done
|
|
58
|
+
return 1
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
parse_args() {
|
|
62
|
+
TIMEOUT=$DEFAULT_TIMEOUT
|
|
63
|
+
local arg value
|
|
64
|
+
for arg in "$@"; do
|
|
65
|
+
if [[ "$arg" == --timeout=* ]]; then
|
|
66
|
+
value="${arg#--timeout=}"
|
|
67
|
+
if [[ "$value" =~ ^[0-9]+$ ]] && [[ "$value" -gt 0 ]]; then
|
|
68
|
+
TIMEOUT="$value"
|
|
69
|
+
else
|
|
70
|
+
echo "Invalid timeout value, using default" >&2
|
|
71
|
+
fi
|
|
72
|
+
fi
|
|
73
|
+
done
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
main() {
|
|
77
|
+
parse_args "$@"
|
|
78
|
+
check_dependencies
|
|
79
|
+
|
|
80
|
+
local pr_number state start_time current_time elapsed
|
|
81
|
+
pr_number=$(get_pr_number)
|
|
82
|
+
|
|
83
|
+
echo "Waiting ${INITIAL_DELAY}s for CI to start..."
|
|
84
|
+
sleep "$INITIAL_DELAY"
|
|
85
|
+
|
|
86
|
+
state=$(get_ci_state "$pr_number")
|
|
87
|
+
|
|
88
|
+
if [[ -z "$state" ]] || ! is_running_state "$state"; then
|
|
89
|
+
echo "No CI checks in progress"
|
|
90
|
+
exit 0
|
|
91
|
+
fi
|
|
92
|
+
|
|
93
|
+
echo "Waiting for CI checks on PR #${pr_number}..."
|
|
94
|
+
start_time=$(date +%s)
|
|
95
|
+
|
|
96
|
+
while true; do
|
|
97
|
+
current_time=$(date +%s)
|
|
98
|
+
elapsed=$((current_time - start_time))
|
|
99
|
+
|
|
100
|
+
if [[ $elapsed -ge $TIMEOUT ]]; then
|
|
101
|
+
echo ""
|
|
102
|
+
echo "Timeout after ${TIMEOUT}s"
|
|
103
|
+
exit 1
|
|
104
|
+
fi
|
|
105
|
+
|
|
106
|
+
state=$(get_ci_state "$pr_number")
|
|
107
|
+
|
|
108
|
+
if [[ -z "$state" ]] || ! is_running_state "$state"; then
|
|
109
|
+
echo ""
|
|
110
|
+
echo "CI completed${state:+ ($state)}"
|
|
111
|
+
exit 0
|
|
112
|
+
fi
|
|
113
|
+
|
|
114
|
+
printf "."
|
|
115
|
+
sleep "$POLL_INTERVAL"
|
|
116
|
+
done
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
main "$@"
|