@clipboard-health/ai-rules 2.14.24 → 2.14.26
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/package.json +1 -1
- package/skills/babysit-pr/SKILL.md +290 -0
- package/skills/babysit-pr/scripts/commitAndPush.sh +72 -0
- package/skills/babysit-pr/scripts/fetchFailedLogs.sh +117 -0
- package/skills/babysit-pr/scripts/parseNitpicks.sh +132 -0
- package/skills/babysit-pr/scripts/postSentinelPrComment.sh +64 -0
- package/skills/babysit-pr/scripts/postSentinelReply.sh +68 -0
- package/skills/babysit-pr/scripts/unresolvedPrComments.sh +390 -0
- package/skills/simplify/SKILL.md +2 -0
package/package.json
CHANGED
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: babysit-pr
|
|
3
|
+
description: "Watch a PR through CI and review feedback: commit/push, wait for CI, auto-fix high-confidence failures, reply to active review threads, and summarize parsed CodeRabbit nitpicks with sentinel-tagged comments. Runs once by default; pass a short interval like `30s` or `2m` for best-effort same-turn polling; longer cadences should use an external loop wrapper. Use when the user says 'babysit my PR', 'watch my PR', 'keep my PR moving', 'respond to comments', or 'loop on CI'."
|
|
4
|
+
argument-hint: "[interval]"
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# Babysit PR
|
|
8
|
+
|
|
9
|
+
Watch one PR through CI, auto-fix high-confidence failures, and leave a paper-trail reply on every active review thread and CodeRabbit nitpick. Threads stay open for human resolution — this skill only posts replies, it never resolves.
|
|
10
|
+
|
|
11
|
+
This skill is self-contained: it does not invoke other skills. It works in Claude Code and Codex — no subagents, no `Skill` tool calls, no `!` command interpolation, no `$CLAUDE_PLUGIN_ROOT`.
|
|
12
|
+
|
|
13
|
+
## Inputs
|
|
14
|
+
|
|
15
|
+
Parse an optional interval from the invocation arguments if the host exposes them; otherwise read the user's request text.
|
|
16
|
+
|
|
17
|
+
Recognize intervals with this regex (case-insensitive):
|
|
18
|
+
|
|
19
|
+
```regex
|
|
20
|
+
\b(\d+)\s*(s|sec|secs|second|seconds|m|min|mins|minute|minutes|h|hr|hrs|hour|hours)\b
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Rules:
|
|
24
|
+
|
|
25
|
+
- If multiple matches appear in the user's text, take the **first** match.
|
|
26
|
+
- Accept a bare number as seconds **only** when the bare number is the entire argument (not embedded in prose).
|
|
27
|
+
- Normalize to seconds: `s*=1`, `m*=60`, `h*=3600`.
|
|
28
|
+
- Empty → one iteration, then exit with a summary.
|
|
29
|
+
- Normalized `<= 240` → best-effort same-turn loop: `sleep <seconds>` between iterations.
|
|
30
|
+
- Normalized `> 240` → run one pass, then report that longer cadences need an external loop wrapper (the Claude Code `/loop` skill or a shell `while` loop outside the agent). Do not sleep inside the agent turn — blocking `sleep` past ~5 minutes will exceed prompt-cache TTLs and may hit tool-call timeouts.
|
|
31
|
+
|
|
32
|
+
## Sentinel
|
|
33
|
+
|
|
34
|
+
The skill tags every reply it posts with:
|
|
35
|
+
|
|
36
|
+
```html
|
|
37
|
+
<!-- babysit-pr:addressed v1 -->
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
on its own line at the end of the body. This is how the skill knows, on re-runs, which threads and nitpicks it already handled.
|
|
41
|
+
|
|
42
|
+
**Sentinel recency rules.** The script emits a per-thread `activityState` with three values:
|
|
43
|
+
|
|
44
|
+
- **`active`** — no sentinel yet, OR at least one human commented after the last sentinel. Always handle this thread.
|
|
45
|
+
- **`uncertain`** — a sentinel exists AND one or more bot comments appeared after it. The thread carries a `postSentinelBotComments` array listing EVERY such comment. You MUST read every entry in that array (not just the most recent — a later ack must not hide an earlier actionable finding), then decide:
|
|
46
|
+
- **Every** post-sentinel bot comment is a non-actionable acknowledgement (`"Thanks, resolved"`, `"LGTM"`, `"Learnings added"`, etc.) → mark the thread **Skip-reply**; do not post a new reply. (See step 6 — Skip-reply is a distinct classification from the `addressed` activityState value.)
|
|
47
|
+
- **Any** post-sentinel bot comment carries new actionable content (new nit, new finding, corrected diagnosis) → treat as **active**; reply again AND mention in the final summary that you reactivated an "uncertain" thread and why.
|
|
48
|
+
- If you cannot confidently classify every entry → default to **active** and flag it. Silence is the failure mode we are trying to avoid.
|
|
49
|
+
- **`addressed`** — the sentinel is the newest relevant activity on the thread. Skip it.
|
|
50
|
+
|
|
51
|
+
**Bot detection** uses two signals (union): GraphQL `author.__typename == "Bot"` (primary — catches every GitHub-tagged bot, including ones not on our allowlist), plus a name allowlist (for bots that post via a User-type service account). An unknown bot never falls through to human classification, so a new review bot won't cause an infinite re-reply loop.
|
|
52
|
+
|
|
53
|
+
The bot detection exists ONLY to downgrade the default for post-sentinel bot activity from `"active"` to `"uncertain"`. It NEVER suppresses bot comments or marks a thread `"addressed"` on its own — CodeRabbit's review content would be lost if it did.
|
|
54
|
+
|
|
55
|
+
For nitpicks, the script emits a stable `fingerprint` per nitpick (sha256 of file + line + title + body, no timestamp). Before posting a nitpick summary, search existing PR issue-comments for a prior babysit-pr sentinel comment that already contains those fingerprints; if every current fingerprint is already present in a prior sentinel comment, skip posting.
|
|
56
|
+
|
|
57
|
+
## One iteration
|
|
58
|
+
|
|
59
|
+
Each iteration is a procedure you execute as ordinary tool calls in the same agent turn. No subagents, no `Skill` tool, no `!` prefix.
|
|
60
|
+
|
|
61
|
+
### 1. Preflight
|
|
62
|
+
|
|
63
|
+
Script paths in this procedure are written as `scripts/...`, relative to this SKILL.md. Each host (Claude Code, Codex, …) resolves them from wherever it has the skill installed — do not try to derive an absolute path yourself; it will be wrong under at least one host.
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
git status --short
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
If non-empty, stop and report the dirty files. The skill refuses to start with uncommitted changes so it never sweeps up unrelated work.
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
gh auth status
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
If this fails, stop and tell the user to run `gh auth login`.
|
|
76
|
+
|
|
77
|
+
### 2. Locate the PR
|
|
78
|
+
|
|
79
|
+
```bash
|
|
80
|
+
gh pr view --json number,url,headRefName,statusCheckRollup 2>/dev/null
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
If no PR exists for the current branch:
|
|
84
|
+
|
|
85
|
+
- Verify there are commits ahead of the base branch: `git log --oneline @{u}..HEAD 2>/dev/null || git log --oneline origin/HEAD..HEAD`. If nothing is ahead, stop and report "no commits to push".
|
|
86
|
+
- Push the branch and open a PR:
|
|
87
|
+
|
|
88
|
+
```bash
|
|
89
|
+
git push -u origin HEAD
|
|
90
|
+
gh pr create --fill
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
- Re-fetch `gh pr view --json number,url,statusCheckRollup`.
|
|
94
|
+
|
|
95
|
+
### 3. Wait for CI
|
|
96
|
+
|
|
97
|
+
Wrap the watch call with a timeout so a hung check doesn't wedge the turn:
|
|
98
|
+
|
|
99
|
+
```bash
|
|
100
|
+
rc=0
|
|
101
|
+
if command -v gtimeout >/dev/null 2>&1; then
|
|
102
|
+
gtimeout 600 gh pr checks --watch || rc=$?
|
|
103
|
+
elif command -v timeout >/dev/null 2>&1; then
|
|
104
|
+
timeout 600 gh pr checks --watch || rc=$?
|
|
105
|
+
else
|
|
106
|
+
gh pr checks --watch || rc=$?
|
|
107
|
+
fi
|
|
108
|
+
case $rc in 0|1|8|124) ;; *) exit $rc;; esac
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
Exit codes 0 (pass), 1 (fail), 8 (pending), and 124 (timeout) are expected and handled next. Other codes (auth errors, etc.) re-raise.
|
|
112
|
+
|
|
113
|
+
### 4. Fetch review data
|
|
114
|
+
|
|
115
|
+
```bash
|
|
116
|
+
bash scripts/unresolvedPrComments.sh
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
The output JSON has:
|
|
120
|
+
|
|
121
|
+
- `threads`: every unresolved review thread, with `threadId`, `replyToCommentDatabaseId`, `comments[]`, `lastBabysitSentinelAt`, `lastHumanCommentAt`, `lastBotCommentAt`, `postSentinelBotComments[]`, `postSentinelHumanComments[]`, and `activityState` (`"active"` / `"uncertain"` / `"addressed"`).
|
|
122
|
+
- `activeThreads`: threads where `activityState != "addressed"` — these need attention this iteration (active AND uncertain).
|
|
123
|
+
- `uncertainThreads`: just the uncertain subset. For each, read EVERY entry in `postSentinelBotComments` before deciding.
|
|
124
|
+
- `nitpickComments`: parsed CodeRabbit nitpicks, each with a stable `fingerprint`.
|
|
125
|
+
- `totalActiveThreads`, `totalUncertainThreads`, `totalNitpicks`, `totalUnresolvedComments` for quick checks.
|
|
126
|
+
|
|
127
|
+
### 5. Handle CI failures (conservative)
|
|
128
|
+
|
|
129
|
+
Run `bash scripts/fetchFailedLogs.sh` to stream failed output for every failing check on the PR. The first line is either:
|
|
130
|
+
|
|
131
|
+
- `# babysit-pr: no failing checks` → skip to step 6.
|
|
132
|
+
- `# babysit-pr: failing checks` → followed by one delimited block per failing job or external check:
|
|
133
|
+
- `# --- run=<id> job=<id> ---` blocks carry the job's `--log-failed` output (GitHub Actions).
|
|
134
|
+
- `# --- external check: <name> (<url>) ---` blocks carry no logs — the check isn't a GitHub Actions run (CircleCI, Nx Cloud, semgrep, CodeRabbit, Devin, etc.). Treat these like "External checks with no inspectable logs" in the diagnosis-only list below: stop and report, don't guess a fix.
|
|
135
|
+
|
|
136
|
+
Read the logs and diagnose: **build/type errors first** (they cause cascading test failures), then lint/format, then tests.
|
|
137
|
+
|
|
138
|
+
**Apply a fix directly** only when the cause is high-confidence and inside the PR's changed surface:
|
|
139
|
+
|
|
140
|
+
- Compile/type errors in files the PR touched.
|
|
141
|
+
- Deterministic lint/format violations (auto-fixable).
|
|
142
|
+
- Tests that the PR broke by renaming/removing symbols they reference.
|
|
143
|
+
- Missing test updates for intentional behavior changes.
|
|
144
|
+
|
|
145
|
+
**Stop and report a diagnosis** (do not guess a fix) for:
|
|
146
|
+
|
|
147
|
+
- Flaky / intermittent failures.
|
|
148
|
+
- Infrastructure or provider outages.
|
|
149
|
+
- Permission / auth / missing-secret failures.
|
|
150
|
+
- Unrelated failures (touching code this PR didn't modify).
|
|
151
|
+
- Ambiguous test intent.
|
|
152
|
+
- External checks with no inspectable logs.
|
|
153
|
+
|
|
154
|
+
Scope check: `gh pr diff --name-only`. This is PR-authoritative — works even if the local base ref is missing or stale (e.g., in fresh clones or CI sandboxes). A fix outside these files is out of scope — report it, don't apply it.
|
|
155
|
+
|
|
156
|
+
### 6. Assess active review threads
|
|
157
|
+
|
|
158
|
+
For every thread in `activeThreads` (this includes both `"active"` and `"uncertain"`):
|
|
159
|
+
|
|
160
|
+
- Group comments by file; read each file once (not per comment).
|
|
161
|
+
- If the referenced file no longer exists, record "comment may be outdated" and classify as **Already fixed**.
|
|
162
|
+
- If `activityState == "uncertain"`, read EVERY entry in `postSentinelBotComments` (not just the newest):
|
|
163
|
+
- If EVERY entry is a non-actionable acknowledgement → mark the thread **Skip-reply** (the existing sentinel already covers the thread; posting again would be noise). Do not classify it Agree/Disagree/Already-fixed. Record this in the final summary so the skip is visible.
|
|
164
|
+
- If ANY entry carries new actionable content → treat the thread as new feedback and proceed below. Note in the final summary that an uncertain thread was reactivated, citing the specific comment.
|
|
165
|
+
- For each remaining thread (i.e., NOT marked Skip-reply), pick one verdict — each of these will get a reply posted in step 9:
|
|
166
|
+
- **Agree** — the comment identifies a real issue. Apply the fix. Record the thread ID and a one-line what-changed.
|
|
167
|
+
- **Disagree** — the current code is acceptable. Record a short reasoning.
|
|
168
|
+
- **Already fixed** — a prior commit addresses the concern. Record a pointer (commit SHA, file:line).
|
|
169
|
+
|
|
170
|
+
### 7. Assess nitpicks
|
|
171
|
+
|
|
172
|
+
For every nitpick in `nitpickComments`:
|
|
173
|
+
|
|
174
|
+
- Check whether its `fingerprint` already appears in a prior babysit-pr sentinel comment on the PR. If yes, skip.
|
|
175
|
+
- Otherwise classify (Agree / Disagree / Already fixed) the same way as threads. If Agree, apply the fix.
|
|
176
|
+
|
|
177
|
+
If no nitpicks remain after filtering, skip ONLY the top-level nitpick-summary comment in step 9. Still post thread replies for every non-Skip-reply thread from step 6.
|
|
178
|
+
|
|
179
|
+
### 8. Commit and push (if any edits)
|
|
180
|
+
|
|
181
|
+
If steps 5, 6, or 7 modified any files, decide:
|
|
182
|
+
|
|
183
|
+
- **Which files are yours this iteration.** The worktree may contain unrelated in-progress work. Only stage files this iteration touched — if in doubt, run `git diff --name-only` and pick from that list deliberately.
|
|
184
|
+
- **A focused commit message.** Prefer something like `babysit-pr: <one-line what-changed>`; the project's commitlint expects conventional-commit form, so a `fix(core): ...` or `docs(core): ...` prefix is usually right.
|
|
185
|
+
|
|
186
|
+
Then run:
|
|
187
|
+
|
|
188
|
+
```bash
|
|
189
|
+
bash scripts/commitAndPush.sh "<message>" <file1> [<file2> ...]
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
The script enforces explicit staging (never `git add -A`), never skips hooks, and prints:
|
|
193
|
+
|
|
194
|
+
```text
|
|
195
|
+
sha=<commit-sha>
|
|
196
|
+
url=https://github.com/<owner>/<repo>/commit/<sha>
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
Capture the `url=` line for the reply templates in step 9.
|
|
200
|
+
|
|
201
|
+
### 9. Post replies
|
|
202
|
+
|
|
203
|
+
For every thread assessed in step 6 that was NOT marked **Skip-reply** (i.e., one of Agree / Disagree / Already fixed):
|
|
204
|
+
|
|
205
|
+
```bash
|
|
206
|
+
bash scripts/postSentinelReply.sh "$THREAD_ID" "$BODY"
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
Skip-reply threads (uncertain threads where every post-sentinel bot comment was a non-actionable ack) are left alone — the existing sentinel already covers them.
|
|
210
|
+
|
|
211
|
+
Body templates (the script appends the sentinel if missing):
|
|
212
|
+
|
|
213
|
+
- **Agree**: `Addressed in <commit-url>. <one-line what-changed>.`
|
|
214
|
+
- **Disagree**: `Leaving current behavior. <reasoning>.`
|
|
215
|
+
- **Already fixed**: `Already handled by <commit-url-or-file:line>. <brief pointer>.`
|
|
216
|
+
|
|
217
|
+
The script uses the `addPullRequestReviewThreadReply` GraphQL mutation. It does NOT resolve the thread.
|
|
218
|
+
|
|
219
|
+
If any nitpicks were assessed in step 7, post ONE top-level PR comment summarizing all of them:
|
|
220
|
+
|
|
221
|
+
```bash
|
|
222
|
+
bash scripts/postSentinelPrComment.sh "$PR_NUMBER" "$BODY"
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
The nitpick summary body should:
|
|
226
|
+
|
|
227
|
+
- Group verdicts under **Agree / Disagree / Already fixed** headings.
|
|
228
|
+
- Include the commit URL for fixes.
|
|
229
|
+
- Include every current nitpick's `fingerprint` in a fenced block at the end (one per line, before the sentinel) so future runs can dedupe.
|
|
230
|
+
|
|
231
|
+
### 10. Summarize
|
|
232
|
+
|
|
233
|
+
Report:
|
|
234
|
+
|
|
235
|
+
- Commits made (with URLs).
|
|
236
|
+
- CI checks fixed / still failing / skipped-with-diagnosis.
|
|
237
|
+
- Review threads replied to, grouped by verdict.
|
|
238
|
+
- Nitpicks summarized (or skipped because already covered).
|
|
239
|
+
- Threads left active because of bot-acknowledgement uncertainty (flag by thread URL).
|
|
240
|
+
- The stop condition triggered for this iteration (clean / stuck / continue / long-interval / sanity-cap).
|
|
241
|
+
|
|
242
|
+
## Loop control
|
|
243
|
+
|
|
244
|
+
After an iteration, pick exactly one outcome:
|
|
245
|
+
|
|
246
|
+
- **Exit clean** — all CI checks passed AND every thread in `activeThreads` was either marked Skip-reply during step 6's inspection or has already received a fresh sentinel reply in this iteration, AND every current nitpick fingerprint is covered by an existing sentinel comment. Do not use raw `totalActiveThreads` from the script output — it is pre-inspection and will stay non-zero for Skip-reply cases. Report success and stop.
|
|
247
|
+
- **Exit stuck** — iteration made no commits, posted no new replies, and no CI check changed state from the previous iteration. Report state and stop; tell the user to investigate.
|
|
248
|
+
- **Continue** — interval set, normalized `<= 240`, not clean, not stuck:
|
|
249
|
+
|
|
250
|
+
```bash
|
|
251
|
+
sleep <interval-seconds>
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
Then go back to step 1.
|
|
255
|
+
|
|
256
|
+
- **Long interval** — interval set, normalized `> 240`. Do not sleep. Run one pass, report state, and tell the user to wrap the call in an external loop (Claude Code `/loop <interval> /babysit-pr` or a shell `while true; do ...; sleep ...; done`).
|
|
257
|
+
- **Sanity cap** — hard-stop at 20 iterations regardless, with a clear "sanity cap hit" message. The cap only applies to internal looping.
|
|
258
|
+
|
|
259
|
+
## Portability notes
|
|
260
|
+
|
|
261
|
+
- No `Task` subagent spawning — run everything inline.
|
|
262
|
+
- No `Skill` tool calls — this skill never invokes `core:commit-push-pr`, `core:fix-ci`, or `core:unresolved-pr-comments`.
|
|
263
|
+
- No `!` slash-command prefix in code fences — the agent runs these as ordinary bash.
|
|
264
|
+
- No `$CLAUDE_PLUGIN_ROOT` and no host-specific path discovery. Script paths are relative bundled-resource paths like `scripts/unresolvedPrComments.sh`, resolved relative to this SKILL.md.
|
|
265
|
+
- `argument-hint` is the only intentional nonstandard frontmatter key (included for Claude Code UX). If a strict Codex validator rejects it, move the key into a host-specific wrapper and keep this file's frontmatter to `name` + `description` only.
|
|
266
|
+
|
|
267
|
+
## Examples
|
|
268
|
+
|
|
269
|
+
### Example 1: single pass, exits stuck awaiting CI
|
|
270
|
+
|
|
271
|
+
User: `babysit my PR`
|
|
272
|
+
|
|
273
|
+
- No interval → one iteration.
|
|
274
|
+
- Preflight OK, PR #482 found.
|
|
275
|
+
- `gh pr checks --watch` times out at 600s — two checks still pending.
|
|
276
|
+
- `unresolvedPrComments.sh` returns 0 active threads, 0 nitpicks.
|
|
277
|
+
- No commits, no replies posted, CI state unchanged vs. start.
|
|
278
|
+
- Outcome: **stuck**. Report: "CI still running after 10 min; no comments to address. Re-run `/babysit-pr 2m` once CI settles, or wait and invoke again."
|
|
279
|
+
|
|
280
|
+
### Example 2: `babysit-pr 2m` loop, exits clean on iteration 3
|
|
281
|
+
|
|
282
|
+
User: `babysit-pr 2m`
|
|
283
|
+
|
|
284
|
+
- Iteration 1: CI green, 3 active threads (1 Agree, 1 Disagree, 1 Already-fixed), 2 nitpicks (both Agree). Apply fixes, commit `a1b2c3d`, post 3 thread replies + 1 nitpick summary. Not clean (CI needs to re-run), not stuck. `sleep 120`.
|
|
285
|
+
- Iteration 2: CI fails (lint on the nitpick fix). Log shows unused import. High-confidence + in scope → remove import, commit `d4e5f6a`, push. Threads are all addressed. `sleep 120`.
|
|
286
|
+
- Iteration 3: CI green, 0 active threads, 0 new nitpick fingerprints. **Exit clean.** Report final commit SHAs and reply URLs.
|
|
287
|
+
|
|
288
|
+
## Input
|
|
289
|
+
|
|
290
|
+
Interval: $ARGUMENTS
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# commitAndPush.sh — stage explicit files, commit, push, emit commit URL.
|
|
3
|
+
#
|
|
4
|
+
# Usage: bash commitAndPush.sh "<message>" <file1> [<file2> ...]
|
|
5
|
+
#
|
|
6
|
+
# Output on success (stdout):
|
|
7
|
+
# sha=<commit-sha>
|
|
8
|
+
# url=https://github.com/<owner>/<repo>/commit/<sha>
|
|
9
|
+
#
|
|
10
|
+
# Does NOT use `git add -A` — the caller MUST name every file to stage, so the
|
|
11
|
+
# skill never sweeps up unrelated uncommitted work. Does NOT skip hooks
|
|
12
|
+
# (no --no-verify); a hook failure surfaces as a non-zero exit.
|
|
13
|
+
#
|
|
14
|
+
# Exit 0 on success. Exit 1 on runtime errors. Exit 2 on usage errors.
|
|
15
|
+
#
|
|
16
|
+
# Requires: git, gh, jq.
|
|
17
|
+
|
|
18
|
+
set -euo pipefail
|
|
19
|
+
|
|
20
|
+
if [ $# -lt 2 ]; then
|
|
21
|
+
printf '{"error":"Usage: commitAndPush.sh <message> <file1> [file2 ...]"}\n' >&2
|
|
22
|
+
exit 2
|
|
23
|
+
fi
|
|
24
|
+
|
|
25
|
+
MSG="$1"; shift
|
|
26
|
+
|
|
27
|
+
if [ -z "$MSG" ]; then
|
|
28
|
+
printf '{"error":"commit message cannot be empty"}\n' >&2
|
|
29
|
+
exit 2
|
|
30
|
+
fi
|
|
31
|
+
|
|
32
|
+
for cmd in git gh jq; do
|
|
33
|
+
if ! command -v "$cmd" >/dev/null 2>&1; then
|
|
34
|
+
printf '{"error":"%s not found"}\n' "$cmd" >&2
|
|
35
|
+
exit 1
|
|
36
|
+
fi
|
|
37
|
+
done
|
|
38
|
+
|
|
39
|
+
# Verify each path either exists on disk or is tracked (handles deletions).
|
|
40
|
+
for path in "$@"; do
|
|
41
|
+
if [ ! -e "$path" ] && ! git ls-files --error-unmatch -- "$path" >/dev/null 2>&1; then
|
|
42
|
+
printf '{"error":"path not found and not tracked: %s"}\n' "$path" >&2
|
|
43
|
+
exit 1
|
|
44
|
+
fi
|
|
45
|
+
done
|
|
46
|
+
|
|
47
|
+
git add -- "$@"
|
|
48
|
+
|
|
49
|
+
if git diff --cached --quiet; then
|
|
50
|
+
printf '{"error":"nothing staged after git add — check the listed files actually have changes"}\n' >&2
|
|
51
|
+
exit 1
|
|
52
|
+
fi
|
|
53
|
+
|
|
54
|
+
git commit -m "$MSG"
|
|
55
|
+
git push
|
|
56
|
+
|
|
57
|
+
SHA="$(git rev-parse HEAD)"
|
|
58
|
+
|
|
59
|
+
REPO_JSON="$(gh repo view --json owner,name 2>/dev/null)" || {
|
|
60
|
+
printf '{"error":"could not determine repository"}\n' >&2
|
|
61
|
+
exit 1
|
|
62
|
+
}
|
|
63
|
+
OWNER="$(printf '%s' "$REPO_JSON" | jq -r '.owner.login')"
|
|
64
|
+
REPO="$(printf '%s' "$REPO_JSON" | jq -r '.name')"
|
|
65
|
+
|
|
66
|
+
if [ -z "$OWNER" ] || [ "$OWNER" = "null" ] || [ -z "$REPO" ] || [ "$REPO" = "null" ]; then
|
|
67
|
+
printf '{"error":"failed to parse owner/repo from gh output"}\n' >&2
|
|
68
|
+
exit 1
|
|
69
|
+
fi
|
|
70
|
+
|
|
71
|
+
printf 'sha=%s\n' "$SHA"
|
|
72
|
+
printf 'url=https://github.com/%s/%s/commit/%s\n' "$OWNER" "$REPO" "$SHA"
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# fetchFailedLogs.sh — stream failed-step logs for every failing check on a PR.
|
|
3
|
+
#
|
|
4
|
+
# Usage: bash fetchFailedLogs.sh [pr-number]
|
|
5
|
+
# pr-number: optional; defaults to the PR on the current branch.
|
|
6
|
+
#
|
|
7
|
+
# Output (plain text on stdout). First line is either:
|
|
8
|
+
# # babysit-pr: no failing checks
|
|
9
|
+
# or:
|
|
10
|
+
# # babysit-pr: failing checks
|
|
11
|
+
# followed by one delimited block per failing job:
|
|
12
|
+
# # --- run=<id> job=<id> ---
|
|
13
|
+
# <log body>
|
|
14
|
+
#
|
|
15
|
+
# Exit 0 on normal completion (with or without failures — caller checks the
|
|
16
|
+
# first line). Exit 1 on infrastructure errors (gh missing, not authed, no PR).
|
|
17
|
+
# Exit 2 on usage errors.
|
|
18
|
+
#
|
|
19
|
+
# Filter uses `bucket == "fail"` (gh CLI's normalized lowercase field) instead
|
|
20
|
+
# of `.conclusion` because the two gh APIs disagree on case —
|
|
21
|
+
# `gh pr view --json statusCheckRollup` returns UPPER (GraphQL enum),
|
|
22
|
+
# `gh run view --json jobs` returns lower (REST). `bucket` sidesteps it.
|
|
23
|
+
#
|
|
24
|
+
# Requires: gh, jq.
|
|
25
|
+
|
|
26
|
+
set -euo pipefail
|
|
27
|
+
|
|
28
|
+
if ! command -v gh >/dev/null 2>&1; then
|
|
29
|
+
printf '{"error":"gh CLI not found"}\n' >&2
|
|
30
|
+
exit 1
|
|
31
|
+
fi
|
|
32
|
+
if ! command -v jq >/dev/null 2>&1; then
|
|
33
|
+
printf '{"error":"jq not found"}\n' >&2
|
|
34
|
+
exit 1
|
|
35
|
+
fi
|
|
36
|
+
if ! gh api user --jq '.login' >/dev/null 2>&1; then
|
|
37
|
+
printf '{"error":"not authenticated with GitHub — run: gh auth login"}\n' >&2
|
|
38
|
+
exit 1
|
|
39
|
+
fi
|
|
40
|
+
|
|
41
|
+
PR_ARG="${1:-}"
|
|
42
|
+
|
|
43
|
+
if [ -n "$PR_ARG" ]; then
|
|
44
|
+
if ! printf '%s' "$PR_ARG" | grep -qE '^[0-9]+$'; then
|
|
45
|
+
printf '{"error":"invalid PR number: %s"}\n' "$PR_ARG" >&2
|
|
46
|
+
exit 2
|
|
47
|
+
fi
|
|
48
|
+
ROLLUP="$(gh pr view "$PR_ARG" --json statusCheckRollup --jq '.statusCheckRollup' 2>/dev/null)" \
|
|
49
|
+
|| { printf '{"error":"could not fetch PR %s"}\n' "$PR_ARG" >&2; exit 1; }
|
|
50
|
+
else
|
|
51
|
+
ROLLUP="$(gh pr view --json statusCheckRollup --jq '.statusCheckRollup' 2>/dev/null)" \
|
|
52
|
+
|| { printf '{"error":"no PR for current branch"}\n' >&2; exit 1; }
|
|
53
|
+
fi
|
|
54
|
+
|
|
55
|
+
# Check-failure detection combines THREE signals because gh's rollup is a mix of
|
|
56
|
+
# CheckRun (has .conclusion, GraphQL enum UPPER like "FAILURE") and StatusContext
|
|
57
|
+
# (has .state, GraphQL enum UPPER like "FAILURE" or "ERROR"). .bucket is gh CLI's
|
|
58
|
+
# normalized lowercase ("fail" / "pass" / ...) but isn't always populated on
|
|
59
|
+
# every rollup entry — older gh versions, custom app integrations, and certain
|
|
60
|
+
# status contexts have been observed with .bucket == null. Relying only on
|
|
61
|
+
# .bucket silently drops those entries.
|
|
62
|
+
FAILING_RAW="$(printf '%s' "$ROLLUP" \
|
|
63
|
+
| jq -r '
|
|
64
|
+
def normalized_bucket:
|
|
65
|
+
(.bucket // "") as $b
|
|
66
|
+
| (.conclusion // "" | ascii_downcase) as $c
|
|
67
|
+
| (.state // "" | ascii_downcase) as $s
|
|
68
|
+
| if $b == "fail" or $c == "failure" or $s == "failure" or $s == "error"
|
|
69
|
+
then "fail" else "" end;
|
|
70
|
+
.[]
|
|
71
|
+
| select(normalized_bucket == "fail")
|
|
72
|
+
| [.name // "unknown", .detailsUrl // ""]
|
|
73
|
+
| @tsv
|
|
74
|
+
')"
|
|
75
|
+
|
|
76
|
+
if [ -z "$FAILING_RAW" ]; then
|
|
77
|
+
echo "# babysit-pr: no failing checks"
|
|
78
|
+
exit 0
|
|
79
|
+
fi
|
|
80
|
+
|
|
81
|
+
# Partition into GitHub-Actions runs (we can fetch --log-failed) vs external
|
|
82
|
+
# checks (CircleCI, Nx Cloud, semgrep, CodeRabbit, Devin, etc. — no inline logs).
|
|
83
|
+
RUN_IDS=""
|
|
84
|
+
EXTERNAL_BLOCK=""
|
|
85
|
+
while IFS=$'\t' read -r NAME URL; do
|
|
86
|
+
[ -z "$NAME" ] && continue
|
|
87
|
+
# Match GitHub Actions run URLs specifically. A loose `.*/runs/([0-9]+)` would
|
|
88
|
+
# misclassify Nx Cloud (`cloud.nx.app/runs/<numeric>`) and other hosts that
|
|
89
|
+
# reuse the `/runs/` path as GitHub Actions runs.
|
|
90
|
+
RUN_ID="$(printf '%s' "$URL" | sed -nE 's#^https://github\.com/[^/]+/[^/]+/actions/runs/([0-9]+).*#\1#p')"
|
|
91
|
+
if [ -n "$RUN_ID" ]; then
|
|
92
|
+
RUN_IDS="$RUN_IDS $RUN_ID"
|
|
93
|
+
else
|
|
94
|
+
EXTERNAL_BLOCK="${EXTERNAL_BLOCK}"$'\n'"# --- external check: ${NAME} (${URL:-no URL}) ---"
|
|
95
|
+
fi
|
|
96
|
+
done <<EOF
|
|
97
|
+
$FAILING_RAW
|
|
98
|
+
EOF
|
|
99
|
+
RUN_IDS="$(printf '%s\n' $RUN_IDS | sort -u | tr '\n' ' ')"
|
|
100
|
+
|
|
101
|
+
echo "# babysit-pr: failing checks"
|
|
102
|
+
|
|
103
|
+
for RUN_ID in $RUN_IDS; do
|
|
104
|
+
for JOB_ID in $(gh run view "$RUN_ID" --json jobs \
|
|
105
|
+
--jq '.jobs[] | select(.conclusion == "failure") | .databaseId'); do
|
|
106
|
+
echo ""
|
|
107
|
+
echo "# --- run=$RUN_ID job=$JOB_ID ---"
|
|
108
|
+
gh run view --job "$JOB_ID" --log-failed
|
|
109
|
+
done
|
|
110
|
+
done
|
|
111
|
+
|
|
112
|
+
if [ -n "$EXTERNAL_BLOCK" ]; then
|
|
113
|
+
printf '%s\n' "$EXTERNAL_BLOCK"
|
|
114
|
+
echo ""
|
|
115
|
+
echo "# (no inline logs available for external checks — investigate via the URLs above;"
|
|
116
|
+
echo "# treat these like \"External checks with no inspectable logs\" in step 5's guidance)"
|
|
117
|
+
fi
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# parseNitpicks.sh — Parse CodeRabbit nitpick comments from PR review bodies.
|
|
3
|
+
# Copied from plugins/core/skills/unresolved-pr-comments/scripts/parseNitpicks.sh
|
|
4
|
+
# with one addition: each emitted nitpick includes a stable `fingerprint` field
|
|
5
|
+
# (sha256 of file + normalized line range + title + body), so reposted reviews
|
|
6
|
+
# dedupe to the same fingerprint. Source review timestamps are kept as
|
|
7
|
+
# `createdAt` metadata but NOT included in the fingerprint.
|
|
8
|
+
#
|
|
9
|
+
# Sourced by unresolvedPrComments.sh. Requires: jq, perl with Digest::SHA + Encode.
|
|
10
|
+
|
|
11
|
+
extract_nitpick_comments() {
|
|
12
|
+
local reviews_json="$1"
|
|
13
|
+
|
|
14
|
+
printf '%s' "$reviews_json" | perl -e '
|
|
15
|
+
use strict;
|
|
16
|
+
use warnings;
|
|
17
|
+
use JSON::PP;
|
|
18
|
+
use Digest::SHA qw(sha256_hex);
|
|
19
|
+
use Encode qw(encode_utf8);
|
|
20
|
+
|
|
21
|
+
local $/;
|
|
22
|
+
my $reviews_json = <STDIN>;
|
|
23
|
+
my $reviews = decode_json($reviews_json);
|
|
24
|
+
|
|
25
|
+
my $latest_review;
|
|
26
|
+
my $latest_time = "";
|
|
27
|
+
for my $review (@$reviews) {
|
|
28
|
+
my $author = $review->{author}{login} // "";
|
|
29
|
+
my $body = $review->{body} // "";
|
|
30
|
+
next unless $author eq "coderabbitai" && $body =~ /Nitpick comments/;
|
|
31
|
+
my $created = $review->{createdAt} // "";
|
|
32
|
+
if ($created gt $latest_time) {
|
|
33
|
+
$latest_time = $created;
|
|
34
|
+
$latest_review = $review;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
unless ($latest_review) {
|
|
39
|
+
print "[]";
|
|
40
|
+
exit 0;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
my $body = $latest_review->{body};
|
|
44
|
+
my $author = $latest_review->{author}{login} // "deleted-user";
|
|
45
|
+
my $created_at = $latest_review->{createdAt} // "";
|
|
46
|
+
|
|
47
|
+
my $nitpick_content = extract_nitpick_section($body);
|
|
48
|
+
unless (defined $nitpick_content) {
|
|
49
|
+
print "[]";
|
|
50
|
+
exit 0;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
my @comments;
|
|
54
|
+
while ($nitpick_content =~ /<details>\s*<summary>([^<]+?)\s+\(\d+\)<\/summary>\s*<blockquote>([\s\S]*?)<\/blockquote>\s*<\/details>/g) {
|
|
55
|
+
my $file_name = trim($1);
|
|
56
|
+
my $file_content = $2;
|
|
57
|
+
|
|
58
|
+
while ($file_content =~ /`(\d+(?:-\d+)?)`:\s*\*\*([^*]+)\*\*\s*([\s\S]*?)(?=---|\n`\d|<\/blockquote>|$)/g) {
|
|
59
|
+
my $line_range = $1;
|
|
60
|
+
my $title = trim($2);
|
|
61
|
+
my $clean_body = clean_comment_body(trim($3));
|
|
62
|
+
|
|
63
|
+
# Fingerprint: file + normalized line + title + body (NO timestamp,
|
|
64
|
+
# NO author — reposted reviews must dedupe to the same fingerprint).
|
|
65
|
+
my $fingerprint_input = join("\n", $file_name, $line_range, $title, $clean_body);
|
|
66
|
+
my $fingerprint = substr(sha256_hex(encode_utf8($fingerprint_input)), 0, 16);
|
|
67
|
+
|
|
68
|
+
push @comments, {
|
|
69
|
+
author => $author,
|
|
70
|
+
body => "$title\n\n$clean_body",
|
|
71
|
+
createdAt => $created_at,
|
|
72
|
+
file => $file_name,
|
|
73
|
+
fingerprint => $fingerprint,
|
|
74
|
+
line => $line_range,
|
|
75
|
+
title => $title,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
print encode_json(\@comments);
|
|
81
|
+
|
|
82
|
+
sub extract_nitpick_section {
|
|
83
|
+
my ($text) = @_;
|
|
84
|
+
if ($text =~ /<summary>\x{1f9f9} Nitpick comments \(\d+\)<\/summary>\s*<blockquote>/i) {
|
|
85
|
+
my $content_start = $+[0];
|
|
86
|
+
my $after = substr($text, $content_start);
|
|
87
|
+
|
|
88
|
+
my $depth = 1;
|
|
89
|
+
my @tags;
|
|
90
|
+
while ($after =~ /(<blockquote>|<\/blockquote>)/gi) {
|
|
91
|
+
my $tag = $1;
|
|
92
|
+
my $pos = $-[0];
|
|
93
|
+
my $is_open = ($tag =~ /^<blockquote>/i) ? 1 : 0;
|
|
94
|
+
push @tags, [$pos, $is_open];
|
|
95
|
+
}
|
|
96
|
+
for my $tag (@tags) {
|
|
97
|
+
$depth += $tag->[1] ? 1 : -1;
|
|
98
|
+
if ($depth == 0) {
|
|
99
|
+
return substr($after, 0, $tag->[0]);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return undef;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
sub clean_comment_body {
|
|
107
|
+
my ($text) = @_;
|
|
108
|
+
my $prev = "";
|
|
109
|
+
while ($text ne $prev) {
|
|
110
|
+
$prev = $text;
|
|
111
|
+
$text =~ s/<details>(?:(?!<details>)[\s\S])*?<\/details>//g;
|
|
112
|
+
}
|
|
113
|
+
# Do NOT HTML-escape angle brackets: the nitpick body is posted back to GitHub
|
|
114
|
+
# as Markdown via `gh api`, where `<`/`>` would render literally and
|
|
115
|
+
# corrupt generic-type expressions or HTML snippets from the original review.
|
|
116
|
+
return trim($text);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
sub trim {
|
|
120
|
+
my ($s) = @_;
|
|
121
|
+
$s =~ s/^\s+//;
|
|
122
|
+
$s =~ s/\s+$//;
|
|
123
|
+
return $s;
|
|
124
|
+
}
|
|
125
|
+
'
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
# Extract code scanning alert number from comment body.
|
|
129
|
+
extract_code_scanning_alert_number() {
|
|
130
|
+
local body="$1"
|
|
131
|
+
printf '%s' "$body" | perl -ne 'print $1 if m{/code-scanning/(\d+)}'
|
|
132
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# postSentinelPrComment.sh — Post a top-level PR comment (used for nitpick summaries).
|
|
3
|
+
# Appends the babysit-pr sentinel if missing.
|
|
4
|
+
#
|
|
5
|
+
# Usage: bash postSentinelPrComment.sh <pr-number> <body>
|
|
6
|
+
#
|
|
7
|
+
# Requires: gh, jq. Prints comment URL on stdout, or a JSON {"error": "..."} on failure.
|
|
8
|
+
|
|
9
|
+
set -euo pipefail
|
|
10
|
+
|
|
11
|
+
SENTINEL='<!-- babysit-pr:addressed v1 -->'
|
|
12
|
+
|
|
13
|
+
if [ $# -lt 2 ]; then
|
|
14
|
+
printf '{"error":"Usage: postSentinelPrComment.sh <pr-number> <body>"}\n' >&2
|
|
15
|
+
exit 2
|
|
16
|
+
fi
|
|
17
|
+
|
|
18
|
+
PR_NUMBER="$1"
|
|
19
|
+
BODY="$2"
|
|
20
|
+
|
|
21
|
+
if ! printf '%s' "$PR_NUMBER" | grep -qE '^[0-9]+$'; then
|
|
22
|
+
printf '{"error":"Invalid PR number: %s"}\n' "$PR_NUMBER" >&2
|
|
23
|
+
exit 2
|
|
24
|
+
fi
|
|
25
|
+
if [ -z "$BODY" ]; then
|
|
26
|
+
printf '{"error":"body is required"}\n' >&2
|
|
27
|
+
exit 2
|
|
28
|
+
fi
|
|
29
|
+
|
|
30
|
+
case "$BODY" in
|
|
31
|
+
*"$SENTINEL") ;;
|
|
32
|
+
*)
|
|
33
|
+
BODY="${BODY}
|
|
34
|
+
|
|
35
|
+
${SENTINEL}"
|
|
36
|
+
;;
|
|
37
|
+
esac
|
|
38
|
+
|
|
39
|
+
repo_json="$(gh repo view --json owner,name 2>/dev/null)" || {
|
|
40
|
+
printf '{"error":"Could not determine repository."}\n' >&2
|
|
41
|
+
exit 1
|
|
42
|
+
}
|
|
43
|
+
owner="$(printf '%s' "$repo_json" | jq -r '.owner.login')"
|
|
44
|
+
repo="$(printf '%s' "$repo_json" | jq -r '.name')"
|
|
45
|
+
|
|
46
|
+
if [ -z "$owner" ] || [ "$owner" = "null" ] || [ -z "$repo" ] || [ "$repo" = "null" ]; then
|
|
47
|
+
printf '{"error":"Failed to parse repository info from gh output."}\n' >&2
|
|
48
|
+
exit 1
|
|
49
|
+
fi
|
|
50
|
+
|
|
51
|
+
result="$(gh api "repos/${owner}/${repo}/issues/${PR_NUMBER}/comments" \
|
|
52
|
+
--method POST \
|
|
53
|
+
-f "body=${BODY}" 2>&1)" || {
|
|
54
|
+
printf '{"error":%s}\n' "$(printf '%s' "$result" | jq -Rsc .)" >&2
|
|
55
|
+
exit 1
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
url="$(printf '%s' "$result" | jq -r '.html_url // empty')"
|
|
59
|
+
if [ -z "$url" ]; then
|
|
60
|
+
printf '{"error":"comment posted but no URL returned","raw":%s}\n' "$(printf '%s' "$result" | jq -c .)" >&2
|
|
61
|
+
exit 1
|
|
62
|
+
fi
|
|
63
|
+
|
|
64
|
+
printf '%s\n' "$url"
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# postSentinelReply.sh — Post a threaded reply to a PR review thread.
|
|
3
|
+
# The body MUST end with the babysit-pr sentinel; this script enforces that.
|
|
4
|
+
# Does NOT resolve the thread — that stays with the human.
|
|
5
|
+
#
|
|
6
|
+
# Usage: bash postSentinelReply.sh <thread-id> <body>
|
|
7
|
+
# <thread-id>: GraphQL PullRequestReviewThread.id (from unresolvedPrComments.sh .threads[].threadId)
|
|
8
|
+
# <body>: reply markdown. The sentinel will be appended if not already present.
|
|
9
|
+
#
|
|
10
|
+
# Requires: gh, jq. Prints reply URL on stdout, or a JSON {"error": "..."} on failure.
|
|
11
|
+
|
|
12
|
+
set -euo pipefail
|
|
13
|
+
|
|
14
|
+
SENTINEL='<!-- babysit-pr:addressed v1 -->'
|
|
15
|
+
|
|
16
|
+
if [ $# -lt 2 ]; then
|
|
17
|
+
printf '{"error":"Usage: postSentinelReply.sh <thread-id> <body>"}\n' >&2
|
|
18
|
+
exit 2
|
|
19
|
+
fi
|
|
20
|
+
|
|
21
|
+
THREAD_ID="$1"
|
|
22
|
+
BODY="$2"
|
|
23
|
+
|
|
24
|
+
if [ -z "$THREAD_ID" ]; then
|
|
25
|
+
printf '{"error":"thread-id is required"}\n' >&2
|
|
26
|
+
exit 2
|
|
27
|
+
fi
|
|
28
|
+
if [ -z "$BODY" ]; then
|
|
29
|
+
printf '{"error":"body is required"}\n' >&2
|
|
30
|
+
exit 2
|
|
31
|
+
fi
|
|
32
|
+
|
|
33
|
+
# Ensure sentinel is present at the end.
|
|
34
|
+
case "$BODY" in
|
|
35
|
+
*"$SENTINEL") ;;
|
|
36
|
+
*)
|
|
37
|
+
BODY="${BODY}
|
|
38
|
+
|
|
39
|
+
${SENTINEL}"
|
|
40
|
+
;;
|
|
41
|
+
esac
|
|
42
|
+
|
|
43
|
+
MUTATION='
|
|
44
|
+
mutation($threadId: ID!, $body: String!) {
|
|
45
|
+
addPullRequestReviewThreadReply(input: { pullRequestReviewThreadId: $threadId, body: $body }) {
|
|
46
|
+
comment {
|
|
47
|
+
id
|
|
48
|
+
databaseId
|
|
49
|
+
url
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}'
|
|
53
|
+
|
|
54
|
+
result="$(gh api graphql \
|
|
55
|
+
-f "query=${MUTATION}" \
|
|
56
|
+
-f "threadId=${THREAD_ID}" \
|
|
57
|
+
-f "body=${BODY}" 2>&1)" || {
|
|
58
|
+
printf '{"error":%s}\n' "$(printf '%s' "$result" | jq -Rsc .)" >&2
|
|
59
|
+
exit 1
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
url="$(printf '%s' "$result" | jq -r '.data.addPullRequestReviewThreadReply.comment.url // empty')"
|
|
63
|
+
if [ -z "$url" ]; then
|
|
64
|
+
printf '{"error":"reply posted but no URL returned","raw":%s}\n' "$(printf '%s' "$result" | jq -c .)" >&2
|
|
65
|
+
exit 1
|
|
66
|
+
fi
|
|
67
|
+
|
|
68
|
+
printf '%s\n' "$url"
|
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# unresolvedPrComments.sh — Fetch review threads + nitpicks for babysit-pr.
|
|
3
|
+
# Extended from plugins/core/skills/unresolved-pr-comments/scripts/unresolvedPrComments.sh.
|
|
4
|
+
# Adds: thread IDs, per-thread sentinel recency state, stable nitpick fingerprints.
|
|
5
|
+
#
|
|
6
|
+
# Usage: bash unresolvedPrComments.sh [pr-number]
|
|
7
|
+
# Compatible with macOS bash 3.2. Requires: gh, jq (>= 1.5), perl with Digest::SHA.
|
|
8
|
+
|
|
9
|
+
set -euo pipefail
|
|
10
|
+
|
|
11
|
+
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
12
|
+
# shellcheck source=parseNitpicks.sh
|
|
13
|
+
source "${SCRIPT_DIR}/parseNitpicks.sh"
|
|
14
|
+
|
|
15
|
+
SENTINEL='<!-- babysit-pr:addressed v1 -->'
|
|
16
|
+
|
|
17
|
+
exec 3>&1
|
|
18
|
+
|
|
19
|
+
output_error() {
|
|
20
|
+
printf '%s' "$1" | jq -Rsc '{ error: . }' >&3
|
|
21
|
+
exit 1
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
validate_prerequisites() {
|
|
25
|
+
if ! command -v jq >/dev/null 2>&1; then
|
|
26
|
+
printf '{"error":"jq not found. Install from https://stedolan.github.io/jq"}\n' >&3
|
|
27
|
+
exit 1
|
|
28
|
+
fi
|
|
29
|
+
if ! command -v gh >/dev/null 2>&1; then
|
|
30
|
+
output_error "gh CLI not found. Install from https://cli.github.com"
|
|
31
|
+
fi
|
|
32
|
+
if ! command -v perl >/dev/null 2>&1; then
|
|
33
|
+
output_error "perl not found."
|
|
34
|
+
fi
|
|
35
|
+
if ! perl -MDigest::SHA -e1 >/dev/null 2>&1; then
|
|
36
|
+
output_error "Perl Digest::SHA module not found (should be in core Perl since 5.9.3)."
|
|
37
|
+
fi
|
|
38
|
+
if ! gh api user --jq '.login' >/dev/null 2>&1; then
|
|
39
|
+
output_error "Not authenticated with GitHub. Run: gh auth login"
|
|
40
|
+
fi
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
get_pr_number() {
|
|
44
|
+
local arg="${1:-}"
|
|
45
|
+
if [ -n "$arg" ]; then
|
|
46
|
+
if ! printf '%s' "$arg" | grep -qE '^[0-9]+$'; then
|
|
47
|
+
output_error "Invalid PR number: ${arg}"
|
|
48
|
+
fi
|
|
49
|
+
printf '%s' "$arg"
|
|
50
|
+
return
|
|
51
|
+
fi
|
|
52
|
+
|
|
53
|
+
local pr_json
|
|
54
|
+
if ! pr_json="$(gh pr view --json number 2>/dev/null)"; then
|
|
55
|
+
output_error "No PR found for current branch. Provide PR number as argument."
|
|
56
|
+
fi
|
|
57
|
+
|
|
58
|
+
local pr_num
|
|
59
|
+
pr_num="$(printf '%s' "$pr_json" | jq -r '.number // empty')"
|
|
60
|
+
if [ -z "$pr_num" ]; then
|
|
61
|
+
output_error "No PR found for current branch. Provide PR number as argument."
|
|
62
|
+
fi
|
|
63
|
+
printf '%s' "$pr_num"
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
get_repo_info() {
|
|
67
|
+
local repo_json
|
|
68
|
+
if ! repo_json="$(gh repo view --json owner,name 2>/dev/null)"; then
|
|
69
|
+
output_error "Could not determine repository. Are you in a git repo with a GitHub remote?"
|
|
70
|
+
fi
|
|
71
|
+
|
|
72
|
+
REPO_OWNER="$(printf '%s' "$repo_json" | jq -r '.owner.login // empty')"
|
|
73
|
+
REPO_NAME="$(printf '%s' "$repo_json" | jq -r '.name // empty')"
|
|
74
|
+
|
|
75
|
+
if [ -z "$REPO_OWNER" ] || [ -z "$REPO_NAME" ]; then
|
|
76
|
+
output_error "Failed to parse repository info from gh CLI output."
|
|
77
|
+
fi
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
# Pagination limits: 100 review threads, 20 comments per thread, 100 reviews.
|
|
81
|
+
GRAPHQL_QUERY='
|
|
82
|
+
query($owner: String!, $repo: String!, $pr: Int!) {
|
|
83
|
+
repository(owner: $owner, name: $repo) {
|
|
84
|
+
pullRequest(number: $pr) {
|
|
85
|
+
title
|
|
86
|
+
url
|
|
87
|
+
reviewThreads(first: 100) {
|
|
88
|
+
nodes {
|
|
89
|
+
id
|
|
90
|
+
isResolved
|
|
91
|
+
comments(first: 20) {
|
|
92
|
+
nodes {
|
|
93
|
+
id
|
|
94
|
+
databaseId
|
|
95
|
+
body
|
|
96
|
+
path
|
|
97
|
+
line
|
|
98
|
+
originalLine
|
|
99
|
+
createdAt
|
|
100
|
+
author {
|
|
101
|
+
login
|
|
102
|
+
__typename
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
reviews(first: 100) {
|
|
109
|
+
nodes {
|
|
110
|
+
body
|
|
111
|
+
author { login }
|
|
112
|
+
createdAt
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}'
|
|
118
|
+
|
|
119
|
+
execute_graphql_query() {
|
|
120
|
+
local owner="$1" repo="$2" pr_number="$3"
|
|
121
|
+
local result
|
|
122
|
+
if ! result="$(gh api graphql \
|
|
123
|
+
-f "query=${GRAPHQL_QUERY}" \
|
|
124
|
+
-f "owner=${owner}" \
|
|
125
|
+
-f "repo=${repo}" \
|
|
126
|
+
-F "pr=${pr_number}" 2>&1)"; then
|
|
127
|
+
output_error "GraphQL query failed: ${result}"
|
|
128
|
+
fi
|
|
129
|
+
printf '%s' "$result"
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
is_code_scanning_alert_fixed() {
|
|
133
|
+
local owner="$1" repo="$2" alert_number="$3"
|
|
134
|
+
local result
|
|
135
|
+
if ! result="$(gh api "repos/${owner}/${repo}/code-scanning/alerts/${alert_number}" 2>/dev/null)"; then
|
|
136
|
+
return 1
|
|
137
|
+
fi
|
|
138
|
+
|
|
139
|
+
local state
|
|
140
|
+
state="$(printf '%s' "$result" | jq -r '.most_recent_instance.state // empty')"
|
|
141
|
+
[ "$state" = "fixed" ]
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
main() {
|
|
145
|
+
validate_prerequisites
|
|
146
|
+
|
|
147
|
+
local pr_number
|
|
148
|
+
pr_number="$(get_pr_number "${1:-}")"
|
|
149
|
+
|
|
150
|
+
get_repo_info
|
|
151
|
+
local owner="$REPO_OWNER"
|
|
152
|
+
local repo="$REPO_NAME"
|
|
153
|
+
|
|
154
|
+
local response
|
|
155
|
+
response="$(execute_graphql_query "$owner" "$repo" "$pr_number")"
|
|
156
|
+
|
|
157
|
+
if [ "$(printf '%s' "$response" | jq -r '.data.repository // empty')" = "" ]; then
|
|
158
|
+
output_error "Repository ${owner}/${repo} not found or not accessible."
|
|
159
|
+
fi
|
|
160
|
+
if [ "$(printf '%s' "$response" | jq -r '.data.repository.pullRequest // empty')" = "" ]; then
|
|
161
|
+
output_error "PR #${pr_number} not found or not accessible."
|
|
162
|
+
fi
|
|
163
|
+
|
|
164
|
+
local title url
|
|
165
|
+
title="$(printf '%s' "$response" | jq -r '.data.repository.pullRequest.title')"
|
|
166
|
+
url="$(printf '%s' "$response" | jq -r '.data.repository.pullRequest.url')"
|
|
167
|
+
|
|
168
|
+
# Build threads with sentinel recency state.
|
|
169
|
+
#
|
|
170
|
+
# Bot detection combines TWO signals (union, not intersection):
|
|
171
|
+
# 1. GraphQL `author.__typename == "Bot"` — catches every bot GitHub marks as such,
|
|
172
|
+
# including bots not on our allowlist. This is the primary signal.
|
|
173
|
+
# 2. Login allowlist — catches GitHub Apps/Actions that post via a User-type service
|
|
174
|
+
# account rather than a Bot account.
|
|
175
|
+
# An unknown bot whose login we don't recognize but which is type=Bot still gets
|
|
176
|
+
# classified correctly; we never fall back to treating it as a human.
|
|
177
|
+
#
|
|
178
|
+
# Per-thread emitted fields:
|
|
179
|
+
# - threadId, replyToCommentDatabaseId, comments[], isResolved, file, line
|
|
180
|
+
# - lastBabysitSentinelAt: max createdAt of OUR sentinel replies (null if none)
|
|
181
|
+
# - lastHumanCommentAt: max createdAt of non-sentinel, non-bot comments
|
|
182
|
+
# - lastBotCommentAt: max createdAt of non-sentinel bot comments
|
|
183
|
+
# - postSentinelBotComments: ARRAY of every bot comment after lastBabysitSentinelAt
|
|
184
|
+
# (the agent inspects ALL of them; a later ack must not hide
|
|
185
|
+
# an earlier actionable bot comment)
|
|
186
|
+
# - postSentinelHumanComments: ARRAY of every human comment after lastBabysitSentinelAt
|
|
187
|
+
# - activityState: tri-state, one of:
|
|
188
|
+
# "active" — needs a reply (no sentinel yet, OR a human commented after our sentinel)
|
|
189
|
+
# "uncertain" — sentinel exists, but a bot posted after it; agent MUST inspect every
|
|
190
|
+
# entry in postSentinelBotComments and treat as active unless EVERY one
|
|
191
|
+
# is confidently a non-actionable acknowledgement
|
|
192
|
+
# "addressed" — our sentinel is the newest relevant activity on this thread
|
|
193
|
+
local bots_json='["coderabbitai","coderabbitai[bot]","dependabot","dependabot[bot]","github-actions","github-actions[bot]","github-advanced-security","github-advanced-security[bot]","renovate","renovate[bot]","renovate-bot","pre-commit-ci","pre-commit-ci[bot]","codecov","codecov[bot]","sonarcloud","sonarcloud[bot]"]'
|
|
194
|
+
local threads_json
|
|
195
|
+
threads_json="$(printf '%s' "$response" | jq --arg sentinel "$SENTINEL" --argjson bots "$bots_json" '
|
|
196
|
+
# Exact login equality via IN($bots[]) — do NOT use `inside($bots)`, which
|
|
197
|
+
# does substring matching for strings and would classify login "code" as a
|
|
198
|
+
# bot because it appears inside "codecov".
|
|
199
|
+
def is_bot: ((.author.__typename // "") == "Bot") or ((.author.login // "") | IN($bots[]));
|
|
200
|
+
def is_sentinel: ((.body // "") | contains($sentinel));
|
|
201
|
+
[
|
|
202
|
+
.data.repository.pullRequest.reviewThreads.nodes[]
|
|
203
|
+
| select(.isResolved == false)
|
|
204
|
+
| . as $t
|
|
205
|
+
| ($t.comments.nodes) as $comments
|
|
206
|
+
| {
|
|
207
|
+
threadId: $t.id,
|
|
208
|
+
isResolved: $t.isResolved,
|
|
209
|
+
replyToCommentDatabaseId: ($comments[0].databaseId // null),
|
|
210
|
+
file: ($comments[0].path // null),
|
|
211
|
+
line: ($comments[0].line // $comments[0].originalLine // null),
|
|
212
|
+
comments: [
|
|
213
|
+
$comments[] | {
|
|
214
|
+
id,
|
|
215
|
+
databaseId,
|
|
216
|
+
author: (.author.login // "deleted-user"),
|
|
217
|
+
authorType: (.author.__typename // null),
|
|
218
|
+
body,
|
|
219
|
+
createdAt,
|
|
220
|
+
file: .path,
|
|
221
|
+
line: (.line // .originalLine),
|
|
222
|
+
isBabysitSentinel: is_sentinel,
|
|
223
|
+
isKnownBot: is_bot
|
|
224
|
+
}
|
|
225
|
+
],
|
|
226
|
+
lastBabysitSentinelAt: (
|
|
227
|
+
[$comments[] | select(is_sentinel) | .createdAt] | sort | last
|
|
228
|
+
),
|
|
229
|
+
lastHumanCommentAt: (
|
|
230
|
+
[$comments[] | select(is_sentinel | not) | select(is_bot | not) | .createdAt]
|
|
231
|
+
| sort | last
|
|
232
|
+
),
|
|
233
|
+
lastBotCommentAt: (
|
|
234
|
+
[$comments[] | select(is_sentinel | not) | select(is_bot) | .createdAt]
|
|
235
|
+
| sort | last
|
|
236
|
+
)
|
|
237
|
+
}
|
|
238
|
+
| . as $thread
|
|
239
|
+
| .postSentinelBotComments = (
|
|
240
|
+
if $thread.lastBabysitSentinelAt == null then []
|
|
241
|
+
else [
|
|
242
|
+
$comments[]
|
|
243
|
+
| select(is_bot)
|
|
244
|
+
| select(is_sentinel | not)
|
|
245
|
+
| select(.createdAt > $thread.lastBabysitSentinelAt)
|
|
246
|
+
| {
|
|
247
|
+
id,
|
|
248
|
+
createdAt,
|
|
249
|
+
author: (.author.login // "deleted-user"),
|
|
250
|
+
authorType: (.author.__typename // null),
|
|
251
|
+
body
|
|
252
|
+
}
|
|
253
|
+
]
|
|
254
|
+
end
|
|
255
|
+
)
|
|
256
|
+
| .postSentinelHumanComments = (
|
|
257
|
+
if $thread.lastBabysitSentinelAt == null then []
|
|
258
|
+
else [
|
|
259
|
+
$comments[]
|
|
260
|
+
| select(is_bot | not)
|
|
261
|
+
| select(is_sentinel | not)
|
|
262
|
+
| select(.createdAt > $thread.lastBabysitSentinelAt)
|
|
263
|
+
| {
|
|
264
|
+
id,
|
|
265
|
+
createdAt,
|
|
266
|
+
author: (.author.login // "deleted-user"),
|
|
267
|
+
body
|
|
268
|
+
}
|
|
269
|
+
]
|
|
270
|
+
end
|
|
271
|
+
)
|
|
272
|
+
| .activityState = (
|
|
273
|
+
if .lastBabysitSentinelAt == null then
|
|
274
|
+
"active"
|
|
275
|
+
elif (.postSentinelHumanComments | length) > 0 then
|
|
276
|
+
"active"
|
|
277
|
+
elif (.postSentinelBotComments | length) > 0 then
|
|
278
|
+
"uncertain"
|
|
279
|
+
else
|
|
280
|
+
"addressed"
|
|
281
|
+
end
|
|
282
|
+
)
|
|
283
|
+
]
|
|
284
|
+
')"
|
|
285
|
+
|
|
286
|
+
# Flattened unresolved_comments (retained for backward compat with the prose summary).
|
|
287
|
+
# Includes comments from "active" AND "uncertain" threads so the agent never misses new feedback.
|
|
288
|
+
local all_unresolved
|
|
289
|
+
all_unresolved="$(printf '%s' "$threads_json" | jq '[
|
|
290
|
+
.[]
|
|
291
|
+
| select(.activityState != "addressed")
|
|
292
|
+
| .comments[]
|
|
293
|
+
| select(.isBabysitSentinel | not)
|
|
294
|
+
| {
|
|
295
|
+
author,
|
|
296
|
+
body,
|
|
297
|
+
createdAt,
|
|
298
|
+
file,
|
|
299
|
+
line
|
|
300
|
+
}
|
|
301
|
+
]')"
|
|
302
|
+
|
|
303
|
+
# Filter out fixed code-scanning alerts from github-advanced-security
|
|
304
|
+
local unresolved_comments="[]"
|
|
305
|
+
local count
|
|
306
|
+
count="$(printf '%s' "$all_unresolved" | jq 'length')"
|
|
307
|
+
|
|
308
|
+
local i=0
|
|
309
|
+
while [ "$i" -lt "$count" ]; do
|
|
310
|
+
local comment
|
|
311
|
+
comment="$(printf '%s' "$all_unresolved" | jq ".[$i]")"
|
|
312
|
+
local comment_author
|
|
313
|
+
comment_author="$(printf '%s' "$comment" | jq -r '.author')"
|
|
314
|
+
|
|
315
|
+
local keep=true
|
|
316
|
+
# GitHub Advanced Security's bot posts under either login depending on account
|
|
317
|
+
# type (app installation vs. direct). Match both forms.
|
|
318
|
+
case "$comment_author" in
|
|
319
|
+
"github-advanced-security"|"github-advanced-security[bot]")
|
|
320
|
+
local comment_body alert_number
|
|
321
|
+
comment_body="$(printf '%s' "$comment" | jq -r '.body')"
|
|
322
|
+
alert_number="$(extract_code_scanning_alert_number "$comment_body")"
|
|
323
|
+
if [ -n "$alert_number" ]; then
|
|
324
|
+
if is_code_scanning_alert_fixed "$owner" "$repo" "$alert_number"; then
|
|
325
|
+
keep=false
|
|
326
|
+
fi
|
|
327
|
+
fi
|
|
328
|
+
;;
|
|
329
|
+
esac
|
|
330
|
+
|
|
331
|
+
if [ "$keep" = true ]; then
|
|
332
|
+
unresolved_comments="$(printf '%s' "$unresolved_comments" | jq --argjson c "$comment" '. + [$c]')"
|
|
333
|
+
fi
|
|
334
|
+
|
|
335
|
+
i=$((i + 1))
|
|
336
|
+
done
|
|
337
|
+
|
|
338
|
+
# Nitpicks from coderabbit review bodies
|
|
339
|
+
local reviews_json
|
|
340
|
+
reviews_json="$(printf '%s' "$response" | jq '[.data.repository.pullRequest.reviews.nodes[]]')"
|
|
341
|
+
local nitpick_comments
|
|
342
|
+
nitpick_comments="$(extract_nitpick_comments "$reviews_json")"
|
|
343
|
+
|
|
344
|
+
# Active threads: anything NOT yet addressed. Includes "uncertain" — agent must inspect.
|
|
345
|
+
local active_threads total_active_threads uncertain_threads total_uncertain_threads
|
|
346
|
+
active_threads="$(printf '%s' "$threads_json" | jq '[.[] | select(.activityState != "addressed")]')"
|
|
347
|
+
total_active_threads="$(printf '%s' "$active_threads" | jq 'length')"
|
|
348
|
+
uncertain_threads="$(printf '%s' "$threads_json" | jq '[.[] | select(.activityState == "uncertain")]')"
|
|
349
|
+
total_uncertain_threads="$(printf '%s' "$uncertain_threads" | jq 'length')"
|
|
350
|
+
|
|
351
|
+
local total_unresolved total_nitpicks
|
|
352
|
+
total_unresolved="$(printf '%s' "$unresolved_comments" | jq 'length')"
|
|
353
|
+
total_nitpicks="$(printf '%s' "$nitpick_comments" | jq 'length')"
|
|
354
|
+
|
|
355
|
+
jq -n \
|
|
356
|
+
--argjson activeThreads "$active_threads" \
|
|
357
|
+
--argjson nitpickComments "$nitpick_comments" \
|
|
358
|
+
--arg owner "$owner" \
|
|
359
|
+
--argjson prNumber "$pr_number" \
|
|
360
|
+
--arg repo "$repo" \
|
|
361
|
+
--arg sentinel "$SENTINEL" \
|
|
362
|
+
--arg title "$title" \
|
|
363
|
+
--argjson threads "$threads_json" \
|
|
364
|
+
--argjson totalActiveThreads "$total_active_threads" \
|
|
365
|
+
--argjson totalNitpicks "$total_nitpicks" \
|
|
366
|
+
--argjson totalUncertainThreads "$total_uncertain_threads" \
|
|
367
|
+
--argjson totalUnresolvedComments "$total_unresolved" \
|
|
368
|
+
--argjson uncertainThreads "$uncertain_threads" \
|
|
369
|
+
--argjson unresolvedComments "$unresolved_comments" \
|
|
370
|
+
--arg url "$url" \
|
|
371
|
+
'{
|
|
372
|
+
activeThreads: $activeThreads,
|
|
373
|
+
nitpickComments: $nitpickComments,
|
|
374
|
+
owner: $owner,
|
|
375
|
+
prNumber: $prNumber,
|
|
376
|
+
repo: $repo,
|
|
377
|
+
sentinel: $sentinel,
|
|
378
|
+
threads: $threads,
|
|
379
|
+
title: $title,
|
|
380
|
+
totalActiveThreads: $totalActiveThreads,
|
|
381
|
+
totalNitpicks: $totalNitpicks,
|
|
382
|
+
totalUncertainThreads: $totalUncertainThreads,
|
|
383
|
+
totalUnresolvedComments: $totalUnresolvedComments,
|
|
384
|
+
uncertainThreads: $uncertainThreads,
|
|
385
|
+
unresolvedComments: $unresolvedComments,
|
|
386
|
+
url: $url
|
|
387
|
+
}'
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
main "$@"
|
package/skills/simplify/SKILL.md
CHANGED
|
@@ -32,6 +32,8 @@ Review the same changes for hacky patterns:
|
|
|
32
32
|
4. **Leaky abstractions**: exposing internal details that should be encapsulated, or breaking existing abstraction boundaries
|
|
33
33
|
5. **Stringly-typed code**: using raw strings where constants, enums (string unions), or branded types already exist in the codebase
|
|
34
34
|
6. **Unnecessary JSX nesting**: wrapper Boxes/elements that add no layout value — check if inner component props (flexShrink, alignItems, etc.) already provide the needed behavior
|
|
35
|
+
7. **Nested conditionals**: ternary chains (`a ? x : b ? y : ...`), nested if/else, or nested switch 3+ levels deep — flatten with early returns, guard clauses, a lookup table, or an if/else-if cascade
|
|
36
|
+
8. **Unnecessary comments**: comments explaining WHAT the code does (well-named identifiers already do that), narrating the change, or referencing the task/caller — delete; keep only non-obvious WHY (hidden constraints, subtle invariants, workarounds)
|
|
35
37
|
|
|
36
38
|
### Agent 3: Efficiency Review
|
|
37
39
|
|