From cocosearch
Reviews GitHub PRs and GitLab MRs using CocoSearch for blast radius, dependency impact, pattern consistency, and test coverage. Optionally posts inline comments.
How this skill is triggered — by the user, by Claude, or both
Slash command
/cocosearch:cocosearch-review-prThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
A structured workflow for reviewing pull requests (GitHub) or merge requests (GitLab) using CocoSearch's semantic search and dependency analysis. Goes beyond line-by-line diff reading to assess blast radius, dependency impact, pattern consistency, and test coverage.
A structured workflow for reviewing pull requests (GitHub) or merge requests (GitLab) using CocoSearch's semantic search and dependency analysis. Goes beyond line-by-line diff reading to assess blast radius, dependency impact, pattern consistency, and test coverage.
Read-only by default. The review itself only reads the PR/MR and the codebase. The final step (Step 6) is an optional, confirmation-gated write-back that posts findings as inline comments on the lines that need changes plus an overall summary — nothing is posted without an explicit dry-run preview and your approval.
What this skill adds over manual review:
Resolve index name (use the resolved name for all operations):
cocosearch.yaml for indexName field -- if found, use itlist_indexes() and match the current project's directory name against available indexes. The MCP tools auto-derive index names from directory paths (e.g., my-project/ -> my_project), so a match is likely if the repo was indexed without a config file.cocosearch.yaml is missing.Inventory API tokens. Check which tokens are available before anything else:
python3 -c "
import os
tokens = {
'GITHUB_TOKEN': bool(os.environ.get('GITHUB_TOKEN')),
'GH_TOKEN': bool(os.environ.get('GH_TOKEN')),
'GITLAB_TOKEN': bool(os.environ.get('GITLAB_TOKEN')),
'GITLAB_PAT': bool(os.environ.get('GITLAB_PAT')),
}
found = [k for k, v in tokens.items() if v]
print(f'Available tokens: {found if found else \"none\"}')"
Record what's available. If nothing found, warn early: "No API tokens detected -- will need one after determining platform."
list_indexes() to confirm project is indexed
index_stats(index_name="<configured-name>") to check freshness
Check dependency freshness -- call get_file_dependencies on any known file (e.g., the first changed file from the PR):
get_file_dependencies(file="<any-known-file>", depth=1)
warnings with type deps_outdated or deps_branch_drift:
Warn: "Dependency data is outdated -- blast radius analysis may be incomplete. Want me to re-extract dependencies first? (index_codebase with extract_deps=True)"warnings with type deps_not_extracted:
Warn: "No dependency data found. Blast radius and impact analysis will be limited to search-only. Want me to extract dependencies first?"Linked index health (if cocosearch.yaml has linkedIndexes):
warnings array from index_stats() for entries starting with "Linked index"Parse the PR/MR URL to detect platform:
github.com/{owner}/{repo}/pull/{number} -> GitHub{host}/{group}/{project}/-/merge_requests/{iid} -> GitLab (self-hosted or gitlab.com)Match platform to tokens:
GITHUB_TOKEN over GH_TOKEN. If neither set: "Set GITHUB_TOKEN (or GH_TOKEN) to access the GitHub API. Create one at https://github.com/settings/tokens (needs repo scope for private repos, no scope needed for public repos)." Stop.GITLAB_TOKEN over GITLAB_PAT. If neither set: "Set GITLAB_TOKEN (or GITLAB_PAT) to access the GitLab API. Create one at https://{host}/-/user_settings/personal_access_tokens (needs read_api scope)." Stop.Write scope (only if you'll push comments — Step 6): Reviewing is read-only and the read scopes above are enough. Posting comments back to the PR/MR requires a write-scoped token: GitHub classic PAT
repo(orpublic_repofor public repos), or fine-grained PAT Pull requests: Read and write; GitLab the fullapiscope (read_apicannot post). Do NOT block here on write scope — most reviews never post. If the read token turns out to lack write access, Step 6 reports the 403 cleanly.
Verify API access with a lightweight call (fetch PR/MR metadata -- Step 1 below). If it fails with 401/403, report the auth error and stop.
Important: Use the Python
urllib/curlsnippets provided below for all API calls. Do NOT usegh,glab, or other CLI wrappers -- they may not be installed and add an unnecessary dependency.
Fetch metadata (Python -- primary):
python3 << 'PYEOF'
import json, os, sys, urllib.request, urllib.error
token = os.environ.get("GITHUB_TOKEN") or os.environ.get("GH_TOKEN", "")
if not token:
print("Error: No GitHub token found (checked GITHUB_TOKEN, GH_TOKEN)", file=sys.stderr)
sys.exit(1)
url = "https://api.github.com/repos/{owner}/{repo}/pulls/{number}"
req = urllib.request.Request(url, headers={
"Authorization": f"Bearer {token}",
"Accept": "application/vnd.github+json",
})
try:
with urllib.request.urlopen(req, timeout=30) as resp:
data = json.loads(resp.read().decode())
print(json.dumps({
"title": data["title"],
"body": (data.get("body") or "")[:500],
"author": data["user"]["login"],
"state": data["state"],
"base_branch": data["base"]["ref"],
"head_branch": data["head"]["ref"],
"head_sha": data["head"]["sha"], # used as commit_id when posting comments (Step 6)
"additions": data["additions"],
"deletions": data["deletions"],
"changed_files": data["changed_files"],
}, indent=2))
except urllib.error.HTTPError as e:
print(f"GitHub API error {e.code}: {e.read().decode()}", file=sys.stderr)
sys.exit(1)
PYEOF
Fallback (curl):
curl -sf -H "Authorization: Bearer ${GITHUB_TOKEN:-$GH_TOKEN}" \
-H "Accept: application/vnd.github+json" \
"https://api.github.com/repos/{owner}/{repo}/pulls/{number}"
Fetch changed files (Python -- primary):
python3 << 'PYEOF'
import json, os, sys, urllib.request, urllib.error
token = os.environ.get("GITHUB_TOKEN") or os.environ.get("GH_TOKEN", "")
url = "https://api.github.com/repos/{owner}/{repo}/pulls/{number}/files?per_page=100"
all_files = []
while url:
req = urllib.request.Request(url, headers={
"Authorization": f"Bearer {token}",
"Accept": "application/vnd.github+json",
})
try:
with urllib.request.urlopen(req, timeout=30) as resp:
all_files.extend(json.loads(resp.read().decode()))
# Follow Link header for pagination
link = resp.headers.get("Link", "")
url = None
for part in link.split(","):
if 'rel="next"' in part:
url = part.split("<")[1].split(">")[0]
except urllib.error.HTTPError as e:
print(f"GitHub API error {e.code}: {e.read().decode()}", file=sys.stderr)
sys.exit(1)
for f in all_files:
print(json.dumps({
"filename": f["filename"],
"status": f["status"],
"additions": f["additions"],
"deletions": f["deletions"],
"patch": f.get("patch", "")[:2000],
}))
PYEOF
Fallback (curl):
curl -sf -H "Authorization: Bearer ${GITHUB_TOKEN:-$GH_TOKEN}" \
-H "Accept: application/vnd.github+json" \
"https://api.github.com/repos/{owner}/{repo}/pulls/{number}/files?per_page=100"
For PRs with >100 files, pagination is handled automatically by the Python script (follows Link header). With curl, paginate manually with &page=2, &page=3, etc.
Truncation note: the
patchis truncated to[:2000]chars above — fine for reading findings, but line mapping built from a truncated patch is wrong past the cutoff. If you proceed to Step 6 (posting inline comments), re-fetch the affected files without the[:2000]cap first so hunk line numbers are accurate.
Fetch metadata (Python -- primary):
python3 << 'PYEOF'
import json, os, sys, urllib.request, urllib.error
token = os.environ.get("GITLAB_TOKEN") or os.environ.get("GITLAB_PAT", "")
if not token:
print("Error: No GitLab token found (checked GITLAB_TOKEN, GITLAB_PAT)", file=sys.stderr)
sys.exit(1)
url = "https://{host}/api/v4/projects/{id}/merge_requests/{iid}"
req = urllib.request.Request(url, headers={"PRIVATE-TOKEN": token})
try:
with urllib.request.urlopen(req, timeout=30) as resp:
data = json.loads(resp.read().decode())
print(json.dumps({
"title": data["title"],
"description": (data.get("description") or "")[:500],
"author": data["author"]["username"],
"state": data["state"],
"target_branch": data["target_branch"],
"source_branch": data["source_branch"],
"changes_count": data.get("changes_count"),
# diff_refs (base_sha/start_sha/head_sha) is required to anchor inline
# comments when posting (Step 6). It may be null on very fresh MRs.
"diff_refs": data.get("diff_refs"),
}, indent=2))
except urllib.error.HTTPError as e:
print(f"GitLab API error {e.code}: {e.read().decode()}", file=sys.stderr)
sys.exit(1)
PYEOF
Note: {id} is the URL-encoded project path (e.g., group%2Fproject) or numeric project ID.
Fallback (curl):
curl -sf -H "PRIVATE-TOKEN: ${GITLAB_TOKEN:-$GITLAB_PAT}" \
"https://{host}/api/v4/projects/{id}/merge_requests/{iid}"
Fetch changed files with diffs (Python -- primary):
python3 << 'PYEOF'
import json, os, sys, urllib.request, urllib.error
token = os.environ.get("GITLAB_TOKEN") or os.environ.get("GITLAB_PAT", "")
url = "https://{host}/api/v4/projects/{id}/merge_requests/{iid}/diffs?per_page=100"
all_diffs = []
while url:
req = urllib.request.Request(url, headers={"PRIVATE-TOKEN": token})
try:
with urllib.request.urlopen(req, timeout=30) as resp:
all_diffs.extend(json.loads(resp.read().decode()))
# Follow x-next-page header for pagination
next_page = resp.headers.get("x-next-page", "")
if next_page:
base = url.split("?")[0]
url = f"{base}?per_page=100&page={next_page}"
else:
url = None
except urllib.error.HTTPError as e:
print(f"GitLab API error {e.code}: {e.read().decode()}", file=sys.stderr)
sys.exit(1)
for d in all_diffs:
print(json.dumps({
"old_path": d["old_path"],
"new_path": d["new_path"],
"new_file": d.get("new_file", False),
"renamed_file": d.get("renamed_file", False),
"deleted_file": d.get("deleted_file", False),
"diff": d.get("diff", "")[:2000],
}))
PYEOF
Fallback (curl):
curl -sf -H "PRIVATE-TOKEN: ${GITLAB_TOKEN:-$GITLAB_PAT}" \
"https://{host}/api/v4/projects/{id}/merge_requests/{iid}/diffs?per_page=100"
For MRs with >100 diffs, pagination is handled automatically by the Python script (follows x-next-page header). With curl, paginate manually with &page=2, etc.
Truncation note: the
diffis truncated to[:2000]chars above. If you proceed to Step 6 (posting inline comments), re-fetch the affected files without the[:2000]cap first so hunk line numbers are accurate.
Before proceeding with analysis, show:
## PR #{number}: {title}
Author: {author} | Target: {base_branch} <- {head_branch}
Files changed: {count} | +{additions} -{deletions}
Description:
{body/description, first ~500 chars}
After presenting the summary, check whether the local branch matches the PR's source branch for accurate analysis.
Save current branch:
ORIGINAL_BRANCH=$(git rev-parse --abbrev-ref HEAD)
Compare with {head_branch} from PR metadata.
If they match: Note "Local branch matches PR -- analysis reflects PR changes." Set SWITCHED_BRANCH=false. Proceed to checkpoint.
If they differ:
Check for uncommitted changes:
git status --porcelain
If dirty: warn "You have uncommitted changes" and offer git stash first.
Present options:
git stash (if dirty), git fetch origin {head_branch}, git checkout {head_branch}, then index_codebase() + index_codebase(extract_deps=True) to reindex on the PR branch. Set SWITCHED_BRANCH=true. Note: "Earlier freshness checks are superseded by fresh indexing."SWITCHED_BRANCH=false, proceed with caveat: "Analysis may miss PR-specific additions since the local branch differs."If the branch doesn't exist locally or on the remote: note the issue, set SWITCHED_BRANCH=false, proceed on current branch.
Checkpoint: "Ready to analyze {count} changed files. Proceed?"
Categorize files by review priority:
| Priority | File types | Examples |
|---|---|---|
| HIGH | Source code | .py, .js, .ts, .go, .rs, .java, .rb, .scala |
| MEDIUM | Tests, config, CI/CD | test_*.py, *.test.ts, *.yaml, Dockerfile, .github/workflows/ |
| LOW | Docs, changelog, assets | .md, CHANGELOG, .png, .svg, LICENSE |
Present the triage:
HIGH priority (source code): N files
- src/module/core.py (+45 -12)
- src/module/utils.py (+8 -3)
MEDIUM priority (tests/config): M files
- tests/test_core.py (+20 -5)
- .github/workflows/ci.yaml (+2 -1)
LOW priority (docs): K files
- README.md (+10 -2)
For large PRs (30+ files): "This is a large PR with {count} files. Want me to review all HIGH-priority files, or focus on specific files/directories?"
For small PRs (<10 files): Review all files without asking.
This is the CocoSearch-powered core. For each HIGH-priority file, run these analyses. Run independent queries in parallel where possible.
get_file_impact(file="<changed_file>", depth=2)
Identifies what other files depend on this one. A file with many dependents is high-risk -- changes to its interface affect everything downstream.
Classify:
| Dependents | Impact level |
|---|---|
| 0 | Leaf file -- low risk |
| 1-5 | Moderate -- review dependents |
| 6-15 | High -- check for interface changes |
| 16+ | Critical hub -- extra scrutiny |
get_file_dependencies(file="<changed_file>", depth=1)
Understand what the changed file relies on. Useful for spotting if the PR modifies assumptions that dependencies make.
For each changed file, search for similar patterns elsewhere in the codebase:
search_code(
query="<semantic description of the change>",
use_hybrid_search=True,
smart_context=True
)
Cross-project search: If
linkedIndexesis configured incocosearch.yaml, searches automatically expand to linked indexes. For PRs touching shared code, passindex_names=["project1", "project2"]to check consistency across codebases.
For example, if a file changes how errors are handled, search for error handling patterns across the codebase to check if this change is consistent or introduces a divergence.
Search for tests that cover the changed symbols:
search_code(
query="test <primary_symbol_name>",
symbol_name="test_*<symbol>*",
symbol_type="function",
use_hybrid_search=True
)
Coverage assessment:
Review the actual diff content (from the patch/diff fetched in Step 1) for each file:
For each HIGH-priority file, produce:
#### `path/to/file.py` [{IMPACT_LEVEL} - {N} dependents]
**Blast radius:** {N} files depend on this. Top dependents: {list top 3-5}
**Dependencies:** Relies on {M} internal modules.
**Diff findings:**
- L{line} [{side}] {finding description} [severity: CRITICAL/IMPORTANT/MINOR] -> {line_url}
- suggestion: {replacement code, if a concrete fix exists -- omit otherwise}
- L{line} [{side}] {finding description} [severity: ...] -> {line_url}
**Pattern check:** {Consistent with codebase patterns / Diverges from pattern in X, Y, Z}
**Test coverage:** {Covered / Partially covered / Missing}
Make each diff finding machine-mappable. This structure is what Step 6 turns into inline comments, so every diff finding should carry:
line — the line number the finding is aboutside — RIGHT for added/current code, LEFT for removed code (default RIGHT)severity — CRITICAL / IMPORTANT / MINORsuggestion (optional) — concrete replacement code when you can propose an exact fix; this is
what becomes a one-click ```suggestion block in Step 6line_url — a clickable deep link to the exact line, so the reviewer can jump there (and add a
comment by hand) even if they skip the auto-post. Use the stable blob-at-reviewed-SHA form:
https://github.com/{owner}/{repo}/blob/{head_sha}/{path}#L{line}https://{host}/{group}/{project}/-/blob/{head_sha}/{path}#L{line}#L{start}-L{line} (GitHub) / #L{start}-{line} (GitLab).{group}/{project} namespace from the
MR URL — not the {id} (numeric project ID or URL-encoded path) used for the API calls in
Step 1. Take the namespace from the original MR URL so you don't emit a broken /{id}/-/blob/… link.A finding without a specific line (e.g. "this whole file lacks tests") is fine — just omit line;
Step 6 routes line-less findings into the summary comment instead of an inline one.
After reviewing individual files, look for systemic issues across the entire PR.
Check if files that SHOULD have been modified are absent from the PR:
get_file_impact(file="<renamed_file>", depth=1)
Compare the impact list against the PR's changed files. Any dependent NOT in the PR is a potential missed update.
Documentation references: For each changed source file, query documentation dependents:
get_file_impact(file="<changed_source_file>", depth=1, dep_type="reference")
Filter results for files ending in .md or .mdx. These are docs that reference
the changed code. If any doc file is NOT in the PR's changed file list, flag it:
"Doc update needed: docs/architecture.md references src/cli.py
(doc_link, line 42) but was not updated in this PR."
Include metadata.kind and metadata.line to help locate the specific reference. If no dependency data exists (deps not extracted), fall back to the manual check: "If public API signatures changed, check if docs were updated."
If any changed file has 10+ dependents, highlight it:
"High-impact change: core/models.py has 18 dependents. Changes to its interface could break downstream consumers. Verify that all callers are compatible with the new behavior."
If a pattern was changed in one file, check if the same pattern exists elsewhere and should also change:
search_code(
query="<old pattern that was changed>",
use_hybrid_search=True,
smart_context=True
)
If results show the old pattern still exists in other files, flag: "Pattern was updated in file_a.py but the old version still exists in file_b.py, file_c.py. Intentional divergence or missed update?"
Assemble the full review in this structure:
## PR Review: {title}
**Summary:** {1-2 sentence overview of what the PR does}
**Risk Level:** LOW / MEDIUM / HIGH (based on blast radius and findings)
**Files reviewed:** {N} HIGH, {M} MEDIUM, {K} LOW priority
---
### File-by-File Findings
{Per-file output from Step 3, ordered by impact level (highest first)}
---
### Cross-Cutting Concerns
- {Missing changes, if any}
- {Hub file warnings, if any}
- {Consistency issues, if any}
---
### Test Coverage Summary
| File | Coverage | Notes |
|------|----------|-------|
| path/to/file.py | Covered | test_file.py exercises main paths |
| path/to/other.py | Missing | No tests found for new logic |
---
### Verdict
**{APPROVE / REQUEST CHANGES / NEEDS DISCUSSION}**
{If APPROVE: summary of why the changes look good}
{If REQUEST CHANGES: numbered list of blocking issues}
{If NEEDS DISCUSSION: questions that need answers before approval}
Checkpoint: "Want me to dig deeper into any file or finding? I can also check specific patterns or trace additional dependencies."
This is the only write-capable part of the skill. It is opt-in, comment-only, and always previews before posting. It never approves or requests changes — the human clicks the verdict button. Skip this step entirely if the user only wanted a read-only review.
Ask: "Want me to push this review back to the PR/MR as inline comments?" Default is no.
If yes, warn once: "Posting needs a write-scoped token (GitHub repo / fine-grained Pull requests
RW; GitLab api). A read-only review token will get a 403 — I'll report it cleanly if so."
Inline comments must target lines that are actually in the diff. Build the valid-line sets from the untruncated patches:
[:2000] truncation from Step 1
(raise/remove the cap). Truncated patches produce wrong line numbers.@@ -a,b +c,d @@ and walk the body to collect valid (line, side)
pairs: context lines ( ) are valid on both sides, + lines on RIGHT (new-file line), -
lines on LEFT (old-file line).python3 << 'PYEOF'
import json, re
# Fill in from 6.1: changed files with their UNTRUNCATED patch/diff text, and the
# Step 5 findings (path, line, side, severity, body, optional suggestion).
FILES = {
"{path}": r"""{untruncated_patch_or_diff_text}""",
}
FINDINGS = [
{"path": "{path}", "line": 0, "side": "RIGHT", "severity": "IMPORTANT",
"body": "{finding text}", "suggestion": None},
]
HUNK_RE = re.compile(r"^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@")
def valid_lines(patch):
"""Return (right_lines, left_lines) sets that exist in the diff."""
right, left = set(), set()
old_ln = new_ln = 0
for raw in patch.splitlines():
m = HUNK_RE.match(raw)
if m:
old_ln, new_ln = int(m.group(1)), int(m.group(2))
continue
if not raw:
continue
tag = raw[0]
if tag == " ":
right.add(new_ln); left.add(old_ln); new_ln += 1; old_ln += 1
elif tag == "+":
right.add(new_ln); new_ln += 1
elif tag == "-":
left.add(old_ln); old_ln += 1
# '\ No newline at end of file' and any other lines: ignore
return right, left
maps = {p: valid_lines(patch) for p, patch in FILES.items()}
postable, unmappable = [], []
for f in FINDINGS:
right, left = maps.get(f["path"], (set(), set()))
side = f.get("side", "RIGHT")
pool = right if side == "RIGHT" else left
# validate both endpoints: a multi-line comment's start_line must also be in the diff,
# or GitHub 422s and rejects the entire atomic review.
ok = f.get("line") in pool and (f.get("start_line") is None or f["start_line"] in pool)
(postable if ok else unmappable).append(f)
print(f"POSTABLE (inline): {len(postable)}")
for f in postable:
print(f" {f['path']}:{f['line']} [{f.get('side','RIGHT')}] {f['body'][:80]}")
print(f"UNMAPPABLE (-> summary): {len(unmappable)}")
for f in unmappable:
print(f" {f['path']}:{f.get('line','?')} {f['body'][:80]}")
print(json.dumps({"postable": postable, "unmappable": unmappable}))
PYEOF
Postable findings → inline comments. If a finding has a concrete suggestion, build the
body as the finding text followed by a one-click suggestion block (works on both GitHub and
GitLab); otherwise plain text:
{finding text} [severity]
```suggestion
{replacement code}
```
Unmappable findings → summary body. Never drop them and never let them block the post.
List them in the summary as path:line — text with their line URL so the user can place them
by hand.
Summary body = the Step 5 verdict + 1-2 sentence overview + the unmappable-findings list.
Author the comments as the user. The review is posted under the user's own account, so it must read as their own words. Do NOT add tool/AI/skill attribution, "posted via" / "generated by" lines, emoji sign-offs, "dogfooding"/"feature under test" meta-commentary, or any mention of CocoSearch or this skill. Just the findings and the verdict — nothing about how they were produced.
Print, and do not post yet:
Target: {GitHub|GitLab} {owner/repo|host/id} #{number/iid} @ {head_sha}
Event: COMMENT (comment-only — does not approve or request changes)
Inline comments ({N}):
src/foo.py:42 [RIGHT] — Off-by-one in loop bound [IMPORTANT] -> {line_url}
(suggestion: range(n) -> range(n + 1))
...
Findings going into the summary ({M}, not anchorable to the diff):
src/bar.py:integration — extract shared helper -> {line_url}
Summary body:
{full summary text}
For GitHub, also print the literal JSON payload so there are no surprises.
Ask: "Post these {N} inline comments + summary as a COMMENT review? (yes/no)" Only proceed on an explicit yes.
One POST /pulls/{number}/reviews carries the summary + all inline comments and shows up as one
review/notification. Pre-validation in 6.1 prevents the 422 that a single off-diff line would
otherwise trigger for the whole review.
python3 << 'PYEOF'
import json, os, sys, urllib.request, urllib.error
token = os.environ.get("GITHUB_TOKEN") or os.environ.get("GH_TOKEN", "")
if not token:
print("Error: No GitHub token found (checked GITHUB_TOKEN, GH_TOKEN)", file=sys.stderr)
sys.exit(1)
# Filled in from 6.2/6.4. EVENT stays COMMENT (comment-only policy).
EVENT = "COMMENT"
COMMIT_ID = "{head_sha}" # pin to the reviewed revision; set to "" to target latest
SUMMARY_BODY = r"""{summary_markdown}"""
COMMENTS = [
# single-line on added/current code:
{"path": "{path}", "line": 0, "side": "RIGHT", "body": "{comment_body}"},
# multi-line: add "start_line"/"start_side" alongside "line"/"side"
# on removed code: "side": "LEFT"
]
payload = {"event": EVENT, "body": SUMMARY_BODY, "comments": COMMENTS}
if COMMIT_ID:
payload["commit_id"] = COMMIT_ID
url = "https://api.github.com/repos/{owner}/{repo}/pulls/{number}/reviews"
req = urllib.request.Request(
url, data=json.dumps(payload).encode(), method="POST",
headers={
"Authorization": f"Bearer {token}",
"Accept": "application/vnd.github+json",
"Content-Type": "application/json",
},
)
try:
with urllib.request.urlopen(req, timeout=30) as resp:
data = json.loads(resp.read().decode())
print(f"Posted review ({len(COMMENTS)} inline comments, event={EVENT}): {data.get('html_url')}")
except urllib.error.HTTPError as e:
body = e.read().decode()
if e.code == 403:
print("GitHub 403: token lacks write scope. Classic PAT needs 'repo'; "
"fine-grained PAT needs 'Pull requests: Read and write'.", file=sys.stderr)
elif e.code == 422:
print("GitHub 422: a comment targets a line not in the diff (or bad start_line/line "
"order). Nothing was posted -- drop the offending line or move it to the summary.",
file=sys.stderr)
print(body, file=sys.stderr)
else:
print(f"GitHub API error {e.code}: {body}", file=sys.stderr)
sys.exit(1)
PYEOF
GitLab has no atomic batch: post each inline comment as its own discussion, collect any per-comment
failures, then post the summary note last (appending failures to it). Inline positions need the
diff_refs SHAs captured in Step 1.
python3 << 'PYEOF'
import json, os, sys, urllib.parse, urllib.request, urllib.error
token = os.environ.get("GITLAB_TOKEN") or os.environ.get("GITLAB_PAT", "")
if not token:
print("Error: No GitLab token found (checked GITLAB_TOKEN, GITLAB_PAT)", file=sys.stderr)
sys.exit(1)
# diff_refs from Step 1 MR metadata:
BASE_SHA, START_SHA, HEAD_SHA = "{base_sha}", "{start_sha}", "{head_sha}"
# Filled in from 6.2/6.4. Set new_line for added/context lines, old_line for removed lines.
# Path handling per file status (from the Step 1 diff flags):
# - modified: new_path == old_path
# - renamed_file: new_path != old_path (set both)
# - new_file: omit old_path (None) -- there is no old side; comment with new_line
# - deleted_file: omit new_path (None) -- comment with old_line on old_path
INLINE = [
{"new_path": "{path}", "old_path": "{path}",
"new_line": 0, "old_line": None, "body": "{comment_body}"},
]
SUMMARY_BODY = r"""{summary_markdown}"""
API = "https://{host}/api/v4/projects/{id}/merge_requests/{iid}"
def post(path, form):
req = urllib.request.Request(
API + path, data=urllib.parse.urlencode(form, doseq=True).encode(), method="POST",
headers={"PRIVATE-TOKEN": token, "Content-Type": "application/x-www-form-urlencoded"},
)
with urllib.request.urlopen(req, timeout=30) as resp:
return json.loads(resp.read().decode())
posted, failed = 0, []
try:
for c in INLINE:
form = {"body": c["body"],
"position[position_type]": "text",
"position[base_sha]": BASE_SHA,
"position[start_sha]": START_SHA,
"position[head_sha]": HEAD_SHA}
# Only send the path sides that exist for this file (see INLINE note above).
if c.get("new_path") is not None:
form["position[new_path]"] = c["new_path"]
if c.get("old_path") is not None:
form["position[old_path]"] = c["old_path"]
if c.get("new_line") is not None:
form["position[new_line]"] = c["new_line"]
if c.get("old_line") is not None:
form["position[old_line]"] = c["old_line"]
try:
post("/discussions", form)
posted += 1
except urllib.error.HTTPError as e:
failed.append((c, e.code, e.read().decode()[:200]))
summary = SUMMARY_BODY
if failed:
summary += "\n\n---\nInline comments that could not be anchored to the diff:\n"
summary += "\n".join(f"- {c['new_path']}:{c.get('new_line') or c.get('old_line')} — {c['body'][:120]}"
for c, _, _ in failed)
note = post("/notes", {"body": summary})
print(f"Posted {posted} inline discussions + summary note (id={note.get('id')}).")
for c, code, b in failed:
print(f" inline failed [{code}] {c['new_path']}:{c.get('new_line') or c.get('old_line')}: {b}",
file=sys.stderr)
except urllib.error.HTTPError as e:
body = e.read().decode()
if e.code in (401, 403):
print("GitLab 401/403: token lacks 'api' (read-write) scope. 'read_api' cannot post -- "
"create a token with 'api' scope.", file=sys.stderr)
else:
print(f"GitLab API error {e.code}: {body}", file=sys.stderr)
sys.exit(1)
PYEOF
After presenting the review (and any push), handle branch lifecycle cleanup.
If SWITCHED_BRANCH=false: Skip -- nothing to clean up.
If SWITCHED_BRANCH=true:
{ORIGINAL_BRANCH}?"git checkout {ORIGINAL_BRANCH}git stash pop (only if we stashed earlier){ORIGINAL_BRANCH}? (Only needed if you plan to use CocoSearch on this branch next.)"{head_branch}. Remember to switch back when done."Final note: "Review of PR #{number} is complete."
For common search tips (hybrid search, smart_context, symbol filtering), see skills/README.md.
For installation instructions, see skills/README.md.
npx claudepluginhub violetcranberry/coco-search --plugin cocosearchReviews local changes, PRs/MRs, or branch diffs against project coding guidelines using 5-7 parallel review agents (bug detection, security/logic, guideline compliance, code simplification, test coverage, contract quality). High-signal findings only.
Reviews pull requests using OntoIndex: maps diff to affected execution flows, analyzes blast radius, assesses risk and test coverage.