feat: email the operator when Igor requests his review #451
No reviewers
Labels
No labels
Agent
Compat/Breaking
Kind/Bug
Kind/Documentation
Kind/Enhancement
Kind/Feature
Kind/Security
Kind/Testing
Priority
Critical
Priority
High
Priority
Low
Priority
Medium
Reviewed
Confirmed
Reviewed
Duplicate
Reviewed
Invalid
Reviewed
Won't Fix
Status
Abandoned
Status
Blocked
Status
Need More Info
No milestone
No assignees
2 participants
Notifications
Total time spent: 37 minutes 7 seconds
Due date
igor
37 minutes 7 seconds
No due date set.
Dependencies
No dependencies set
Reference
joshtronic/igor!451
Loading…
Reference in a new issue
No description provided.
Delete branch "feat/439-email-on-review-request"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Part of #439 — deliberately does NOT close it (see below).
Forgejo's own notifications are off — it mails on everything the bot does, and the volume buried the useful mail. So there's currently no signal when a PR lands on your desk. This sends one, deliberately, at the moment that means something: when Igor requests your review.
Hooked at
forgejo_request_review, not at its callers. Ten call sites — terminal verdicts, unvalidated repo, escalation after N rework rounds, rework that produced no commits — all mean the same thing, and hooking callers means remembering to hook the eleventh.Deduped on
repo#number+ head sha.forgejo_request_reviewis idempotent by design, so callers fire it freely; mailing on every call would rebuild the flood you turned Forgejo off to escape. Same head → quiet. New head → code you haven't seen, second mail. Recorded only after a send lands, so a failed send retries instead of marking you notified about mail you never got.Scope — why this is
Part of, notCloses#439 specifies the whole needs-you set: PRs, issues gaining
Status/Blocked/Need More Info, CEO proposals awaiting a greenlight, failed systemd units, wait times, a weekly still-waiting summary. It was markedStatus/Blockedfor scope (792 lines, over the per-issue cap) before any of this landed.This PR implements one source of that set — "Igor requested your review on a PR" — which is the slice you asked for directly. #449 (merged) built the detection half and only logs. Merging this with a
Closeskeyword would auto-close #439 and silently take the unbuilt remainder with it. HencePart of.Two rounds of shadow review found four real defects in the original commit
site-work-block.shandideation-pipeline.shrequest your review from child processes that never sourced the notifier — they'd have landed the request and mailed nothing. Both now source it, andcheck-sync.shwalks each entry point's transitive source set and fails if one can reachforgejo_request_review/forgejo_open_prwithout the notifier, so the design claim enforces itself instead of relying on memory.mkdir -pthe state dir (a fresh host would have run with dedup permanently off behind one warning), andmktempbeside the state file so themvis a rename, not a cross-mount copy.Tests
bin/test-reviewnotify.sh, picked up automatically bycheck-sync.sh. Mutation-checked against hook-unwired, dedup-disabled, and record-before-send — each caught. The wiring block drives the realforgejo_request_reviewwith only its documented HTTP seam stubbed, because every other assertion would pass just as happily if the hook were never wired.🤖 Generated with Claude Code
https://claude.ai/code/session_01QfDyMfn2Go73gw21a92wNB
🤖 Review —
REQUEST_CHANGES(automated)CI for
96ccaab6: successAdds
lib/reviewnotify.sh+ a hook inforgejo_request_review's 2xx branch to email the operator once per PR-head when Igor hands a PR over. The shell is careful, the test file is genuinely adversarial (it drives the realforgejo_request_reviewand guards its own guards), CI is green, and the diff is ~350 lines with no drive-by refactors. Two things block.Blocking
1.
Closes #439overclaims — this implements roughly one bullet of five.Issue #439 defines the needs-you set as PRs plus issues gaining
Status/Blocked/Status/Need More Info, CEO proposals/questions awaiting greenlight, and failed systemd units; it also requires grouping by repo and wait time in the content, and names four required tests (first entry notifies, unchanged set silent, removal silent, second distinct item notifies). This PR covers only "Igor requested your review on a PR", with no wait time, no grouping, and no removal semantics. The issue was explicitly blocked on 2026-07-28 with "Split this into smaller issues" — so merging this withCloses #439auto-closes the tracking issue and silently drops the other four sources plus the wait-time requirement.Fix: change the description to
Part of #439(or reference the specific split-out sub-issue this actually closes), and make sure the remaining slices exist as issues before #439 disappears.2. The hook only fires in processes that source
lib/reviewnotify.sh— and onlybin/tick.shdoes.lib/forgejo.sh:289gates ondeclare -F review_notify_human, and the only new.line in the diff is inbin/tick.sh. The description's central claim ("ten call sites … hooking the function means you can't forget the eleventh") holds only for call sites that execute inside the tick process. Anyforgejo_request_reviewreached from abin/agent-*.shhelper run as a subprocess silently no-ops — the same "forgot to hook the caller" failure mode the design is arguing against, just moved to process boundaries. The diff alone doesn't let me verify where the ten call sites live.Fix: either state (and ideally assert) that all ten call sites are in
lib/*sourced bytick.sh, or sourcereviewnotify.shwhereverforgejo.shis sourced. A one-line check inbin/check-sync.sh(every file callingforgejo_request_reviewtransitively sourcesreviewnotify.sh) would make the claim self-enforcing.Non-blocking, worth addressing
lib/reviewnotify.sh:118:email_send "$subject" "<pre>${body}</pre>" "$body" ...interpolates the PR title (from the Forgejo API, not necessarily authored by Igor) into HTML unescaped. Low severity for a mail client, but it's the one untrusted string on the path; escaping& < >costs nothing.review_notify_humandeliberately sends and records nothing whenforgejo_get_prfails. With ~10 call sites and a persistently failing fetch (bad scope, endpoint change), that's an unbounded email per call per tick — precisely the flood the dedup exists to prevent. Consider a coarse fallback key (repo#numberwith no sha, or a timestamp floor) so "no head" degrades to at-most-one-per-hour rather than unbounded.lib/reviewnotify.sh:104:[ -f "$f" ] || echo '{}' > "$f"fails silently ifAGENT_STATE_DIRdoesn't exist yet, after which every write fails and dedup is permanently off (only a warning log).mkdir -p "$(dirname "$f")"first.mktemp+mvis cross-filesystem —lib/reviewnotify.sh:129-131:mktempdefaults to/tmp, so themvmay be copy+unlink rather than an atomic rename onto the state file. Usemktempin the state dir (other repo code may already do this — worth matching).$3is; if a non-operator reviewer is ever requested, the operator still gets "Needs you". Fine today, fragile later.Test coverage
Coverage of what's implemented is good — dedup key, fail-open cases, record-only-after-send, retry-after-failure, config guards (with an explicit guard-the-guard), and a real-function wiring block. The subshell-swallowed-assertion fix called out in the description is visibly in the diff. Missing relative to #439: nothing for removal-is-silent or non-PR sources, which is consistent with the reduced scope but not with
Closes #439.Independent review by the harness on
claude-opus-5(effort: high). The human reviewer is requested once Igor has reviewed; a human still merges.🔧 Rework — round 1 (automated)
Addressed the review on
claude-opus-5at effort high — 2 new commit(s).Follow-ups on the previous commit, all from re-reading it: - check-sync's source-walk ended its loop body on `[ -n "$next" ] && ...`, which returns 1 for every file that sources nothing -- the common case, and the shape errexit trips on. Made it an `if`. - The wiring test stubbed the hook with a bare `$3`. Under `set -u` a dropped reviewer aborted the suite instead of failing an assertion, so the mutation that removes the pass-through produced no output at all rather than a named failure. `${3:-}` now reports it. - The success log read "(head )" when the fetch failed and the mail went out on the hour bucket. It logs the key instead. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>🤖 Review —
REQUEST_CHANGES(automated)CI for
c63ed41e: successAdds
lib/reviewnotify.sh+ a best-effort hook inforgejo_request_reviewso the operator gets one email per PR-per-head when Igor hands a PR over. The core module is careful, well-commented, and genuinely well tested (the "drive the realforgejo_request_reviewwith only the HTTP seam stubbed" block is the right instinct). CI is green. Blocking on the issue-closure claim and two verification gaps, not on the design.Blocking
Closes #439over-claims the work. Issue #439 defines the needs-you set as four sources: PRs awaiting the human, issues gainingStatus/Blocked/Status/Need More Info, CEO proposals/questions awaiting greenlight, and failed systemd units — plus "group by repo" and "include how long it has been waiting". This PR implements exactly one source (PR review requests), no wait time, no grouping. The issue's own blocked note says to split into smaller issues, which is what this appears to be — but merging it withCloseswill auto-close #439 and silently drop the other three sources and the wait-time/grouping requirements. Change the description toPart of #439(orRefs #439) and file the follow-up issues for the remaining sources before this lands. This is the one item the human would otherwise have to catch by hand.Two new entry points source
lib/email.shwith no runtime coverage.bin/site-work-block.sh:101andbin/ideation-pipeline.sh:102now sourcelib/email.shandlib/reviewnotify.sh. Neither script is exercised bycheck-sync.sh, and both run underset -u-style strictness elsewhere in the repo. Iflib/email.shhas any top-level code that touches unset config (SMTP2GO_*, recipient lists), these two entry points now abort at startup — a regression in the PR-opening path itself, which is far worse than a missed email. Please confirmlib/email.shis side-effect-free at source time (a one-line note in the PR is enough if so).The delivery seam is never exercised, even indirectly.
review_notify_humancallsrecipients_with_primary ""andemail_send "$subject" "$html" "$text" "$recipients";bin/test-reviewnotify.shstubs both. Nothing in the diff or the tests pins those signatures to the real ones inlib/email.sh, so an argument-order mismatch ships green and produces mail that is wrong (or no mail at all) — invisible, because the whole module is best-effort and returns 0 on failure. Either add a cheap assertion against the realemail_send's arity/param order, or state in the PR that you verified it againstlib/email.sh(which isn't in the diff, so I can't).Non-blocking findings
discretionary-state.json.review_notify_humandoes its own read-modify-write (jq … "$state_file" > "$tmp"; mv). If any other writer —tick.sh, the.reviewrecorder — snapshots the file in memory earlier in the tick and rewrites it wholesale at the end, the.review_notifiedentry written mid-tick is lost, dedup never sticks, and you get the per-tick flood this feature exists to prevent. Worth confirming every writer is read-modify-write-at-write-time. No test covers the interaction.check-sync.shwiring guard (55 lines, a new repo-wide invariant over everybin/*.sh) is arguably its own change.check-sync.shguard or the two entry-point source additions. The check-sync change is the one a human most needs told about: it now fails the build for anybin/*.shthat can reachforgejo_request_review/forgejo_open_prwithout sourcingreviewnotify.sh. That's a new global constraint on future scripts and it isn't in the description.mktemp "${state_file}.XXXXXX"+mvwill reset the state file's mode to 0600 on every successful record. Fine if it's single-user; a problem if anything else reads it.reviewnotify_dedup_head's hour bucket is a good fix for unbounded fail-open, but note that a persistently brokenforgejo_get_prstill yields one mail/hour/PR with no title and no URL — i.e. an alert the operator can't act on. Consider saying "PR details unavailable" in the body for that path.Test coverage
Good. The dedup matrix, fail-open cases, record-only-on-success, HTML escaping, reviewer mismatch, and the fresh-state-dir path are all real assertions, and the two subshell-swallowed guards being pulled in-shell is exactly the kind of self-check I'd want. The gap is everything downstream of
email_send(above) and the two new entry points that sourceemail.sh.Independent review by the harness on
claude-opus-5(effort: high). The human reviewer is requested once Igor has reviewed; a human still merges.🔧 Rework — round 2 (automated)
Addressed the review on
claude-opus-5at effort xhigh — 1 new commit(s).🤖 Review —
COMMENT(automated)CI for
df468d9d: successAdds
lib/reviewnotify.sh(email the operator when Igor requests his review), hooks it intoforgejo_request_review, wires three entry points, and adds acheck-sync.shguard plus a 323-line test file. CI is green and I found no defect I'd block on — but the diff is well over the size budget and a few behaviors are worth a human glance, so this is a COMMENT rather than an approve.Checklist / description honesty
Verified — every claim in the description maps to something real in the diff:
forgejo_request_reviewonly (lib/forgejo.sh:289-298), fired by name viadeclare -F,|| trueso a notifier failure can't fail a landed request. ✔repo#number+ head sha, recorded only after a successful send (lib/reviewnotify.sh:180-206). ✔bin/site-work-block.sh,bin/ideation-pipeline.sh) + the transitivecheck-sync.shguard; hour-bucket cap inreviewnotify_dedup_head;reviewnotify_escape_htmlon the title;mkdir -p+mktempbeside the state file. ✔Part of #439(notCloses) is the correct call — #439 specifies issues/CEO proposals/systemd/wait-times/weekly summary, none of which are here.Findings (non-blocking, but I'd want eyes on these)
Size: ~618 added lines, over the ~400 budget.
lib/reviewnotify.shis +208 (roughly half comment prose) andbin/test-reviewnotify.shis +323. Nothing was trimmed to fit — the opposite — so this isn't the "shrunk by deleting tests" failure, but it's the same cap that got #439 blocked at 792 lines. If the repo enforces the budget, the natural split is (a)lib/reviewnotify.sh+ hook + tests, then (b) thecheck-sync.shwiring guard as a follow-up.A transient PR-fetch failure costs two extra emails, not one (
lib/reviewnotify.sh:79-88,170-176). Sequence: mailed atabc→ fetch fails → key becomesnohead-N, differs fromabc, sends and overwrites the recorded sha → fetch recovers, head is stillabc, which now differs fromnohead-N→ sends again. Consistent with the deliberate fail-open design, but the module docs describe it as "costs nothing once the fetch recovers," which isn't quite true. Recording the bucket under a separate key (or keeping the last-known real sha alongside) would make it exactly one.New unlocked writers to
discretionary-state.jsonfrom child processes.review_notify_humandoes read-modify-write +mv(atomic rename, so no corruption), butbin/site-work-block.shandbin/ideation-pipeline.shnow write this file too. If either can overlap withbin/tick.sh, a lost update clobbers whatever other subsystem wrote between thejqread and themv— not just a duplicate email. The in-code comment addresses in-memory snapshots, which is a different hazard. Worth confirming these are strictly sequential children of the tick.The
check-sync.shguard is weaker than "the design claim enforces itself."sourced_libsonly recognises. "$AGENT_HOME/lib/x.sh"(bin/check-sync.sh:120). An entry point that sources via a different variable ($HERE, a relative path, a computed path) yields an empty reach set and hits the[ -n "${callers// /}" ] || continueearly-out — i.e. it passes silently rather than failing. Same for any future entry point outsidebin/*.sh. Good guard, but it's a lint-grade heuristic, not the invariant the comment claims.Subject line isn't escaped/sanitised, only the HTML body (
reviewnotify_subject, used raw atlib/reviewnotify.sh:170). Low risk — SMTP2GO's JSON API sets headers, so no CRLF header injection, and mail subjects render as text — but the title is the one attacker-influenceable string on this path and the asymmetry is worth being deliberate about.log "reviewnotify: emailed ${recipients} ..."writes the operator's address to the log. Not a secret; flagging only in case logs are shipped anywhere.What I couldn't verify from the diff
lib/email.shandlib/review.sharen't in the diff, so I can't independently confirmemail_send's 4-arg signature orrecipients_with_primary's. The final test block sources the reallib/email.shand stubs onlycurl, which is exactly the right way to pin this — I'm relying on green CI for it.bin/*.sh(e.g.agent-*.sh) textually reachesforgejo_request_review/forgejo_open_prand would now be forced to source the notifier. Green CI says none do today, but the new guard makes that a standing constraint on every future entry point.Test coverage
Genuinely good, and unusually honest about its own blind spots: the "hook is actually wired" block drives the real
forgejo_request_reviewwith only_forgejo_post_reviewersstubbed, the delivery-seam block sources the realemail.shand writes the curl payload to a file (correctly noting the command-substitution subshell would swallow a variable), and theset -usource-cleanliness check guards the two entry points this PR newly made depend onemail.sh. The mutation-testing claim ("hook-unwired, dedup-disabled, record-before-send each caught") is a process assertion I can't check from the diff, but the corresponding assertions do exist.Independent review by the harness on
claude-opus-5(effort: high). The human reviewer is requested once Igor has reviewed; a human still merges.