-
Notifications
You must be signed in to change notification settings - Fork 8
feat(review-pr): add rate-anomaly safeguard for review requests #13
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 8 commits
5836af5
b69a05c
ab4a2a6
633b8e9
7f5e3c3
8b63e17
8080ae7
c6bbbd3
9576ee0
bf2774f
731b522
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -80,6 +80,20 @@ permissions: | |
| id-token: write | ||
| actions: read # download-artifact across workflow_run boundary | ||
|
|
||
| # 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 | ||
|
|
||
| jobs: | ||
| resolve-context: | ||
| if: inputs.trigger-run-id != '' | ||
|
|
@@ -315,10 +329,30 @@ jobs: | |
| REQUESTER: ${{ github.event.sender.login }} | ||
| run: node "$DOCKER_AGENT_ACTION_ROOT/dist/check-org-membership.js" | ||
|
|
||
| # Rate-anomaly safeguard: count how many docker-agent review/reply comments | ||
| # were posted on this PR in the recent window. An authorized account can | ||
| # still drive the bot at high frequency (each request costs an LLM run), so | ||
| # 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 | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [LOW] The if: |
steps.membership.outputs.is_member == 'true' &&
steps.command.outputs.is_review != 'false' &&
steps.draft.outputs.skip != 'true'When an This means an org member can flood |
||
| id: rate | ||
| if: | | ||
| steps.membership.outputs.is_member == 'true' && | ||
| steps.command.outputs.is_review != 'false' && | ||
| steps.draft.outputs.skip != 'true' | ||
| continue-on-error: true # fail-open: a rate-check error must not block reviews | ||
| shell: bash | ||
| env: | ||
| GITHUB_TOKEN: ${{ env.GITHUB_APP_TOKEN || github.token }} | ||
| RATE_PR_NUMBER: ${{ steps.pr.outputs.number }} | ||
| run: node "$DOCKER_AGENT_ACTION_ROOT/dist/rate-limit.js" | ||
|
|
||
| - name: Create check run | ||
| if: | | ||
| (steps.pr.outputs.source == 'event' || steps.pr.outputs.source == 'trigger') && | ||
| steps.membership.outputs.is_member == 'true' | ||
| steps.membership.outputs.is_member == 'true' && | ||
| steps.rate.outputs.anomalous != 'true' | ||
| id: create-check | ||
| continue-on-error: true | ||
| uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 | ||
|
|
@@ -349,7 +383,8 @@ jobs: | |
| if: | | ||
| steps.membership.outputs.is_member == 'true' && | ||
| steps.command.outputs.is_review != 'false' && | ||
| steps.draft.outputs.skip != 'true' | ||
| steps.draft.outputs.skip != 'true' && | ||
| steps.rate.outputs.anomalous != 'true' | ||
| uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 | ||
| with: | ||
| fetch-depth: 0 | ||
|
|
@@ -359,7 +394,8 @@ jobs: | |
| if: | | ||
| steps.membership.outputs.is_member == 'true' && | ||
| steps.command.outputs.is_review != 'false' && | ||
| steps.draft.outputs.skip != 'true' | ||
| steps.draft.outputs.skip != 'true' && | ||
| steps.rate.outputs.anomalous != 'true' | ||
| id: run-review | ||
| continue-on-error: true | ||
| uses: docker/docker-agent-action/review-pr@e96a4bb40cac114f64358621e1d08346c8eadc8c # v2.0.1 | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -8,6 +8,15 @@ on: | |
| pull_request_review_comment: | ||
| types: [ created ] | ||
|
|
||
| # Rate-anomaly safeguard: collapse a burst of review_requested events on the same | ||
| # PR at the source — re-requesting a review is redundant, so cancel-in-progress | ||
| # drops superseded context-capture runs and only the latest fans out to a review. | ||
| # 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' }} | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. nitpick (super non-blocking): if group: pr-review-trigger-${{ github.event.pull_request.number || github.run_id }}-${{ github.event.comment.id || 'review-request' }} |
||
| cancel-in-progress: true | ||
|
|
||
| permissions: {} | ||
|
|
||
| jobs: | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,130 @@ | ||
| // Copyright The Docker Agent Action authors | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. suggestion (non-blocking): the coverage of
a mistake in any of these ships silently. 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
});
});
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. quick follow-up: CI caught two issues in the new commit. unit test failure ( lint error ( const submittedAt = (r as ReviewLike).submitted_at;
!(submittedAt) || Date.parse(submittedAt) < windowStartMs,lint error ( |
||
| // SPDX-License-Identifier: Apache-2.0 | ||
|
|
||
| import { beforeEach, describe, expect, it, vi } from 'vitest'; | ||
|
|
||
| vi.mock('@actions/core'); | ||
|
|
||
| const { mockPaginate, mockListComments, mockListReviewComments, MockOctokit } = vi.hoisted(() => { | ||
| const mockListComments = { endpoint: 'issues.listComments' }; | ||
| const mockListReviewComments = { endpoint: 'pulls.listReviewComments' }; | ||
| const mockPaginate = vi.fn(); | ||
|
|
||
| class MockOctokit { | ||
| paginate = mockPaginate; | ||
| rest = { | ||
| issues: { listComments: mockListComments }, | ||
| pulls: { listReviewComments: mockListReviewComments }, | ||
| }; | ||
| } | ||
| return { mockPaginate, mockListComments, mockListReviewComments, MockOctokit }; | ||
| }); | ||
|
|
||
| vi.mock('@octokit/rest', () => ({ Octokit: MockOctokit })); | ||
|
|
||
| import { detectRateAnomaly } from '../index.js'; | ||
|
|
||
| const NOW = Date.parse('2026-06-24T10:10:00.000Z'); | ||
| const within = (secAgo: number) => new Date(NOW - secAgo * 1000).toISOString(); | ||
|
|
||
| const BOT = 'docker-agent'; | ||
| const REVIEW_MARKER = '<!-- docker-agent-review -->'; | ||
| const REPLY_MARKER = '<!-- docker-agent-review-reply -->'; | ||
|
|
||
| function agentComment(secAgo: number, marker = REVIEW_MARKER) { | ||
| return { user: { login: BOT }, body: `Review body ${marker}`, created_at: within(secAgo) }; | ||
| } | ||
|
|
||
| // Route paginate() to the right dataset based on which endpoint it was given. | ||
| function routePaginate(issue: unknown[], review: unknown[]) { | ||
| mockPaginate.mockImplementation((endpoint: unknown) => { | ||
| if (endpoint === mockListReviewComments) return Promise.resolve(review); | ||
| return Promise.resolve(issue); | ||
| }); | ||
| } | ||
|
|
||
| beforeEach(() => { | ||
| vi.clearAllMocks(); | ||
| }); | ||
|
|
||
| describe('detectRateAnomaly', () => { | ||
| const base = { | ||
| owner: 'docker', | ||
| repo: 'repo', | ||
| prNumber: 5, | ||
| windowSeconds: 600, | ||
| threshold: 3, | ||
| botLogin: BOT, | ||
| nowMs: NOW, | ||
| }; | ||
|
|
||
| it('counts agent review + reply comments within the window across both comment types', async () => { | ||
| routePaginate( | ||
| [agentComment(60), agentComment(120, REPLY_MARKER)], | ||
| [agentComment(30), agentComment(90)], | ||
| ); | ||
|
|
||
| const r = await detectRateAnomaly('tok', base); | ||
|
|
||
| expect(r.count).toBe(4); | ||
| expect(r.anomalous).toBe(true); | ||
| expect(r.threshold).toBe(3); | ||
| }); | ||
|
|
||
| it('is not anomalous below the threshold', async () => { | ||
| routePaginate([agentComment(60)], [agentComment(30)]); | ||
| const r = await detectRateAnomaly('tok', base); | ||
| expect(r.count).toBe(2); | ||
| expect(r.anomalous).toBe(false); | ||
| }); | ||
|
|
||
| it('ignores comments outside the window (created before windowStart)', async () => { | ||
| routePaginate( | ||
| [agentComment(60), agentComment(2000 /* 33min ago, outside 600s */)], | ||
| [agentComment(30)], | ||
| ); | ||
| const r = await detectRateAnomaly('tok', base); | ||
| expect(r.count).toBe(2); | ||
| }); | ||
|
|
||
| it('ignores comments from other users', async () => { | ||
| routePaginate( | ||
| [{ user: { login: 'mallory' }, body: `spam ${REVIEW_MARKER}`, created_at: within(10) }], | ||
| [], | ||
| ); | ||
| const r = await detectRateAnomaly('tok', base); | ||
| expect(r.count).toBe(0); | ||
| expect(r.anomalous).toBe(false); | ||
| }); | ||
|
|
||
| it('ignores agent comments that lack a review marker (e.g. ordinary chatter)', async () => { | ||
| routePaginate( | ||
| [{ user: { login: BOT }, body: 'just a plain comment', created_at: within(10) }], | ||
| [], | ||
| ); | ||
| const r = await detectRateAnomaly('tok', base); | ||
| expect(r.count).toBe(0); | ||
| }); | ||
|
|
||
| it('counts legacy cagent markers during the migration window', async () => { | ||
| routePaginate( | ||
| [{ user: { login: BOT }, body: 'old <!-- cagent-review -->', created_at: within(10) }], | ||
| [], | ||
| ); | ||
| const r = await detectRateAnomaly('tok', { ...base, threshold: 1 }); | ||
| expect(r.count).toBe(1); | ||
| expect(r.anomalous).toBe(true); | ||
| }); | ||
|
|
||
| it('passes a since timestamp derived from the window to the API', async () => { | ||
| routePaginate([], []); | ||
| await detectRateAnomaly('tok', base); | ||
| const issueCall = mockPaginate.mock.calls.find((c) => c[0] === mockListComments); | ||
| expect(issueCall?.[1]).toMatchObject({ | ||
| owner: 'docker', | ||
| repo: 'repo', | ||
| issue_number: 5, | ||
| since: new Date(NOW - 600 * 1000).toISOString(), | ||
| }); | ||
| }); | ||
| }); | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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-L146There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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 usescancel-in-progressto 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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
makes sense, thanks for the explanation!