claude-dev-env 1.93.1 → 1.94.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/_shared/pr-loop/CLAUDE.md +1 -0
- package/_shared/pr-loop/audit-contract.md +1 -1
- package/_shared/pr-loop/gh-payloads.md +3 -3
- package/_shared/pr-loop/post-audit-thread-contract.md +51 -0
- package/_shared/pr-loop/scripts/README.md +2 -2
- package/_shared/pr-loop/scripts/fix_hookspath.py +47 -12
- package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +0 -69
- package/_shared/pr-loop/scripts/tests/test_fix_hookspath.py +497 -374
- package/_shared/pr-loop/state-schema.md +3 -22
- package/bin/CLAUDE.md +1 -1
- package/bin/install.mjs +2 -7
- package/bin/install.test.mjs +10 -4
- package/hooks/blocking/CLAUDE.md +2 -0
- package/hooks/blocking/pii_commit_command.py +398 -0
- package/hooks/blocking/pii_payload_scan.py +133 -0
- package/hooks/blocking/pii_prevention_blocker.py +151 -449
- package/hooks/blocking/test_pii_prevention_blocker.py +299 -0
- package/hooks/blocking/test_verdict_directory_write_blocker.py +42 -7
- package/hooks/hooks_constants/CLAUDE.md +2 -2
- package/hooks/hooks_constants/code_rules_path_utils_constants.py +0 -2
- package/hooks/hooks_constants/local_identity.py +65 -15
- package/hooks/hooks_constants/pii_prevention_constants.py +16 -0
- package/hooks/hooks_constants/pr_description_enforcer_constants.py +1 -1
- package/hooks/hooks_constants/test_local_identity.py +68 -0
- package/package.json +1 -1
- package/scripts/CLAUDE.md +10 -1
- package/scripts/Get-SessionAccount.ps1 +210 -0
- package/scripts/tests/CLAUDE.md +15 -1
- package/scripts/tests/Get-SessionAccount.Tests.ps1 +350 -0
- package/skills/CLAUDE.md +3 -25
- package/skills/anthropic-plan/SKILL.md +1 -13
- package/skills/autoconverge/CLAUDE.md +5 -2
- package/skills/autoconverge/SKILL.md +112 -387
- package/skills/autoconverge/reference/CLAUDE.md +4 -1
- package/skills/autoconverge/reference/closing-report.md +9 -2
- package/skills/autoconverge/reference/convergence.md +26 -22
- package/skills/autoconverge/reference/headless-safety.md +44 -0
- package/skills/autoconverge/reference/multi-pr.md +88 -0
- package/skills/autoconverge/reference/self-closing-loop.md +84 -0
- package/skills/autoconverge/reference/stop-conditions.md +21 -13
- package/skills/autoconverge/workflow/converge.clean-audit.test.mjs +87 -27
- package/skills/autoconverge/workflow/converge.contract.test.mjs +40 -10
- package/skills/autoconverge/workflow/converge.copilot-gate.test.mjs +179 -47
- package/skills/autoconverge/workflow/converge.fix-progress.test.mjs +1 -1
- package/skills/autoconverge/workflow/converge.mjs +284 -94
- package/skills/bugteam/CLAUDE.md +1 -1
- package/skills/bugteam/CONSTRAINTS.md +3 -3
- package/skills/bugteam/PROMPTS.md +8 -19
- package/skills/bugteam/SKILL.md +13 -14
- package/skills/bugteam/reference/CLAUDE.md +1 -1
- package/skills/bugteam/reference/audit-and-teammates.md +2 -2
- package/skills/bugteam/reference/copilot-gap-analysis.md +2 -0
- package/skills/bugteam/reference/github-pr-reviews.md +56 -38
- package/skills/bugteam/reference/team-setup.md +11 -11
- package/skills/bugteam/scripts/CLAUDE.md +6 -6
- package/skills/bugteam/scripts/README.md +20 -8
- package/skills/bugteam/scripts/bugteam_code_rules_gate.py +13 -1463
- package/skills/bugteam/scripts/bugteam_fix_hookspath.py +23 -358
- package/skills/bugteam/scripts/bugteam_preflight.py +13 -315
- package/skills/bugteam/scripts/bugteam_scripts_constants/CLAUDE.md +2 -3
- package/skills/bugteam/scripts/test_bugteam_code_rules_gate.py +34 -1053
- package/skills/bugteam/scripts/test_bugteam_fix_hookspath.py +30 -347
- package/skills/bugteam/scripts/test_bugteam_preflight.py +30 -305
- package/skills/copilot-finding-triage/SKILL.md +80 -28
- package/skills/copilot-finding-triage/reference/tier-rubric.md +70 -11
- package/skills/copilot-finding-triage/templates/notification.md +8 -3
- package/skills/everything-search/SKILL.md +1 -2
- package/skills/grokify/SKILL.md +58 -0
- package/skills/grokify/templates/handoff-template.md +92 -0
- package/skills/orchestrator/SKILL.md +12 -8
- package/skills/orchestrator-refresh/SKILL.md +3 -3
- package/skills/pr-converge/SKILL.md +132 -233
- package/skills/pr-converge/reference/convergence-gates.md +102 -66
- package/skills/pr-converge/reference/examples.md +37 -25
- package/skills/pr-converge/reference/fix-protocol.md +7 -7
- package/skills/pr-converge/reference/multi-pr-orchestration.md +10 -4
- package/skills/pr-converge/reference/per-tick.md +64 -41
- package/skills/pr-converge/reference/state-schema.md +27 -2
- package/skills/pr-converge/scripts/CLAUDE.md +8 -0
- package/skills/pr-converge/scripts/README.md +4 -3
- package/skills/pr-converge/scripts/_pr_converge_path_setup.py +23 -0
- package/skills/pr-converge/scripts/check_convergence.py +189 -645
- package/skills/pr-converge/scripts/check_convergence_gates.py +235 -0
- package/skills/pr-converge/scripts/check_convergence_thread_gates.py +219 -0
- package/skills/pr-converge/scripts/conftest.py +8 -0
- package/skills/pr-converge/scripts/pr_converge_scripts_constants/CLAUDE.md +1 -0
- package/skills/pr-converge/scripts/pr_converge_scripts_constants/convergence_gate_constants.py +36 -0
- package/skills/pr-converge/scripts/test__pr_converge_path_setup.py +26 -0
- package/skills/pr-converge/scripts/test_check_convergence.py +73 -228
- package/skills/pr-converge/scripts/test_check_convergence_contract.py +321 -0
- package/skills/pr-converge/scripts/test_check_convergence_gates.py +68 -0
- package/skills/pr-converge/scripts/test_check_convergence_thread_gates.py +75 -0
- package/skills/privacy-hygiene/SKILL.md +4 -3
- package/skills/reviewer-gates/SKILL.md +26 -8
- package/skills/session-log/SKILL.md +11 -12
- package/skills/session-tidy/SKILL.md +40 -30
- package/skills/skill-builder/CLAUDE.md +13 -10
- package/skills/skill-builder/SKILL.md +31 -31
- package/skills/skill-builder/references/CLAUDE.md +10 -6
- package/skills/skill-builder/references/delegation-map.md +13 -4
- package/skills/skill-builder/references/description-field.md +113 -0
- package/skills/skill-builder/references/self-audit-checklist.md +21 -4
- package/skills/skill-builder/references/skill-modularity.md +113 -0
- package/skills/skill-builder/references/skill-types.md +4 -0
- package/skills/skill-builder/templates/CLAUDE.md +4 -4
- package/skills/skill-builder/templates/gap-analysis.md +25 -1
- package/skills/skill-builder/workflows/CLAUDE.md +6 -4
- package/skills/skill-builder/workflows/improve-skill.md +17 -16
- package/skills/skill-builder/workflows/new-skill.md +34 -10
- package/skills/skill-builder/workflows/polish-skill.md +59 -43
- package/skills/usage-pause/SKILL.md +10 -14
- package/skills/bdd-protocol/CLAUDE.md +0 -26
- package/skills/bdd-protocol/SKILL.md +0 -30
- package/skills/bdd-protocol/references/CLAUDE.md +0 -10
- package/skills/bdd-protocol/references/anti-patterns.md +0 -26
- package/skills/bdd-protocol/references/example-mapping.md +0 -23
- package/skills/bg-agent/CLAUDE.md +0 -17
- package/skills/bg-agent/SKILL.md +0 -69
- package/skills/bugteam/scripts/bugteam_scripts_constants/bugteam_code_rules_gate_constants.py +0 -55
- package/skills/bugteam/scripts/bugteam_scripts_constants/bugteam_fix_hookspath_constants.py +0 -26
- package/skills/bugteam/scripts/bugteam_scripts_constants/bugteam_preflight_constants.py +0 -35
- package/skills/caveman/CLAUDE.md +0 -15
- package/skills/caveman/SKILL.md +0 -38
- package/skills/code/CLAUDE.md +0 -17
- package/skills/code/SKILL.md +0 -178
- package/skills/deep-research/CLAUDE.md +0 -17
- package/skills/deep-research/SKILL.md +0 -80
- package/skills/findbugs/CLAUDE.md +0 -20
- package/skills/findbugs/SKILL.md +0 -241
- package/skills/fixbugs/CLAUDE.md +0 -19
- package/skills/fixbugs/SKILL.md +0 -142
- package/skills/gh-paginate/CLAUDE.md +0 -18
- package/skills/gh-paginate/SKILL.md +0 -84
- package/skills/gotcha/CLAUDE.md +0 -33
- package/skills/gotcha/SKILL.md +0 -73
- package/skills/implement/CLAUDE.md +0 -27
- package/skills/implement/SKILL.md +0 -66
- package/skills/implement/scripts/CLAUDE.md +0 -22
- package/skills/implement/scripts/append_note.py +0 -133
- package/skills/implement/scripts/implement_scripts_constants/CLAUDE.md +0 -22
- package/skills/implement/scripts/implement_scripts_constants/__init__.py +0 -0
- package/skills/implement/scripts/implement_scripts_constants/notes_constants.py +0 -12
- package/skills/implement/scripts/test_append_note.py +0 -191
- package/skills/log-audit/CLAUDE.md +0 -20
- package/skills/log-audit/SKILL.md +0 -66
- package/skills/log-audit/reference/CLAUDE.md +0 -9
- package/skills/log-audit/reference/charter.md +0 -52
- package/skills/log-audit/scripts/CLAUDE.md +0 -27
- package/skills/log-audit/scripts/cluster_recurrences.py +0 -261
- package/skills/log-audit/scripts/collect_log_window.py +0 -199
- package/skills/log-audit/scripts/log_audit_constants/CLAUDE.md +0 -12
- package/skills/log-audit/scripts/log_audit_constants/__init__.py +0 -0
- package/skills/log-audit/scripts/log_audit_constants/cluster_recurrences_constants.py +0 -23
- package/skills/log-audit/scripts/log_audit_constants/collect_log_window_constants.py +0 -24
- package/skills/log-audit/scripts/log_audit_constants/mine_copilot_findings_constants.py +0 -49
- package/skills/log-audit/scripts/mine_copilot_findings.py +0 -302
- package/skills/log-audit/scripts/test_cluster_recurrences.py +0 -160
- package/skills/log-audit/scripts/test_collect_log_window.py +0 -111
- package/skills/log-audit/scripts/test_mine_copilot_findings.py +0 -126
- package/skills/logifix/CLAUDE.md +0 -36
- package/skills/logifix/SKILL.md +0 -69
- package/skills/logifix/scripts/CLAUDE.md +0 -16
- package/skills/logifix/scripts/logifix.ps1 +0 -205
- package/skills/monitor-open-prs/CLAUDE.md +0 -34
- package/skills/monitor-open-prs/SKILL.md +0 -88
- package/skills/monitor-open-prs/scripts/CLAUDE.md +0 -17
- package/skills/monitor-open-prs/scripts/discover_open_prs.py +0 -69
- package/skills/monitor-open-prs/scripts/test_discover_open_prs.py +0 -149
- package/skills/monitor-open-prs/test_skill_contract.py +0 -27
- package/skills/post-audit-findings/SKILL.md +0 -80
- package/skills/pr-consistency-audit/CLAUDE.md +0 -34
- package/skills/pr-consistency-audit/SKILL.md +0 -116
- package/skills/pr-consistency-audit/reference/CLAUDE.md +0 -16
- package/skills/pr-consistency-audit/reference/detection-rules.md +0 -96
- package/skills/pr-consistency-audit/reference/illustrations.md +0 -78
- package/skills/pr-fix-protocol/SKILL.md +0 -65
- package/skills/pr-review-responder/CLAUDE.md +0 -35
- package/skills/pr-review-responder/EXAMPLES.md +0 -590
- package/skills/pr-review-responder/PRINCIPLES.md +0 -533
- package/skills/pr-review-responder/README.md +0 -168
- package/skills/pr-review-responder/SKILL.md +0 -203
- package/skills/pr-review-responder/TESTING.md +0 -350
- package/skills/pr-scope-resolve/SKILL.md +0 -45
- package/skills/pre-compact/CLAUDE.md +0 -24
- package/skills/pre-compact/SKILL.md +0 -134
- package/skills/qbug/CLAUDE.md +0 -40
- package/skills/qbug/SKILL.md +0 -387
- package/skills/qbug/test_qbug_skill_audit_schema.py +0 -152
- package/skills/qbug/test_qbug_skill_post_fix_audit.py +0 -103
- package/skills/refine/CLAUDE.md +0 -44
- package/skills/refine/SKILL.md +0 -262
- package/skills/refine/templates/CLAUDE.md +0 -17
- package/skills/refine/templates/implementation-notes-template.html +0 -56
- package/skills/refine/templates/plan-template.md +0 -60
- package/skills/research-mode/CLAUDE.md +0 -35
- package/skills/research-mode/SKILL.md +0 -53
- package/skills/structure-prompt/CLAUDE.md +0 -42
- package/skills/structure-prompt/SKILL.md +0 -44
- package/skills/structure-prompt/reference/CLAUDE.md +0 -28
- package/skills/structure-prompt/reference/adversarial-tuning.md +0 -62
- package/skills/structure-prompt/reference/block-classification.md +0 -27
- package/skills/structure-prompt/reference/canonical-case.md +0 -48
- package/skills/structure-prompt/reference/citation-depth.md +0 -70
- package/skills/structure-prompt/reference/cleanup.md +0 -33
- package/skills/structure-prompt/reference/constraints.md +0 -33
- package/skills/structure-prompt/reference/directives.md +0 -37
- package/skills/structure-prompt/reference/examples.md +0 -72
- package/skills/structure-prompt/reference/instantiation.md +0 -51
- package/skills/structure-prompt/reference/output-contract.md +0 -72
- package/skills/structure-prompt/reference/per-category.md +0 -23
- package/skills/structure-prompt/reference/persona.md +0 -38
- package/skills/structure-prompt/reference/research.md +0 -33
- package/skills/structure-prompt/reference/structure.md +0 -28
- package/skills/verified-build/CLAUDE.md +0 -33
- package/skills/verified-build/SKILL.md +0 -33
|
@@ -1,302 +0,0 @@
|
|
|
1
|
-
"""Turn the defects reviewers keep catching into upstream skill-edit proposals.
|
|
2
|
-
|
|
3
|
-
Copilot and Bugbot flag the same kinds of defect over and over — an untyped
|
|
4
|
-
return here, a broad except there. Each catch is a defect that reached review
|
|
5
|
-
because nothing blocked it at write time. This script reads recent reviewer
|
|
6
|
-
comments, sorts them into defect classes, and for each class names a concrete
|
|
7
|
-
skill or rule edit that would block that class upstream. The proposals are for a
|
|
8
|
-
human to apply through review, not for the agent to commit on its own.
|
|
9
|
-
|
|
10
|
-
Usage:
|
|
11
|
-
mine_copilot_findings.py --repo owner/name
|
|
12
|
-
"""
|
|
13
|
-
|
|
14
|
-
from __future__ import annotations
|
|
15
|
-
|
|
16
|
-
import argparse
|
|
17
|
-
import json
|
|
18
|
-
import subprocess
|
|
19
|
-
import sys
|
|
20
|
-
from collections import defaultdict
|
|
21
|
-
from dataclasses import dataclass
|
|
22
|
-
from pathlib import Path
|
|
23
|
-
|
|
24
|
-
_script_directory = str(Path(__file__).resolve().parent)
|
|
25
|
-
if _script_directory not in sys.path:
|
|
26
|
-
sys.path.insert(0, _script_directory)
|
|
27
|
-
|
|
28
|
-
from log_audit_constants.mine_copilot_findings_constants import ( # noqa: E402
|
|
29
|
-
ALL_REVIEWER_BOT_LOGINS,
|
|
30
|
-
KEYWORDS_BY_DEFECT_CLASS,
|
|
31
|
-
MAX_EXAMPLES_PER_CLUSTER,
|
|
32
|
-
PROPOSAL_BY_DEFECT_CLASS,
|
|
33
|
-
PULL_COMMENTS_ENDPOINT_TEMPLATE,
|
|
34
|
-
RECENT_PULL_COUNT,
|
|
35
|
-
RECENT_PULLS_ENDPOINT_TEMPLATE,
|
|
36
|
-
)
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
class GitHubCommandError(RuntimeError):
|
|
40
|
-
"""Raised when a gh api call fails or returns output that is not JSON."""
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
@dataclass(frozen=True)
|
|
44
|
-
class ReviewerComment:
|
|
45
|
-
"""One review comment left by a reviewer bot.
|
|
46
|
-
|
|
47
|
-
Attributes:
|
|
48
|
-
pull_number: The pull request the comment was left on.
|
|
49
|
-
author: The reviewer bot's login.
|
|
50
|
-
body: The comment text.
|
|
51
|
-
"""
|
|
52
|
-
|
|
53
|
-
pull_number: int
|
|
54
|
-
author: str
|
|
55
|
-
body: str
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
@dataclass(frozen=True)
|
|
59
|
-
class DefectCluster:
|
|
60
|
-
"""The reviewer comments that fall into one defect class.
|
|
61
|
-
|
|
62
|
-
Attributes:
|
|
63
|
-
defect_class: The class name the comments were sorted into.
|
|
64
|
-
count: How many comments fell into the class.
|
|
65
|
-
example_bodies: A capped sample of the comment bodies in the class.
|
|
66
|
-
"""
|
|
67
|
-
|
|
68
|
-
defect_class: str
|
|
69
|
-
count: int
|
|
70
|
-
example_bodies: tuple[str, ...]
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
@dataclass(frozen=True)
|
|
74
|
-
class SkillProposal:
|
|
75
|
-
"""A proposed skill-definition edit that blocks one defect class upstream.
|
|
76
|
-
|
|
77
|
-
Attributes:
|
|
78
|
-
defect_class: The defect class the edit would block.
|
|
79
|
-
proposal: The concrete edit to make.
|
|
80
|
-
"""
|
|
81
|
-
|
|
82
|
-
defect_class: str
|
|
83
|
-
proposal: str
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
def classify_defect(comment_body: str) -> str | None:
|
|
87
|
-
"""Sort a review comment into a defect class by its keywords.
|
|
88
|
-
|
|
89
|
-
Args:
|
|
90
|
-
comment_body: The review comment text.
|
|
91
|
-
|
|
92
|
-
Returns:
|
|
93
|
-
The first defect class whose keywords appear in the comment, or None
|
|
94
|
-
when no class matches.
|
|
95
|
-
"""
|
|
96
|
-
lowered_body = comment_body.lower()
|
|
97
|
-
for each_defect_class, each_keywords in KEYWORDS_BY_DEFECT_CLASS.items():
|
|
98
|
-
if any(each_keyword in lowered_body for each_keyword in each_keywords):
|
|
99
|
-
return each_defect_class
|
|
100
|
-
return None
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
def cluster_defects(all_comments: list[ReviewerComment]) -> list[DefectCluster]:
|
|
104
|
-
"""Group review comments by defect class and rank by how often each recurs.
|
|
105
|
-
|
|
106
|
-
Args:
|
|
107
|
-
all_comments: The reviewer comments to sort.
|
|
108
|
-
|
|
109
|
-
Returns:
|
|
110
|
-
One cluster per defect class present, most frequent first, each with a
|
|
111
|
-
capped sample of its comment bodies.
|
|
112
|
-
"""
|
|
113
|
-
bodies_by_class: dict[str, list[str]] = defaultdict(list)
|
|
114
|
-
for each_comment in all_comments:
|
|
115
|
-
defect_class = classify_defect(each_comment.body)
|
|
116
|
-
if defect_class is None:
|
|
117
|
-
continue
|
|
118
|
-
bodies_by_class[defect_class].append(each_comment.body)
|
|
119
|
-
clusters: list[DefectCluster] = []
|
|
120
|
-
for each_defect_class, each_bodies in bodies_by_class.items():
|
|
121
|
-
clusters.append(
|
|
122
|
-
DefectCluster(
|
|
123
|
-
defect_class=each_defect_class,
|
|
124
|
-
count=len(each_bodies),
|
|
125
|
-
example_bodies=tuple(each_bodies[:MAX_EXAMPLES_PER_CLUSTER]),
|
|
126
|
-
)
|
|
127
|
-
)
|
|
128
|
-
return sorted(clusters, key=lambda cluster: cluster.count, reverse=True)
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
def proposal_for_defect_class(defect_class: str) -> SkillProposal:
|
|
132
|
-
"""Return the skill-edit proposal that blocks a defect class upstream.
|
|
133
|
-
|
|
134
|
-
Args:
|
|
135
|
-
defect_class: The defect class to propose an edit for.
|
|
136
|
-
|
|
137
|
-
Returns:
|
|
138
|
-
The proposal mapped to the class.
|
|
139
|
-
"""
|
|
140
|
-
return SkillProposal(
|
|
141
|
-
defect_class=defect_class, proposal=PROPOSAL_BY_DEFECT_CLASS[defect_class]
|
|
142
|
-
)
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
def _pull_number_from_url(pull_request_url: str) -> int | None:
|
|
146
|
-
"""Read the trailing pull number from a GitHub pull-request URL."""
|
|
147
|
-
last_segment = pull_request_url.rsplit("/", 1)[-1]
|
|
148
|
-
try:
|
|
149
|
-
return int(last_segment)
|
|
150
|
-
except ValueError:
|
|
151
|
-
return None
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
def _reviewer_comment_from_payload(payload: object) -> ReviewerComment | None:
|
|
155
|
-
"""Build a ReviewerComment from one gh comment payload, or None to skip it."""
|
|
156
|
-
if not isinstance(payload, dict):
|
|
157
|
-
return None
|
|
158
|
-
user_field = payload.get("user")
|
|
159
|
-
login = user_field.get("login") if isinstance(user_field, dict) else None
|
|
160
|
-
body = payload.get("body")
|
|
161
|
-
pull_request_url = payload.get("pull_request_url")
|
|
162
|
-
if not isinstance(login, str) or login not in ALL_REVIEWER_BOT_LOGINS:
|
|
163
|
-
return None
|
|
164
|
-
if not isinstance(body, str) or not isinstance(pull_request_url, str):
|
|
165
|
-
return None
|
|
166
|
-
pull_number = _pull_number_from_url(pull_request_url)
|
|
167
|
-
if pull_number is None:
|
|
168
|
-
return None
|
|
169
|
-
return ReviewerComment(pull_number=pull_number, author=login, body=body)
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
def _flatten_pages(slurped_payload: object) -> list[object]:
|
|
173
|
-
"""Flatten the per-page arrays a gh --slurp response holds into one list.
|
|
174
|
-
|
|
175
|
-
Args:
|
|
176
|
-
slurped_payload: The parsed output of a ``gh api --paginate --slurp``
|
|
177
|
-
call: a list whose entries are the individual page responses.
|
|
178
|
-
|
|
179
|
-
Returns:
|
|
180
|
-
Every entry across the pages in page order. A page that is itself a list
|
|
181
|
-
contributes its entries; any other page contributes itself.
|
|
182
|
-
"""
|
|
183
|
-
flattened_entries: list[object] = []
|
|
184
|
-
if isinstance(slurped_payload, list):
|
|
185
|
-
for each_page in slurped_payload:
|
|
186
|
-
if isinstance(each_page, list):
|
|
187
|
-
flattened_entries.extend(each_page)
|
|
188
|
-
else:
|
|
189
|
-
flattened_entries.append(each_page)
|
|
190
|
-
return flattened_entries
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
def _run_gh_json(endpoint: str, *, should_paginate: bool = False) -> object:
|
|
194
|
-
"""Run one gh api call for an endpoint and return its parsed JSON payload.
|
|
195
|
-
|
|
196
|
-
Args:
|
|
197
|
-
endpoint: The gh api endpoint path to request.
|
|
198
|
-
should_paginate: When True, follow pagination with ``--paginate
|
|
199
|
-
--slurp`` and flatten the per-page arrays into one list, so a list
|
|
200
|
-
endpoint with more than one page returns every entry rather than
|
|
201
|
-
only the first page.
|
|
202
|
-
|
|
203
|
-
Returns:
|
|
204
|
-
The parsed JSON payload: the flattened entry list when should_paginate
|
|
205
|
-
is True, otherwise the single response parsed as-is.
|
|
206
|
-
|
|
207
|
-
Raises:
|
|
208
|
-
GitHubCommandError: When gh is not installed, the gh api call fails, or
|
|
209
|
-
its output is not JSON.
|
|
210
|
-
"""
|
|
211
|
-
gh_command = ["gh", "api", endpoint]
|
|
212
|
-
if should_paginate:
|
|
213
|
-
gh_command += ["--paginate", "--slurp"]
|
|
214
|
-
try:
|
|
215
|
-
completed_process = subprocess.run(
|
|
216
|
-
gh_command,
|
|
217
|
-
capture_output=True,
|
|
218
|
-
text=True,
|
|
219
|
-
check=True,
|
|
220
|
-
)
|
|
221
|
-
except FileNotFoundError as missing_gh_error:
|
|
222
|
-
raise GitHubCommandError(
|
|
223
|
-
"gh command not found; install the GitHub CLI to mine reviewer comments"
|
|
224
|
-
) from missing_gh_error
|
|
225
|
-
except subprocess.CalledProcessError as gh_failure_error:
|
|
226
|
-
raise GitHubCommandError(
|
|
227
|
-
f"gh api {endpoint} failed: {gh_failure_error.stderr.strip()}"
|
|
228
|
-
) from gh_failure_error
|
|
229
|
-
try:
|
|
230
|
-
parsed_payload = json.loads(completed_process.stdout)
|
|
231
|
-
except json.JSONDecodeError as decode_error:
|
|
232
|
-
raise GitHubCommandError(
|
|
233
|
-
f"gh api {endpoint} returned output that is not JSON"
|
|
234
|
-
) from decode_error
|
|
235
|
-
if should_paginate:
|
|
236
|
-
return _flatten_pages(parsed_payload)
|
|
237
|
-
return parsed_payload
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
def _fetch_recent_pull_numbers(repo: str, pull_count: int) -> list[int]:
|
|
241
|
-
"""Fetch the most recently updated pull numbers, capped at pull_count."""
|
|
242
|
-
pulls_payload = _run_gh_json(
|
|
243
|
-
RECENT_PULLS_ENDPOINT_TEMPLATE.format(repo=repo, pull_count=pull_count)
|
|
244
|
-
)
|
|
245
|
-
recent_pull_numbers: list[int] = []
|
|
246
|
-
if isinstance(pulls_payload, list):
|
|
247
|
-
for each_pull in pulls_payload:
|
|
248
|
-
if isinstance(each_pull, dict):
|
|
249
|
-
pull_number = each_pull.get("number")
|
|
250
|
-
if isinstance(pull_number, int):
|
|
251
|
-
recent_pull_numbers.append(pull_number)
|
|
252
|
-
return recent_pull_numbers
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
def _fetch_pull_comments(repo: str, pull_number: int) -> list[object]:
|
|
256
|
-
"""Fetch one pull request's review comments through gh."""
|
|
257
|
-
comments_payload = _run_gh_json(
|
|
258
|
-
PULL_COMMENTS_ENDPOINT_TEMPLATE.format(repo=repo, pull_number=pull_number),
|
|
259
|
-
should_paginate=True,
|
|
260
|
-
)
|
|
261
|
-
if isinstance(comments_payload, list):
|
|
262
|
-
return list(comments_payload)
|
|
263
|
-
return []
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
def _fetch_reviewer_comments(repo: str) -> list[ReviewerComment]:
|
|
267
|
-
"""Fetch reviewer-bot comments from the repo's most recent pull requests."""
|
|
268
|
-
recent_pull_numbers = _fetch_recent_pull_numbers(repo, RECENT_PULL_COUNT)
|
|
269
|
-
reviewer_comments: list[ReviewerComment] = []
|
|
270
|
-
for each_pull_number in recent_pull_numbers:
|
|
271
|
-
for each_payload in _fetch_pull_comments(repo, each_pull_number):
|
|
272
|
-
parsed_comment = _reviewer_comment_from_payload(each_payload)
|
|
273
|
-
if parsed_comment is not None:
|
|
274
|
-
reviewer_comments.append(parsed_comment)
|
|
275
|
-
return reviewer_comments
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
def main() -> int:
|
|
279
|
-
"""Print one skill-edit proposal per recurring reviewer defect class.
|
|
280
|
-
|
|
281
|
-
Returns:
|
|
282
|
-
The process exit code; zero on success.
|
|
283
|
-
"""
|
|
284
|
-
parser = argparse.ArgumentParser(
|
|
285
|
-
description="Mine reviewer defect patterns into skill-edit proposals."
|
|
286
|
-
)
|
|
287
|
-
parser.add_argument("--repo", required=True)
|
|
288
|
-
parsed_arguments = parser.parse_args()
|
|
289
|
-
try:
|
|
290
|
-
comments = _fetch_reviewer_comments(parsed_arguments.repo)
|
|
291
|
-
except GitHubCommandError as gh_error:
|
|
292
|
-
print(str(gh_error), file=sys.stderr)
|
|
293
|
-
return 1
|
|
294
|
-
clusters = cluster_defects(comments)
|
|
295
|
-
for each_cluster in clusters:
|
|
296
|
-
proposal = proposal_for_defect_class(each_cluster.defect_class)
|
|
297
|
-
print(f"{each_cluster.count}\t{each_cluster.defect_class}\t{proposal.proposal}")
|
|
298
|
-
return 0
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
if __name__ == "__main__":
|
|
302
|
-
raise SystemExit(main())
|
|
@@ -1,160 +0,0 @@
|
|
|
1
|
-
"""Tests for cluster_recurrences — signature grouping and timing regressions."""
|
|
2
|
-
|
|
3
|
-
import io
|
|
4
|
-
import sys
|
|
5
|
-
from datetime import datetime, timedelta
|
|
6
|
-
from pathlib import Path
|
|
7
|
-
|
|
8
|
-
import pytest
|
|
9
|
-
|
|
10
|
-
_script_directory = str(Path(__file__).resolve().parent)
|
|
11
|
-
if _script_directory not in sys.path:
|
|
12
|
-
sys.path.insert(0, _script_directory)
|
|
13
|
-
|
|
14
|
-
from cluster_recurrences import ( # noqa: E402
|
|
15
|
-
TimingSample,
|
|
16
|
-
detect_timing_regressions,
|
|
17
|
-
main,
|
|
18
|
-
normalize_signature,
|
|
19
|
-
recency_weight,
|
|
20
|
-
rank_signature_clusters,
|
|
21
|
-
)
|
|
22
|
-
from collect_log_window import LogRecord # noqa: E402
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
def _record(timestamp: datetime, message: str) -> LogRecord:
|
|
26
|
-
return LogRecord(
|
|
27
|
-
timestamp=timestamp, source="a_hook", level="block", message=message
|
|
28
|
-
)
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
class TestNormalizeSignature:
|
|
32
|
-
def test_two_messages_differing_only_in_numbers_share_a_signature(self) -> None:
|
|
33
|
-
first = normalize_signature("blocked at line 12 with 3 issues")
|
|
34
|
-
second = normalize_signature("blocked at line 348 with 9 issues")
|
|
35
|
-
assert first == second
|
|
36
|
-
|
|
37
|
-
def test_messages_differing_only_in_a_path_share_a_signature(self) -> None:
|
|
38
|
-
first = normalize_signature(r"cannot read C:\a\b\one.py right now")
|
|
39
|
-
second = normalize_signature(r"cannot read C:\x\y\two.py right now")
|
|
40
|
-
assert first == second
|
|
41
|
-
|
|
42
|
-
def test_messages_differing_only_in_a_hash_share_a_signature(self) -> None:
|
|
43
|
-
first = normalize_signature("commit a1b2c3d failed to apply")
|
|
44
|
-
second = normalize_signature("commit deadbeef failed to apply")
|
|
45
|
-
assert first == second
|
|
46
|
-
|
|
47
|
-
def test_distinct_messages_keep_distinct_signatures(self) -> None:
|
|
48
|
-
assert normalize_signature("heavy word detected") != normalize_signature(
|
|
49
|
-
"missing type hint"
|
|
50
|
-
)
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
class TestRecencyWeight:
|
|
54
|
-
def test_a_fresh_record_weighs_about_one(self) -> None:
|
|
55
|
-
now = datetime(2026, 7, 3, 12, 0, 0)
|
|
56
|
-
weight = recency_weight(now, now)
|
|
57
|
-
assert abs(weight - 1.0) < 0.001
|
|
58
|
-
|
|
59
|
-
def test_a_record_one_half_life_old_weighs_about_the_decay_base(self) -> None:
|
|
60
|
-
now = datetime(2026, 7, 3, 12, 0, 0)
|
|
61
|
-
one_half_life_ago = now - timedelta(hours=24)
|
|
62
|
-
weight = recency_weight(one_half_life_ago, now)
|
|
63
|
-
assert abs(weight - 0.5) < 0.001
|
|
64
|
-
|
|
65
|
-
def test_a_future_record_is_clamped_to_at_most_one(self) -> None:
|
|
66
|
-
now = datetime(2026, 7, 3, 12, 0, 0)
|
|
67
|
-
one_hour_ahead = now + timedelta(hours=1)
|
|
68
|
-
weight = recency_weight(one_hour_ahead, now)
|
|
69
|
-
assert abs(weight - 1.0) < 0.001
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
class TestRankSignatureClusters:
|
|
73
|
-
def test_the_most_frequent_recent_signature_ranks_first(self) -> None:
|
|
74
|
-
now = datetime(2026, 7, 3, 12, 0, 0)
|
|
75
|
-
records = [
|
|
76
|
-
_record(now - timedelta(hours=1), "blocked at line 1"),
|
|
77
|
-
_record(now - timedelta(hours=2), "blocked at line 2"),
|
|
78
|
-
_record(now - timedelta(hours=1), "blocked at line 3"),
|
|
79
|
-
_record(now - timedelta(hours=200), "a rare stale failure"),
|
|
80
|
-
]
|
|
81
|
-
clusters = rank_signature_clusters(records, now)
|
|
82
|
-
assert clusters[0].count == 3
|
|
83
|
-
assert clusters[0].signature == normalize_signature("blocked at line 1")
|
|
84
|
-
|
|
85
|
-
def test_each_distinct_signature_becomes_one_cluster(self) -> None:
|
|
86
|
-
now = datetime(2026, 7, 3, 12, 0, 0)
|
|
87
|
-
records = [
|
|
88
|
-
_record(now, "heavy word here"),
|
|
89
|
-
_record(now, "missing type hint"),
|
|
90
|
-
]
|
|
91
|
-
clusters = rank_signature_clusters(records, now)
|
|
92
|
-
assert len(clusters) == 2
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
class TestDetectTimingRegressions:
|
|
96
|
-
def test_flags_an_operation_whose_recent_runs_are_slower(self) -> None:
|
|
97
|
-
base = datetime(2026, 7, 3, 0, 0, 0)
|
|
98
|
-
samples = [
|
|
99
|
-
TimingSample("extract", base + timedelta(minutes=1), 100.0),
|
|
100
|
-
TimingSample("extract", base + timedelta(minutes=2), 110.0),
|
|
101
|
-
TimingSample("extract", base + timedelta(minutes=3), 105.0),
|
|
102
|
-
TimingSample("extract", base + timedelta(minutes=40), 300.0),
|
|
103
|
-
TimingSample("extract", base + timedelta(minutes=41), 320.0),
|
|
104
|
-
TimingSample("extract", base + timedelta(minutes=42), 310.0),
|
|
105
|
-
]
|
|
106
|
-
regressions = detect_timing_regressions(samples)
|
|
107
|
-
assert len(regressions) == 1
|
|
108
|
-
assert regressions[0].operation == "extract"
|
|
109
|
-
assert regressions[0].ratio > 1.5
|
|
110
|
-
|
|
111
|
-
def test_ignores_a_steady_operation(self) -> None:
|
|
112
|
-
base = datetime(2026, 7, 3, 0, 0, 0)
|
|
113
|
-
samples = [
|
|
114
|
-
TimingSample("steady", base + timedelta(minutes=each_minute_offset), 100.0)
|
|
115
|
-
for each_minute_offset in range(6)
|
|
116
|
-
]
|
|
117
|
-
assert detect_timing_regressions(samples) == []
|
|
118
|
-
|
|
119
|
-
def test_ignores_an_operation_with_too_few_samples(self) -> None:
|
|
120
|
-
base = datetime(2026, 7, 3, 0, 0, 0)
|
|
121
|
-
samples = [
|
|
122
|
-
TimingSample("sparse", base + timedelta(minutes=1), 100.0),
|
|
123
|
-
TimingSample("sparse", base + timedelta(minutes=2), 900.0),
|
|
124
|
-
]
|
|
125
|
-
assert detect_timing_regressions(samples) == []
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
class TestMain:
|
|
129
|
-
def test_rejects_non_json_stdin(
|
|
130
|
-
self,
|
|
131
|
-
monkeypatch: pytest.MonkeyPatch,
|
|
132
|
-
capsys: pytest.CaptureFixture[str],
|
|
133
|
-
) -> None:
|
|
134
|
-
monkeypatch.setattr(sys, "stdin", io.StringIO("not json"))
|
|
135
|
-
exit_code = main()
|
|
136
|
-
captured = capsys.readouterr()
|
|
137
|
-
assert exit_code == 1
|
|
138
|
-
assert "not valid JSON" in captured.err
|
|
139
|
-
|
|
140
|
-
def test_rejects_a_non_list_payload(
|
|
141
|
-
self,
|
|
142
|
-
monkeypatch: pytest.MonkeyPatch,
|
|
143
|
-
capsys: pytest.CaptureFixture[str],
|
|
144
|
-
) -> None:
|
|
145
|
-
monkeypatch.setattr(sys, "stdin", io.StringIO('{"not": "a list"}'))
|
|
146
|
-
exit_code = main()
|
|
147
|
-
captured = capsys.readouterr()
|
|
148
|
-
assert exit_code == 1
|
|
149
|
-
assert "must be a list" in captured.err
|
|
150
|
-
|
|
151
|
-
def test_rejects_a_malformed_record(
|
|
152
|
-
self,
|
|
153
|
-
monkeypatch: pytest.MonkeyPatch,
|
|
154
|
-
capsys: pytest.CaptureFixture[str],
|
|
155
|
-
) -> None:
|
|
156
|
-
monkeypatch.setattr(sys, "stdin", io.StringIO('[{"missing": "fields"}]'))
|
|
157
|
-
exit_code = main()
|
|
158
|
-
captured = capsys.readouterr()
|
|
159
|
-
assert exit_code == 1
|
|
160
|
-
assert "malformed record" in captured.err
|
|
@@ -1,111 +0,0 @@
|
|
|
1
|
-
"""Tests for collect_log_window — parsing and windowing hook-block records."""
|
|
2
|
-
|
|
3
|
-
import json
|
|
4
|
-
import sys
|
|
5
|
-
from datetime import datetime, timedelta
|
|
6
|
-
from pathlib import Path
|
|
7
|
-
|
|
8
|
-
_script_directory = str(Path(__file__).resolve().parent)
|
|
9
|
-
if _script_directory not in sys.path:
|
|
10
|
-
sys.path.insert(0, _script_directory)
|
|
11
|
-
|
|
12
|
-
from collect_log_window import ( # noqa: E402
|
|
13
|
-
LogRecord,
|
|
14
|
-
collect_records,
|
|
15
|
-
parse_log_line,
|
|
16
|
-
read_log_window,
|
|
17
|
-
)
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
def _block_line(timestamp_text: str, hook_name: str, reason_text: str) -> str:
|
|
21
|
-
return json.dumps(
|
|
22
|
-
{
|
|
23
|
-
"timestamp": timestamp_text,
|
|
24
|
-
"hook": hook_name,
|
|
25
|
-
"event": "PreToolUse",
|
|
26
|
-
"reason": reason_text,
|
|
27
|
-
}
|
|
28
|
-
)
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
class TestParseLogLine:
|
|
32
|
-
def test_parses_a_well_formed_block_record(self) -> None:
|
|
33
|
-
line = _block_line(
|
|
34
|
-
"2026-07-03T09:00:00", "plain_language_blocker", "Heavy word"
|
|
35
|
-
)
|
|
36
|
-
record = parse_log_line(line)
|
|
37
|
-
assert record == LogRecord(
|
|
38
|
-
timestamp=datetime(2026, 7, 3, 9, 0, 0),
|
|
39
|
-
source="plain_language_blocker",
|
|
40
|
-
level="block",
|
|
41
|
-
message="Heavy word",
|
|
42
|
-
)
|
|
43
|
-
|
|
44
|
-
def test_returns_none_for_a_blank_line(self) -> None:
|
|
45
|
-
assert parse_log_line(" ") is None
|
|
46
|
-
|
|
47
|
-
def test_returns_none_for_non_json(self) -> None:
|
|
48
|
-
assert parse_log_line("not json at all") is None
|
|
49
|
-
|
|
50
|
-
def test_returns_none_when_reason_is_missing(self) -> None:
|
|
51
|
-
line = json.dumps({"timestamp": "2026-07-03T09:00:00", "hook": "x"})
|
|
52
|
-
assert parse_log_line(line) is None
|
|
53
|
-
|
|
54
|
-
def test_returns_none_for_an_unparseable_timestamp(self) -> None:
|
|
55
|
-
line = _block_line("not-a-timestamp", "x", "y")
|
|
56
|
-
assert parse_log_line(line) is None
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
class TestCollectRecords:
|
|
60
|
-
def test_keeps_only_records_at_or_after_the_window_start(self) -> None:
|
|
61
|
-
window_start = datetime(2026, 7, 3, 8, 0, 0)
|
|
62
|
-
log_text = "\n".join(
|
|
63
|
-
[
|
|
64
|
-
_block_line("2026-07-03T07:59:59", "old_hook", "before window"),
|
|
65
|
-
_block_line("2026-07-03T08:00:00", "edge_hook", "on the boundary"),
|
|
66
|
-
_block_line("2026-07-03T09:30:00", "new_hook", "inside window"),
|
|
67
|
-
]
|
|
68
|
-
)
|
|
69
|
-
records = collect_records(log_text, window_start)
|
|
70
|
-
assert [each_record.source for each_record in records] == [
|
|
71
|
-
"edge_hook",
|
|
72
|
-
"new_hook",
|
|
73
|
-
]
|
|
74
|
-
|
|
75
|
-
def test_skips_malformed_lines(self) -> None:
|
|
76
|
-
window_start = datetime(2026, 7, 3, 0, 0, 0)
|
|
77
|
-
log_text = "\n".join(
|
|
78
|
-
[
|
|
79
|
-
"garbage",
|
|
80
|
-
_block_line("2026-07-03T09:00:00", "good_hook", "kept"),
|
|
81
|
-
"",
|
|
82
|
-
]
|
|
83
|
-
)
|
|
84
|
-
records = collect_records(log_text, window_start)
|
|
85
|
-
assert len(records) == 1
|
|
86
|
-
assert records[0].source == "good_hook"
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
class TestReadLogWindow:
|
|
90
|
-
def test_returns_empty_list_when_the_log_is_absent(self, tmp_path: Path) -> None:
|
|
91
|
-
missing_path = tmp_path / "hook-blocks.log"
|
|
92
|
-
assert read_log_window(missing_path, 24, datetime(2026, 7, 3, 12, 0, 0)) == []
|
|
93
|
-
|
|
94
|
-
def test_reads_records_inside_the_window_from_a_real_file(
|
|
95
|
-
self, tmp_path: Path
|
|
96
|
-
) -> None:
|
|
97
|
-
now = datetime(2026, 7, 3, 12, 0, 0)
|
|
98
|
-
recent_time = (now - timedelta(hours=1)).isoformat()
|
|
99
|
-
stale_time = (now - timedelta(hours=48)).isoformat()
|
|
100
|
-
log_path = tmp_path / "hook-blocks.log"
|
|
101
|
-
log_path.write_text(
|
|
102
|
-
"\n".join(
|
|
103
|
-
[
|
|
104
|
-
_block_line(stale_time, "stale_hook", "too old"),
|
|
105
|
-
_block_line(recent_time, "recent_hook", "in window"),
|
|
106
|
-
]
|
|
107
|
-
),
|
|
108
|
-
encoding="utf-8",
|
|
109
|
-
)
|
|
110
|
-
records = read_log_window(log_path, 24, now)
|
|
111
|
-
assert [each_record.source for each_record in records] == ["recent_hook"]
|
|
@@ -1,126 +0,0 @@
|
|
|
1
|
-
"""Tests for mine_copilot_findings — defect classification and clustering."""
|
|
2
|
-
|
|
3
|
-
import sys
|
|
4
|
-
from pathlib import Path
|
|
5
|
-
|
|
6
|
-
_script_directory = str(Path(__file__).resolve().parent)
|
|
7
|
-
if _script_directory not in sys.path:
|
|
8
|
-
sys.path.insert(0, _script_directory)
|
|
9
|
-
|
|
10
|
-
from mine_copilot_findings import ( # noqa: E402
|
|
11
|
-
ReviewerComment,
|
|
12
|
-
_flatten_pages,
|
|
13
|
-
_pull_number_from_url,
|
|
14
|
-
_reviewer_comment_from_payload,
|
|
15
|
-
classify_defect,
|
|
16
|
-
cluster_defects,
|
|
17
|
-
proposal_for_defect_class,
|
|
18
|
-
)
|
|
19
|
-
from log_audit_constants.mine_copilot_findings_constants import ( # noqa: E402
|
|
20
|
-
PROPOSAL_BY_DEFECT_CLASS,
|
|
21
|
-
)
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
class TestClassifyDefect:
|
|
25
|
-
def test_recognizes_a_missing_type_hint_comment(self) -> None:
|
|
26
|
-
assert (
|
|
27
|
-
classify_defect("This function is missing a type hint on the return")
|
|
28
|
-
== "missing-type-hint"
|
|
29
|
-
)
|
|
30
|
-
|
|
31
|
-
def test_recognizes_a_broad_except_comment(self) -> None:
|
|
32
|
-
assert classify_defect("There is a broad except swallowing errors") == (
|
|
33
|
-
"broad-except"
|
|
34
|
-
)
|
|
35
|
-
|
|
36
|
-
def test_recognizes_a_magic_value_comment(self) -> None:
|
|
37
|
-
assert classify_defect("42 is a magic number, extract it") == "magic-value"
|
|
38
|
-
|
|
39
|
-
def test_returns_none_for_praise(self) -> None:
|
|
40
|
-
assert classify_defect("Looks great, nicely done!") is None
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
class TestClusterDefects:
|
|
44
|
-
def test_groups_comments_by_class_and_ranks_by_count(self) -> None:
|
|
45
|
-
comments = [
|
|
46
|
-
ReviewerComment(1, "cursor[bot]", "missing type hint here"),
|
|
47
|
-
ReviewerComment(2, "cursor[bot]", "another missing annotation"),
|
|
48
|
-
ReviewerComment(3, "cursor[bot]", "this is a broad except"),
|
|
49
|
-
ReviewerComment(4, "cursor[bot]", "nice work"),
|
|
50
|
-
]
|
|
51
|
-
clusters = cluster_defects(comments)
|
|
52
|
-
assert clusters[0].defect_class == "missing-type-hint"
|
|
53
|
-
assert clusters[0].count == 2
|
|
54
|
-
assert len(clusters) == 2
|
|
55
|
-
|
|
56
|
-
def test_caps_the_number_of_example_bodies(self) -> None:
|
|
57
|
-
comments = [
|
|
58
|
-
ReviewerComment(
|
|
59
|
-
each_index, "cursor[bot]", f"missing type hint number {each_index}"
|
|
60
|
-
)
|
|
61
|
-
for each_index in range(10)
|
|
62
|
-
]
|
|
63
|
-
clusters = cluster_defects(comments)
|
|
64
|
-
assert clusters[0].count == 10
|
|
65
|
-
assert len(clusters[0].example_bodies) == 3
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
class TestProposalForDefectClass:
|
|
69
|
-
def test_maps_a_class_to_its_skill_edit_proposal(self) -> None:
|
|
70
|
-
proposal = proposal_for_defect_class("broad-except")
|
|
71
|
-
assert proposal.defect_class == "broad-except"
|
|
72
|
-
assert proposal.proposal == PROPOSAL_BY_DEFECT_CLASS["broad-except"]
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
class TestPullNumberFromUrl:
|
|
76
|
-
def test_reads_the_trailing_pull_number(self) -> None:
|
|
77
|
-
url = "https://api.github.com/repos/owner/name/pulls/867"
|
|
78
|
-
assert _pull_number_from_url(url) == 867
|
|
79
|
-
|
|
80
|
-
def test_returns_none_when_the_last_segment_is_not_a_number(self) -> None:
|
|
81
|
-
assert _pull_number_from_url("https://api.github.com/repos/owner/name") is None
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
class TestReviewerCommentFromPayload:
|
|
85
|
-
def test_builds_a_comment_from_a_reviewer_bot_payload(self) -> None:
|
|
86
|
-
payload = {
|
|
87
|
-
"user": {"login": "cursor[bot]"},
|
|
88
|
-
"body": "missing type hint",
|
|
89
|
-
"pull_request_url": "https://api.github.com/repos/owner/name/pulls/12",
|
|
90
|
-
}
|
|
91
|
-
parsed_comment = _reviewer_comment_from_payload(payload)
|
|
92
|
-
assert parsed_comment == ReviewerComment(
|
|
93
|
-
pull_number=12, author="cursor[bot]", body="missing type hint"
|
|
94
|
-
)
|
|
95
|
-
|
|
96
|
-
def test_skips_a_comment_from_a_non_reviewer_login(self) -> None:
|
|
97
|
-
payload = {
|
|
98
|
-
"user": {"login": "some-human"},
|
|
99
|
-
"body": "looks good",
|
|
100
|
-
"pull_request_url": "https://api.github.com/repos/owner/name/pulls/12",
|
|
101
|
-
}
|
|
102
|
-
assert _reviewer_comment_from_payload(payload) is None
|
|
103
|
-
|
|
104
|
-
def test_skips_a_payload_that_is_not_a_dict(self) -> None:
|
|
105
|
-
assert _reviewer_comment_from_payload("not a payload") is None
|
|
106
|
-
|
|
107
|
-
def test_skips_a_payload_missing_the_pull_request_url(self) -> None:
|
|
108
|
-
payload = {"user": {"login": "cursor[bot]"}, "body": "missing type hint"}
|
|
109
|
-
assert _reviewer_comment_from_payload(payload) is None
|
|
110
|
-
|
|
111
|
-
def test_skips_a_payload_whose_url_has_no_numeric_pull_number(self) -> None:
|
|
112
|
-
payload = {
|
|
113
|
-
"user": {"login": "cursor[bot]"},
|
|
114
|
-
"body": "missing type hint",
|
|
115
|
-
"pull_request_url": "https://api.github.com/repos/owner/name/pulls",
|
|
116
|
-
}
|
|
117
|
-
assert _reviewer_comment_from_payload(payload) is None
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
class TestFlattenPages:
|
|
121
|
-
def test_flattens_multiple_pages_into_one_list(self) -> None:
|
|
122
|
-
slurped_payload = [[{"id": 1}, {"id": 2}], [{"id": 3}]]
|
|
123
|
-
assert _flatten_pages(slurped_payload) == [{"id": 1}, {"id": 2}, {"id": 3}]
|
|
124
|
-
|
|
125
|
-
def test_returns_empty_list_for_a_non_list_payload(self) -> None:
|
|
126
|
-
assert _flatten_pages({"message": "not a list"}) == []
|