claude-dev-env 1.35.0 → 1.36.1

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 (115) hide show
  1. package/agents/clean-coder.md +109 -1
  2. package/bin/install.mjs +28 -8
  3. package/bin/install.test.mjs +9 -1
  4. package/docs/CODE_RULES.md +3 -0
  5. package/docs/agents-md-alignment-plan.md +123 -0
  6. package/hooks/blocking/code_rules_enforcer.py +451 -39
  7. package/hooks/blocking/es_exe_path_rewriter.py +10 -4
  8. package/hooks/blocking/test_code_rules_enforcer.py +182 -0
  9. package/hooks/blocking/test_code_rules_enforcer_banned_identifier.py +106 -0
  10. package/hooks/blocking/test_code_rules_enforcer_cap_meta.py +173 -0
  11. package/hooks/blocking/test_code_rules_enforcer_collection_prefix.py +191 -0
  12. package/hooks/blocking/test_code_rules_enforcer_constant_equality.py +40 -0
  13. package/hooks/blocking/test_code_rules_enforcer_hardcoded_user_path.py +291 -0
  14. package/hooks/blocking/test_code_rules_enforcer_loop_variable_naming.py +87 -3
  15. package/hooks/blocking/test_code_rules_enforcer_naming_pattern.py +49 -0
  16. package/hooks/blocking/test_code_rules_enforcer_sys_path_insert.py +157 -0
  17. package/hooks/blocking/test_code_rules_enforcer_unused_imports.py +244 -0
  18. package/hooks/blocking/test_es_exe_path_rewriter.py +81 -3
  19. package/hooks/blocking/test_windows_rmtree_blocker.py +120 -8
  20. package/hooks/blocking/windows_rmtree_blocker.py +23 -6
  21. package/hooks/config/banned_identifiers_constants.py +24 -0
  22. package/hooks/config/hardcoded_user_path_constants.py +12 -0
  23. package/hooks/config/hook_log_extractor_constants.py +1 -1
  24. package/hooks/config/pre_tool_use_stdin.py +48 -0
  25. package/hooks/config/setup_project_paths_constants.py +4 -0
  26. package/hooks/config/stuttering_check_config.py +14 -0
  27. package/hooks/config/stuttering_import_binding_constants.py +11 -0
  28. package/hooks/config/sys_path_insert_constants.py +4 -0
  29. package/hooks/config/test_banned_identifiers_constants.py +48 -0
  30. package/hooks/config/test_hardcoded_user_path_constants.py +78 -0
  31. package/hooks/config/test_hook_log_extractor_constants.py +3 -3
  32. package/hooks/config/test_pre_tool_use_stdin.py +80 -0
  33. package/hooks/config/unused_module_import_constants.py +7 -0
  34. package/hooks/config/windows_rmtree_blocker_constants.py +3 -0
  35. package/hooks/diagnostic/hook_log_stop_wrapper.py +7 -4
  36. package/hooks/git-hooks/config.py +3 -3
  37. package/hooks/git-hooks/test_gate_utils.py +10 -10
  38. package/hooks/mypy.ini +2 -0
  39. package/package.json +1 -1
  40. package/rules/gh-paginate.md +125 -0
  41. package/skills/bugteam/CONSTRAINTS.md +12 -6
  42. package/skills/bugteam/SKILL.md +364 -154
  43. package/skills/bugteam/SKILL_EVALS.md +25 -23
  44. package/skills/bugteam/reference/README.md +2 -0
  45. package/skills/bugteam/reference/audit-and-teammates.md +2 -2
  46. package/skills/bugteam/reference/teardown-publish-permissions.md +1 -1
  47. package/skills/bugteam/reference/workflow-path-a-orchestrated-teams.md +113 -0
  48. package/skills/bugteam/reference/workflow-path-b-task-harness.md +48 -0
  49. package/skills/bugteam/scripts/reflow_skill_md.py +298 -0
  50. package/skills/bugteam/test_skill_additions.py +13 -4
  51. package/skills/bugteam/test_team_lifecycle.py +103 -0
  52. package/skills/findbugs/SKILL.md +3 -3
  53. package/skills/fixbugs/SKILL.md +4 -4
  54. package/skills/monitor-open-prs/SKILL.md +32 -2
  55. package/skills/monitor-open-prs/test_team_lifecycle.py +46 -0
  56. package/skills/pr-converge/SKILL.md +1206 -131
  57. package/skills/pr-converge/scripts/README.md +145 -0
  58. package/skills/pr-converge/scripts/caller-window-pid.ps1 +86 -0
  59. package/skills/pr-converge/scripts/check_pr_mergeability.py +79 -0
  60. package/skills/pr-converge/scripts/config/pr_converge_constants.py +65 -0
  61. package/skills/pr-converge/scripts/config/test_pr_converge_constants.py +176 -0
  62. package/skills/pr-converge/scripts/cursor-agents-continue-caller.cmd +9 -0
  63. package/skills/pr-converge/scripts/cursor-agents-continue-stop-others.ps1 +16 -0
  64. package/skills/pr-converge/scripts/cursor-agents-continue.ahk +172 -0
  65. package/skills/pr-converge/scripts/cursor-agents-continue.cmd +2 -0
  66. package/skills/pr-converge/scripts/evict_cached_config_modules.py +20 -0
  67. package/skills/pr-converge/scripts/fetch_bugbot_inline_comments.py +110 -0
  68. package/skills/pr-converge/scripts/fetch_bugbot_reviews.py +103 -0
  69. package/skills/pr-converge/scripts/fetch_copilot_inline_comments.py +112 -0
  70. package/skills/pr-converge/scripts/fetch_copilot_reviews.py +121 -0
  71. package/skills/pr-converge/scripts/mark_pr_ready.py +54 -0
  72. package/skills/pr-converge/scripts/open_followup_copilot_pr.py +136 -0
  73. package/skills/pr-converge/scripts/post-bugbot-run.helpers.ps1 +49 -0
  74. package/skills/pr-converge/scripts/post-bugbot-run.ps1 +33 -0
  75. package/skills/pr-converge/scripts/reflow_skill_md.py +288 -0
  76. package/skills/pr-converge/scripts/reply_to_inline_comment.py +84 -0
  77. package/skills/pr-converge/scripts/request_copilot_review.py +71 -0
  78. package/skills/pr-converge/scripts/resolve_pr_head.py +58 -0
  79. package/skills/pr-converge/scripts/review_field_helpers.py +43 -0
  80. package/skills/pr-converge/scripts/test_check_pr_mergeability.py +126 -0
  81. package/skills/pr-converge/scripts/test_evict_cached_config_modules.py +22 -0
  82. package/skills/pr-converge/scripts/test_fetch_bugbot_inline_comments.py +342 -0
  83. package/skills/pr-converge/scripts/test_fetch_bugbot_reviews.py +220 -0
  84. package/skills/pr-converge/scripts/test_fetch_copilot_inline_comments.py +372 -0
  85. package/skills/pr-converge/scripts/test_fetch_copilot_reviews.py +280 -0
  86. package/skills/pr-converge/scripts/test_mark_pr_ready.py +69 -0
  87. package/skills/pr-converge/scripts/test_open_followup_copilot_pr.py +236 -0
  88. package/skills/pr-converge/scripts/test_post_bugbot_run.py +195 -0
  89. package/skills/pr-converge/scripts/test_reply_to_inline_comment.py +159 -0
  90. package/skills/pr-converge/scripts/test_request_copilot_review.py +101 -0
  91. package/skills/pr-converge/scripts/test_resolve_pr_head.py +79 -0
  92. package/skills/pr-converge/scripts/test_review_field_helpers.py +80 -0
  93. package/skills/pr-converge/scripts/test_trigger_bugbot.py +139 -0
  94. package/skills/pr-converge/scripts/test_view_pr_context.py +111 -0
  95. package/skills/pr-converge/scripts/trigger_bugbot.py +77 -0
  96. package/skills/pr-converge/scripts/view_pr_context.py +47 -0
  97. package/skills/pr-converge/test_team_lifecycle.py +56 -0
  98. package/skills/pr-converge/workflows/ahk-auto-continue-loop.md +108 -0
  99. package/skills/pr-converge/workflows/schedule-wakeup-loop.md +37 -0
  100. package/skills/qbug/SKILL.md +4 -4
  101. package/skills/qbug/test_qbug_skill_post_fix_audit.py +2 -2
  102. package/skills/resume-review/SKILL.md +261 -0
  103. package/skills/bugteam/scripts/README.md +0 -58
  104. package/skills/bugteam/scripts/_claude_permissions_common.py +0 -219
  105. package/skills/bugteam/scripts/bugteam_code_rules_gate.py +0 -633
  106. package/skills/bugteam/scripts/bugteam_fix_hookspath.py +0 -260
  107. package/skills/bugteam/scripts/bugteam_preflight.py +0 -201
  108. package/skills/bugteam/scripts/config/bugteam_fix_hookspath_constants.py +0 -17
  109. package/skills/bugteam/scripts/grant_project_claude_permissions.py +0 -109
  110. package/skills/bugteam/scripts/revoke_project_claude_permissions.py +0 -135
  111. package/skills/bugteam/scripts/test_bugteam_code_rules_gate.py +0 -271
  112. package/skills/bugteam/scripts/test_bugteam_fix_hookspath.py +0 -267
  113. package/skills/bugteam/scripts/test_bugteam_preflight.py +0 -189
  114. package/skills/bugteam/scripts/test_claude_permissions_common.py +0 -44
  115. /package/skills/{bugteam → pr-converge}/scripts/config/__init__.py +0 -0
@@ -0,0 +1,58 @@
1
+ """Resolve the current HEAD SHA of a pull request.
2
+
3
+ Calls the single-object PR endpoint (`repos/{owner}/{repo}/pulls/{number}`) which
4
+ is NOT paginated, so `--paginate` / `--slurp` are unnecessary and `gh`'s
5
+ built-in `--jq` is safe to use here.
6
+ """
7
+
8
+ import argparse
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 GH_PR_OBJECT_PATH_TEMPLATE
21
+
22
+
23
+ def resolve_pr_head(*, owner: str, repo: str, number: int) -> str:
24
+ """Return the head_sha for the given PR."""
25
+ pr_endpoint = GH_PR_OBJECT_PATH_TEMPLATE.format(
26
+ owner=owner, repo=repo, number=number
27
+ )
28
+ gh_command: list[str] = [
29
+ "gh",
30
+ "api",
31
+ pr_endpoint,
32
+ "--jq",
33
+ ".head.sha",
34
+ ]
35
+ completed = subprocess.run(
36
+ gh_command,
37
+ capture_output=True,
38
+ check=True,
39
+ text=True,
40
+ encoding="utf-8",
41
+ errors="replace",
42
+ )
43
+ return completed.stdout.strip()
44
+
45
+
46
+ def main() -> int:
47
+ parser = argparse.ArgumentParser(description=__doc__)
48
+ parser.add_argument("--owner", required=True)
49
+ parser.add_argument("--repo", required=True)
50
+ parser.add_argument("--number", required=True, type=int)
51
+ parsed_arguments = parser.parse_args()
52
+ head_sha = resolve_pr_head(owner=parsed_arguments.owner, repo=parsed_arguments.repo, number=parsed_arguments.number)
53
+ sys.stdout.write(f"{head_sha}\n")
54
+ return 0
55
+
56
+
57
+ if __name__ == "__main__":
58
+ sys.exit(main())
@@ -0,0 +1,43 @@
1
+ """Shared field-extraction helpers for GitHub PR review and inline-comment payloads.
2
+
3
+ The four ``fetch_*_reviews.py`` and ``fetch_*_inline_comments.py`` scripts in
4
+ this directory each parse the same JSON shapes and need the same defensive
5
+ field-coercion logic for ``user.login``, ``body``, ``submitted_at``, and
6
+ ``state``. Centralizing the helpers here keeps a single source of truth and
7
+ prevents the bug-fix-in-one-copy-only failure mode flagged on PR #337.
8
+ """
9
+
10
+
11
+ def login_of(field_by_key: dict[str, object]) -> str | None:
12
+ """Return the ``user.login`` string from a review/comment payload, or ``None``."""
13
+ user_field = field_by_key.get("user")
14
+ if not isinstance(user_field, dict):
15
+ return None
16
+ login_field = user_field.get("login")
17
+ if not isinstance(login_field, str):
18
+ return None
19
+ return login_field
20
+
21
+
22
+ def body_of(field_by_key: dict[str, object]) -> str:
23
+ """Return the ``body`` string from a review/comment payload, or ``""`` when missing."""
24
+ body_field = field_by_key.get("body")
25
+ if not isinstance(body_field, str):
26
+ return ""
27
+ return body_field
28
+
29
+
30
+ def submitted_at_of(field_by_key: dict[str, object]) -> str:
31
+ """Return the ``submitted_at`` string from a review payload, or ``""`` when missing."""
32
+ submitted_at_field = field_by_key.get("submitted_at")
33
+ if not isinstance(submitted_at_field, str):
34
+ return ""
35
+ return submitted_at_field
36
+
37
+
38
+ def state_of(field_by_key: dict[str, object]) -> str:
39
+ """Return the ``state`` string from a review payload, or ``""`` when missing."""
40
+ state_field = field_by_key.get("state")
41
+ if not isinstance(state_field, str):
42
+ return ""
43
+ return state_field
@@ -0,0 +1,126 @@
1
+ """Tests for check_pr_mergeability.
2
+
3
+ Covers:
4
+ - gh pr view is invoked with the documented mergeability --json field list
5
+ - the parsed JSON object is returned with mergeable/mergeStateStatus/headRefOid keys
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 / "check_pr_mergeability.py"
23
+ spec = importlib.util.spec_from_file_location("check_pr_mergeability", 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
+ check_pr_mergeability_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_mergeability_field_list() -> None:
42
+ payload = json.dumps(
43
+ {
44
+ "mergeable": "MERGEABLE",
45
+ "mergeStateStatus": "CLEAN",
46
+ "headRefOid": "abc123",
47
+ }
48
+ )
49
+ with patch("subprocess.run") as mock_run:
50
+ mock_run.return_value = _completed(payload)
51
+ check_pr_mergeability_module.check_pr_mergeability(
52
+ owner="acme", repo="widget", number=42
53
+ )
54
+ invoked_argv = mock_run.call_args[0][0]
55
+ assert invoked_argv[0:3] == ["gh", "pr", "view"]
56
+ assert "--json" in invoked_argv
57
+ fields_arg = invoked_argv[invoked_argv.index("--json") + 1]
58
+ for required_field in ("mergeable", "mergeStateStatus", "headRefOid"):
59
+ assert required_field in fields_arg
60
+
61
+
62
+ def test_should_pass_pr_number_and_repo_arg_for_explicit_targeting() -> None:
63
+ payload = json.dumps(
64
+ {
65
+ "mergeable": "MERGEABLE",
66
+ "mergeStateStatus": "CLEAN",
67
+ "headRefOid": "abc123",
68
+ }
69
+ )
70
+ with patch("subprocess.run") as mock_run:
71
+ mock_run.return_value = _completed(payload)
72
+ check_pr_mergeability_module.check_pr_mergeability(
73
+ owner="acme", repo="widget", number=42
74
+ )
75
+ invoked_argv = mock_run.call_args[0][0]
76
+ assert invoked_argv[3] == "42"
77
+ assert "--repo" in invoked_argv
78
+ repo_arg_value = invoked_argv[invoked_argv.index("--repo") + 1]
79
+ assert repo_arg_value == "acme/widget"
80
+
81
+
82
+ def test_should_return_parsed_json_object_with_mergeability_keys() -> None:
83
+ payload = {
84
+ "mergeable": "CONFLICTING",
85
+ "mergeStateStatus": "DIRTY",
86
+ "headRefOid": "deadbeef",
87
+ }
88
+ with patch("subprocess.run") as mock_run:
89
+ mock_run.return_value = _completed(json.dumps(payload))
90
+ mergeability_state = check_pr_mergeability_module.check_pr_mergeability(
91
+ owner="acme", repo="widget", number=42
92
+ )
93
+ assert mergeability_state == payload
94
+ assert mergeability_state["mergeable"] == "CONFLICTING"
95
+ assert mergeability_state["mergeStateStatus"] == "DIRTY"
96
+ assert mergeability_state["headRefOid"] == "deadbeef"
97
+
98
+
99
+ def test_should_raise_when_gh_subprocess_fails() -> None:
100
+ failure = subprocess.CalledProcessError(
101
+ returncode=1, cmd=["gh"], stderr="auth failure"
102
+ )
103
+ with patch("subprocess.run", side_effect=failure):
104
+ with pytest.raises(subprocess.CalledProcessError):
105
+ check_pr_mergeability_module.check_pr_mergeability(
106
+ owner="acme", repo="widget", number=42
107
+ )
108
+
109
+
110
+ def test_should_pass_imported_constant_directly_without_local_alias() -> None:
111
+ payload = json.dumps(
112
+ {
113
+ "mergeable": "MERGEABLE",
114
+ "mergeStateStatus": "CLEAN",
115
+ "headRefOid": "abc",
116
+ }
117
+ )
118
+ with patch("subprocess.run") as mock_run:
119
+ mock_run.return_value = _completed(payload)
120
+ check_pr_mergeability_module.check_pr_mergeability(
121
+ owner="acme", repo="widget", number=42
122
+ )
123
+ invoked_argv = mock_run.call_args[0][0]
124
+ fields_arg = invoked_argv[invoked_argv.index("--json") + 1]
125
+ expected_fields = check_pr_mergeability_module.MERGEABILITY_FIELDS
126
+ assert fields_arg is expected_fields
@@ -0,0 +1,22 @@
1
+ """Tests for evict_cached_config_modules."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+ import types
7
+ from pathlib import Path
8
+
9
+ _scripts_directory = Path(__file__).resolve().parent
10
+ if str(_scripts_directory) not in sys.path:
11
+ sys.path.insert(0, str(_scripts_directory))
12
+
13
+ from evict_cached_config_modules import evict_cached_config_modules
14
+
15
+
16
+ def test_should_remove_root_config_and_submodules() -> None:
17
+ fake = types.ModuleType("config")
18
+ sys.modules["config"] = fake
19
+ sys.modules["config.stale_submodule"] = types.ModuleType("config.stale_submodule")
20
+ evict_cached_config_modules()
21
+ assert "config" not in sys.modules
22
+ assert "config.stale_submodule" not in sys.modules
@@ -0,0 +1,342 @@
1
+ """Tests for fetch_bugbot_inline_comments.
2
+
3
+ Covers:
4
+ - gh command uses --paginate --slurp on the comments endpoint
5
+ - only cursor[bot] inline comments are returned
6
+ - comments not anchored to the requested commit are filtered out
7
+ - comments on the same commit but from an older Bugbot review are filtered out
8
+ - multi-page responses are flattened correctly
9
+ - subprocess errors propagate
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import importlib.util
15
+ import json
16
+ import subprocess
17
+ from pathlib import Path
18
+ from types import ModuleType
19
+ from unittest.mock import MagicMock, patch
20
+
21
+ import pytest
22
+
23
+
24
+ def _load_module() -> ModuleType:
25
+ module_path = Path(__file__).parent / "fetch_bugbot_inline_comments.py"
26
+ spec = importlib.util.spec_from_file_location(
27
+ "fetch_bugbot_inline_comments", module_path
28
+ )
29
+ assert spec is not None
30
+ assert spec.loader is not None
31
+ module = importlib.util.module_from_spec(spec)
32
+ spec.loader.exec_module(module)
33
+ return module
34
+
35
+
36
+ fetch_bugbot_inline_comments_module = _load_module()
37
+
38
+
39
+ def _completed(stdout: str) -> subprocess.CompletedProcess:
40
+ process = MagicMock(spec=subprocess.CompletedProcess)
41
+ process.stdout = stdout
42
+ process.returncode = 0
43
+ return process
44
+
45
+
46
+ def _default_review_for_head(*, commit: str, review_id: int) -> list[dict]:
47
+ return [
48
+ {
49
+ "review_id": review_id,
50
+ "commit_id": commit,
51
+ "submitted_at": "2026-01-01T00:00:00Z",
52
+ "body": "Cursor Bugbot has reviewed your changes and found 0 potential issue",
53
+ "classification": "clean",
54
+ }
55
+ ]
56
+
57
+
58
+ def test_should_invoke_gh_with_paginate_slurp_against_comments_endpoint() -> None:
59
+ pages_payload = json.dumps([[]])
60
+ with patch.object(
61
+ fetch_bugbot_inline_comments_module,
62
+ "fetch_bugbot_reviews",
63
+ return_value=_default_review_for_head(commit="abc123", review_id=1),
64
+ ), patch("subprocess.run") as mock_run:
65
+ mock_run.return_value = _completed(pages_payload)
66
+ fetch_bugbot_inline_comments_module.fetch_bugbot_inline_comments(
67
+ owner="acme", repo="widget", number=42, current_head="abc123"
68
+ )
69
+ invoked_argv = mock_run.call_args[0][0]
70
+ assert invoked_argv[0] == "gh"
71
+ assert invoked_argv[1] == "api"
72
+ assert "repos/acme/widget/pulls/42/comments?per_page=100" in invoked_argv[2]
73
+ assert "--paginate" in invoked_argv
74
+ assert "--slurp" in invoked_argv
75
+
76
+
77
+ def test_should_filter_to_cursor_bot_only() -> None:
78
+ pages_payload = json.dumps(
79
+ [
80
+ [
81
+ {
82
+ "id": 100,
83
+ "user": {"login": "copilot-pull-request-reviewer[bot]"},
84
+ "commit_id": "abc123",
85
+ "pull_request_review_id": 1,
86
+ "body": "copilot finding",
87
+ "path": "x.py",
88
+ "line": 5,
89
+ },
90
+ {
91
+ "id": 101,
92
+ "user": {"login": "cursor[bot]"},
93
+ "commit_id": "abc123",
94
+ "pull_request_review_id": 1,
95
+ "body": "bugbot finding",
96
+ "path": "x.py",
97
+ "line": 6,
98
+ },
99
+ ]
100
+ ]
101
+ )
102
+ with patch.object(
103
+ fetch_bugbot_inline_comments_module,
104
+ "fetch_bugbot_reviews",
105
+ return_value=_default_review_for_head(commit="abc123", review_id=1),
106
+ ), patch("subprocess.run") as mock_run:
107
+ mock_run.return_value = _completed(pages_payload)
108
+ all_inline_comments = (
109
+ fetch_bugbot_inline_comments_module.fetch_bugbot_inline_comments(
110
+ owner="acme", repo="widget", number=42, current_head="abc123"
111
+ )
112
+ )
113
+ assert len(all_inline_comments) == 1
114
+ assert all_inline_comments[0]["comment_id"] == 101
115
+
116
+
117
+ def test_should_filter_out_comments_not_on_current_head() -> None:
118
+ pages_payload = json.dumps(
119
+ [
120
+ [
121
+ {
122
+ "id": 200,
123
+ "user": {"login": "cursor[bot]"},
124
+ "commit_id": "old_sha",
125
+ "pull_request_review_id": 1,
126
+ "body": "stale finding",
127
+ "path": "x.py",
128
+ "line": 5,
129
+ },
130
+ {
131
+ "id": 201,
132
+ "user": {"login": "cursor[bot]"},
133
+ "commit_id": "current_sha",
134
+ "pull_request_review_id": 2,
135
+ "body": "fresh finding",
136
+ "path": "x.py",
137
+ "line": 6,
138
+ },
139
+ ]
140
+ ]
141
+ )
142
+ with patch.object(
143
+ fetch_bugbot_inline_comments_module,
144
+ "fetch_bugbot_reviews",
145
+ return_value=_default_review_for_head(commit="current_sha", review_id=2),
146
+ ), patch("subprocess.run") as mock_run:
147
+ mock_run.return_value = _completed(pages_payload)
148
+ all_inline_comments = (
149
+ fetch_bugbot_inline_comments_module.fetch_bugbot_inline_comments(
150
+ owner="acme", repo="widget", number=42, current_head="current_sha"
151
+ )
152
+ )
153
+ assert len(all_inline_comments) == 1
154
+ assert all_inline_comments[0]["comment_id"] == 201
155
+
156
+
157
+ def test_should_ignore_inline_comments_from_older_bugbot_review_on_same_commit() -> None:
158
+ pages_payload = json.dumps(
159
+ [
160
+ [
161
+ {
162
+ "id": 300,
163
+ "user": {"login": "cursor[bot]"},
164
+ "commit_id": "same_sha",
165
+ "pull_request_review_id": 10,
166
+ "body": "stale dirty thread",
167
+ "path": "x.py",
168
+ "line": 1,
169
+ },
170
+ {
171
+ "id": 301,
172
+ "user": {"login": "cursor[bot]"},
173
+ "commit_id": "same_sha",
174
+ "pull_request_review_id": 11,
175
+ "body": "current clean thread",
176
+ "path": "x.py",
177
+ "line": 2,
178
+ },
179
+ ]
180
+ ]
181
+ )
182
+ reviews_newest_first = [
183
+ {
184
+ "review_id": 11,
185
+ "commit_id": "same_sha",
186
+ "submitted_at": "2026-01-02T00:00:00Z",
187
+ "body": "clean",
188
+ "classification": "clean",
189
+ },
190
+ {
191
+ "review_id": 10,
192
+ "commit_id": "same_sha",
193
+ "submitted_at": "2026-01-01T00:00:00Z",
194
+ "body": "Cursor Bugbot has reviewed your changes and found 1 potential issue",
195
+ "classification": "dirty",
196
+ },
197
+ ]
198
+ with patch.object(
199
+ fetch_bugbot_inline_comments_module,
200
+ "fetch_bugbot_reviews",
201
+ return_value=reviews_newest_first,
202
+ ), patch("subprocess.run") as mock_run:
203
+ mock_run.return_value = _completed(pages_payload)
204
+ all_inline_comments = (
205
+ fetch_bugbot_inline_comments_module.fetch_bugbot_inline_comments(
206
+ owner="acme", repo="widget", number=42, current_head="same_sha"
207
+ )
208
+ )
209
+ assert [each_comment["comment_id"] for each_comment in all_inline_comments] == [301]
210
+
211
+
212
+ def test_should_return_empty_when_no_bugbot_review_exists_for_commit() -> None:
213
+ with patch.object(
214
+ fetch_bugbot_inline_comments_module,
215
+ "fetch_bugbot_reviews",
216
+ return_value=[
217
+ {
218
+ "review_id": 1,
219
+ "commit_id": "other_sha",
220
+ "submitted_at": "2026-01-01T00:00:00Z",
221
+ "body": "",
222
+ "classification": "clean",
223
+ }
224
+ ],
225
+ ), patch("subprocess.run") as mock_run:
226
+ all_inline_comments = (
227
+ fetch_bugbot_inline_comments_module.fetch_bugbot_inline_comments(
228
+ owner="acme", repo="widget", number=42, current_head="missing_sha"
229
+ )
230
+ )
231
+ assert all_inline_comments == []
232
+ mock_run.assert_not_called()
233
+
234
+
235
+ def test_should_flatten_across_pages() -> None:
236
+ pages_payload = json.dumps(
237
+ [
238
+ [
239
+ {
240
+ "id": 1,
241
+ "user": {"login": "cursor[bot]"},
242
+ "commit_id": "abc",
243
+ "pull_request_review_id": 9,
244
+ "body": "a",
245
+ "path": "f.py",
246
+ "line": 1,
247
+ }
248
+ ],
249
+ [
250
+ {
251
+ "id": 2,
252
+ "user": {"login": "cursor[bot]"},
253
+ "commit_id": "abc",
254
+ "pull_request_review_id": 9,
255
+ "body": "b",
256
+ "path": "f.py",
257
+ "line": 2,
258
+ },
259
+ {
260
+ "id": 3,
261
+ "user": {"login": "cursor[bot]"},
262
+ "commit_id": "abc",
263
+ "pull_request_review_id": 9,
264
+ "body": "c",
265
+ "path": "f.py",
266
+ "line": 3,
267
+ },
268
+ ],
269
+ ]
270
+ )
271
+ with patch.object(
272
+ fetch_bugbot_inline_comments_module,
273
+ "fetch_bugbot_reviews",
274
+ return_value=_default_review_for_head(commit="abc", review_id=9),
275
+ ), patch("subprocess.run") as mock_run:
276
+ mock_run.return_value = _completed(pages_payload)
277
+ all_inline_comments = (
278
+ fetch_bugbot_inline_comments_module.fetch_bugbot_inline_comments(
279
+ owner="acme", repo="widget", number=42, current_head="abc"
280
+ )
281
+ )
282
+ assert [each_comment["comment_id"] for each_comment in all_inline_comments] == [
283
+ 1,
284
+ 2,
285
+ 3,
286
+ ]
287
+
288
+
289
+ def test_should_reference_cursor_bot_login_constant_directly_without_local_alias() -> None:
290
+ source_text = (
291
+ Path(__file__).resolve().parent / "fetch_bugbot_inline_comments.py"
292
+ ).read_text(encoding="utf-8")
293
+ assert "cursor_bot_login = CURSOR_BOT_LOGIN" not in source_text
294
+ assert "CURSOR_BOT_LOGIN" in source_text
295
+
296
+
297
+ def test_should_raise_when_gh_subprocess_fails() -> None:
298
+ failure = subprocess.CalledProcessError(
299
+ returncode=1, cmd=["gh"], stderr="auth failure"
300
+ )
301
+ with patch.object(
302
+ fetch_bugbot_inline_comments_module,
303
+ "fetch_bugbot_reviews",
304
+ return_value=_default_review_for_head(commit="abc", review_id=1),
305
+ ), patch("subprocess.run", side_effect=failure):
306
+ with pytest.raises(subprocess.CalledProcessError):
307
+ fetch_bugbot_inline_comments_module.fetch_bugbot_inline_comments(
308
+ owner="acme", repo="widget", number=42, current_head="abc"
309
+ )
310
+
311
+
312
+ def test_should_return_entries_whose_keys_are_strings() -> None:
313
+ pages_payload = json.dumps(
314
+ [
315
+ [
316
+ {
317
+ "id": 101,
318
+ "user": {"login": "cursor[bot]"},
319
+ "commit_id": "abc123",
320
+ "pull_request_review_id": 1,
321
+ "body": "bugbot finding",
322
+ "path": "x.py",
323
+ "line": 6,
324
+ }
325
+ ]
326
+ ]
327
+ )
328
+ with patch.object(
329
+ fetch_bugbot_inline_comments_module,
330
+ "fetch_bugbot_reviews",
331
+ return_value=_default_review_for_head(commit="abc123", review_id=1),
332
+ ), patch("subprocess.run") as mock_run:
333
+ mock_run.return_value = _completed(pages_payload)
334
+ all_inline_comments = (
335
+ fetch_bugbot_inline_comments_module.fetch_bugbot_inline_comments(
336
+ owner="acme", repo="widget", number=42, current_head="abc123"
337
+ )
338
+ )
339
+ assert len(all_inline_comments) == 1
340
+ first_comment_entry = all_inline_comments[0]
341
+ assert isinstance(first_comment_entry, dict)
342
+ assert all(isinstance(each_key, str) for each_key in first_comment_entry.keys())