claude-dev-env 1.37.1 → 1.38.0
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.
- package/CLAUDE.md +3 -0
- package/_shared/pr-loop/audit-contract.md +4 -3
- package/_shared/pr-loop/fix-protocol.md +2 -0
- package/_shared/pr-loop/gh-payloads.md +38 -37
- package/_shared/pr-loop/scripts/README.md +0 -1
- package/_shared/pr-loop/scripts/preflight.py +2 -1
- package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +2 -2
- package/_shared/pr-loop/scripts/tests/test_preflight.py +22 -0
- package/_shared/pr-loop/state-schema.md +10 -10
- package/agents/clean-coder.md +4 -0
- package/agents/code-quality-agent.md +23 -85
- package/agents/groq-coder.md +8 -6
- package/hooks/blocking/__init__.py +0 -0
- package/hooks/blocking/hedging_language_blocker.py +2 -2
- package/hooks/blocking/state_description_blocker.py +243 -0
- package/hooks/blocking/tdd_enforcer.py +94 -0
- package/hooks/blocking/test_hedging_language_blocker.py +1 -1
- package/hooks/blocking/test_state_description_blocker.py +618 -0
- package/hooks/blocking/test_tdd_enforcer.py +152 -0
- package/hooks/config/state_description_blocker_constants.py +130 -0
- package/hooks/hooks.json +10 -0
- package/package.json +1 -1
- package/rules/no-historical-clutter.md +31 -10
- package/scripts/config/groq_bugteam_config.py +13 -5
- package/skills/bugteam/CONSTRAINTS.md +20 -27
- package/skills/bugteam/EXAMPLES.md +1 -1
- package/skills/bugteam/PROMPTS.md +60 -31
- package/skills/bugteam/SKILL.md +47 -47
- package/skills/bugteam/SKILL_EVALS.md +8 -8
- package/skills/bugteam/reference/github-pr-reviews.md +31 -31
- package/skills/bugteam/reference/team-setup.md +1 -1
- package/skills/bugteam/reference/teardown-publish-permissions.md +4 -4
- package/skills/copilot-review/SKILL.md +7 -14
- package/skills/findbugs/SKILL.md +2 -2
- package/skills/fixbugs/SKILL.md +1 -1
- package/skills/monitor-open-prs/SKILL.md +6 -6
- package/skills/pr-converge/SKILL.md +7 -6
- package/skills/pr-converge/reference/convergence-gates.md +28 -30
- package/skills/pr-converge/reference/examples.md +4 -4
- package/skills/pr-converge/reference/fix-protocol.md +6 -8
- package/skills/pr-converge/reference/multi-pr-orchestration.md +10 -10
- package/skills/pr-converge/reference/per-tick.md +18 -33
- package/skills/pr-converge/reference/stop-conditions.md +7 -7
- package/skills/pr-converge/scripts/README.md +65 -117
- package/skills/pr-review-responder/EXAMPLES.md +2 -2
- package/skills/pr-review-responder/PRINCIPLES.md +2 -8
- package/skills/pr-review-responder/README.md +7 -48
- package/skills/pr-review-responder/SKILL.md +2 -3
- package/skills/pr-review-responder/TESTING.md +8 -65
- package/skills/qbug/SKILL.md +10 -16
- package/_shared/pr-loop/scripts/config/gh_util_constants.py +0 -31
- package/_shared/pr-loop/scripts/gh_util.py +0 -193
- package/_shared/pr-loop/scripts/tests/test_gh_util.py +0 -257
- package/_shared/pr-loop/scripts/tests/test_gh_util_constants.py +0 -61
- package/skills/pr-converge/scripts/check_pr_mergeability.py +0 -78
- package/skills/pr-converge/scripts/config/pr_converge_constants.py +0 -134
- package/skills/pr-converge/scripts/config/test_pr_converge_constants.py +0 -152
- package/skills/pr-converge/scripts/fetch_bugbot_inline_comments.py +0 -70
- package/skills/pr-converge/scripts/fetch_bugbot_reviews.py +0 -57
- package/skills/pr-converge/scripts/fetch_claude_inline_comments.py +0 -70
- package/skills/pr-converge/scripts/fetch_claude_reviews.py +0 -61
- package/skills/pr-converge/scripts/fetch_copilot_inline_comments.py +0 -70
- package/skills/pr-converge/scripts/fetch_copilot_reviews.py +0 -61
- package/skills/pr-converge/scripts/mark_pr_ready.py +0 -54
- package/skills/pr-converge/scripts/post-bugbot-run.helpers.ps1 +0 -49
- package/skills/pr-converge/scripts/post-bugbot-run.ps1 +0 -33
- package/skills/pr-converge/scripts/reply_to_inline_comment.py +0 -84
- package/skills/pr-converge/scripts/request_copilot_review.py +0 -71
- package/skills/pr-converge/scripts/resolve_pr_head.py +0 -58
- package/skills/pr-converge/scripts/review_field_helpers.py +0 -43
- package/skills/pr-converge/scripts/reviewer_fetch_core.py +0 -153
- package/skills/pr-converge/scripts/reviewer_specs.py +0 -98
- package/skills/pr-converge/scripts/test_check_pr_mergeability.py +0 -126
- package/skills/pr-converge/scripts/test_fetch_bugbot_inline_comments.py +0 -443
- package/skills/pr-converge/scripts/test_fetch_bugbot_reviews.py +0 -299
- package/skills/pr-converge/scripts/test_fetch_claude_inline_comments.py +0 -485
- package/skills/pr-converge/scripts/test_fetch_claude_reviews.py +0 -368
- package/skills/pr-converge/scripts/test_fetch_copilot_inline_comments.py +0 -440
- package/skills/pr-converge/scripts/test_fetch_copilot_reviews.py +0 -366
- package/skills/pr-converge/scripts/test_mark_pr_ready.py +0 -69
- package/skills/pr-converge/scripts/test_post_bugbot_run.py +0 -195
- package/skills/pr-converge/scripts/test_reply_to_inline_comment.py +0 -159
- package/skills/pr-converge/scripts/test_request_copilot_review.py +0 -101
- package/skills/pr-converge/scripts/test_resolve_pr_head.py +0 -79
- package/skills/pr-converge/scripts/test_review_field_helpers.py +0 -80
- package/skills/pr-converge/scripts/test_reviewer_fetch_core.py +0 -448
- package/skills/pr-converge/scripts/test_reviewer_specs.py +0 -107
- package/skills/pr-converge/scripts/test_trigger_bugbot.py +0 -139
- package/skills/pr-converge/scripts/test_view_pr_context.py +0 -155
- package/skills/pr-converge/scripts/trigger_bugbot.py +0 -77
- package/skills/pr-converge/scripts/view_pr_context.py +0 -78
- package/skills/pr-review-responder/scripts/respond_to_reviews.py +0 -376
|
@@ -1,155 +0,0 @@
|
|
|
1
|
-
"""Tests for view_pr_context.
|
|
2
|
-
|
|
3
|
-
Covers:
|
|
4
|
-
- gh pr view is invoked with the documented --json field list
|
|
5
|
-
- the parsed JSON object is returned
|
|
6
|
-
- subprocess errors propagate
|
|
7
|
-
"""
|
|
8
|
-
|
|
9
|
-
from __future__ import annotations
|
|
10
|
-
|
|
11
|
-
import importlib.util
|
|
12
|
-
import json
|
|
13
|
-
import subprocess
|
|
14
|
-
from pathlib import Path
|
|
15
|
-
from types import ModuleType
|
|
16
|
-
from unittest.mock import MagicMock, patch
|
|
17
|
-
|
|
18
|
-
import pytest
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
def _load_module() -> ModuleType:
|
|
22
|
-
module_path = Path(__file__).parent / "view_pr_context.py"
|
|
23
|
-
spec = importlib.util.spec_from_file_location("view_pr_context", module_path)
|
|
24
|
-
assert spec is not None
|
|
25
|
-
assert spec.loader is not None
|
|
26
|
-
module = importlib.util.module_from_spec(spec)
|
|
27
|
-
spec.loader.exec_module(module)
|
|
28
|
-
return module
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
view_pr_context_module = _load_module()
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
def _completed(stdout: str) -> subprocess.CompletedProcess:
|
|
35
|
-
process = MagicMock(spec=subprocess.CompletedProcess)
|
|
36
|
-
process.stdout = stdout
|
|
37
|
-
process.returncode = 0
|
|
38
|
-
return process
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
def test_should_invoke_gh_pr_view_with_documented_field_list() -> None:
|
|
42
|
-
payload = json.dumps(
|
|
43
|
-
{
|
|
44
|
-
"number": 42,
|
|
45
|
-
"url": "https://github.com/acme/widget/pull/42",
|
|
46
|
-
"headRefOid": "abc123",
|
|
47
|
-
"baseRefName": "main",
|
|
48
|
-
"headRefName": "feat/x",
|
|
49
|
-
"isDraft": True,
|
|
50
|
-
}
|
|
51
|
-
)
|
|
52
|
-
with patch("subprocess.run") as mock_run:
|
|
53
|
-
mock_run.return_value = _completed(payload)
|
|
54
|
-
view_pr_context_module.view_pr_context()
|
|
55
|
-
invoked_argv = mock_run.call_args[0][0]
|
|
56
|
-
assert invoked_argv[0:3] == ["gh", "pr", "view"]
|
|
57
|
-
assert "--json" in invoked_argv
|
|
58
|
-
fields_arg = invoked_argv[invoked_argv.index("--json") + 1]
|
|
59
|
-
for required_field in (
|
|
60
|
-
"number",
|
|
61
|
-
"url",
|
|
62
|
-
"headRefOid",
|
|
63
|
-
"baseRefName",
|
|
64
|
-
"headRefName",
|
|
65
|
-
"isDraft",
|
|
66
|
-
):
|
|
67
|
-
assert required_field in fields_arg
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
def test_should_return_parsed_json_object() -> None:
|
|
71
|
-
payload = {
|
|
72
|
-
"number": 42,
|
|
73
|
-
"url": "https://github.com/acme/widget/pull/42",
|
|
74
|
-
"headRefOid": "abc123",
|
|
75
|
-
"baseRefName": "main",
|
|
76
|
-
"headRefName": "feat/x",
|
|
77
|
-
"isDraft": True,
|
|
78
|
-
}
|
|
79
|
-
with patch("subprocess.run") as mock_run:
|
|
80
|
-
mock_run.return_value = _completed(json.dumps(payload))
|
|
81
|
-
pr_context = view_pr_context_module.view_pr_context()
|
|
82
|
-
assert pr_context == payload
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
def test_should_raise_when_gh_subprocess_fails() -> None:
|
|
86
|
-
failure = subprocess.CalledProcessError(
|
|
87
|
-
returncode=1, cmd=["gh"], stderr="auth failure"
|
|
88
|
-
)
|
|
89
|
-
with patch("subprocess.run", side_effect=failure):
|
|
90
|
-
with pytest.raises(subprocess.CalledProcessError):
|
|
91
|
-
view_pr_context_module.view_pr_context()
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
def test_should_append_number_and_repo_flag_when_owner_repo_and_number_provided() -> None:
|
|
95
|
-
payload = json.dumps(
|
|
96
|
-
{
|
|
97
|
-
"number": 25,
|
|
98
|
-
"url": "https://github.com/acme/widget/pull/25",
|
|
99
|
-
"headRefOid": "abc123",
|
|
100
|
-
"baseRefName": "main",
|
|
101
|
-
"headRefName": "feat/x",
|
|
102
|
-
"isDraft": True,
|
|
103
|
-
}
|
|
104
|
-
)
|
|
105
|
-
with patch("subprocess.run") as mock_run:
|
|
106
|
-
mock_run.return_value = _completed(payload)
|
|
107
|
-
view_pr_context_module.view_pr_context(number="25", owner="acme", repo="widget")
|
|
108
|
-
invoked_argv = mock_run.call_args[0][0]
|
|
109
|
-
assert "25" in invoked_argv
|
|
110
|
-
assert "--repo" in invoked_argv
|
|
111
|
-
assert "acme/widget" in invoked_argv
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
def test_should_pass_imported_constant_directly_without_local_alias() -> None:
|
|
115
|
-
payload = json.dumps(
|
|
116
|
-
{
|
|
117
|
-
"number": 7,
|
|
118
|
-
"url": "https://github.com/acme/widget/pull/7",
|
|
119
|
-
"headRefOid": "deadbeef",
|
|
120
|
-
"baseRefName": "main",
|
|
121
|
-
"headRefName": "feat/y",
|
|
122
|
-
"isDraft": False,
|
|
123
|
-
}
|
|
124
|
-
)
|
|
125
|
-
with patch("subprocess.run") as mock_run:
|
|
126
|
-
mock_run.return_value = _completed(payload)
|
|
127
|
-
view_pr_context_module.view_pr_context()
|
|
128
|
-
invoked_argv = mock_run.call_args[0][0]
|
|
129
|
-
fields_arg = invoked_argv[invoked_argv.index("--json") + 1]
|
|
130
|
-
expected_fields = view_pr_context_module.PR_CONTEXT_FIELDS
|
|
131
|
-
assert fields_arg is expected_fields
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
def test_should_not_exit_when_number_provided_alone() -> None:
|
|
135
|
-
payload = json.dumps(
|
|
136
|
-
{
|
|
137
|
-
"number": 42,
|
|
138
|
-
"url": "https://github.com/acme/widget/pull/42",
|
|
139
|
-
"headRefOid": "abc123",
|
|
140
|
-
"baseRefName": "main",
|
|
141
|
-
"headRefName": "feat/x",
|
|
142
|
-
"isDraft": True,
|
|
143
|
-
}
|
|
144
|
-
)
|
|
145
|
-
with patch("subprocess.run") as mock_run:
|
|
146
|
-
mock_run.return_value = _completed(payload)
|
|
147
|
-
with patch("sys.argv", ["view_pr_context.py", "--number", "42"]):
|
|
148
|
-
return_code = view_pr_context_module.main()
|
|
149
|
-
assert return_code == 0
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
def test_should_exit_when_owner_and_repo_provided_without_number() -> None:
|
|
153
|
-
with patch("sys.argv", ["view_pr_context.py", "--owner", "acme", "--repo", "widget"]):
|
|
154
|
-
with pytest.raises(SystemExit):
|
|
155
|
-
view_pr_context_module.main()
|
|
@@ -1,77 +0,0 @@
|
|
|
1
|
-
"""Post a `bugbot run` comment to re-trigger a Cursor Bugbot review.
|
|
2
|
-
|
|
3
|
-
Writes the literal trigger phrase to a temp file (per the gh-body-file rule —
|
|
4
|
-
`gh pr comment --body "..."` may corrupt backticks), invokes
|
|
5
|
-
`gh pr comment --body-file`, and removes the temp file on success or failure.
|
|
6
|
-
"""
|
|
7
|
-
|
|
8
|
-
import argparse
|
|
9
|
-
import os
|
|
10
|
-
import subprocess
|
|
11
|
-
import sys
|
|
12
|
-
import tempfile
|
|
13
|
-
from pathlib import Path
|
|
14
|
-
|
|
15
|
-
if str(Path(__file__).resolve().parent) not in sys.path:
|
|
16
|
-
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
17
|
-
|
|
18
|
-
from evict_cached_config_modules import evict_cached_config_modules
|
|
19
|
-
|
|
20
|
-
evict_cached_config_modules()
|
|
21
|
-
|
|
22
|
-
from config.pr_converge_constants import (
|
|
23
|
-
BUGBOT_RUN_TEMPFILE_PREFIX,
|
|
24
|
-
BUGBOT_RUN_TEMPFILE_SUFFIX,
|
|
25
|
-
BUGBOT_RUN_TRIGGER_PHRASE,
|
|
26
|
-
GH_REPO_ARG_TEMPLATE,
|
|
27
|
-
)
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
def trigger_bugbot(*, owner: str, repo: str, number: int) -> str:
|
|
31
|
-
"""Post the bugbot re-trigger comment, return the comment URL gh emits."""
|
|
32
|
-
file_descriptor, raw_path = tempfile.mkstemp(
|
|
33
|
-
suffix=BUGBOT_RUN_TEMPFILE_SUFFIX, prefix=BUGBOT_RUN_TEMPFILE_PREFIX
|
|
34
|
-
)
|
|
35
|
-
try:
|
|
36
|
-
os.close(file_descriptor)
|
|
37
|
-
body_file_path = Path(raw_path)
|
|
38
|
-
body_file_path.write_text(BUGBOT_RUN_TRIGGER_PHRASE, encoding="utf-8")
|
|
39
|
-
repo_arg = GH_REPO_ARG_TEMPLATE.format(owner=owner, repo=repo)
|
|
40
|
-
gh_command: list[str] = [
|
|
41
|
-
"gh",
|
|
42
|
-
"pr",
|
|
43
|
-
"comment",
|
|
44
|
-
str(number),
|
|
45
|
-
"--repo",
|
|
46
|
-
repo_arg,
|
|
47
|
-
"--body-file",
|
|
48
|
-
str(body_file_path),
|
|
49
|
-
]
|
|
50
|
-
completed = subprocess.run(
|
|
51
|
-
gh_command,
|
|
52
|
-
capture_output=True,
|
|
53
|
-
check=True,
|
|
54
|
-
text=True,
|
|
55
|
-
encoding="utf-8",
|
|
56
|
-
errors="replace",
|
|
57
|
-
)
|
|
58
|
-
return completed.stdout.strip()
|
|
59
|
-
finally:
|
|
60
|
-
Path(raw_path).unlink(missing_ok=True)
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
def main() -> int:
|
|
64
|
-
parser = argparse.ArgumentParser(description=__doc__)
|
|
65
|
-
parser.add_argument("--owner", required=True)
|
|
66
|
-
parser.add_argument("--repo", required=True)
|
|
67
|
-
parser.add_argument("--number", required=True, type=int)
|
|
68
|
-
parsed_arguments = parser.parse_args()
|
|
69
|
-
comment_url = trigger_bugbot(
|
|
70
|
-
owner=parsed_arguments.owner, repo=parsed_arguments.repo, number=parsed_arguments.number
|
|
71
|
-
)
|
|
72
|
-
sys.stdout.write(f"{comment_url}\n")
|
|
73
|
-
return 0
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
if __name__ == "__main__":
|
|
77
|
-
sys.exit(main())
|
|
@@ -1,78 +0,0 @@
|
|
|
1
|
-
"""Resolve the per-tick PR context (number, url, head sha, branch names, draft state).
|
|
2
|
-
|
|
3
|
-
Wraps `gh pr view --json ...` so the skill body emits one script invocation
|
|
4
|
-
instead of repeating the field list inline.
|
|
5
|
-
"""
|
|
6
|
-
|
|
7
|
-
import argparse
|
|
8
|
-
import json
|
|
9
|
-
import subprocess
|
|
10
|
-
import sys
|
|
11
|
-
from pathlib import Path
|
|
12
|
-
|
|
13
|
-
if str(Path(__file__).resolve().parent) not in sys.path:
|
|
14
|
-
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
15
|
-
|
|
16
|
-
from evict_cached_config_modules import evict_cached_config_modules
|
|
17
|
-
|
|
18
|
-
evict_cached_config_modules()
|
|
19
|
-
|
|
20
|
-
from config.pr_converge_constants import (
|
|
21
|
-
GH_REPO_ARG_TEMPLATE,
|
|
22
|
-
GH_REPO_FLAG,
|
|
23
|
-
PR_CONTEXT_FIELDS,
|
|
24
|
-
PR_DETACHED_HEAD_ARGS_ERROR,
|
|
25
|
-
PR_NUMBER_ARG_FLAG,
|
|
26
|
-
PR_NUMBER_ARG_HELP,
|
|
27
|
-
PR_OWNER_ARG_FLAG,
|
|
28
|
-
PR_OWNER_ARG_HELP,
|
|
29
|
-
PR_REPO_ARG_FLAG,
|
|
30
|
-
PR_REPO_ARG_HELP,
|
|
31
|
-
)
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
def view_pr_context(
|
|
35
|
-
number: str | None = None,
|
|
36
|
-
owner: str | None = None,
|
|
37
|
-
repo: str | None = None,
|
|
38
|
-
) -> dict[str, object]:
|
|
39
|
-
"""Return the parsed JSON object from `gh pr view --json <fields>`."""
|
|
40
|
-
gh_command: list[str] = ["gh", "pr", "view", "--json", PR_CONTEXT_FIELDS]
|
|
41
|
-
if owner and repo and number:
|
|
42
|
-
gh_command.append(number)
|
|
43
|
-
gh_command.append(GH_REPO_FLAG)
|
|
44
|
-
gh_command.append(GH_REPO_ARG_TEMPLATE.format(owner=owner, repo=repo))
|
|
45
|
-
elif number:
|
|
46
|
-
gh_command.append(number)
|
|
47
|
-
completed = subprocess.run(
|
|
48
|
-
gh_command,
|
|
49
|
-
capture_output=True,
|
|
50
|
-
check=True,
|
|
51
|
-
text=True,
|
|
52
|
-
encoding="utf-8",
|
|
53
|
-
errors="replace",
|
|
54
|
-
)
|
|
55
|
-
return json.loads(completed.stdout)
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
def main() -> int:
|
|
59
|
-
parser = argparse.ArgumentParser(description=__doc__)
|
|
60
|
-
parser.add_argument(PR_NUMBER_ARG_FLAG, default=None, help=PR_NUMBER_ARG_HELP)
|
|
61
|
-
parser.add_argument(PR_OWNER_ARG_FLAG, default=None, help=PR_OWNER_ARG_HELP)
|
|
62
|
-
parser.add_argument(PR_REPO_ARG_FLAG, default=None, help=PR_REPO_ARG_HELP)
|
|
63
|
-
parsed = parser.parse_args()
|
|
64
|
-
number = (parsed.number.strip() or None) if parsed.number else None
|
|
65
|
-
owner = (parsed.owner.strip() or None) if parsed.owner else None
|
|
66
|
-
repo = (parsed.repo.strip() or None) if parsed.repo else None
|
|
67
|
-
needs_repo = owner is not None or repo is not None
|
|
68
|
-
has_all = number is not None and owner is not None and repo is not None
|
|
69
|
-
if needs_repo and not has_all:
|
|
70
|
-
parser.error(PR_DETACHED_HEAD_ARGS_ERROR)
|
|
71
|
-
pr_context = view_pr_context(number=number, owner=owner, repo=repo)
|
|
72
|
-
json.dump(pr_context, sys.stdout)
|
|
73
|
-
sys.stdout.write("\n")
|
|
74
|
-
return 0
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
if __name__ == "__main__":
|
|
78
|
-
sys.exit(main())
|
|
@@ -1,376 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
"""
|
|
3
|
-
Standalone script to respond to GitHub PR review comments.
|
|
4
|
-
|
|
5
|
-
Usage:
|
|
6
|
-
python respond_to_reviews.py [--pr PR_NUMBER] [--auto-approve]
|
|
7
|
-
|
|
8
|
-
Requirements:
|
|
9
|
-
- gh CLI installed and authenticated
|
|
10
|
-
- Git repository with GitHub remote
|
|
11
|
-
- Python 3.8+
|
|
12
|
-
"""
|
|
13
|
-
|
|
14
|
-
import json
|
|
15
|
-
import subprocess
|
|
16
|
-
import sys
|
|
17
|
-
from dataclasses import dataclass
|
|
18
|
-
from pathlib import Path
|
|
19
|
-
from typing import Dict, List, Optional, Set
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
@dataclass
|
|
23
|
-
class ReviewComment:
|
|
24
|
-
id: int
|
|
25
|
-
path: str
|
|
26
|
-
line: int
|
|
27
|
-
body: str
|
|
28
|
-
user: str
|
|
29
|
-
created_at: str
|
|
30
|
-
in_reply_to: Optional[int]
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
@dataclass
|
|
34
|
-
class FileChange:
|
|
35
|
-
path: str
|
|
36
|
-
lines_changed: Set[int]
|
|
37
|
-
diff: str
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
def run_command(cmd: List[str]) -> str:
|
|
41
|
-
"""Run shell command and return output."""
|
|
42
|
-
try:
|
|
43
|
-
result = subprocess.run(
|
|
44
|
-
cmd,
|
|
45
|
-
capture_output=True,
|
|
46
|
-
text=True,
|
|
47
|
-
check=True
|
|
48
|
-
)
|
|
49
|
-
return result.stdout.strip()
|
|
50
|
-
except subprocess.CalledProcessError as e:
|
|
51
|
-
print(f"Error running command: {' '.join(cmd)}", file=sys.stderr)
|
|
52
|
-
print(f"Error: {e.stderr}", file=sys.stderr)
|
|
53
|
-
sys.exit(1)
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
def get_current_pr() -> Optional[Dict]:
|
|
57
|
-
"""Get PR number for current branch."""
|
|
58
|
-
output = run_command(['gh', 'pr', 'view', '--json', 'number,title,url'])
|
|
59
|
-
if not output:
|
|
60
|
-
return None
|
|
61
|
-
return json.loads(output)
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
def get_review_comments(pr_number: int, repo: str) -> List[ReviewComment]:
|
|
65
|
-
"""Fetch all review comments from PR."""
|
|
66
|
-
cmd = [
|
|
67
|
-
'gh', 'api',
|
|
68
|
-
f'repos/{repo}/pulls/{pr_number}/comments',
|
|
69
|
-
'--jq',
|
|
70
|
-
'.[] | {id, path, line, body, user: .user.login, created_at, in_reply_to}'
|
|
71
|
-
]
|
|
72
|
-
output = run_command(cmd)
|
|
73
|
-
|
|
74
|
-
comments = []
|
|
75
|
-
for line in output.split('\n'):
|
|
76
|
-
if not line:
|
|
77
|
-
continue
|
|
78
|
-
data = json.loads(line)
|
|
79
|
-
comments.append(ReviewComment(
|
|
80
|
-
id=data['id'],
|
|
81
|
-
path=data['path'],
|
|
82
|
-
line=data['line'],
|
|
83
|
-
body=data['body'],
|
|
84
|
-
user=data['user'],
|
|
85
|
-
created_at=data['created_at'],
|
|
86
|
-
in_reply_to=data.get('in_reply_to')
|
|
87
|
-
))
|
|
88
|
-
|
|
89
|
-
return comments
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
def get_current_user() -> str:
|
|
93
|
-
"""Get current GitHub username."""
|
|
94
|
-
return run_command(['gh', 'api', 'user', '--jq', '.login'])
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
def filter_unresponded_comments(
|
|
98
|
-
comments: List[ReviewComment],
|
|
99
|
-
current_user: str
|
|
100
|
-
) -> List[ReviewComment]:
|
|
101
|
-
"""Filter for comments that haven't been responded to."""
|
|
102
|
-
# Group comments by thread (in_reply_to chain)
|
|
103
|
-
threads: Dict[int, List[ReviewComment]] = {}
|
|
104
|
-
|
|
105
|
-
for comment in comments:
|
|
106
|
-
if comment.in_reply_to is None:
|
|
107
|
-
# Top-level comment
|
|
108
|
-
thread_id = comment.id
|
|
109
|
-
else:
|
|
110
|
-
# Reply to another comment
|
|
111
|
-
thread_id = comment.in_reply_to
|
|
112
|
-
|
|
113
|
-
if thread_id not in threads:
|
|
114
|
-
threads[thread_id] = []
|
|
115
|
-
threads[thread_id].append(comment)
|
|
116
|
-
|
|
117
|
-
# Find threads where we haven't replied
|
|
118
|
-
unresponded = []
|
|
119
|
-
for thread_id, thread_comments in threads.items():
|
|
120
|
-
# Check if current user has replied in this thread
|
|
121
|
-
user_replied = any(c.user == current_user for c in thread_comments)
|
|
122
|
-
|
|
123
|
-
if not user_replied:
|
|
124
|
-
# Find the original comment (first in thread)
|
|
125
|
-
original = min(thread_comments, key=lambda c: c.created_at)
|
|
126
|
-
if original.user != current_user:
|
|
127
|
-
unresponded.append(original)
|
|
128
|
-
|
|
129
|
-
return unresponded
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
def get_changed_files() -> List[FileChange]:
|
|
133
|
-
"""Get files changed in the last commit."""
|
|
134
|
-
# Get list of changed files
|
|
135
|
-
files_output = run_command(['git', 'diff', '--name-only', 'HEAD~1..HEAD'])
|
|
136
|
-
|
|
137
|
-
changes = []
|
|
138
|
-
for file_path in files_output.split('\n'):
|
|
139
|
-
if not file_path:
|
|
140
|
-
continue
|
|
141
|
-
|
|
142
|
-
# Get diff for this file
|
|
143
|
-
diff = run_command(['git', 'diff', 'HEAD~1..HEAD', '--', file_path])
|
|
144
|
-
|
|
145
|
-
# Parse changed line numbers from diff
|
|
146
|
-
lines_changed = set()
|
|
147
|
-
for line in diff.split('\n'):
|
|
148
|
-
if line.startswith('@@'):
|
|
149
|
-
# Parse @@ -old_start,old_count +new_start,new_count @@
|
|
150
|
-
parts = line.split(' ')
|
|
151
|
-
if len(parts) >= 3:
|
|
152
|
-
new_range = parts[2] # +new_start,new_count
|
|
153
|
-
if ',' in new_range:
|
|
154
|
-
start, count = new_range[1:].split(',')
|
|
155
|
-
start_line = int(start)
|
|
156
|
-
count_lines = int(count)
|
|
157
|
-
lines_changed.update(range(start_line, start_line + count_lines))
|
|
158
|
-
|
|
159
|
-
changes.append(FileChange(
|
|
160
|
-
path=file_path,
|
|
161
|
-
lines_changed=lines_changed,
|
|
162
|
-
diff=diff
|
|
163
|
-
))
|
|
164
|
-
|
|
165
|
-
return changes
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
def match_comments_to_changes(
|
|
169
|
-
comments: List[ReviewComment],
|
|
170
|
-
changes: List[FileChange]
|
|
171
|
-
) -> List[tuple[ReviewComment, FileChange]]:
|
|
172
|
-
"""Match review comments to file changes."""
|
|
173
|
-
matches = []
|
|
174
|
-
|
|
175
|
-
changes_by_path = {c.path: c for c in changes}
|
|
176
|
-
|
|
177
|
-
for comment in comments:
|
|
178
|
-
if comment.path in changes_by_path:
|
|
179
|
-
change = changes_by_path[comment.path]
|
|
180
|
-
# Check if the commented line was changed
|
|
181
|
-
if comment.line in change.lines_changed or not change.lines_changed:
|
|
182
|
-
# Either the exact line changed, or we changed the file (good enough)
|
|
183
|
-
matches.append((comment, change))
|
|
184
|
-
|
|
185
|
-
return matches
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
def draft_response(comment: ReviewComment, change: FileChange) -> str:
|
|
189
|
-
"""Draft a concise response to a review comment."""
|
|
190
|
-
# Analyze the diff to understand what changed
|
|
191
|
-
diff_lines = change.diff.split('\n')
|
|
192
|
-
|
|
193
|
-
# Look for common patterns
|
|
194
|
-
if 'class ' in change.diff and '- class ' in change.diff:
|
|
195
|
-
return "Removed wrapper class, using direct approach"
|
|
196
|
-
|
|
197
|
-
if 'def ' in change.diff:
|
|
198
|
-
if '+ def ' in change.diff:
|
|
199
|
-
return "Extracted to shared function"
|
|
200
|
-
if 'Type[' in change.diff or ': ' in change.diff:
|
|
201
|
-
return "Added type hints"
|
|
202
|
-
|
|
203
|
-
if 'import ' in change.diff:
|
|
204
|
-
return "Updated imports"
|
|
205
|
-
|
|
206
|
-
if '.css' in comment.path or 'style' in change.diff:
|
|
207
|
-
return "Moved CSS values to stylesheet"
|
|
208
|
-
|
|
209
|
-
if 'select_related' in change.diff or 'prefetch_related' in change.diff:
|
|
210
|
-
return "Added query optimization to eliminate N+1"
|
|
211
|
-
|
|
212
|
-
if comment.path.endswith('.py'):
|
|
213
|
-
# Generic Python change
|
|
214
|
-
return f"Updated {Path(comment.path).name}"
|
|
215
|
-
|
|
216
|
-
# Generic fallback
|
|
217
|
-
return f"Addressed feedback in {comment.path}"
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
def post_response(comment_id: int, response: str, repo: str) -> bool:
|
|
221
|
-
"""Post response to GitHub review comment."""
|
|
222
|
-
formatted_response = f"✅ **Fixed**: {response}"
|
|
223
|
-
|
|
224
|
-
try:
|
|
225
|
-
run_command([
|
|
226
|
-
'gh', 'api',
|
|
227
|
-
f'repos/{repo}/pulls/comments/{comment_id}/replies',
|
|
228
|
-
'-X', 'POST',
|
|
229
|
-
'-f', f'body={formatted_response}'
|
|
230
|
-
])
|
|
231
|
-
return True
|
|
232
|
-
except Exception as e:
|
|
233
|
-
print(f"Failed to post response: {e}", file=sys.stderr)
|
|
234
|
-
return False
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
def get_repo_name() -> str:
|
|
238
|
-
"""Get owner/repo from git remote."""
|
|
239
|
-
remote_url = run_command(['git', 'remote', 'get-url', 'origin'])
|
|
240
|
-
|
|
241
|
-
# Parse GitHub URL
|
|
242
|
-
# SSH: git@github.com:owner/repo.git
|
|
243
|
-
# HTTPS: https://github.com/owner/repo.git
|
|
244
|
-
|
|
245
|
-
if 'github.com' not in remote_url:
|
|
246
|
-
print("Error: Not a GitHub repository", file=sys.stderr)
|
|
247
|
-
sys.exit(1)
|
|
248
|
-
|
|
249
|
-
if remote_url.startswith('git@'):
|
|
250
|
-
# SSH format
|
|
251
|
-
repo_part = remote_url.split(':')[1]
|
|
252
|
-
else:
|
|
253
|
-
# HTTPS format
|
|
254
|
-
repo_part = '/'.join(remote_url.split('/')[-2:])
|
|
255
|
-
|
|
256
|
-
# Remove .git suffix
|
|
257
|
-
return repo_part.replace('.git', '')
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
def main():
|
|
261
|
-
import argparse
|
|
262
|
-
|
|
263
|
-
parser = argparse.ArgumentParser(
|
|
264
|
-
description='Respond to GitHub PR review comments'
|
|
265
|
-
)
|
|
266
|
-
parser.add_argument(
|
|
267
|
-
'--pr',
|
|
268
|
-
type=int,
|
|
269
|
-
help='PR number (auto-detected if not provided)'
|
|
270
|
-
)
|
|
271
|
-
parser.add_argument(
|
|
272
|
-
'--auto-approve',
|
|
273
|
-
action='store_true',
|
|
274
|
-
help='Auto-approve all responses without confirmation'
|
|
275
|
-
)
|
|
276
|
-
|
|
277
|
-
args = parser.parse_args()
|
|
278
|
-
|
|
279
|
-
# Get PR number
|
|
280
|
-
if args.pr:
|
|
281
|
-
pr_number = args.pr
|
|
282
|
-
pr_title = f"PR #{pr_number}"
|
|
283
|
-
pr_url = ""
|
|
284
|
-
else:
|
|
285
|
-
pr = get_current_pr()
|
|
286
|
-
if not pr:
|
|
287
|
-
print("Error: No PR found for current branch", file=sys.stderr)
|
|
288
|
-
print("Create a PR first or specify --pr NUMBER", file=sys.stderr)
|
|
289
|
-
sys.exit(1)
|
|
290
|
-
pr_number = pr['number']
|
|
291
|
-
pr_title = pr['title']
|
|
292
|
-
pr_url = pr['url']
|
|
293
|
-
|
|
294
|
-
print(f"Checking PR #{pr_number}: {pr_title}")
|
|
295
|
-
if pr_url:
|
|
296
|
-
print(f"URL: {pr_url}")
|
|
297
|
-
print()
|
|
298
|
-
|
|
299
|
-
# Get repository name
|
|
300
|
-
repo = get_repo_name()
|
|
301
|
-
|
|
302
|
-
# Get current user
|
|
303
|
-
current_user = get_current_user()
|
|
304
|
-
|
|
305
|
-
# Fetch review comments
|
|
306
|
-
print("Fetching review comments...")
|
|
307
|
-
all_comments = get_review_comments(pr_number, repo)
|
|
308
|
-
print(f"Found {len(all_comments)} total review comments")
|
|
309
|
-
|
|
310
|
-
# Filter for unresponded comments
|
|
311
|
-
unresponded = filter_unresponded_comments(all_comments, current_user)
|
|
312
|
-
print(f"Found {len(unresponded)} unresponded comments")
|
|
313
|
-
|
|
314
|
-
if not unresponded:
|
|
315
|
-
print("\nNo unresponded comments found!")
|
|
316
|
-
return
|
|
317
|
-
|
|
318
|
-
# Get changed files
|
|
319
|
-
print("\nAnalyzing recent changes...")
|
|
320
|
-
changes = get_changed_files()
|
|
321
|
-
print(f"Found {len(changes)} changed files in last commit")
|
|
322
|
-
|
|
323
|
-
# Match comments to changes
|
|
324
|
-
matches = match_comments_to_changes(unresponded, changes)
|
|
325
|
-
|
|
326
|
-
if not matches:
|
|
327
|
-
print("\nNo review comments match your recent changes.")
|
|
328
|
-
print(f"\nReview comments are about:")
|
|
329
|
-
for comment in unresponded:
|
|
330
|
-
print(f" - {comment.path}:{comment.line}")
|
|
331
|
-
print(f"\nBut you changed:")
|
|
332
|
-
for change in changes:
|
|
333
|
-
print(f" - {change.path}")
|
|
334
|
-
return
|
|
335
|
-
|
|
336
|
-
# Draft responses
|
|
337
|
-
print(f"\nFound {len(matches)} review comments addressed:\n")
|
|
338
|
-
|
|
339
|
-
responses = []
|
|
340
|
-
for i, (comment, change) in enumerate(matches, 1):
|
|
341
|
-
response = draft_response(comment, change)
|
|
342
|
-
responses.append((comment, response))
|
|
343
|
-
|
|
344
|
-
print(f"{i}. @{comment.user} on {comment.path}:{comment.line}")
|
|
345
|
-
print(f" Comment: {comment.body[:80]}...")
|
|
346
|
-
print(f" Response: ✅ **Fixed**: {response}")
|
|
347
|
-
print()
|
|
348
|
-
|
|
349
|
-
# Get approval
|
|
350
|
-
if not args.auto_approve:
|
|
351
|
-
answer = input(f"Post these {len(responses)} responses to the PR? (y/n) ")
|
|
352
|
-
if answer.lower() != 'y':
|
|
353
|
-
print("Cancelled.")
|
|
354
|
-
return
|
|
355
|
-
|
|
356
|
-
# Post responses
|
|
357
|
-
print("\nPosting responses...")
|
|
358
|
-
success_count = 0
|
|
359
|
-
|
|
360
|
-
for comment, response in responses:
|
|
361
|
-
if post_response(comment.id, response, repo):
|
|
362
|
-
print(f" ✓ {comment.path}:{comment.line}")
|
|
363
|
-
success_count += 1
|
|
364
|
-
else:
|
|
365
|
-
print(f" ✗ {comment.path}:{comment.line}")
|
|
366
|
-
|
|
367
|
-
print(f"\nPosted {success_count}/{len(responses)} responses to PR #{pr_number}")
|
|
368
|
-
|
|
369
|
-
if pr_url:
|
|
370
|
-
print(f"View PR: {pr_url}")
|
|
371
|
-
|
|
372
|
-
print("\nReady to push!")
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
if __name__ == '__main__':
|
|
376
|
-
main()
|