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.
@@ -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
+ ![Autonomous Review Loop](assets/review_loop.webp)
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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.28.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ reviewloop = reviewloop_cli.cli:main
@@ -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)
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 "$@"