claude-dev-env 1.34.1 → 1.36.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.
Files changed (148) hide show
  1. package/agents/clean-coder.md +109 -1
  2. package/agents/docs-agent.md +1 -1
  3. package/agents/project-docs-analyzer.md +0 -1
  4. package/agents/skill-to-agent-converter.md +0 -1
  5. package/bin/install.mjs +28 -8
  6. package/bin/install.test.mjs +9 -1
  7. package/commands/initialize.md +0 -1
  8. package/commands/readability-review.md +4 -4
  9. package/commands/review-plan.md +2 -4
  10. package/commands/stubcheck.md +1 -2
  11. package/docs/CODE_RULES.md +3 -0
  12. package/docs/agents-md-alignment-plan.md +123 -0
  13. package/hooks/blocking/code_rules_enforcer.py +686 -60
  14. package/hooks/blocking/es_exe_path_rewriter.py +10 -4
  15. package/hooks/blocking/test_code_rules_enforcer.py +273 -39
  16. package/hooks/blocking/test_code_rules_enforcer_annotations.py +97 -0
  17. package/hooks/blocking/test_code_rules_enforcer_banned_identifier.py +106 -0
  18. package/hooks/blocking/test_code_rules_enforcer_cap_meta.py +173 -0
  19. package/hooks/blocking/test_code_rules_enforcer_collection_prefix.py +328 -0
  20. package/hooks/blocking/test_code_rules_enforcer_config_path.py +0 -20
  21. package/hooks/blocking/test_code_rules_enforcer_constant_equality.py +33 -11
  22. package/hooks/blocking/test_code_rules_enforcer_existence_checks.py +0 -18
  23. package/hooks/blocking/test_code_rules_enforcer_hardcoded_user_path.py +291 -0
  24. package/hooks/blocking/test_code_rules_enforcer_inline_literal_collections.py +155 -0
  25. package/hooks/blocking/test_code_rules_enforcer_loop_variable_naming.py +194 -0
  26. package/hooks/blocking/test_code_rules_enforcer_naming_pattern.py +49 -13
  27. package/hooks/blocking/test_code_rules_enforcer_skip_decorators.py +0 -26
  28. package/hooks/blocking/test_code_rules_enforcer_string_magic.py +234 -0
  29. package/hooks/blocking/test_code_rules_enforcer_sys_path_insert.py +157 -0
  30. package/hooks/blocking/test_code_rules_enforcer_unused_imports.py +244 -0
  31. package/hooks/blocking/test_es_exe_path_rewriter.py +81 -3
  32. package/hooks/blocking/test_windows_rmtree_blocker.py +120 -8
  33. package/hooks/blocking/windows_rmtree_blocker.py +23 -6
  34. package/hooks/config/banned_identifiers_constants.py +24 -0
  35. package/hooks/config/hardcoded_user_path_constants.py +12 -0
  36. package/hooks/config/hook_log_extractor_constants.py +1 -1
  37. package/hooks/config/pre_tool_use_stdin.py +48 -0
  38. package/hooks/config/setup_project_paths_constants.py +4 -0
  39. package/hooks/config/stuttering_check_config.py +14 -0
  40. package/hooks/config/stuttering_import_binding_constants.py +11 -0
  41. package/hooks/config/sys_path_insert_constants.py +4 -0
  42. package/hooks/config/test_banned_identifiers_constants.py +48 -0
  43. package/hooks/config/test_hardcoded_user_path_constants.py +78 -0
  44. package/hooks/config/test_hook_log_extractor_constants.py +3 -3
  45. package/hooks/config/test_pre_tool_use_stdin.py +80 -0
  46. package/hooks/config/unused_module_import_constants.py +7 -0
  47. package/hooks/config/windows_rmtree_blocker_constants.py +3 -0
  48. package/hooks/diagnostic/hook_log_stop_wrapper.py +7 -4
  49. package/hooks/git-hooks/config.py +3 -3
  50. package/hooks/git-hooks/test_gate_utils.py +10 -10
  51. package/hooks/mypy.ini +2 -0
  52. package/package.json +1 -1
  53. package/rules/gh-paginate.md +125 -0
  54. package/skills/bugteam/CONSTRAINTS.md +12 -6
  55. package/skills/bugteam/PROMPTS.md +0 -39
  56. package/skills/bugteam/SKILL.md +93 -125
  57. package/skills/bugteam/SKILL_EVALS.md +25 -23
  58. package/skills/bugteam/reference/README.md +2 -0
  59. package/skills/bugteam/reference/audit-and-teammates.md +2 -2
  60. package/skills/bugteam/reference/copilot-gap-analysis.md +12 -0
  61. package/skills/bugteam/reference/teardown-publish-permissions.md +1 -1
  62. package/skills/bugteam/reference/workflow-path-a-orchestrated-teams.md +113 -0
  63. package/skills/bugteam/reference/workflow-path-b-task-harness.md +48 -0
  64. package/skills/bugteam/test_skill_additions.py +13 -4
  65. package/skills/bugteam/test_team_lifecycle.py +94 -0
  66. package/skills/findbugs/SKILL.md +3 -3
  67. package/skills/fixbugs/SKILL.md +4 -4
  68. package/skills/monitor-open-prs/SKILL.md +32 -2
  69. package/skills/monitor-open-prs/test_team_lifecycle.py +46 -0
  70. package/skills/pr-converge/SKILL.md +576 -95
  71. package/skills/pr-converge/scripts/README.md +145 -0
  72. package/skills/pr-converge/scripts/caller-window-pid.ps1 +86 -0
  73. package/skills/pr-converge/scripts/check_pr_mergeability.py +79 -0
  74. package/skills/pr-converge/scripts/config/pr_converge_constants.py +65 -0
  75. package/skills/pr-converge/scripts/config/test_pr_converge_constants.py +176 -0
  76. package/skills/pr-converge/scripts/cursor-agents-continue-caller.cmd +9 -0
  77. package/skills/pr-converge/scripts/cursor-agents-continue-stop-others.ps1 +16 -0
  78. package/skills/pr-converge/scripts/cursor-agents-continue.ahk +172 -0
  79. package/skills/pr-converge/scripts/cursor-agents-continue.cmd +2 -0
  80. package/skills/pr-converge/scripts/evict_cached_config_modules.py +20 -0
  81. package/skills/pr-converge/scripts/fetch_bugbot_inline_comments.py +110 -0
  82. package/skills/pr-converge/scripts/fetch_bugbot_reviews.py +103 -0
  83. package/skills/pr-converge/scripts/fetch_copilot_inline_comments.py +112 -0
  84. package/skills/pr-converge/scripts/fetch_copilot_reviews.py +121 -0
  85. package/skills/pr-converge/scripts/mark_pr_ready.py +54 -0
  86. package/skills/pr-converge/scripts/open_followup_copilot_pr.py +136 -0
  87. package/skills/pr-converge/scripts/post-bugbot-run.helpers.ps1 +49 -0
  88. package/skills/pr-converge/scripts/post-bugbot-run.ps1 +33 -0
  89. package/skills/pr-converge/scripts/reply_to_inline_comment.py +84 -0
  90. package/skills/pr-converge/scripts/request_copilot_review.py +71 -0
  91. package/skills/pr-converge/scripts/resolve_pr_head.py +58 -0
  92. package/skills/pr-converge/scripts/review_field_helpers.py +43 -0
  93. package/skills/pr-converge/scripts/test_check_pr_mergeability.py +126 -0
  94. package/skills/pr-converge/scripts/test_evict_cached_config_modules.py +22 -0
  95. package/skills/pr-converge/scripts/test_fetch_bugbot_inline_comments.py +342 -0
  96. package/skills/pr-converge/scripts/test_fetch_bugbot_reviews.py +220 -0
  97. package/skills/pr-converge/scripts/test_fetch_copilot_inline_comments.py +372 -0
  98. package/skills/pr-converge/scripts/test_fetch_copilot_reviews.py +280 -0
  99. package/skills/pr-converge/scripts/test_mark_pr_ready.py +69 -0
  100. package/skills/pr-converge/scripts/test_open_followup_copilot_pr.py +236 -0
  101. package/skills/pr-converge/scripts/test_post_bugbot_run.py +195 -0
  102. package/skills/pr-converge/scripts/test_reply_to_inline_comment.py +159 -0
  103. package/skills/pr-converge/scripts/test_request_copilot_review.py +101 -0
  104. package/skills/pr-converge/scripts/test_resolve_pr_head.py +79 -0
  105. package/skills/pr-converge/scripts/test_review_field_helpers.py +80 -0
  106. package/skills/pr-converge/scripts/test_trigger_bugbot.py +139 -0
  107. package/skills/pr-converge/scripts/test_view_pr_context.py +111 -0
  108. package/skills/pr-converge/scripts/trigger_bugbot.py +77 -0
  109. package/skills/pr-converge/scripts/view_pr_context.py +47 -0
  110. package/skills/pr-converge/test_team_lifecycle.py +47 -0
  111. package/skills/pr-converge/workflows/ahk-auto-continue-loop.md +108 -0
  112. package/skills/pr-converge/workflows/schedule-wakeup-loop.md +37 -0
  113. package/skills/qbug/SKILL.md +4 -4
  114. package/skills/qbug/test_qbug_skill_post_fix_audit.py +2 -2
  115. package/skills/resume-review/SKILL.md +261 -0
  116. package/agents/agent-writer.md +0 -157
  117. package/agents/config-centralizer.md +0 -686
  118. package/agents/config-extraction-agent.md +0 -225
  119. package/agents/doc-orchestrator.md +0 -47
  120. package/agents/docx-agent.md +0 -211
  121. package/agents/magic-value-eliminator-agent.md +0 -72
  122. package/agents/mandatory-agent-workflow-agent.md +0 -88
  123. package/agents/parallel-workflow-coordinator.md +0 -779
  124. package/agents/pdf-agent.md +0 -302
  125. package/agents/project-context-loader.md +0 -238
  126. package/agents/readability-review-agent.md +0 -76
  127. package/agents/refactoring-specialist.md +0 -69
  128. package/agents/right-sized-engineer.md +0 -129
  129. package/agents/session-continuity-manager.md +0 -53
  130. package/agents/stub-detector-agent.md +0 -140
  131. package/agents/tdd-test-writer.md +0 -62
  132. package/agents/test-data-builder.md +0 -68
  133. package/agents/tooling-builder.md +0 -78
  134. package/agents/validation-expert.md +0 -71
  135. package/agents/xlsx-agent.md +0 -169
  136. package/skills/bugteam/scripts/README.md +0 -58
  137. package/skills/bugteam/scripts/_claude_permissions_common.py +0 -219
  138. package/skills/bugteam/scripts/bugteam_code_rules_gate.py +0 -633
  139. package/skills/bugteam/scripts/bugteam_fix_hookspath.py +0 -260
  140. package/skills/bugteam/scripts/bugteam_preflight.py +0 -201
  141. package/skills/bugteam/scripts/config/bugteam_fix_hookspath_constants.py +0 -17
  142. package/skills/bugteam/scripts/grant_project_claude_permissions.py +0 -109
  143. package/skills/bugteam/scripts/revoke_project_claude_permissions.py +0 -135
  144. package/skills/bugteam/scripts/test_bugteam_code_rules_gate.py +0 -271
  145. package/skills/bugteam/scripts/test_bugteam_fix_hookspath.py +0 -267
  146. package/skills/bugteam/scripts/test_bugteam_preflight.py +0 -189
  147. package/skills/bugteam/scripts/test_claude_permissions_common.py +0 -44
  148. /package/skills/{bugteam → pr-converge}/scripts/config/__init__.py +0 -0
@@ -0,0 +1,136 @@
1
+ """Open a follow-up draft PR addressing Copilot findings from the parent PR.
2
+
3
+ Subprocess sequence:
4
+
5
+ 1. ``gh pr view <parent_number> --json baseRefName`` to resolve the parent's base ref.
6
+ 2. ``git fetch origin <head_sha>`` to make the SHA available locally.
7
+ 3. ``git switch -c <new_branch> <head_sha>`` to create the follow-up branch off ``head_sha``.
8
+ 4. ``git push -u origin <new_branch>`` to publish it.
9
+ 5. ``gh pr create --draft --base <base_ref> --head <new_branch> --title <...> --body-file <findings_file>``
10
+ per the gh-body-file rule.
11
+
12
+ Returns the trimmed PR URL emitted by ``gh pr create`` on stdout.
13
+ """
14
+
15
+ import argparse
16
+ import json
17
+ import subprocess
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 config.pr_converge_constants import (
29
+ COPILOT_FOLLOWUP_BRANCH_TEMPLATE,
30
+ COPILOT_FOLLOWUP_PR_TITLE_TEMPLATE,
31
+ COPILOT_FOLLOWUP_SHORT_SHA_LENGTH,
32
+ GH_REPO_ARG_TEMPLATE,
33
+ PR_BASE_REF_FIELDS,
34
+ )
35
+
36
+
37
+ def open_followup_copilot_pr(
38
+ *,
39
+ owner: str,
40
+ repo: str,
41
+ parent_number: int,
42
+ head: str,
43
+ findings_file: Path,
44
+ ) -> str:
45
+ """Create the follow-up branch + draft PR; return the new PR URL."""
46
+ repo_arg = GH_REPO_ARG_TEMPLATE.format(owner=owner, repo=repo)
47
+ parent_base_ref = _resolve_parent_base_ref(
48
+ parent_number=parent_number, repo_arg=repo_arg
49
+ )
50
+ short_sha = head[:COPILOT_FOLLOWUP_SHORT_SHA_LENGTH]
51
+ new_branch_name = COPILOT_FOLLOWUP_BRANCH_TEMPLATE.format(
52
+ parent_number=parent_number, short_sha=short_sha
53
+ )
54
+ _run_checked(["git", "fetch", "origin", head])
55
+ _run_checked(["git", "switch", "-c", new_branch_name, head])
56
+ _run_checked(["git", "push", "-u", "origin", new_branch_name])
57
+ pr_title = COPILOT_FOLLOWUP_PR_TITLE_TEMPLATE.format(parent_number=parent_number)
58
+ completed = _run_checked(
59
+ [
60
+ "gh",
61
+ "pr",
62
+ "create",
63
+ "--repo",
64
+ repo_arg,
65
+ "--draft",
66
+ "--base",
67
+ parent_base_ref,
68
+ "--head",
69
+ new_branch_name,
70
+ "--title",
71
+ pr_title,
72
+ "--body-file",
73
+ str(findings_file),
74
+ ]
75
+ )
76
+ return completed.stdout.strip()
77
+
78
+
79
+ def _resolve_parent_base_ref(*, parent_number: int, repo_arg: str) -> str:
80
+ completed = _run_checked(
81
+ [
82
+ "gh",
83
+ "pr",
84
+ "view",
85
+ str(parent_number),
86
+ "--repo",
87
+ repo_arg,
88
+ "--json",
89
+ PR_BASE_REF_FIELDS,
90
+ ]
91
+ )
92
+ parent_pr_metadata = json.loads(completed.stdout)
93
+ base_ref_name_field = parent_pr_metadata.get("baseRefName")
94
+ if not isinstance(base_ref_name_field, str):
95
+ raise TypeError(
96
+ f"gh pr view baseRefName field is not str: {type(base_ref_name_field).__name__}"
97
+ )
98
+ return base_ref_name_field
99
+
100
+
101
+ def _run_checked(all_command_arguments: list[str]) -> subprocess.CompletedProcess:
102
+ return subprocess.run(
103
+ all_command_arguments,
104
+ capture_output=True,
105
+ check=True,
106
+ text=True,
107
+ encoding="utf-8",
108
+ errors="replace",
109
+ )
110
+
111
+
112
+ def main() -> int:
113
+ parser = argparse.ArgumentParser(description=__doc__)
114
+ parser.add_argument("--owner", required=True)
115
+ parser.add_argument("--repo", required=True)
116
+ parser.add_argument(
117
+ "--parent-number", required=True, type=int, dest="parent_number"
118
+ )
119
+ parser.add_argument("--head", required=True)
120
+ parser.add_argument(
121
+ "--findings-file", required=True, type=Path, dest="findings_file"
122
+ )
123
+ parsed_arguments = parser.parse_args()
124
+ new_pr_url = open_followup_copilot_pr(
125
+ owner=parsed_arguments.owner,
126
+ repo=parsed_arguments.repo,
127
+ parent_number=parsed_arguments.parent_number,
128
+ head=parsed_arguments.head,
129
+ findings_file=parsed_arguments.findings_file,
130
+ )
131
+ sys.stdout.write(f"{new_pr_url}\n")
132
+ return 0
133
+
134
+
135
+ if __name__ == "__main__":
136
+ sys.exit(main())
@@ -0,0 +1,49 @@
1
+ function Resolve-InvocationMode {
2
+ param([string] $PullRequestInput, [string] $RepositoryInput, [int] $NumberInput)
3
+
4
+ if ($NumberInput -gt 0) {
5
+ if ([string]::IsNullOrWhiteSpace($RepositoryInput)) {
6
+ throw 'When -Number is set, -Repository must be owner/repo (for example jl-cmd/claude-code-config).'
7
+ }
8
+ return @{ Mode = 'RepoNumber'; Repository = $RepositoryInput; Number = $NumberInput }
9
+ }
10
+
11
+ $trimmed = $PullRequestInput.Trim()
12
+ if (-not [string]::IsNullOrWhiteSpace($trimmed)) {
13
+ return @{ Mode = 'Uri'; PullRequest = $trimmed }
14
+ }
15
+
16
+ throw 'Provide a pull request URL, owner/repo#number as the first argument, or -Repository with -Number.'
17
+ }
18
+
19
+ function Build-GhArgumentList {
20
+ param([hashtable] $Invocation, [string] $BodyFilePath)
21
+
22
+ if ($Invocation.Mode -eq 'RepoNumber') {
23
+ return @(
24
+ 'pr', 'comment',
25
+ $Invocation.Number.ToString(),
26
+ '-R', $Invocation.Repository,
27
+ '--body-file', $BodyFilePath
28
+ )
29
+ }
30
+
31
+ $trimmed = $Invocation.PullRequest
32
+ if ($trimmed -match '^https://github\.com/[^/]+/[^/]+/pull/\d+$') {
33
+ return @('pr', 'comment', $trimmed, '--body-file', $BodyFilePath)
34
+ }
35
+
36
+ if ($trimmed -match '^([^/]+)/([^/#]+)#(\d+)$') {
37
+ $owner = $Matches[1]
38
+ $repository_name = $Matches[2]
39
+ $pull_number = $Matches[3]
40
+ return @(
41
+ 'pr', 'comment',
42
+ $pull_number,
43
+ '-R', ('{0}/{1}' -f $owner, $repository_name),
44
+ '--body-file', $BodyFilePath
45
+ )
46
+ }
47
+
48
+ throw ('Unrecognized PullRequest "{0}". Use a https://github.com/owner/repo/pull/NN URL or owner/repo#NN.' -f $trimmed)
49
+ }
@@ -0,0 +1,33 @@
1
+ [CmdletBinding()]
2
+ param(
3
+ [Parameter(Position = 0, HelpMessage = 'GitHub pull request URL or owner/repo#number.')]
4
+ [string] $PullRequest = '',
5
+ [Parameter(HelpMessage = 'owner/repo when using -Number instead of a URL or owner/repo#number.')]
6
+ [string] $Repository = '',
7
+ [Parameter(HelpMessage = 'Pull request number; requires -Repository.')]
8
+ [int] $Number = 0
9
+ )
10
+
11
+ $helpers_path = Join-Path $PSScriptRoot 'post-bugbot-run.helpers.ps1'
12
+ . $helpers_path
13
+
14
+ $LiteralBugbotRunBody = "bugbot run`n"
15
+
16
+ $invocation = Resolve-InvocationMode -PullRequestInput $PullRequest -RepositoryInput $Repository -NumberInput $Number
17
+ $scratch_temp_path = [System.IO.Path]::GetTempFileName()
18
+ $body_file_path = [System.IO.Path]::ChangeExtension($scratch_temp_path, '.md')
19
+
20
+ try {
21
+ $utf8_without_byte_order_mark = New-Object System.Text.UTF8Encoding $false
22
+ [System.IO.File]::WriteAllText($body_file_path, $LiteralBugbotRunBody, $utf8_without_byte_order_mark)
23
+
24
+ $null = Get-Command gh -ErrorAction Stop
25
+ $argument_list = Build-GhArgumentList -Invocation $invocation -BodyFilePath $body_file_path
26
+ & gh @argument_list
27
+ if ($LASTEXITCODE -ne 0) {
28
+ throw ('gh exited with code {0}.' -f $LASTEXITCODE)
29
+ }
30
+ } finally {
31
+ Remove-Item -LiteralPath $body_file_path -Force -ErrorAction SilentlyContinue
32
+ Remove-Item -LiteralPath $scratch_temp_path -Force -ErrorAction SilentlyContinue
33
+ }
@@ -0,0 +1,84 @@
1
+ """Post an inline reply to a PR review comment.
2
+
3
+ Reply body is sourced from a file via `gh api ... -F body=@<path>` (per the
4
+ gh-body-file rule — passing a string body to gh can corrupt backticks).
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_FIELD_BODY_AT_PREFIX,
22
+ GH_INLINE_COMMENT_REPLY_PATH_TEMPLATE,
23
+ )
24
+
25
+
26
+ def reply_to_inline_comment(
27
+ *,
28
+ owner: str,
29
+ repo: str,
30
+ number: int,
31
+ comment_id: int,
32
+ body_file: Path,
33
+ ) -> int:
34
+ """POST an inline reply to a PR review comment, return gh's reply id."""
35
+ replies_endpoint = GH_INLINE_COMMENT_REPLY_PATH_TEMPLATE.format(
36
+ owner=owner, repo=repo, number=number, comment_id=comment_id
37
+ )
38
+ gh_command: list[str] = [
39
+ "gh",
40
+ "api",
41
+ "-X",
42
+ "POST",
43
+ replies_endpoint,
44
+ "-F",
45
+ f"{GH_FIELD_BODY_AT_PREFIX}{body_file}",
46
+ ]
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
+ response_payload: dict[str, object] = json.loads(completed.stdout)
56
+ raw_reply_id = response_payload["id"]
57
+ if not isinstance(raw_reply_id, (int, str)):
58
+ raise TypeError(
59
+ f"gh response id field is not int|str: {type(raw_reply_id).__name__}"
60
+ )
61
+ return int(raw_reply_id)
62
+
63
+
64
+ def main() -> int:
65
+ parser = argparse.ArgumentParser(description=__doc__)
66
+ parser.add_argument("--owner", required=True)
67
+ parser.add_argument("--repo", required=True)
68
+ parser.add_argument("--number", required=True, type=int)
69
+ parser.add_argument("--comment-id", required=True, type=int, dest="comment_id")
70
+ parser.add_argument("--body-file", required=True, type=Path, dest="body_file")
71
+ parsed_arguments = parser.parse_args()
72
+ reply_id = reply_to_inline_comment(
73
+ owner=parsed_arguments.owner,
74
+ repo=parsed_arguments.repo,
75
+ number=parsed_arguments.number,
76
+ comment_id=parsed_arguments.comment_id,
77
+ body_file=parsed_arguments.body_file,
78
+ )
79
+ sys.stdout.write(f"{reply_id}\n")
80
+ return 0
81
+
82
+
83
+ if __name__ == "__main__":
84
+ sys.exit(main())
@@ -0,0 +1,71 @@
1
+ """Request a Copilot review on the current PR via the requested_reviewers API.
2
+
3
+ The reviewer ID literal is ``copilot-pull-request-reviewer[bot]`` - the
4
+ ``[bot]`` suffix is load-bearing per ``skills/copilot-review/SKILL.md``;
5
+ ``Copilot``, ``copilot``, and ``github-copilot`` all silently no-op on this
6
+ endpoint. After this POST returns, GitHub schedules Copilot to render a review
7
+ on the current HEAD; the caller polls ``fetch_copilot_reviews.py`` to converge.
8
+ """
9
+
10
+ import argparse
11
+ import subprocess
12
+ import sys
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
+ COPILOT_REVIEWER_REQUEST_ID,
24
+ GH_REQUESTED_REVIEWERS_FIELD_TEMPLATE,
25
+ GH_REQUESTED_REVIEWERS_PATH_TEMPLATE,
26
+ )
27
+
28
+
29
+ def request_copilot_review(*, owner: str, repo: str, number: int) -> None:
30
+ """POST a Copilot review request to the PR's requested_reviewers endpoint."""
31
+ requested_reviewers_endpoint = GH_REQUESTED_REVIEWERS_PATH_TEMPLATE.format(
32
+ owner=owner, repo=repo, number=number
33
+ )
34
+ reviewer_field_value = GH_REQUESTED_REVIEWERS_FIELD_TEMPLATE.format(
35
+ reviewer_id=COPILOT_REVIEWER_REQUEST_ID
36
+ )
37
+ gh_command: list[str] = [
38
+ "gh",
39
+ "api",
40
+ "-X",
41
+ "POST",
42
+ requested_reviewers_endpoint,
43
+ "-f",
44
+ reviewer_field_value,
45
+ ]
46
+ subprocess.run(
47
+ gh_command,
48
+ capture_output=True,
49
+ check=True,
50
+ text=True,
51
+ encoding="utf-8",
52
+ errors="replace",
53
+ )
54
+
55
+
56
+ def main() -> int:
57
+ parser = argparse.ArgumentParser(description=__doc__)
58
+ parser.add_argument("--owner", required=True)
59
+ parser.add_argument("--repo", required=True)
60
+ parser.add_argument("--number", required=True, type=int)
61
+ parsed_arguments = parser.parse_args()
62
+ request_copilot_review(
63
+ owner=parsed_arguments.owner,
64
+ repo=parsed_arguments.repo,
65
+ number=parsed_arguments.number,
66
+ )
67
+ return 0
68
+
69
+
70
+ if __name__ == "__main__":
71
+ sys.exit(main())
@@ -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