feat(review-pr): add rate-anomaly safeguard for review requests#13
Conversation
Harden the comment and event triggered PR review pipeline against abuse on every trigger path (review_requested, /review, @docker-agent mention, automatic opened/synchronize, and the workflow_run fork path). - Org membership: verify the PR author on the direct pull_request auto-review path (previously an empty comment author was checked) via a new resolveUsername helper with a live PR-author lookup fallback; fail closed when no user resolves. - Audit log: new src/audit-log module emits one structured record per request (requester, time, trigger, PR, reviewed SHA, allow/deny/throttle decision) as a notice, a job-summary line, and a 90 day JSONL artifact, on allow and deny paths, with an inline fallback when setup-credentials is unavailable. - Rate anomaly: new src/rate-limit module skips the review when too many agent review/reply comments land on a PR within a window; per-PR concurrency groups collapse same-trigger bursts; the existing cache lock still prevents concurrent reviews. - Force push: new src/check-staleness module compares the requested SHA against current head, records the actually reviewed SHA, and posts a notice on rebase. Documented in SECURITY.md and review-pr/README.md. Unit tests added for all new modules.
CodeQL (actions/untrusted-checkout-toctou/critical) flagged the new "Audit review request" run-step because it executed after "Checkout PR head" checks out untrusted PR code in an issue_comment-privileged workflow. The audit decision is fully determined before checkout (membership, rate, staleness), so move the audit and audit-upload steps ahead of the checkout. No post-checkout run-step is introduced by this change; behavior and recorded fields are unchanged.
docker-agent
left a comment
There was a problem hiding this comment.
Assessment: 🟢 APPROVE
The PR introduces three new modules (src/audit-log, src/rate-limit, src/check-staleness) and wires them into the review workflow with a clear security-first design. The changes are well-structured and the logic holds up under scrutiny.
What was reviewed:
src/audit-log/index.ts— JSONL artifact, job summary, audit record emission: no data-loss paths in the CLI call chainsrc/rate-limit/index.ts— comment counting withsince/updated_atvs.created_at: the explicit client-sidecreated_atfilter correctly compensates for GitHub'supdated_at-basedsinceparameter; no under-count scenario is possible sinceupdated_at ≥ created_atalways holds on GitHubsrc/check-staleness/index.ts— SHA comparison and stale-post logic: correctsrc/check-org-membership/index.ts—resolveUsernamefallback on non-event paths: the PR-author fallback ontrigger/inputpaths is intentional design; both paths are gated by trusted callers who control the PR number, so there is no bypass.github/workflows/review-pr.yml— rate check ordering, concurrency groups,needs:dependencies: correctly ordered; rate check precedes check creation as documented- The fail-open behavior for the rate-limit step and the audit decision logging are consistent with the documented design
No confirmed or likely bugs were found in the changed code.
> [!IMPORTANT] > **Merge #16 first.** This PR documents the requester-authorized review flow for external and fork contributor PRs, which only works once #16 (`feat(review-pr): authorize review_requested via the requester for external contributors`) lands. Merging this ahead of #16 would describe a flow that does not yet function and would briefly contradict the on-main defense-in-depth text that #16 updates. ## Summary Documents the UX for org members handling external and fork contributor PRs across three docs. The flow, with no special commands or workflow inputs: 1. An org member approves the workflow run (GitHub gates Actions on PRs from first-time and external contributors). 2. An org member requests a review from `docker-agent` via the native review-request UI (PR sidebar, Reviewers). | File | Change | | --- | --- | | `review-pr/README.md` | New `External and fork contributor PRs` section with the full two-step flow | | `CONTRIBUTING.md` | New `Automated PR Review` section (org members vs external and fork contributors) | | `README.md` | One-line pointer and link in the PR Review Workflow section | ## Alignment with open PRs | PR | Relationship | | --- | --- | | #16 | Source of the documented behavior. The review is authorized by the requesting org member, not the PR author. This PR's wording matches that model. | | #13 | Compatible. The "only triage or write access can request a reviewer" phrasing matches #13's GitHub-native enforcement framing. | ## Conflict avoidance Changes are additive. The new section in `review-pr/README.md` is inserted before `Customizing`, outside the regions #16 and #13 edit (the "What you get" trigger table and the defense-in-depth blockquote), so it merges cleanly against both. ## Validation | Check | Result | | --- | --- | | Merges cleanly against #16 and #13 (additive, non-overlapping regions) | yes | | Em-dash, double-hyphen, or emoji in added prose | none | | Cross-file anchor links resolve (`#external-and-fork-contributor-prs`) | yes | | Markdown lists, links, and backticks valid | yes |
Reconcile the abuse safeguards with the requester-based authorization rework that landed on main (PRs #16, #17, #18). check-org-membership: take main's evaluateMembership model. It subsumes this branch's resolveUsername helper (the directly wired pull_request auto-review path now verifies the PR author via a live API lookup) and adds the review_requested requester path so an org member can pull an external contributor's PR into review. review-pr.yml: provide EVENT_NAME, EVENT_ACTION and REQUESTER to the membership step; drop the PR_AUTHOR env that main's code never reads. tsup.config.ts: keep both the rate-limit (this branch) and score-confidence (main) entry points. review-pr/README.md: keep main's requester-model wording and preserve this branch's audit, throttle and force-push safeguards. SECURITY.md: update section 1 to the requester-based model and remove the stale resolveUsername and PR_AUTHOR references.
derekmisler
left a comment
There was a problem hiding this comment.
i'm sorry, i forgot to hit "submit" yesterday. some of these comments may be out-of-date now, though
| # Rate-anomaly safeguard: serialize same-trigger bursts on a PR (e.g. rapid | ||
| # force-pushes firing repeated auto-reviews) instead of running N in parallel. | ||
| # The group is keyed per PR AND per trigger intent: the comment id (or event | ||
| # name) suffix keeps distinct comments/replies in distinct groups, so a quick | ||
| # conversational reply is never queued behind a 45-minute review. cancel-in- | ||
| # progress is false so an in-flight review/reply is never killed mid-post. | ||
| # Per-PR request *frequency* is enforced by the rate-limit check below, and the | ||
| # in-action cache lock (review-pr/action.yml) prevents concurrent reviews; the | ||
| # workflow_run/fork path (PR number only in the artifact) falls back to a per-run | ||
| # group, where those two mechanisms still bound abuse. | ||
| concurrency: | ||
| group: pr-review-${{ github.event.pull_request.number || github.event.issue.number || inputs.pr-number || github.run_id }}-${{ github.event.comment.id || github.event_name }} | ||
| cancel-in-progress: false |
There was a problem hiding this comment.
This actually already exists, but doesn't use GH's concurrency, and only 1 review-per-PR can run within a window of time: https://github.com/docker/docker-agent-action/blob/main/review-pr/action.yml#L142-L146
There was a problem hiding this comment.
Correct that a per-PR collapse already exists via the cache lock. The new concurrency: group is additive rather than equivalent: it is keyed per (PR + trigger intent), queues instead of skipping, applies before a runner spins up, and has no race window (the lock has a documented one). The trigger workflow also uses cancel-in-progress to drop superseded context-capture runs. If relying on the lock plus the rate-limit check is preferred, the group can be dropped; it is left in place for now as a cheap, race-free pre-resource layer.
There was a problem hiding this comment.
makes sense, thanks for the explanation!
| * check-staleness — detect when a PR was force-pushed / rebased between the | ||
| * moment a review was requested and the moment it actually runs. |
There was a problem hiding this comment.
i'm not totally sure if this is an actual problem, because i think we want the reviewer to review the whole PR. realistically, this change would lead to this user experience:
- user requests review
- review starts
- user pushes new commit
- review runs against everything before that commit
- reviewer posts stale review
- user re-requests review to include the last commit
so we're kind of forcing a user to run extra reviews. i think it's fine if the user pushed a commit after triggering a review, and the reviewer includes it in the reviewed diff
There was a problem hiding this comment.
eventually, what's more important, is that we're running reviews only against commits that were pushed after the last review, so we're no re-reviewing the same diff over and over again.
There was a problem hiding this comment.
Removed in 7f5e3c3. Reviews check out refs/pull/N/head, so a commit pushed after the request is already part of the reviewed diff; the stale-pre-push concern did not apply, and the module only emitted a notice plus one audit field. The incremental-review goal (review only commits pushed since the last review) is separate work and is tracked as a follow-up.
| * rate-limit — detect (and let the workflow prevent) abnormally frequent review | ||
| * activity on a single pull request. | ||
| * | ||
| * The existing per-PR cache lock (review-pr/action.yml) only stops *concurrent* |
There was a problem hiding this comment.
that's not totally true, there's a window of time where concurrent reviews are prevented. we can increase that window of time easily enough (people found it annoying, though).
There was a problem hiding this comment.
this is an interesting idea! i don't think storing them in GH is the right approach, though (i can't remember if artifacts have 90-day or 7-day time limits). this feels like something we'd want to store somewhere we can easily query. we should exclude this idea from this PR and talk about it more in depth for a later PR.
There was a problem hiding this comment.
Removed from this PR in 7f5e3c3 and deferred to a follow-up, so the storage backend (a queryable store rather than Actions artifacts) can be designed separately. The src/audit-log module, its two workflow steps, the tsup entry, and the SECURITY.md / README sections are gone. Nothing in the merge-gating path depended on it.
There was a problem hiding this comment.
we should talk through this one some more, because i think i'm missing something. we just want to allow docker org members to request reviews from docker-agent, regardless of the PR author, right? so we don't actually have to check docker org membership status of the PR author (probably ever; we might be able to remove the current implementation where we prevent reviews completely if the PR author is not a member of the docker org).
auto-reviews already can't run unless the author isn't a member of the docker org (i think security already prevents all CI checks, including this one, from running for outside contributors).
also, since /review is deprecated, i don't think we need to support that specific comment flow anymore. what i think is more important is sanitizing comments like we do with the diff, so don't have to check org membership status at all and the agent can reply to anyone
There was a problem hiding this comment.
This PR makes no change to src/check-org-membership: the merge of main superseded the earlier author-verification commit with the evaluateMembership rewrite from #16 / #18, so the merged diff does not touch this file. The points raised are good follow-ups, tracked separately: retire the deprecated /review branch, and sanitize @mention comments instead of gating replies on org membership. One nuance for that discussion: on the automatic pull_request path the PR-author org check is currently the sole gate (the native "only triage/write can request a reviewer" control only covers the review_requested path), so dropping it warrants confirming the auto-review threat model first.
Per review feedback, defer the audit log to a follow-up (GitHub artifacts are a weak audit store; the storage backend should be designed separately) and remove the force-push staleness check (reviews already run against refs/pull/N/head, so the latest commit is included; incremental review is the real future want and is tracked separately). Keeps the rate-anomaly safeguard (rate-limit module + per-PR concurrency groups); no merge-gating step depended on the removed modules.
|
Follow-up on the review. Scope narrowed and the branch brought up to date with main (clean merge, no conflicts). Changes in response to the review:
|
docker-agent
left a comment
There was a problem hiding this comment.
Assessment: 🟡 NEEDS ATTENTION
The rate-anomaly safeguard is well-structured overall: the sliding-window logic is correct, the fail-open behavior is properly realized, and the workflow correctly consumes the anomalous output. Two issues found in the new code:
- [MEDIUM]
src/rate-limit/index.ts— Full PR Reviews (/pulls/{pr}/reviewsendpoint) are not counted; only issue comments and inline diff-thread comments are queried. Since the bot posts reviews via the Reviews API, the rate counter stays at 0 regardless of review frequency. - [LOW]
.github/workflows/review-pr.yml— The rate check is gated onis_review != 'false', leaving thereply-to-mentionjob (a separate job) with no throttle. May be intentional scope deferral, but worth an explicit comment if so.
| since, | ||
| per_page: 100, | ||
| }); | ||
| const reviewComments: CommentLike[] = await octokit.paginate( |
There was a problem hiding this comment.
[MEDIUM] detectRateAnomaly() skips /pulls/{pr}/reviews — full PR reviews are not counted toward the rate limit
The new detectRateAnomaly() function queries two endpoints:
issues.listComments— issue-level (top-level PR) commentspulls.listReviewComments— inline diff-thread comments
It does not query pulls.listReviews (/pulls/{pr}/reviews), which is the endpoint for full PR Review objects — the kind that carry the <!-- docker-agent-review --> marker in their body field. This is how the docker-agent-action bot posts its code reviews (a single Review object with inline comments attached).
As a result, every full review the bot posts goes uncounted. An org member can request 20 reviews in a 10-minute window, the rate counter stays at 0, and the anomaly threshold is never reached.
Fix: add a third paginated fetch for PR Reviews and merge the results before counting:
const prReviews: CommentLike[] = await octokit.paginate(octokit.rest.pulls.listReviews, {
owner: opts.owner,
repo: opts.repo,
pull_number: opts.prNumber,
per_page: 100,
});
const count = [...issueComments, ...reviewComments, ...prReviews].filter((c) =>
isAgentReviewComment(c, opts.botLogin, windowStartMs),
).length;Note: Octokit's CommentLike interface already fits the shape of Review objects (they have user, body, and submitted_at). You may need to remap submitted_at → created_at or extend CommentLike to accept either field.
| # a burst above the threshold is flagged and the expensive review is skipped. | ||
| # Runs BEFORE "Create check run" so a throttled request creates no check run | ||
| # (a skipped review must not surface as a green "PR Review" check). | ||
| - name: Check rate anomaly |
There was a problem hiding this comment.
[LOW] Check rate anomaly is scoped to review requests only — @docker-agent mention floods are unthrottled
The Check rate anomaly step has:
if: |
steps.membership.outputs.is_member == 'true' &&
steps.command.outputs.is_review != 'false' &&
steps.draft.outputs.skip != 'true'When an @docker-agent mention arrives (not a /review command), steps.command.outputs.is_review is 'false', so the rate check is skipped. The reply-to-mention job (line ~785) is a separate job with no rate-limit integration at all.
This means an org member can flood @docker-agent mentions on a PR and drive up LLM inference costs with no throttle. The PR title scopes itself to "review requests", so this may be an intentional deferral — if so, a comment in the workflow noting the known gap would be helpful. If unintentional, the reply-to-mention job should also consult dist/rate-limit.js with its own window/threshold (likely a looser one, since mention replies are cheaper than full reviews).
The rate-anomaly safeguard only queried issues.listComments and pulls.listReviewComments, but the bot posts every review through the Reviews API with no inline marker on the body. Zero-finding APPROVEs and the timeout/error/LGTM fallbacks therefore counted as 0, and a findings review counted as N (one per inline comment) instead of 1, so the counter measured the wrong unit. Count one unit per LLM run instead: - Reviews are counted from pulls.listReviews by bot author with a non-empty body. The non-empty-body check skips the empty-body review entries GitHub creates for standalone inline comments and replies, so a review run is counted exactly once and an inline reply is not double-counted. - Replies are counted from the comment endpoints on the -reply markers only; the -review finding markers are dropped because that review is already counted via the Reviews API. - Bot author matching now accepts both "docker-agent" and "docker-agent[bot]". Document the deliberate scope of the gate: it guards the review run only. The conversational reply jobs (reply-to-feedback, reply-to-mention) stay unthrottled (org-gated, per-PR serialized, and trigger-bound to a human comment), though their replies still feed the window count. Update SECURITY.md and the reusable-workflow README to match.
docker-agent
left a comment
There was a problem hiding this comment.
Assessment: 🟢 APPROVE
The rate-anomaly safeguard is well-designed and correctly implements a fail-open safety model. The core sliding-window logic in detectRateAnomaly is sound, the bot-identity matching covers both docker-agent and docker-agent[bot] token variants, pagination is used on all three API endpoints, and the <!-- docker-agent-review --> marker is correctly excluded from REPLY_MARKERS to avoid double-counting inline finding comments as replies.
Fail-open correctness confirmed: All error paths — missing token, invalid repo slug, and uncaught exceptions — call core.setOutput('anomalous', 'false') before returning, ensuring API failures never block a legitimate review. The workflow's steps.rate.outputs.anomalous != 'true' gate is correctly a negative check so an empty/unset output also fails open.
Minor observation (no action required): The two early-return guards in main() (lines ~195–205 of src/rate-limit/index.ts) set anomalous and count but omit window and threshold outputs. The workflow only reads anomalous, so there is no functional impact — these outputs would just appear empty in the Actions log on those rarely-hit paths. Worth a follow-up if richer diagnostics are desired.
Concurrency groups: The per-PR concurrency group on review-pr.yml correctly serialises same-PR bursts. The acknowledged github.run_id fallback for the workflow_run/fork path is an acceptable known limitation.
No bugs introduced by this PR. Ship it.
derekmisler
left a comment
There was a problem hiding this comment.
solid feature overall. the fail-open design, marker-based counting, and the distinction between review runs and reply runs are all the right calls. two real issues in the exported detectRateAnomaly (missing input guards that main() handles but the library export doesn't) plus a handful of non-blocking suggestions, mostly around the listReviews pagination and test coverage for main().
| // `since` filters the comment endpoints server-side by updated_at; the | ||
| // per-comment created_at check below enforces the precise window. paginate() | ||
| // handles busy PRs. | ||
| const issueComments: CommentLike[] = await octokit.paginate(octokit.rest.issues.listComments, { |
There was a problem hiding this comment.
suggestion (non-blocking): these three octokit.paginate calls are awaited sequentially, so on a busy PR you're waiting for each one to finish before starting the next. since they're independent, Promise.all gets them running in parallel and cuts wall-clock time by ~2/3:
const [issueComments, reviewComments, reviews] = await Promise.all([
octokit.paginate(octokit.rest.issues.listComments, { owner, repo, issue_number: prNumber, since, per_page: 100 }),
octokit.paginate(octokit.rest.pulls.listReviewComments, { owner, repo, pull_number: prNumber, since, per_page: 100 }),
octokit.paginate(octokit.rest.pulls.listReviews, { owner, repo, pull_number: prNumber, per_page: 100 }),
]);not blocking given continue-on-error: true, but worth picking up.
| // submitted_at below. This is the only endpoint where a review run is | ||
| // observable: the bot posts every review (findings, zero-finding APPROVE, and | ||
| // the timeout/error/LGTM fallbacks) here with no inline marker on the body. | ||
| const reviews: ReviewLike[] = await octokit.paginate(octokit.rest.pulls.listReviews, { |
There was a problem hiding this comment.
suggestion (non-blocking): listComments and listReviewComments accept since so they're server-side filtered to the window. listReviews has no since parameter (you noted this in the comment), so octokit.paginate fetches every review ever posted on the PR. on a long-lived, heavily-reviewed PR that could be hundreds of pages of API calls, which risks exhausting the job timer and falling through to the fail-open path, i.e. the rate safeguard silently disappears on the exact PRs most likely to need it.
a cleaner approach: use octokit.paginate.iterator and break once you've passed the window boundary. GitHub returns reviews oldest-first, so once you see items older than windowStartMs you can stop (or just cap at a reasonable max like 500 reviews before bailing out):
const reviews: ReviewLike[] = [];
for await (const page of octokit.paginate.iterator(octokit.rest.pulls.listReviews, {
owner: opts.owner, repo: opts.repo, pull_number: opts.prNumber, per_page: 100,
})) {
reviews.push(...page.data);
// all items on this page are older than the window: no point fetching more
if (page.data.every(r => !r.submitted_at || Date.parse(r.submitted_at) < windowStartMs)) break;
}(double-check the sort order in the GitHub docs before assuming direction, but it is oldest-first)
| ): Promise<RateAnomalyResult> { | ||
| const octokit = new Octokit({ auth: token }); | ||
| const now = opts.nowMs ?? Date.now(); | ||
| const windowStartMs = now - opts.windowSeconds * 1000; |
There was a problem hiding this comment.
issue: detectRateAnomaly is an exported function, so it can be called as a library without going through main(). the CLI path is safe because parsePositiveInt rejects NaN, 0, and negatives. but the exported function has no guard, so if opts.windowSeconds is NaN, Infinity, or a negative number:
windowStartMs = now - NaN * 1000 // NaN
new Date(NaN).toISOString() // throws: RangeError: Invalid time value
that propagates to the caller uncaught. a simple guard at the top of detectRateAnomaly covers it:
if (!Number.isFinite(opts.windowSeconds) || opts.windowSeconds <= 0) {
throw new RangeError(`windowSeconds must be a positive finite number, got ${opts.windowSeconds}`);
}
if (opts.threshold <= 0) {
throw new RangeError(`threshold must be a positive integer, got ${opts.threshold}`);
}the threshold <= 0 case is also worth guarding here (see the comment on the anomalous line below): count >= 0 is always true, so threshold: 0 permanently throttles everything.
|
|
||
| return { | ||
| count, | ||
| anomalous: count >= opts.threshold, |
There was a problem hiding this comment.
issue: count >= opts.threshold, when threshold is 0, 0 >= 0 is true, so every call is flagged anomalous and all reviews are blocked. main() protects against this via parsePositiveInt (which rejects 0 and falls back to 8), but any direct caller of detectRateAnomaly with threshold: 0 (misconfiguration, bad test setup, etc.) will silently permanently throttle the bot.
the guard I mentioned on line 127 covers this too, so it's one small addition in one place.
| // Fail open: a missing token or unparseable PR number must not block reviews. | ||
| if (!token || !Number.isInteger(prNumber) || prNumber <= 0) { | ||
| core.warning('rate-limit: missing token or PR number — skipping rate check (fail-open)'); | ||
| core.setOutput('anomalous', 'false'); |
There was a problem hiding this comment.
suggestion (non-blocking): the jsdoc at the top of the file documents four outputs: anomalous, count, window, threshold. but all three early-exit / fail-open paths (here, the invalid-repo check ~10 lines down, and the catch block) only emit anomalous and count. window and threshold are left as empty strings.
today's callers only read anomalous so nothing breaks. but it's inconsistent with your own spec, and anyone who tries to read steps.rate.outputs.threshold in a debug step or a future consumer will get an empty string and be confused.
easy fix: set all four in every exit path. you have windowSeconds and threshold already parsed from env vars at this point, so it's just:
core.setOutput('window', String(windowSeconds));
core.setOutput('threshold', String(threshold));| @@ -0,0 +1,212 @@ | |||
| // Copyright The Docker Agent Action authors | |||
There was a problem hiding this comment.
suggestion (non-blocking): the coverage of detectRateAnomaly here is really thorough, good work on that. one gap though: main() (the actual CLI entry point) has zero test coverage. that means all of this is untested:
- env-var name correctness (
RATE_MAX_REQUESTSvsRATE_THRESHOLD, etc.) parsePositiveIntfallback behavior- all three
setOutputpaths (success / missing-token / invalid-repo / api-error) - the
core.warning/core.infolog messages - the early-return fail-open conditions
a mistake in any of these ships silently. vi.stubEnv makes it pretty painless:
describe('main()', () => {
it('fails open and emits anomalous=false when token is missing', async () => {
vi.stubEnv('GITHUB_TOKEN', '');
vi.stubEnv('GH_TOKEN', '');
vi.stubEnv('RATE_PR_NUMBER', '5');
// call main(), assert core.setOutput called with ('anomalous', 'false')
});
it('emits all four outputs on a successful run', async () => {
vi.stubEnv('GITHUB_TOKEN', 'tok');
vi.stubEnv('GITHUB_REPOSITORY', 'docker/repo');
vi.stubEnv('RATE_PR_NUMBER', '5');
// mock detectRateAnomaly, assert window + threshold outputs are set
});
});There was a problem hiding this comment.
quick follow-up: CI caught two issues in the new commit.
unit test failure (emits all four outputs on a successful anomalous run): the test is hitting a fail-open path instead of the success path, outputting anomalous=false and count=0. the most likely cause is that vi.clearAllMocks() in beforeEach is clearing the mockImplementation set by routePaginate from a previous test run, or the CI runner environment has GITHUB_REPOSITORY set to docker/docker-agent-action (which is a valid owner/repo string, so slashIdx >= 0) and routePaginate is returning the wrong dataset for that repo path. worth running pnpm test locally with --reporter=verbose to see exactly which fail-open branch is firing. also try adding vi.resetAllMocks() in beforeEach (instead of clearAllMocks) to make sure mock implementations don't leak between tests.
lint error (src/rate-limit/index.ts:171): biome flags the non-null assertion (r as ReviewLike).submitted_at!. since the check on line 170 already guards !(r as ReviewLike).submitted_at, the ! on line 171 is redundant. replace it with ?? '' or restructure slightly:
const submittedAt = (r as ReviewLike).submitted_at;
!(submittedAt) || Date.parse(submittedAt) < windowStartMs,lint error (src/rate-limit/__tests__/rate-limit.test.ts): biome wants the chained .rejects.toThrow(RangeError) calls split across multiple lines. run pnpm biome format --write src/rate-limit/__tests__/rate-limit.test.ts and it'll fix the formatting automatically.
| # Distinct review comments stay independent (the comment id keys them into their | ||
| # own group) so a real reply is never dropped by another comment on the same PR. | ||
| concurrency: | ||
| group: pr-review-trigger-${{ github.event.pull_request.number }}-${{ github.event.comment.id || 'review-request' }} |
There was a problem hiding this comment.
nitpick (super non-blocking): if github.event.pull_request.number is somehow absent (low probability, but possible if the event shape changes), the group key collapses to pr-review-trigger--<comment.id>, which could let two different PRs land in the same group. a || github.run_id at the end is a cheap safety net:
group: pr-review-trigger-${{ github.event.pull_request.number || github.run_id }}-${{ github.event.comment.id || 'review-request' }}- add input guards to detectRateAnomaly for windowSeconds and threshold (NaN/Infinity/non-positive values now throw RangeError instead of propagating silently or permanently throttling all reviews) - parallelize the three paginate calls with Promise.all - replace paginate() with paginate.iterator + early break for listReviews to avoid unbounded API calls on heavily-reviewed PRs - emit all four outputs (anomalous, count, window, threshold) in every fail-open path (missing token, invalid repo, API error catch block) - export main() and add test coverage: input guard tests, and six main() tests covering env-var parsing, all fail-open paths, and the four-output invariant - add github.run_id fallback to self-review-pr-trigger concurrency key
The Lint job failed on a forbidden non-null assertion in the listReviews early-stop predicate and on biome formatting of the long RangeError throw. Extract submitted_at into a local so the predicate needs no '!', and let biome reflow the affected lines. The Unit Tests job failed on the main() anomalous-run case: main() reads the clock through Date.now() (no injectable nowMs), so the mocked replies timestamped relative to the fixed NOW fell outside the window against the real clock, yielding count 0. Pin the system clock to NOW with fake timers in the main() describe so the fixtures stay inside the sliding window.
derekmisler
left a comment
There was a problem hiding this comment.
all issues addressed and ci is green. approving!
Summary
Adds a rate-anomaly safeguard against abuse of review requests, layered on the
existing per-PR cache lock.
src/rate-limit: counts docker-agent review/reply comments posted on a PRwithin a sliding window and, past a threshold, flags a rate anomaly. The
workflow then skips the expensive review. Fails open, so an API error never
blocks a legitimate review.
concurrency:groups onreview-pr.ymlandself-review-pr-trigger.ymlcollapse same-trigger bursts; the trigger cancels superseded context-capture
runs so only the latest fans out to a review.
Scope after review
Following review feedback, the PR was narrowed:
weak audit store (configurable/deletable retention, no query/export); the
storage backend should be designed separately.
refs/pull/N/head(current head), so a commit pushed after the request is already included; the
force-push case is therefore covered. Incremental review (only commits since
the last review) is the real future want and is tracked separately.
mainsupersededthe earlier author-verification commit with the
evaluateMembershiprewritefrom feat(review-pr): authorize review_requested via the requester for external contributors #16/docs: explain org-member review flow for external contributor PRs #18, so the merged diff makes no change to that module.
Validation
Follow-ups (out of scope here)
/reviewcomment branch.@mentioncomments (like the diff) instead of gating on org membership.