@olegkoval/agent-skills 1.54.4 → 1.55.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (29) hide show
  1. package/adapters/claude/olko-github-pr/skills/lekker-review/SKILL.md +135 -8
  2. package/adapters/claude/olko-github-pr/skills/lekker-review/references/agents/conventions.md +21 -0
  3. package/adapters/claude/olko-github-pr/skills/lekker-review/references/fix-mode.md +3 -1
  4. package/adapters/claude/olko-github-pr/skills/lekker-review/scripts/bot-precision.mjs +285 -0
  5. package/adapters/claude/olko-github-pr/skills/lekker-review/scripts/lint-findings.mjs +239 -0
  6. package/adapters/claude/olko-github-pr/skills/lekker-review/scripts/mine-bot-comments.sh +280 -0
  7. package/adapters/claude/olko-github-pr/skills/lekker-review/scripts/selftest.mjs +186 -0
  8. package/adapters/claude/olko-github-pr/skills/lekker-review/scripts/setup-worktree.sh +62 -3
  9. package/package.json +1 -1
  10. package/plugins/olko-apple-kit/.claude-plugin/plugin.json +1 -1
  11. package/plugins/olko-creative/.claude-plugin/plugin.json +1 -1
  12. package/plugins/olko-garmin-kit/.claude-plugin/plugin.json +1 -1
  13. package/plugins/olko-git-tools/.claude-plugin/plugin.json +1 -1
  14. package/plugins/olko-github-pr/.claude-plugin/plugin.json +1 -1
  15. package/plugins/olko-github-pr/skills/lekker-review/SKILL.md +135 -8
  16. package/plugins/olko-github-pr/skills/lekker-review/references/agents/conventions.md +21 -0
  17. package/plugins/olko-github-pr/skills/lekker-review/references/fix-mode.md +3 -1
  18. package/plugins/olko-github-pr/skills/lekker-review/scripts/bot-precision.mjs +285 -0
  19. package/plugins/olko-github-pr/skills/lekker-review/scripts/lint-findings.mjs +239 -0
  20. package/plugins/olko-github-pr/skills/lekker-review/scripts/mine-bot-comments.sh +280 -0
  21. package/plugins/olko-github-pr/skills/lekker-review/scripts/selftest.mjs +186 -0
  22. package/plugins/olko-github-pr/skills/lekker-review/scripts/setup-worktree.sh +62 -3
  23. package/plugins/olko-github-pr/skills/lekker-review/workflow.js +10 -0
  24. package/plugins/olko-obsidian/.claude-plugin/plugin.json +1 -1
  25. package/plugins/olko-product/.claude-plugin/plugin.json +1 -1
  26. package/plugins/olko-reflection/.claude-plugin/plugin.json +1 -1
  27. package/plugins/olko-release/.claude-plugin/plugin.json +1 -1
  28. package/plugins/olko-skill-meta/.claude-plugin/plugin.json +1 -1
  29. 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