@olegkoval/agent-skills 1.54.4 → 1.55.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/adapters/claude/olko-github-pr/skills/lekker-review/SKILL.md +135 -8
- package/adapters/claude/olko-github-pr/skills/lekker-review/references/agents/conventions.md +21 -0
- package/adapters/claude/olko-github-pr/skills/lekker-review/references/fix-mode.md +3 -1
- package/adapters/claude/olko-github-pr/skills/lekker-review/scripts/bot-precision.mjs +285 -0
- package/adapters/claude/olko-github-pr/skills/lekker-review/scripts/lint-findings.mjs +239 -0
- package/adapters/claude/olko-github-pr/skills/lekker-review/scripts/mine-bot-comments.sh +280 -0
- package/adapters/claude/olko-github-pr/skills/lekker-review/scripts/selftest.mjs +186 -0
- package/adapters/claude/olko-github-pr/skills/lekker-review/scripts/setup-worktree.sh +62 -3
- package/package.json +1 -1
- package/plugins/olko-apple-kit/.claude-plugin/plugin.json +1 -1
- package/plugins/olko-creative/.claude-plugin/plugin.json +1 -1
- package/plugins/olko-garmin-kit/.claude-plugin/plugin.json +1 -1
- package/plugins/olko-git-tools/.claude-plugin/plugin.json +1 -1
- package/plugins/olko-github-pr/.claude-plugin/plugin.json +1 -1
- package/plugins/olko-github-pr/skills/lekker-review/SKILL.md +135 -8
- package/plugins/olko-github-pr/skills/lekker-review/references/agents/conventions.md +21 -0
- package/plugins/olko-github-pr/skills/lekker-review/references/fix-mode.md +3 -1
- package/plugins/olko-github-pr/skills/lekker-review/scripts/bot-precision.mjs +285 -0
- package/plugins/olko-github-pr/skills/lekker-review/scripts/lint-findings.mjs +239 -0
- package/plugins/olko-github-pr/skills/lekker-review/scripts/mine-bot-comments.sh +280 -0
- package/plugins/olko-github-pr/skills/lekker-review/scripts/selftest.mjs +186 -0
- package/plugins/olko-github-pr/skills/lekker-review/scripts/setup-worktree.sh +62 -3
- package/plugins/olko-github-pr/skills/lekker-review/workflow.js +10 -0
- package/plugins/olko-obsidian/.claude-plugin/plugin.json +1 -1
- package/plugins/olko-product/.claude-plugin/plugin.json +1 -1
- package/plugins/olko-reflection/.claude-plugin/plugin.json +1 -1
- package/plugins/olko-release/.claude-plugin/plugin.json +1 -1
- package/plugins/olko-skill-meta/.claude-plugin/plugin.json +1 -1
- package/plugins/olko-web-ops/.claude-plugin/plugin.json +1 -1
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// eslint JSON report -> lekker-review findings, tagged botPredicted.
|
|
3
|
+
//
|
|
4
|
+
// Why this exists: the bot reviewers (Greptile, Gemini) spend most of their
|
|
5
|
+
// budget restating what the repo's own eslint config already says, and they are
|
|
6
|
+
// wrong about it more often than eslint is. On one observed PR three of four
|
|
7
|
+
// predicted bot comments were eslint rules, and eslint OVERRULED the model's
|
|
8
|
+
// guess (it had suggested `.forEach`, which that config bans). So the linter is
|
|
9
|
+
// the authority for this class of finding, not an LLM.
|
|
10
|
+
//
|
|
11
|
+
// Usage:
|
|
12
|
+
// node lint-findings.mjs <eslint-json-file> <out-findings-json> [--max N] [--base <sha>]
|
|
13
|
+
//
|
|
14
|
+
// --base keeps only messages on lines the change added (git diff -U0 <sha> HEAD
|
|
15
|
+
// in LEKKER_WORKTREE_PATH). eslint lints whole files, but bots comment on diff
|
|
16
|
+
// lines, so standing debt elsewhere in a touched file is not a prediction and
|
|
17
|
+
// must not reach fix mode. The dropped count is reported as summary.outOfDiff.
|
|
18
|
+
//
|
|
19
|
+
// Reads an `eslint -f json` report. Writes {"findings":[...], "summary": {...}}
|
|
20
|
+
// where each finding matches the FINDINGS_SCHEMA shape used by workflow.js,
|
|
21
|
+
// plus `botPredicted: true` and `rule: "LINT:<ruleId>"`.
|
|
22
|
+
//
|
|
23
|
+
// Findings are grouped by ruleId and file so a rule that fires 40 times in a
|
|
24
|
+
// file produces one finding listing the other sites, not 40 findings that drown
|
|
25
|
+
// the review. Per file, because fix mode routes each finding to its one file.
|
|
26
|
+
|
|
27
|
+
import { execFileSync } from 'node:child_process';
|
|
28
|
+
import { readFileSync, realpathSync, writeFileSync } from 'node:fs';
|
|
29
|
+
import { isAbsolute, join, relative } from 'node:path';
|
|
30
|
+
|
|
31
|
+
const [, , inPath, outPath, ...rest] = process.argv;
|
|
32
|
+
|
|
33
|
+
if (!inPath || !outPath) {
|
|
34
|
+
console.error('usage: lint-findings.mjs <eslint-json-file> <out-json> [--max N] [--base <sha>]');
|
|
35
|
+
process.exit(2);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const maxIdx = rest.indexOf('--max');
|
|
39
|
+
const MAX_FINDINGS = maxIdx === -1 ? 25 : Number(rest[maxIdx + 1]) || 25;
|
|
40
|
+
const baseIdx = rest.indexOf('--base');
|
|
41
|
+
const BASE = baseIdx === -1 ? null : rest[baseIdx + 1];
|
|
42
|
+
|
|
43
|
+
// eslint severity 2 = error, 1 = warning.
|
|
44
|
+
//
|
|
45
|
+
// An error is something CI will reject and a bot will certainly comment on, so
|
|
46
|
+
// it is `important`. A warning is style the team has chosen not to enforce, so
|
|
47
|
+
// it lands in `idiomatic` where it cannot affect the verdict.
|
|
48
|
+
function severityFor(eslintSeverity) {
|
|
49
|
+
return eslintSeverity === 2 ? 'important' : 'idiomatic';
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Write a zero-finding result whose status says why, then stop. A missing,
|
|
53
|
+
// empty or malformed report is "unknown", never "clean", so the review header
|
|
54
|
+
// cannot claim a green lint that never ran.
|
|
55
|
+
function bail(status, why) {
|
|
56
|
+
writeFileSync(
|
|
57
|
+
outPath,
|
|
58
|
+
JSON.stringify({ findings: [], summary: { status, ruleCount: 0, messageCount: 0 } }, null, 2),
|
|
59
|
+
);
|
|
60
|
+
console.log(`lint-findings: ${why}, 0 findings`);
|
|
61
|
+
process.exit(0);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
let report;
|
|
65
|
+
try {
|
|
66
|
+
const raw = readFileSync(inPath, 'utf8').trim();
|
|
67
|
+
if (!raw) bail('empty-report', 'empty eslint report');
|
|
68
|
+
report = JSON.parse(raw);
|
|
69
|
+
} catch (err) {
|
|
70
|
+
bail('unreadable: ' + err.message, `could not read eslint report (${err.message})`);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (!Array.isArray(report)) bail('not-an-eslint-json-report', 'report is not an eslint JSON array');
|
|
74
|
+
// eslint emits one entry per linted file, so [] means nothing was linted.
|
|
75
|
+
if (report.length === 0) bail('empty-report', 'eslint report lists no files');
|
|
76
|
+
|
|
77
|
+
// Resolve symlinks on both sides: on macOS /tmp is /private/tmp, so eslint's
|
|
78
|
+
// filePath and the worktree path given here can spell the same dir differently.
|
|
79
|
+
function real(p) {
|
|
80
|
+
try {
|
|
81
|
+
return realpathSync(p);
|
|
82
|
+
} catch {
|
|
83
|
+
return p;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const repoRoot = process.env.LEKKER_WORKTREE_PATH ? real(process.env.LEKKER_WORKTREE_PATH) : '';
|
|
88
|
+
|
|
89
|
+
function relativize(p) {
|
|
90
|
+
if (!repoRoot || !isAbsolute(p)) return p;
|
|
91
|
+
const rel = relative(repoRoot, real(p));
|
|
92
|
+
return rel.startsWith('..') ? p : rel;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// repo-relative path -> Set of line numbers the change added, or null when no
|
|
96
|
+
// --base was given or git could not answer (then nothing is filtered).
|
|
97
|
+
function addedLines() {
|
|
98
|
+
if (!BASE || !repoRoot) return null;
|
|
99
|
+
let out;
|
|
100
|
+
try {
|
|
101
|
+
// Explicit prefixes: diff.noprefix / diff.mnemonicPrefix would otherwise
|
|
102
|
+
// change the "+++ b/" header the parser below slices.
|
|
103
|
+
const args = ['-C', repoRoot, 'diff', '-U0', '--no-color', '--no-ext-diff', '--src-prefix=a/', '--dst-prefix=b/', BASE, 'HEAD'];
|
|
104
|
+
out = execFileSync('git', args, {
|
|
105
|
+
encoding: 'utf8',
|
|
106
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
107
|
+
});
|
|
108
|
+
} catch {
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
const added = new Map();
|
|
112
|
+
let file = null;
|
|
113
|
+
for (const line of out.split('\n')) {
|
|
114
|
+
if (line.startsWith('+++ ')) {
|
|
115
|
+
file = line === '+++ /dev/null' ? null : line.slice(6);
|
|
116
|
+
if (file && !added.has(file)) added.set(file, new Set());
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
const hunk = file && /^@@ -\S+ \+(\d+)(?:,(\d+))? @@/.exec(line);
|
|
120
|
+
if (!hunk) continue;
|
|
121
|
+
const start = Number(hunk[1]);
|
|
122
|
+
const count = hunk[2] === undefined ? 1 : Number(hunk[2]);
|
|
123
|
+
for (let i = 0; i < count; i++) added.get(file).add(start + i);
|
|
124
|
+
}
|
|
125
|
+
return added;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const added = addedLines();
|
|
129
|
+
let outOfDiff = 0;
|
|
130
|
+
|
|
131
|
+
// Group every message by ruleId. Parse errors arrive with ruleId null; they are
|
|
132
|
+
// real and worth surfacing, so they get their own bucket.
|
|
133
|
+
const byRule = new Map();
|
|
134
|
+
let messageCount = 0;
|
|
135
|
+
|
|
136
|
+
for (const file of report) {
|
|
137
|
+
const filePath = String(file?.filePath ?? '');
|
|
138
|
+
for (const m of file?.messages ?? []) {
|
|
139
|
+
// A message with no ruleId is a parse error only when eslint marks it
|
|
140
|
+
// fatal. The rest are notices such as "File ignored because of a matching
|
|
141
|
+
// ignore pattern", which flag nothing wrong with the code.
|
|
142
|
+
if (!m.ruleId && !m.fatal) continue;
|
|
143
|
+
const ruleId = m.ruleId ?? 'parse-error';
|
|
144
|
+
// A parse error breaks the whole file, so it counts wherever it lands.
|
|
145
|
+
if (added && ruleId !== 'parse-error' && !added.get(relativize(filePath))?.has(m.line)) {
|
|
146
|
+
outOfDiff++;
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
messageCount++;
|
|
150
|
+
const key = `${ruleId}\0${filePath}`;
|
|
151
|
+
if (!byRule.has(key)) {
|
|
152
|
+
byRule.set(key, { ruleId, severity: m.severity ?? 2, fixable: false, sites: [] });
|
|
153
|
+
}
|
|
154
|
+
const bucket = byRule.get(key);
|
|
155
|
+
// A rule reported at both levels keeps the louder one.
|
|
156
|
+
bucket.severity = Math.max(bucket.severity, m.severity ?? 2);
|
|
157
|
+
if (m.fix) {
|
|
158
|
+
bucket.fixable = true;
|
|
159
|
+
}
|
|
160
|
+
bucket.sites.push({
|
|
161
|
+
file: filePath,
|
|
162
|
+
line: m.line ?? 1,
|
|
163
|
+
column: m.column ?? 1,
|
|
164
|
+
message: String(m.message ?? '').trim(),
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Errors before warnings, then the rules that fire most - a rule hit 12 times
|
|
170
|
+
// is a pattern the author is repeating, and worth more than a one-off.
|
|
171
|
+
const ruleCount = new Set([...byRule.values()].map(b => b.ruleId)).size;
|
|
172
|
+
const buckets = [...byRule.values()].sort((a, b) => {
|
|
173
|
+
if (b.severity !== a.severity) return b.severity - a.severity;
|
|
174
|
+
return b.sites.length - a.sites.length;
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
// The offending source line, so badCode is code and not a location string.
|
|
178
|
+
function sourceLine(file, line) {
|
|
179
|
+
try {
|
|
180
|
+
const abs = isAbsolute(file) ? file : join(repoRoot, file);
|
|
181
|
+
const text = readFileSync(abs, 'utf8').split('\n')[line - 1];
|
|
182
|
+
if (text?.trim()) return text.trim();
|
|
183
|
+
} catch {
|
|
184
|
+
// unreadable file: fall back to the location
|
|
185
|
+
}
|
|
186
|
+
return null;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const findings = buckets.slice(0, MAX_FINDINGS).map(bucket => {
|
|
190
|
+
const first = bucket.sites[0];
|
|
191
|
+
const others = bucket.sites.slice(1);
|
|
192
|
+
const extra = others.length
|
|
193
|
+
? ' Also at: ' + others.slice(0, 8).map(s => `${relativize(s.file)}:${s.line}`).join(', ') +
|
|
194
|
+
(others.length > 8 ? ` (+${others.length - 8} more)` : '') + '.'
|
|
195
|
+
: '';
|
|
196
|
+
|
|
197
|
+
return {
|
|
198
|
+
file: relativize(first.file),
|
|
199
|
+
line: first.line,
|
|
200
|
+
severity: severityFor(bucket.severity),
|
|
201
|
+
title: `${bucket.ruleId} (${bucket.sites.length} site${bucket.sites.length === 1 ? '' : 's'})`,
|
|
202
|
+
description:
|
|
203
|
+
`The repo's own eslint config reports \`${bucket.ruleId}\` on the changed files: ` +
|
|
204
|
+
`"${first.message}".${extra} ` +
|
|
205
|
+
`This is the repo's configured rule, not a preference - it is the single most likely ` +
|
|
206
|
+
`bot-reviewer comment on this diff, and CI enforces it.` +
|
|
207
|
+
(bucket.fixable ? ' eslint can fix this automatically (`eslint --fix`).' : ''),
|
|
208
|
+
badCode:
|
|
209
|
+
sourceLine(first.file, first.line) ?? `${relativize(first.file)}:${first.line}:${first.column}`,
|
|
210
|
+
fix: bucket.fixable
|
|
211
|
+
? `Apply eslint's autofix for \`${bucket.ruleId}\` to the cited lines only, then re-read the hunk. ` +
|
|
212
|
+
`A file-wide \`eslint --fix\` also rewrites lines this change did not touch.`
|
|
213
|
+
: `Resolve \`${bucket.ruleId}\` as the config intends. Check what the rule permits before ` +
|
|
214
|
+
`substituting another form: a config that bans one idiom often bans its obvious replacement too.`,
|
|
215
|
+
rule: `LINT:${bucket.ruleId}`,
|
|
216
|
+
botPredicted: true,
|
|
217
|
+
lintFixable: bucket.fixable,
|
|
218
|
+
lintSiteCount: bucket.sites.length,
|
|
219
|
+
// Every site, so bot-precision can match a bot comment on any of them.
|
|
220
|
+
lintSites: bucket.sites.map(s => ({ file: relativize(s.file), line: s.line })),
|
|
221
|
+
};
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
const result = {
|
|
225
|
+
findings,
|
|
226
|
+
summary: {
|
|
227
|
+
status: messageCount === 0 ? 'clean' : 'issues',
|
|
228
|
+
ruleCount: ruleCount,
|
|
229
|
+
messageCount,
|
|
230
|
+
truncated: Math.max(0, buckets.length - findings.length),
|
|
231
|
+
outOfDiff,
|
|
232
|
+
},
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
writeFileSync(outPath, JSON.stringify(result, null, 2));
|
|
236
|
+
console.log(
|
|
237
|
+
`lint-findings: ${messageCount} message(s) across ${ruleCount} rule(s) -> ` +
|
|
238
|
+
`${findings.length} finding(s)${result.summary.truncated ? ` (${result.summary.truncated} rule(s) truncated)` : ''}`,
|
|
239
|
+
);
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Mine real Greptile / Gemini review comments from a repo's newest PR review comments
|
|
3
|
+
# and cluster them into the patterns worth pre-empting.
|
|
4
|
+
#
|
|
5
|
+
# Why this exists: predicting bot-reviewer comments from a model's intuition is
|
|
6
|
+
# guesswork. The bots have already told us what they comment on - dozens of
|
|
7
|
+
# times, in this exact repo, on this exact codebase. Mining that is free and it
|
|
8
|
+
# is evidence, not a guess.
|
|
9
|
+
#
|
|
10
|
+
# Usage:
|
|
11
|
+
# mine-bot-comments.sh <repo-slug> [out-md] [--limit N]
|
|
12
|
+
#
|
|
13
|
+
# repo-slug e.g. acme/web-app
|
|
14
|
+
# out-md default: $LEKKER_DATA_DIR/bot-patterns.md, where LEKKER_DATA_DIR
|
|
15
|
+
# defaults to ~/.cache/lekker-review (outside the skill dir, so a
|
|
16
|
+
# plugin update does not wipe the mined history)
|
|
17
|
+
# --limit N max review comments to fetch, newest first (default 400);
|
|
18
|
+
# paging stops there, so a long history is never pulled whole
|
|
19
|
+
#
|
|
20
|
+
# Writes/replaces a "## <repo-slug>" section in out-md, leaving other repos'
|
|
21
|
+
# sections alone, and prints a one-line summary. The section carries a
|
|
22
|
+
# "<!-- mined: YYYY-MM-DD -->" marker that SKILL.md reads to decide staleness;
|
|
23
|
+
# a repo with no bot comments still gets a section, so it is not re-mined on
|
|
24
|
+
# every run.
|
|
25
|
+
|
|
26
|
+
set -uo pipefail
|
|
27
|
+
|
|
28
|
+
REPO_SLUG="${1:-}"
|
|
29
|
+
if [[ -z "$REPO_SLUG" ]]; then
|
|
30
|
+
echo "usage: mine-bot-comments.sh <repo-slug> [out-md] [--limit N]" >&2
|
|
31
|
+
exit 2
|
|
32
|
+
fi
|
|
33
|
+
shift
|
|
34
|
+
|
|
35
|
+
DATA_DIR="${LEKKER_DATA_DIR:-$HOME/.cache/lekker-review}"
|
|
36
|
+
OUT_MD="$DATA_DIR/bot-patterns.md"
|
|
37
|
+
LIMIT=400
|
|
38
|
+
|
|
39
|
+
while [[ $# -gt 0 ]]; do
|
|
40
|
+
case "$1" in
|
|
41
|
+
--limit) LIMIT="${2:-}"; shift; [[ $# -gt 0 ]] && shift ;;
|
|
42
|
+
*) OUT_MD="$1"; shift ;;
|
|
43
|
+
esac
|
|
44
|
+
done
|
|
45
|
+
|
|
46
|
+
if ! [[ "$LIMIT" =~ ^[1-9][0-9]*$ ]]; then
|
|
47
|
+
echo "mine-bot-comments: --limit needs a positive integer, got '$LIMIT'" >&2
|
|
48
|
+
exit 2
|
|
49
|
+
fi
|
|
50
|
+
|
|
51
|
+
mkdir -p "$(dirname "$OUT_MD")"
|
|
52
|
+
|
|
53
|
+
python3 - "$REPO_SLUG" "$OUT_MD" "$LIMIT" <<'PYEOF'
|
|
54
|
+
import datetime, json, os, re, subprocess, sys
|
|
55
|
+
from collections import defaultdict
|
|
56
|
+
|
|
57
|
+
repo_slug, out_md, limit = sys.argv[1:4]
|
|
58
|
+
limit = int(limit)
|
|
59
|
+
|
|
60
|
+
# Review comments are the inline ones - the class the bots actually bill for.
|
|
61
|
+
# Page by hand rather than `gh api --paginate`, which fetches the whole history
|
|
62
|
+
# before LIMIT could apply.
|
|
63
|
+
comments = []
|
|
64
|
+
page = 1
|
|
65
|
+
while len(comments) < limit:
|
|
66
|
+
res = subprocess.run(
|
|
67
|
+
["gh", "api", "repos/%s/pulls/comments?per_page=100&sort=created&direction=desc&page=%d"
|
|
68
|
+
% (repo_slug, page)],
|
|
69
|
+
capture_output=True, text=True)
|
|
70
|
+
if res.returncode != 0:
|
|
71
|
+
print("mine-bot-comments: gh api failed for %s (auth? repo name?)" % repo_slug, file=sys.stderr)
|
|
72
|
+
sys.exit(1)
|
|
73
|
+
batch = json.loads(res.stdout or "[]")
|
|
74
|
+
if not isinstance(batch, list):
|
|
75
|
+
break
|
|
76
|
+
comments.extend(batch)
|
|
77
|
+
if len(batch) < 100:
|
|
78
|
+
break
|
|
79
|
+
page += 1
|
|
80
|
+
comments = comments[:limit]
|
|
81
|
+
|
|
82
|
+
BOT_RE = re.compile(r'gemini-code-assist|greptile|coderabbit', re.I)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def is_bot(c):
|
|
86
|
+
# A reply in a bot's own thread ("Thanks, updated") is not a new finding.
|
|
87
|
+
# The account type too: a human login can contain a bot's name.
|
|
88
|
+
user = c.get("user") or {}
|
|
89
|
+
return (user.get("type") == "Bot" and bool(BOT_RE.search(user.get("login", "")))
|
|
90
|
+
and not c.get("in_reply_to_id"))
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
bot_comments = [c for c in comments if is_bot(c)]
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def first_claim(body):
|
|
97
|
+
"""The sentence that carries the finding, with the bot's chrome removed."""
|
|
98
|
+
text = body or ""
|
|
99
|
+
# Both bots lead with chrome: Gemini an image badge, Greptile an <a><img>
|
|
100
|
+
# priority pill. Neither is the claim.
|
|
101
|
+
text = re.sub(r'!\[[^\]]*\]\([^)]*\)', '', text)
|
|
102
|
+
text = re.sub(r'<!--.*?-->', '', text, flags=re.S)
|
|
103
|
+
text = re.sub(r'<[^>]+>', ' ', text)
|
|
104
|
+
# Fenced code is the suggestion, not the claim.
|
|
105
|
+
text = re.sub(r'```.*?```', ' ', text, flags=re.S)
|
|
106
|
+
text = re.sub(r'`[^`]*`', ' CODE ', text)
|
|
107
|
+
text = re.sub(r'^\s*[#>*\-]+\s*', '', text, flags=re.M)
|
|
108
|
+
text = re.sub(r'\s+', ' ', text).strip()
|
|
109
|
+
parts = re.split(r'(?<=[.!?])\s', text)
|
|
110
|
+
for p in parts:
|
|
111
|
+
if len(p.strip()) > 25:
|
|
112
|
+
return p.strip()[:400]
|
|
113
|
+
return text[:400]
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
STOP = set("""a an and are as at be been but by can could does for from has have if in into is it
|
|
117
|
+
its may not of on or should that the their them there these this to was were will with would you your
|
|
118
|
+
we i be his her they code line file change changes when which what while also""".split())
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def signature(claim):
|
|
122
|
+
words = re.findall(r'[a-z]+', claim.lower())
|
|
123
|
+
words = [w for w in words if len(w) > 3 and w not in STOP]
|
|
124
|
+
# The 6 longest words are a good enough fingerprint at this scale: short
|
|
125
|
+
# common words carry little. Ties break alphabetically so wording order
|
|
126
|
+
# does not split one pattern into two clusters.
|
|
127
|
+
return tuple(sorted(set(words), key=lambda w: (-len(w), w))[:6])
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
# A curated taxonomy on top of the free-text clustering. Fingerprint clustering
|
|
131
|
+
# splits one concern across several wordings ("casting violates policy", "the use
|
|
132
|
+
# of CODE is a type cast", "TypeScript casts bypass checks" are one comment the
|
|
133
|
+
# bots make three ways), and the CATEGORY is what a reviewer can act on before
|
|
134
|
+
# the bot ever runs.
|
|
135
|
+
CATEGORIES = [
|
|
136
|
+
("type cast / any", r'\btype (cast|assertion)|\bcasting\b|\bas unknown\b|\bany\b type|\bnon-null assertion'),
|
|
137
|
+
("null / undefined guard", r'\bnull\b|\bundefined\b|optional chain|\bmissing (a )?check'),
|
|
138
|
+
("error handling", r'\berror (handling|is swallowed)|silently (fail|ignor)|catch block|\bthrow\b|unhandled'),
|
|
139
|
+
("pagination", r'\bpaginat|\bpageInfo\b|\bhasNextPage\b|\bcursor\b|page size'),
|
|
140
|
+
("await / concurrency", r'\bawait\b|floating promise|not awaited|\brace\b|concurren|fire.and.forget'),
|
|
141
|
+
("unused / dead code", r'\bunused\b|not used|dead code|\bunreachable\b|never referenced'),
|
|
142
|
+
("debug / logging", r'\bconsole\.|debug(ging)? (statement|log)|log(ging)? (noise|level)|should be removed before'),
|
|
143
|
+
("hardcoded / magic value",r'\bhardcoded\b|magic (number|string)|should be (a )?constant|configurable'),
|
|
144
|
+
("naming", r'\bnaming\b|\brename\b|\bmisleading name|the name CODE'),
|
|
145
|
+
("comment / docs", r'\bcomment (is|does|adds)|\bredundant comment|\bjsdoc\b|\btypo\b'),
|
|
146
|
+
("test coverage", r'\btest (coverage|case)|\bnot (covered|tested)|missing (a )?test'),
|
|
147
|
+
("performance / N+1", r'\bN\+1\b|\bin a loop\b|inefficien|\bperformance\b|every (run|iteration)|batch'),
|
|
148
|
+
("schedule / config", r'\bcron\b|\bschedul|\benv (var|variable)|\byaml\b|\bvalues\.'),
|
|
149
|
+
("SQL / query", r'\bSQL\b|\bquery uses\b|\bwhere clause\b|\bindex\b|\btransaction\b'),
|
|
150
|
+
]
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def categorize(body):
|
|
154
|
+
text = body or ""
|
|
155
|
+
hits = [name for name, rx in CATEGORIES if re.search(rx, text, re.I)]
|
|
156
|
+
return hits or ["other"]
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
PRIORITY_RE = re.compile(r'badges/(p[0-9])\.svg|\b(P[0-9])\b')
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def priority(body):
|
|
163
|
+
m = PRIORITY_RE.search(body or "")
|
|
164
|
+
if not m:
|
|
165
|
+
return None
|
|
166
|
+
return (m.group(1) or m.group(2) or "").upper()
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
category_counts = defaultdict(int)
|
|
170
|
+
category_priority = defaultdict(list)
|
|
171
|
+
for c in bot_comments:
|
|
172
|
+
prio = priority(c.get("body", ""))
|
|
173
|
+
for name in categorize(c.get("body", "")):
|
|
174
|
+
category_counts[name] += 1
|
|
175
|
+
if prio:
|
|
176
|
+
category_priority[name].append(prio)
|
|
177
|
+
|
|
178
|
+
clusters = defaultdict(list)
|
|
179
|
+
for c in bot_comments:
|
|
180
|
+
claim = first_claim(c.get("body", ""))
|
|
181
|
+
if not claim:
|
|
182
|
+
continue
|
|
183
|
+
clusters[signature(claim)].append((claim, c))
|
|
184
|
+
|
|
185
|
+
# Merge clusters that share most of their fingerprint - one pattern phrased two
|
|
186
|
+
# ways should not read as two patterns with a count of 1 each.
|
|
187
|
+
merged = []
|
|
188
|
+
for sig, items in sorted(clusters.items(), key=lambda kv: -len(kv[1])):
|
|
189
|
+
for m in merged:
|
|
190
|
+
shared = len(set(sig) & set(m["sig"]))
|
|
191
|
+
if sig and m["sig"] and shared / max(len(set(sig) | set(m["sig"])), 1) >= 0.5:
|
|
192
|
+
m["items"].extend(items)
|
|
193
|
+
break
|
|
194
|
+
else:
|
|
195
|
+
merged.append({"sig": sig, "items": list(items)})
|
|
196
|
+
|
|
197
|
+
merged.sort(key=lambda m: -len(m["items"]))
|
|
198
|
+
|
|
199
|
+
lines = []
|
|
200
|
+
lines.append("## %s" % repo_slug)
|
|
201
|
+
lines.append("<!-- mined: %s -->" % datetime.date.today().isoformat())
|
|
202
|
+
lines.append("")
|
|
203
|
+
if not bot_comments:
|
|
204
|
+
lines.append("No bot review comments in the newest %d review comment(s)." % len(comments))
|
|
205
|
+
lines.append("")
|
|
206
|
+
else:
|
|
207
|
+
lines.append("Mined from %d bot review comment(s) (%s). Counts are how many times the "
|
|
208
|
+
"bots have raised that pattern in THIS repo - the higher the count, the more "
|
|
209
|
+
"certain it is to come back on the next PR."
|
|
210
|
+
% (len(bot_comments), ", ".join(sorted({(c.get("user") or {}).get("login", "?")
|
|
211
|
+
for c in bot_comments}))))
|
|
212
|
+
lines.append("")
|
|
213
|
+
lines.append("### Categories, most-raised first")
|
|
214
|
+
lines.append("")
|
|
215
|
+
lines.append("| times raised | category | typical priority |")
|
|
216
|
+
lines.append("|---|---|---|")
|
|
217
|
+
for name, count in sorted(category_counts.items(), key=lambda kv: -kv[1]):
|
|
218
|
+
prios = category_priority.get(name, [])
|
|
219
|
+
typical = min(prios) if prios else "-"
|
|
220
|
+
lines.append("| %d | %s | %s |" % (count, name, typical))
|
|
221
|
+
lines.append("")
|
|
222
|
+
lines.append("A comment can land in more than one category, so the column sums above the")
|
|
223
|
+
lines.append("comment total. Read the categories as priors: on a diff that touches one of")
|
|
224
|
+
lines.append("them, assume the bots will raise it and settle it before opening the PR.")
|
|
225
|
+
lines.append("")
|
|
226
|
+
lines.append("### Concrete comments, most-repeated first")
|
|
227
|
+
lines.append("")
|
|
228
|
+
|
|
229
|
+
for m in merged:
|
|
230
|
+
if len(m["items"]) < 2 and len(merged) > 12:
|
|
231
|
+
continue
|
|
232
|
+
claim, c = m["items"][0]
|
|
233
|
+
count = len(m["items"])
|
|
234
|
+
path = c.get("path") or "?"
|
|
235
|
+
lines.append("- **x%d** - %s" % (count, claim))
|
|
236
|
+
lines.append(" - e.g. `%s` ([comment](%s))" % (path, c.get("html_url", "")))
|
|
237
|
+
|
|
238
|
+
lines.append("")
|
|
239
|
+
|
|
240
|
+
body = "\n".join(lines)
|
|
241
|
+
|
|
242
|
+
# The file is shared by every repo, so two reviews mining at once must not
|
|
243
|
+
# lose each other's sections: hold a lock across read-modify-write, and replace
|
|
244
|
+
# the file atomically so a reader never sees half of it.
|
|
245
|
+
import fcntl, tempfile
|
|
246
|
+
lock = open(out_md + ".lock", "w")
|
|
247
|
+
fcntl.flock(lock, fcntl.LOCK_EX)
|
|
248
|
+
|
|
249
|
+
existing = ""
|
|
250
|
+
if os.path.exists(out_md):
|
|
251
|
+
with open(out_md) as f:
|
|
252
|
+
existing = f.read()
|
|
253
|
+
|
|
254
|
+
header = ("# Bot reviewer patterns\n\n"
|
|
255
|
+
"What Greptile / Gemini / CodeRabbit have actually commented on, per repo, mined\n"
|
|
256
|
+
"from review-comment history by `scripts/mine-bot-comments.sh`. This is evidence,\n"
|
|
257
|
+
"not prediction: every line below is a comment a bot really left.\n\n"
|
|
258
|
+
"Delivered to the review agents as `botPatternsFile`. Regenerate per repo with\n"
|
|
259
|
+
"`scripts/mine-bot-comments.sh <repo-slug>`.\n\n")
|
|
260
|
+
|
|
261
|
+
if not existing.startswith("# Bot reviewer patterns"):
|
|
262
|
+
existing = header + existing
|
|
263
|
+
|
|
264
|
+
# Replace this repo's section in place; leave every other repo alone.
|
|
265
|
+
pattern = re.compile(r'^## %s$.*?(?=^## |\Z)' % re.escape(repo_slug), re.M | re.S)
|
|
266
|
+
if pattern.search(existing):
|
|
267
|
+
# A function replacement, so backslashes in mined bot text are literal.
|
|
268
|
+
existing = pattern.sub(lambda _: body, existing)
|
|
269
|
+
else:
|
|
270
|
+
existing = existing.rstrip() + "\n\n" + body
|
|
271
|
+
|
|
272
|
+
fd, tmp = tempfile.mkstemp(dir=os.path.dirname(os.path.abspath(out_md)), prefix=".bot-patterns.")
|
|
273
|
+
with os.fdopen(fd, "w") as f:
|
|
274
|
+
f.write(existing)
|
|
275
|
+
os.replace(tmp, out_md)
|
|
276
|
+
fcntl.flock(lock, fcntl.LOCK_UN)
|
|
277
|
+
|
|
278
|
+
print("mine-bot-comments: %d bot comment(s) -> %d pattern(s) -> %s"
|
|
279
|
+
% (len(bot_comments), len(merged), out_md))
|
|
280
|
+
PYEOF
|