claude-dev-env 1.36.2 → 1.37.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 (76) hide show
  1. package/_shared/pr-loop/scripts/config/preflight_constants.py +29 -8
  2. package/_shared/pr-loop/scripts/preflight.py +242 -20
  3. package/_shared/pr-loop/scripts/tests/test_preflight.py +362 -25
  4. package/_shared/pr-loop/scripts/tests/test_preflight_constants.py +9 -14
  5. package/hooks/blocking/code_rules_enforcer.py +269 -23
  6. package/hooks/blocking/test_code_rules_enforcer_unused_imports.py +157 -1
  7. package/hooks/config/test_unused_module_import_constants.py +48 -0
  8. package/hooks/config/unused_module_import_constants.py +41 -0
  9. package/package.json +1 -1
  10. package/rules/gh-paginate.md +4 -50
  11. package/rules/no-historical-clutter.md +36 -0
  12. package/skills/bg-agent/SKILL.md +69 -0
  13. package/skills/bugteam/CONSTRAINTS.md +10 -19
  14. package/skills/bugteam/PROMPTS.md +21 -14
  15. package/skills/bugteam/SKILL.md +122 -208
  16. package/skills/bugteam/SKILL_EVALS.md +75 -114
  17. package/skills/bugteam/reference/README.md +2 -4
  18. package/skills/bugteam/reference/audit-and-teammates.md +21 -48
  19. package/skills/bugteam/reference/audit-contract.md +7 -7
  20. package/skills/bugteam/reference/design-rationale.md +3 -8
  21. package/skills/bugteam/reference/team-setup.md +11 -19
  22. package/skills/bugteam/reference/teardown-publish-permissions.md +2 -14
  23. package/skills/bugteam/scripts/config/__init__.py +0 -0
  24. package/skills/bugteam/scripts/config/reflow_skill_md_constants.py +12 -0
  25. package/skills/bugteam/scripts/reflow_skill_md.py +51 -47
  26. package/skills/bugteam/sources.md +1 -25
  27. package/skills/bugteam/test_skill_additions.py +4 -13
  28. package/skills/fresh-branch/SKILL.md +71 -0
  29. package/skills/gotcha/SKILL.md +73 -0
  30. package/skills/monitor-open-prs/SKILL.md +4 -37
  31. package/skills/monitor-open-prs/test_skill_contract.py +0 -5
  32. package/skills/pr-converge/SKILL.md +60 -1298
  33. package/skills/pr-converge/reference/convergence-gates.md +122 -0
  34. package/skills/pr-converge/reference/examples.md +76 -0
  35. package/skills/pr-converge/reference/fix-protocol.md +56 -0
  36. package/skills/pr-converge/reference/ground-rules.md +13 -0
  37. package/skills/pr-converge/reference/multi-pr-orchestration.md +204 -0
  38. package/skills/pr-converge/reference/per-tick.md +204 -0
  39. package/skills/pr-converge/reference/state-schema.md +19 -0
  40. package/skills/pr-converge/reference/stop-conditions.md +26 -0
  41. package/skills/pr-converge/scripts/README.md +36 -9
  42. package/skills/pr-converge/scripts/check_pr_mergeability.py +1 -2
  43. package/skills/pr-converge/scripts/config/pr_converge_constants.py +74 -5
  44. package/skills/pr-converge/scripts/config/reflow_skill_md_constants.py +13 -0
  45. package/skills/pr-converge/scripts/config/test_pr_converge_constants.py +0 -24
  46. package/skills/pr-converge/scripts/cursor-agents-continue.ahk +22 -2
  47. package/skills/pr-converge/scripts/fetch_bugbot_inline_comments.py +19 -59
  48. package/skills/pr-converge/scripts/fetch_bugbot_reviews.py +15 -61
  49. package/skills/pr-converge/scripts/fetch_claude_inline_comments.py +70 -0
  50. package/skills/pr-converge/scripts/fetch_claude_reviews.py +61 -0
  51. package/skills/pr-converge/scripts/fetch_copilot_inline_comments.py +19 -61
  52. package/skills/pr-converge/scripts/fetch_copilot_reviews.py +14 -74
  53. package/skills/pr-converge/scripts/reflow_skill_md.py +71 -50
  54. package/skills/pr-converge/scripts/reviewer_fetch_core.py +153 -0
  55. package/skills/pr-converge/scripts/reviewer_specs.py +98 -0
  56. package/skills/pr-converge/scripts/test_cursor_agents_continue.py +65 -0
  57. package/skills/pr-converge/scripts/test_fetch_bugbot_inline_comments.py +107 -6
  58. package/skills/pr-converge/scripts/test_fetch_bugbot_reviews.py +85 -6
  59. package/skills/pr-converge/scripts/test_fetch_claude_inline_comments.py +485 -0
  60. package/skills/pr-converge/scripts/test_fetch_claude_reviews.py +368 -0
  61. package/skills/pr-converge/scripts/test_fetch_copilot_inline_comments.py +74 -6
  62. package/skills/pr-converge/scripts/test_fetch_copilot_reviews.py +94 -8
  63. package/skills/pr-converge/scripts/test_reflow_skill_md.py +162 -0
  64. package/skills/pr-converge/scripts/test_reviewer_fetch_core.py +448 -0
  65. package/skills/pr-converge/scripts/test_reviewer_specs.py +107 -0
  66. package/skills/pr-converge/scripts/test_view_pr_context.py +44 -0
  67. package/skills/pr-converge/scripts/view_pr_context.py +35 -4
  68. package/skills/pr-converge/workflows/schedule-wakeup-loop.md +24 -22
  69. package/skills/bugteam/reference/workflow-path-a-orchestrated-teams.md +0 -113
  70. package/skills/bugteam/reference/workflow-path-b-task-harness.md +0 -48
  71. package/skills/bugteam/test_team_lifecycle.py +0 -103
  72. package/skills/monitor-open-prs/test_team_lifecycle.py +0 -46
  73. package/skills/pr-converge/scripts/open_followup_copilot_pr.py +0 -136
  74. package/skills/pr-converge/scripts/test_open_followup_copilot_pr.py +0 -236
  75. package/skills/pr-converge/test_team_lifecycle.py +0 -56
  76. package/skills/pr-converge/workflows/ahk-auto-continue-loop.md +0 -108
@@ -0,0 +1,61 @@
1
+ """Fetch Claude reviewer-bot reviews newest-first, classified as dirty or clean.
2
+
3
+ Thin wrapper around ``reviewer_fetch_core.fetch_reviewer_reviews`` parameterised
4
+ by ``claude_spec``. Classification follows the review's ``state`` field
5
+ (``APPROVED`` -> clean; ``CHANGES_REQUESTED`` -> dirty; ``COMMENTED`` with
6
+ non-empty body -> dirty; everything else -> clean) - see ``reviewer_specs``.
7
+
8
+ Wraps the gh CLI invocation required by the gh-paginate rule:
9
+ ``gh api '...?per_page=100' --paginate --slurp`` piped through external Python
10
+ JSON handling (instead of ``gh --jq``, which runs per-page and breaks
11
+ cross-page operations like sort/reverse - see GitHub CLI issue 10459).
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import argparse
17
+ import json
18
+ import sys
19
+ from pathlib import Path
20
+
21
+ if str(Path(__file__).resolve().parent) not in sys.path:
22
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
23
+
24
+ from evict_cached_config_modules import evict_cached_config_modules
25
+
26
+ evict_cached_config_modules()
27
+
28
+ from reviewer_fetch_core import fetch_reviewer_reviews
29
+ from reviewer_specs import claude_spec
30
+
31
+
32
+ def fetch_claude_reviews(
33
+ *,
34
+ owner: str,
35
+ repo: str,
36
+ number: int,
37
+ ) -> list[dict[str, object]]:
38
+ """Return Claude reviews newest-first, each with a classification."""
39
+ return fetch_reviewer_reviews(
40
+ claude_spec, owner=owner, repo=repo, number=number
41
+ )
42
+
43
+
44
+ def main() -> int:
45
+ parser = argparse.ArgumentParser(description=__doc__)
46
+ parser.add_argument("--owner", required=True)
47
+ parser.add_argument("--repo", required=True)
48
+ parser.add_argument("--number", required=True, type=int)
49
+ parsed_arguments = parser.parse_args()
50
+ all_reviews = fetch_claude_reviews(
51
+ owner=parsed_arguments.owner,
52
+ repo=parsed_arguments.repo,
53
+ number=parsed_arguments.number,
54
+ )
55
+ json.dump(all_reviews, sys.stdout)
56
+ sys.stdout.write("\n")
57
+ return 0
58
+
59
+
60
+ if __name__ == "__main__":
61
+ sys.exit(main())
@@ -1,17 +1,19 @@
1
1
  """Fetch unaddressed Copilot inline comments for the latest Copilot review on a commit.
2
2
 
3
- Uses ``fetch_copilot_reviews`` to find the newest submitted Copilot review whose ``commit_id`` matches the caller
4
- ``current_head``, then returns only ``copilot-pull-request-reviewer[bot]`` inline comments whose
5
- ``pull_request_review_id`` matches that review. This avoids misclassifying a PR when Copilot posts more than one review
6
- on the same SHA: older inline threads stay anchored to the earlier review id even when they share the same commit id.
3
+ Thin wrapper around ``reviewer_fetch_core.fetch_reviewer_inline_comments``
4
+ parameterised by ``copilot_spec``. The ``fetch_copilot_reviews`` call lives
5
+ here (rather than inside the core) so tests can patch it on this module to
6
+ exercise the inline-comments fetch in isolation.
7
7
 
8
- Wraps the gh CLI invocation required by the gh-paginate rule for the comments list:
9
- ``gh api`` on ``repos/{owner}/{repo}/pulls/{number}/comments`` with ``--paginate --slurp`` and external JSON handling.
8
+ Wraps the gh CLI invocation required by the gh-paginate rule for the comments
9
+ list: ``gh api`` on ``repos/{owner}/{repo}/pulls/{number}/comments`` with
10
+ ``--paginate --slurp`` and external JSON handling.
10
11
  """
11
12
 
13
+ from __future__ import annotations
14
+
12
15
  import argparse
13
16
  import json
14
- import subprocess
15
17
  import sys
16
18
  from pathlib import Path
17
19
 
@@ -22,12 +24,9 @@ from evict_cached_config_modules import evict_cached_config_modules
22
24
 
23
25
  evict_cached_config_modules()
24
26
 
25
- from config.pr_converge_constants import (
26
- COPILOT_REVIEWER_LOGIN,
27
- GH_INLINE_COMMENTS_PATH_TEMPLATE,
28
- )
29
27
  from fetch_copilot_reviews import fetch_copilot_reviews
30
- from review_field_helpers import body_of, login_of
28
+ from reviewer_fetch_core import fetch_reviewer_inline_comments
29
+ from reviewer_specs import copilot_spec
31
30
 
32
31
 
33
32
  def fetch_copilot_inline_comments(
@@ -37,57 +36,16 @@ def fetch_copilot_inline_comments(
37
36
  number: int,
38
37
  current_head: str,
39
38
  ) -> list[dict[str, object]]:
40
- """Return Copilot inline comments for the latest Copilot review on ``current_head``.
41
-
42
- Each entry contains comment_id, commit_id, path, line, and body.
43
- """
39
+ """Return Copilot inline comments for the latest Copilot review on ``current_head``."""
44
40
  all_copilot_reviews = fetch_copilot_reviews(owner=owner, repo=repo, number=number)
45
- latest_copilot_review_for_head = next(
46
- (
47
- each_review
48
- for each_review in all_copilot_reviews
49
- if each_review.get("commit_id") == current_head
50
- ),
51
- None,
52
- )
53
- if latest_copilot_review_for_head is None:
54
- return []
55
- target_pull_request_review_id = latest_copilot_review_for_head["review_id"]
56
- comments_endpoint = GH_INLINE_COMMENTS_PATH_TEMPLATE.format(
57
- owner=owner, repo=repo, number=number
58
- )
59
- gh_command: list[str] = [
60
- "gh",
61
- "api",
62
- comments_endpoint,
63
- "--paginate",
64
- "--slurp",
65
- ]
66
- completed = subprocess.run(
67
- gh_command,
68
- capture_output=True,
69
- check=True,
70
- text=True,
71
- encoding="utf-8",
72
- errors="replace",
41
+ return fetch_reviewer_inline_comments(
42
+ copilot_spec,
43
+ owner=owner,
44
+ repo=repo,
45
+ number=number,
46
+ current_head=current_head,
47
+ all_reviews=all_copilot_reviews,
73
48
  )
74
- pages: list[list[dict[str, object]]] = json.loads(completed.stdout)
75
- all_flat_comments = [
76
- each_comment for each_page in pages for each_comment in each_page
77
- ]
78
- return [
79
- {
80
- "comment_id": each_comment["id"],
81
- "commit_id": each_comment.get("commit_id"),
82
- "path": each_comment.get("path"),
83
- "line": each_comment.get("line"),
84
- "body": body_of(each_comment),
85
- }
86
- for each_comment in all_flat_comments
87
- if login_of(each_comment) == COPILOT_REVIEWER_LOGIN
88
- and each_comment.get("commit_id") == current_head
89
- and each_comment.get("pull_request_review_id") == target_pull_request_review_id
90
- ]
91
49
 
92
50
 
93
51
  def main() -> int:
@@ -1,21 +1,20 @@
1
1
  """Fetch GitHub Copilot reviewer reviews newest-first, classified as dirty or clean.
2
2
 
3
+ Thin wrapper around ``reviewer_fetch_core.fetch_reviewer_reviews`` parameterised
4
+ by ``copilot_spec``. Classification follows the review's ``state`` field
5
+ (``APPROVED`` -> clean; ``CHANGES_REQUESTED`` -> dirty; ``COMMENTED`` with
6
+ non-empty body -> dirty; everything else -> clean) - see ``reviewer_specs``.
7
+
3
8
  Wraps the gh CLI invocation required by the gh-paginate rule:
4
9
  ``gh api '...?per_page=100' --paginate --slurp`` piped through external Python
5
- JSON handling (instead of ``gh --jq``, which runs per-page and breaks cross-page
6
- operations like sort/reverse - see GitHub CLI #10459).
7
-
8
- Classification follows the review's ``state`` field:
9
- - ``APPROVED`` -> ``"clean"``
10
- - ``CHANGES_REQUESTED`` -> ``"dirty"``
11
- - ``COMMENTED`` with non-empty body -> ``"dirty"`` (Copilot uses COMMENTED + body
12
- to flag findings without a hard block)
13
- - everything else -> ``"clean"`` (no actionable findings on PR)
10
+ JSON handling (instead of ``gh --jq``, which runs per-page and breaks
11
+ cross-page operations like sort/reverse - see GitHub CLI issue 10459).
14
12
  """
15
13
 
14
+ from __future__ import annotations
15
+
16
16
  import argparse
17
17
  import json
18
- import subprocess
19
18
  import sys
20
19
  from pathlib import Path
21
20
 
@@ -26,14 +25,8 @@ from evict_cached_config_modules import evict_cached_config_modules
26
25
 
27
26
  evict_cached_config_modules()
28
27
 
29
- from config.pr_converge_constants import (
30
- ALL_COPILOT_DIRTY_REVIEW_STATES,
31
- COPILOT_CLEAN_REVIEW_STATE,
32
- COPILOT_REVIEWER_LOGIN,
33
- COPILOT_SOFT_DIRTY_REVIEW_STATE,
34
- GH_REVIEWS_PATH_TEMPLATE,
35
- )
36
- from review_field_helpers import body_of, login_of, state_of, submitted_at_of
28
+ from reviewer_fetch_core import fetch_reviewer_reviews
29
+ from reviewer_specs import copilot_spec
37
30
 
38
31
 
39
32
  def fetch_copilot_reviews(
@@ -42,63 +35,10 @@ def fetch_copilot_reviews(
42
35
  repo: str,
43
36
  number: int,
44
37
  ) -> list[dict[str, object]]:
45
- """Return Copilot reviews newest-first, each with a clean/dirty classification.
46
-
47
- Each entry contains review_id, commit_id, submitted_at, state, body, and classification.
48
- """
49
- reviews_endpoint = GH_REVIEWS_PATH_TEMPLATE.format(
50
- owner=owner, repo=repo, number=number
38
+ """Return Copilot reviews newest-first, each with a classification."""
39
+ return fetch_reviewer_reviews(
40
+ copilot_spec, owner=owner, repo=repo, number=number
51
41
  )
52
- gh_command: list[str] = [
53
- "gh",
54
- "api",
55
- reviews_endpoint,
56
- "--paginate",
57
- "--slurp",
58
- ]
59
- completed = subprocess.run(
60
- gh_command,
61
- capture_output=True,
62
- check=True,
63
- text=True,
64
- encoding="utf-8",
65
- errors="replace",
66
- )
67
- pages: list[list[dict[str, object]]] = json.loads(completed.stdout)
68
- all_flat_reviews = [each_review for each_page in pages for each_review in each_page]
69
- all_copilot_reviews = [
70
- each_review
71
- for each_review in all_flat_reviews
72
- if login_of(each_review) == COPILOT_REVIEWER_LOGIN
73
- and each_review.get("submitted_at") is not None
74
- and each_review.get("id") is not None
75
- ]
76
- all_copilot_reviews.sort(
77
- key=lambda each_review: submitted_at_of(each_review), reverse=True
78
- )
79
- return [
80
- {
81
- "review_id": each_review["id"],
82
- "commit_id": each_review.get("commit_id"),
83
- "submitted_at": each_review["submitted_at"],
84
- "state": state_of(each_review),
85
- "body": body_of(each_review),
86
- "classification": _classify_review(each_review),
87
- }
88
- for each_review in all_copilot_reviews
89
- ]
90
-
91
-
92
- def _classify_review(field_by_key: dict[str, object]) -> str:
93
- review_state = state_of(field_by_key)
94
- if review_state == COPILOT_CLEAN_REVIEW_STATE:
95
- return "clean"
96
- if review_state not in ALL_COPILOT_DIRTY_REVIEW_STATES:
97
- return "clean"
98
- state_requires_body = review_state == COPILOT_SOFT_DIRTY_REVIEW_STATE
99
- if state_requires_body and not body_of(field_by_key):
100
- return "clean"
101
- return "dirty"
102
42
 
103
43
 
104
44
  def main() -> int:
@@ -9,16 +9,30 @@ Run: python3 packages/claude-dev-env/skills/pr-converge/scripts/reflow_skill_md.
9
9
 
10
10
  from __future__ import annotations
11
11
 
12
- import re
12
+ import sys
13
13
  import textwrap
14
14
  from pathlib import Path
15
15
 
16
- MAX_WIDTH = 80
17
- SKILL_PATH = Path(__file__).resolve().parent.parent / "SKILL.md"
16
+ script_directory = str(Path(__file__).resolve().parent)
18
17
 
19
- ORDERED_RE = re.compile(r"^(\s*)(\d+\.\s)(.*)$")
20
- BULLET_RE = re.compile(r"^(\s*)([-*]\s)(.*)$")
21
- UNFINISHED_MD_LINK_TARGET = re.compile(r"\]\([^)]*$")
18
+ while script_directory in sys.path:
19
+ sys.path.remove(script_directory)
20
+ if script_directory not in sys.path:
21
+ sys.path.insert(0, script_directory)
22
+
23
+ from evict_cached_config_modules import evict_cached_config_modules
24
+
25
+ evict_cached_config_modules()
26
+
27
+ from config.reflow_skill_md_constants import (
28
+ BASH_CONTINUATION_MARKER_WIDTH,
29
+ BULLET_LIST_ITEM_PATTERN as BULLET_RE,
30
+ MARKDOWN_REFERENCE_DEFINITION_PATTERN as REF_DEF_RE,
31
+ MAXIMUM_LINE_WIDTH as MAX_WIDTH,
32
+ ORDERED_LIST_ITEM_PATTERN as ORDERED_RE,
33
+ TARGET_SKILL_PATH as SKILL_PATH,
34
+ UNFINISHED_MARKDOWN_LINK_TARGET_PATTERN as UNFINISHED_MD_LINK_TARGET,
35
+ )
22
36
 
23
37
 
24
38
  def wrap_paragraph_plain(text: str) -> list[str]:
@@ -49,11 +63,14 @@ def wrap_list_item(lead_ws: str, marker: str, body: str) -> list[str]:
49
63
  ).splitlines()
50
64
 
51
65
 
52
- def reflow_yaml_description_block(lines: list[str], body_start: int) -> tuple[list[str], int]:
66
+ def reflow_yaml_description_block(
67
+ all_lines: list[str],
68
+ body_start: int,
69
+ ) -> tuple[list[str], int]:
53
70
  body_parts: list[str] = []
54
71
  index = body_start
55
- while index < len(lines):
56
- line = lines[index]
72
+ while index < len(all_lines):
73
+ line = all_lines[index]
57
74
  if line.strip() == "---":
58
75
  index += 1
59
76
  break
@@ -62,10 +79,6 @@ def reflow_yaml_description_block(lines: list[str], body_start: int) -> tuple[li
62
79
  body_parts.append(stripped)
63
80
  index += 1
64
81
  merged = " ".join(body_parts)
65
- merged = merged.replace(
66
- "`<TMPDIR>/pr-converge-<session_id>/state.json` per",
67
- "`<TMPDIR>/pr-converge-<session_id>/state.json>` per",
68
- )
69
82
  wrapped = textwrap.fill(
70
83
  merged,
71
84
  width=MAX_WIDTH,
@@ -96,6 +109,8 @@ def is_new_logical_line(stripped: str) -> bool:
96
109
  return True
97
110
  if ORDERED_RE.match(stripped) or BULLET_RE.match(stripped):
98
111
  return True
112
+ if REF_DEF_RE.match(stripped):
113
+ return True
99
114
  return False
100
115
 
101
116
 
@@ -110,30 +125,30 @@ def merge_without_space(buffer: str, continuation: str) -> bool:
110
125
  return False
111
126
 
112
127
 
113
- def merge_soft_breaks(lines: list[str]) -> list[str]:
114
- output: list[str] = []
128
+ def merge_soft_breaks(all_lines: list[str]) -> list[str]:
129
+ reflowed_lines: list[str] = []
115
130
  index = 0
116
- in_fence = False
117
- while index < len(lines):
118
- raw = lines[index]
131
+ is_inside_fence = False
132
+ while index < len(all_lines):
133
+ raw = all_lines[index]
119
134
  line = raw.rstrip("\n")
120
135
  if line.lstrip().startswith("```"):
121
- in_fence = not in_fence
122
- output.append(line)
136
+ is_inside_fence = not is_inside_fence
137
+ reflowed_lines.append(line)
123
138
  index += 1
124
139
  continue
125
- if in_fence:
126
- output.append(line)
140
+ if is_inside_fence:
141
+ reflowed_lines.append(line)
127
142
  index += 1
128
143
  continue
129
144
  if line.strip() == "":
130
- output.append(line)
145
+ reflowed_lines.append(line)
131
146
  index += 1
132
147
  continue
133
148
  buffer_line = line
134
149
  index += 1
135
- while index < len(lines):
136
- next_raw = lines[index].rstrip("\n")
150
+ while index < len(all_lines):
151
+ next_raw = all_lines[index].rstrip("\n")
137
152
  if next_raw.strip() == "":
138
153
  break
139
154
  if next_raw.lstrip().startswith("```"):
@@ -146,8 +161,8 @@ def merge_soft_breaks(lines: list[str]) -> list[str]:
146
161
  else:
147
162
  buffer_line = f"{buffer_line.rstrip()} {stripped_next}"
148
163
  index += 1
149
- output.append(buffer_line)
150
- return output
164
+ reflowed_lines.append(buffer_line)
165
+ return reflowed_lines
151
166
 
152
167
 
153
168
  def reflow_merged_line(line: str) -> list[str]:
@@ -191,6 +206,9 @@ def reflow_merged_line(line: str) -> list[str]:
191
206
  break_on_hyphens=False,
192
207
  ).splitlines()
193
208
 
209
+ if REF_DEF_RE.match(stripped):
210
+ return [stripped]
211
+
194
212
  ordered = ORDERED_RE.match(line)
195
213
  if ordered:
196
214
  return wrap_list_item(ordered.group(1), ordered.group(2), ordered.group(3))
@@ -202,39 +220,42 @@ def reflow_merged_line(line: str) -> list[str]:
202
220
  return wrap_paragraph_plain(stripped)
203
221
 
204
222
 
205
- def reflow_markdown_body(lines: list[str]) -> list[str]:
206
- merged = merge_soft_breaks(lines)
207
- output: list[str] = []
223
+ def reflow_markdown_body(all_lines: list[str]) -> list[str]:
224
+ merged = merge_soft_breaks(all_lines)
225
+ reflowed_lines: list[str] = []
208
226
  for each_line in merged:
209
227
  if each_line.strip() == "":
210
- output.append("")
228
+ reflowed_lines.append("")
211
229
  continue
212
- output.extend(reflow_merged_line(each_line))
213
- return output
230
+ reflowed_lines.extend(reflow_merged_line(each_line))
231
+ return reflowed_lines
214
232
 
215
233
 
216
- def wrap_long_bash_fence_lines(lines: list[str]) -> list[str]:
234
+ def wrap_long_bash_fence_lines(all_lines: list[str]) -> list[str]:
217
235
  """Hard-wrap only ```bash fence bodies that still exceed MAX_WIDTH."""
218
- output: list[str] = []
219
- in_bash_fence = False
220
- for line in lines:
221
- stripped = line.lstrip()
236
+ wrapped_lines: list[str] = []
237
+ is_inside_bash_fence = False
238
+ for each_line in all_lines:
239
+ stripped = each_line.lstrip()
222
240
  if stripped.startswith("```"):
223
- if not in_bash_fence:
241
+ if not is_inside_bash_fence:
224
242
  lang = stripped[3:].strip().lower()
225
- in_bash_fence = lang == "bash"
243
+ is_inside_bash_fence = lang == "bash"
226
244
  else:
227
- in_bash_fence = False
228
- output.append(line)
245
+ is_inside_bash_fence = False
246
+ wrapped_lines.append(each_line)
229
247
  continue
230
- if in_bash_fence and len(line) > MAX_WIDTH:
231
- indent_len = len(line) - len(line.lstrip())
232
- indent = line[:indent_len]
233
- content = line.lstrip()
248
+ if is_inside_bash_fence and len(each_line) > MAX_WIDTH:
249
+ indent_len = len(each_line) - len(each_line.lstrip())
250
+ indent = each_line[:indent_len]
251
+ if len(indent) + BASH_CONTINUATION_MARKER_WIDTH >= MAX_WIDTH:
252
+ wrapped_lines.append(each_line)
253
+ continue
254
+ content = each_line.lstrip()
234
255
  wrapped_segments: list[str] = []
235
256
  rest = content
236
257
  while len(rest) > MAX_WIDTH - len(indent):
237
- room = MAX_WIDTH - len(indent) - 2
258
+ room = MAX_WIDTH - len(indent) - BASH_CONTINUATION_MARKER_WIDTH
238
259
  window = rest[:room]
239
260
  break_at = window.rfind(" ")
240
261
  if break_at <= 0:
@@ -244,10 +265,10 @@ def wrap_long_bash_fence_lines(lines: list[str]) -> list[str]:
244
265
  wrapped_segments.append(indent + piece + " \\")
245
266
  if rest:
246
267
  wrapped_segments.append(indent + (" " if wrapped_segments else "") + rest)
247
- output.extend(wrapped_segments)
268
+ wrapped_lines.extend(wrapped_segments)
248
269
  else:
249
- output.append(line)
250
- return output
270
+ wrapped_lines.append(each_line)
271
+ return wrapped_lines
251
272
 
252
273
 
253
274
  def main() -> None:
@@ -0,0 +1,153 @@
1
+ """Shared fetch primitives for PR reviewer bots (Bugbot, Copilot, Claude).
2
+
3
+ The reviewer-specific scripts (``fetch_bugbot_reviews.py``,
4
+ ``fetch_copilot_reviews.py``, ``fetch_claude_reviews.py`` and their
5
+ inline-comment counterparts) are thin entry points that pass a ``ReviewerSpec``
6
+ to these functions. The spec carries the substring used to recognise the
7
+ reviewer's GitHub login (case-insensitive substring match - required because
8
+ some bots emit different login strings at the review-level vs inline-comment
9
+ endpoints) and the per-reviewer classify callable.
10
+
11
+ Wraps the gh CLI invocations required by the gh-paginate rule:
12
+ ``gh api '...?per_page=100' --paginate --slurp`` piped through external Python
13
+ JSON handling (instead of ``gh --jq``, which runs per-page and breaks
14
+ cross-page operations like sort/reverse - see GitHub CLI issue 10459).
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import json
20
+ import subprocess
21
+ import sys
22
+ from pathlib import Path
23
+
24
+ if str(Path(__file__).resolve().parent) not in sys.path:
25
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
26
+
27
+ from evict_cached_config_modules import evict_cached_config_modules
28
+
29
+ evict_cached_config_modules()
30
+
31
+ from config.pr_converge_constants import (
32
+ GH_INLINE_COMMENTS_PATH_TEMPLATE,
33
+ GH_REVIEWS_PATH_TEMPLATE,
34
+ )
35
+ from review_field_helpers import body_of, login_of, state_of, submitted_at_of
36
+ from reviewer_specs import ReviewerSpec
37
+
38
+
39
+ def _login_matches_substring(
40
+ field_by_key: dict[str, object], login_filter_substring: str
41
+ ) -> bool:
42
+ author_login = login_of(field_by_key) or ""
43
+ return login_filter_substring.lower() in author_login.lower()
44
+
45
+
46
+ def _run_gh_paginated(*, endpoint_path: str) -> list[dict[str, object]]:
47
+ gh_command: list[str] = [
48
+ "gh",
49
+ "api",
50
+ endpoint_path,
51
+ "--paginate",
52
+ "--slurp",
53
+ ]
54
+ completed = subprocess.run(
55
+ gh_command,
56
+ capture_output=True,
57
+ check=True,
58
+ text=True,
59
+ encoding="utf-8",
60
+ errors="replace",
61
+ )
62
+ pages: list[list[dict[str, object]]] = json.loads(completed.stdout)
63
+ return [each_entry for each_page in pages for each_entry in each_page]
64
+
65
+
66
+ def fetch_reviewer_reviews(
67
+ spec: ReviewerSpec,
68
+ *,
69
+ owner: str,
70
+ repo: str,
71
+ number: int,
72
+ ) -> list[dict[str, object]]:
73
+ """Return reviews from the matching reviewer newest-first, with classification.
74
+
75
+ Each entry contains ``review_id``, ``commit_id``, ``submitted_at``,
76
+ ``state``, ``body``, and ``classification`` (``"clean"`` or ``"dirty"``).
77
+ Entries whose payload is missing ``submitted_at`` or ``id`` are dropped.
78
+ """
79
+ reviews_endpoint = GH_REVIEWS_PATH_TEMPLATE.format(
80
+ owner=owner, repo=repo, number=number
81
+ )
82
+ all_flat_reviews = _run_gh_paginated(endpoint_path=reviews_endpoint)
83
+ all_matching_reviews = [
84
+ each_review
85
+ for each_review in all_flat_reviews
86
+ if _login_matches_substring(each_review, spec.login_filter_substring)
87
+ and each_review.get("submitted_at") is not None
88
+ and each_review.get("id") is not None
89
+ ]
90
+ all_matching_reviews.sort(
91
+ key=lambda each_review: submitted_at_of(each_review), reverse=True
92
+ )
93
+ return [
94
+ {
95
+ "review_id": each_review["id"],
96
+ "commit_id": each_review.get("commit_id"),
97
+ "submitted_at": each_review["submitted_at"],
98
+ "state": state_of(each_review),
99
+ "body": body_of(each_review),
100
+ "classification": spec.classify_review(each_review),
101
+ }
102
+ for each_review in all_matching_reviews
103
+ ]
104
+
105
+
106
+ def fetch_reviewer_inline_comments(
107
+ spec: ReviewerSpec,
108
+ *,
109
+ owner: str,
110
+ repo: str,
111
+ number: int,
112
+ current_head: str,
113
+ all_reviews: list[dict[str, object]],
114
+ ) -> list[dict[str, object]]:
115
+ """Return inline comments anchored to the latest matching review on ``current_head``.
116
+
117
+ The ``all_reviews`` list is supplied by the caller (not fetched internally)
118
+ so the entry-point scripts retain a patchable seam: tests that patch
119
+ ``fetch_X_reviews`` on the entry-point module continue to work because the
120
+ entry-point is what calls the reviews fetch.
121
+
122
+ Each entry contains ``comment_id``, ``commit_id``, ``path``, ``line``, and
123
+ ``body``. Returns an empty list when no review in ``all_reviews`` is
124
+ anchored to ``current_head``.
125
+ """
126
+ latest_review_for_head = next(
127
+ (
128
+ each_review
129
+ for each_review in all_reviews
130
+ if each_review.get("commit_id") == current_head
131
+ ),
132
+ None,
133
+ )
134
+ if latest_review_for_head is None:
135
+ return []
136
+ target_pull_request_review_id = latest_review_for_head["review_id"]
137
+ comments_endpoint = GH_INLINE_COMMENTS_PATH_TEMPLATE.format(
138
+ owner=owner, repo=repo, number=number
139
+ )
140
+ all_flat_comments = _run_gh_paginated(endpoint_path=comments_endpoint)
141
+ return [
142
+ {
143
+ "comment_id": each_comment["id"],
144
+ "commit_id": each_comment.get("commit_id"),
145
+ "path": each_comment.get("path"),
146
+ "line": each_comment.get("line"),
147
+ "body": body_of(each_comment),
148
+ }
149
+ for each_comment in all_flat_comments
150
+ if _login_matches_substring(each_comment, spec.login_filter_substring)
151
+ and each_comment.get("commit_id") == current_head
152
+ and each_comment.get("pull_request_review_id") == target_pull_request_review_id
153
+ ]