diff --git a/.github/scripts/pr_rate_limit.test.js b/.github/scripts/pr_rate_limit.test.js new file mode 100644 index 000000000000..40723db79480 --- /dev/null +++ b/.github/scripts/pr_rate_limit.test.js @@ -0,0 +1,283 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const { test } = require('node:test'); +const workflow = fs.readFileSync(`${__dirname}/../workflows/pr-rate-limit.yml`, 'utf8'); +function extractScript(source) { + const parts = source.split(' script: |\n'); + assert.equal(parts.length, 2, 'Expected exactly one inline script block'); + const lines = parts[1].split('\n'); + const end = lines.findIndex((line) => line.trim() && !line.startsWith(' ')); + return lines.slice(0, end < 0 ? lines.length : end) + .map((line) => line.replace(/^ {12}/, '')).join('\n'); +} +const script = extractScript(workflow); +const execute = new (Object.getPrototypeOf(async function () {}).constructor)( + 'github', 'context', 'core', 'process', 'Date', script, +); +const now = Date.parse('2026-09-09T12:00:00Z'); +const hour = 3600000; +function pr(number, overrides = {}) { + return { number, created_at: new Date(now - hour + number * 1000).toISOString(), + state: 'open', labels: [], user: { id: 42, login: 'new-user', type: 'User' }, + pull_request: {}, ...overrides }; +} +async function run(options = {}) { + const current = options.current || pr(6); + const writes = options.writes || []; + const calls = []; + const warnings = []; + const github = { rest: { + pulls: { + get: async ({ pull_number }) => { + calls.push(pull_number); + if (options.readError) throw new Error('API failure'); + return { data: pull_number === current.number + ? (calls.filter((n) => n === current.number).length > 1 && options.recheck || current) + : { ...pr(pull_number), merged_at: options.merged ? '2026-09-08T00:00:00Z' : null } }; + }, + update: async (args) => { + if (options.closeError) throw new Error('close failed'); + writes.push({ kind: 'close', ...args }); + }, + }, + repos: { getCollaboratorPermissionLevel: async () => { + if (options.permissionError) throw Object.assign(new Error('permission failed'), { status: options.permissionStatus }); + return { data: { permission: options.permission || 'read', user: { permissions: { push: options.push } } } }; + } }, + issues: { + listForRepo: async (args) => { + assert.equal(args.creator, current.user.login); + assert.ok(['all', 'open'].includes(args.state)); + if (options.historyError || args.page === options.failedPage) throw new Error('history failed'); + if (args.state === 'open' && options.openHistoryError) throw new Error('open history failed'); + if (args.state === 'open') return { data: args.page === 1 + ? options.openHistory || (options.history || Array.from({ length: 6 }, (_, i) => pr(i + 1))) + .filter((item) => item.state === 'open') : [] }; + return { data: options.pages ? (options.pages[args.page - 1] || []) + : options.history || Array.from({ length: 6 }, (_, i) => pr(i + 1)) }; + }, + listComments: () => {}, + createComment: async (args) => { + if (options.commentError) throw new Error('comment failed'); + writes.push({ kind: 'comment', ...args }); + }, + }, + }, paginate: async () => { + if (options.commentsError) throw new Error('comments failed'); + return options.comments || []; + } }; + const context = { repo: { owner: 'NVIDIA', repo: 'TensorRT-LLM' }, payload: options.payload || { pull_request: current } }; + await execute(github, context, { info: () => {}, warning: (message) => warnings.push(message) }, { env: options.env || {} }, + class extends Date { static now() { return now; } }); + return { writes, calls, warnings }; +} +test('first five pass; sixth receives explanation before closure', async () => { + for (let n = 1; n <= 5; n++) assert.deepEqual((await run({ current: pr(n), history: Array.from({ length: n }, (_, i) => pr(i + 1)) })).writes, []); + const { writes } = await run(); + assert.deepEqual(writes.map((w) => w.kind), ['comment', 'close']); + assert.match(writes[0].body, /currently have 6 open PRs/); + assert.match(writes[0].body, /no merged PRs/); + assert.match(writes[0].body, /wait until your existing PRs are reviewed or merged/); + assert.doesNotMatch(writes[0].body, /24|cooldown|consolidate|rolling/); + assert.equal(writes[1].state, 'closed'); +}); +test('counts drafts but excludes closed PRs, ordinary issues and another author', async () => { + const excluded = [pr(-2, { state: 'closed' }), pr(0, { pull_request: undefined }), pr(-1, { user: { id: 99 } })]; + assert.deepEqual((await run({ history: [...Array.from({ length: 4 }, (_, i) => pr(i + 1, { draft: true })), ...excluded] })).writes, []); + const { writes } = await run({ history: [...Array.from({ length: 5 }, (_, i) => pr(i + 1, { draft: true })), ...excluded] }); + assert.deepEqual(writes.map((w) => w.kind), ['comment', 'close']); + assert.match(writes[0].body, /currently have 6 open PRs/); +}); + + +test('history pagination and duplicate entries do not change quota', async () => { + const first = Array.from({ length: 100 }, () => pr(1)); + const { writes } = await run({ pages: [first, [pr(2), pr(3), pr(4), pr(5)]] }); + assert.equal(writes.length, 2); + assert.match(writes[0].body, /currently have 6 open PRs/); +}); +test('actual merged history raises the cap; author association alone does not', async () => { + const history = Array.from({ length: 6 }, (_, i) => pr(i + 1)); + history.push(pr(0, { state: 'closed', pull_request: { merged_at: '2026-09-08T00:00:00Z' } })); + const result = await run({ history }); + assert.deepEqual(result.writes, []); + assert.deepEqual(result.calls, [6]); + assert.equal((await run({ current: pr(6, { author_association: 'CONTRIBUTOR' }) })).writes.length, 2); +}); +test('merged contributors may keep ten open PRs but the eleventh closes, including drafts', async () => { + const merged = pr(0, { state: 'closed', pull_request: { merged_at: '2026-09-08T00:00:00Z' } }); + for (let n = 6; n <= 10; n++) { + const history = [merged, ...Array.from({ length: n }, (_, i) => pr(i + 1))]; + assert.deepEqual((await run({ current: pr(n), history })).writes, []); + } + const history = [merged, ...Array.from({ length: 10 }, (_, i) => pr(i + 1))]; + const current = pr(11, { draft: true }); + const { writes, calls } = await run({ current, history }); + assert.deepEqual(writes.map((w) => w.kind), ['comment', 'close']); + assert.match(writes[0].body, /11 open PRs, including this one \(limit: 10\)/); + assert.match(writes[0].body, /Contributors with merged PRs/); + assert.match(writes[0].body, /fewer than 10 of your other PRs/); + assert.ok(calls.every((number) => number === 11)); + assert.deepEqual((await run({ current, history, openHistory: history.slice(1, 10) })).writes, []); + assert.deepEqual((await run({ current, history, env: { DRY_RUN: 'true' } })).writes, []); +}); +test('the established-contributor cap is configurable and invalid values skip safely', async () => { + const history = [pr(0, { state: 'closed', pull_request: { merged_at: '2026-09-08T00:00:00Z' } }), + ...Array.from({ length: 7 }, (_, i) => pr(i + 1))]; + const { writes } = await run({ current: pr(7), history, env: { MAX_OPEN_ESTABLISHED: '6' } }); + assert.match(writes[0].body, /limit: 6/); + for (const value of ['0', '-1', '1.5', 'bad', 'Infinity', '9007199254740992']) { + const result = await run({ env: { MAX_OPEN_ESTABLISHED: value } }); + assert.deepEqual(result.calls, []); + assert.deepEqual(result.writes, []); + assert.equal(result.warnings.length, 1); + } +}); +test('maintainers, bots, allowlisted users and labeled PRs are exempt', async () => { + for (const permission of ['write', 'admin', 'maintain']) + assert.deepEqual((await run({ permission })).writes, []); + assert.deepEqual((await run({ env: { EXEMPT_USERS: 'other, NEW-USER ' } })).writes, []); + assert.deepEqual((await run({ push: true })).writes, []); + assert.deepEqual((await run({ current: pr(6, { user: { id: 42, login: 'bot', type: 'Bot' } }) })).writes, []); + assert.deepEqual((await run({ current: pr(6, { labels: [{ name: 'pr-rate-limit-exempt' }] }) })).writes, []); +}); +test('dry run, closed PRs, and exemptions added during evaluation make no writes', async () => { + assert.deepEqual((await run({ env: { DRY_RUN: 'true' } })).writes, []); + assert.deepEqual((await run({ current: pr(6, { state: 'closed' }) })).writes, []); + assert.deepEqual((await run({ recheck: pr(6, { labels: [{ name: 'pr-rate-limit-exempt' }] }) })).writes, []); + assert.deepEqual((await run({ recheck: pr(6, { state: 'closed' }) })).writes, []); +}); + +test('reruns reuse only bot-authored comments and retry closure', async () => { + const body = ''; + const trusted = { user: { login: 'github-actions[bot]' }, body }; + assert.deepEqual((await run({ comments: [trusted] })).writes.map((w) => w.kind), ['close']); + assert.equal((await run({ comments: [{ user: { login: 'new-user' }, body }] })).writes.length, 2); +}); +test('unexpected API failures abort without moderation', async () => { + for (const options of [{ readError: true }, { historyError: true }, { commentError: true }, + { permissionError: true }, { commentsError: true }, { openHistoryError: true }, + { pages: [Array.from({ length: 100 }, () => pr(1))], failedPage: 2 }]) { + const writes = []; + await assert.rejects(run({ ...options, writes })); + assert.deepEqual(writes, []); + } +}); +test('successful comment survives a close failure and is not duplicated on retry', async () => { + const writes = []; + await assert.rejects(run({ closeError: true, writes }), /close failed/); + assert.deepEqual(writes.map((w) => w.kind), ['comment']); + const comments = [{ user: { login: 'github-actions[bot]' }, body: writes[0].body }]; + assert.deepEqual((await run({ comments })).writes.map((w) => w.kind), ['close']); +}); +test('workflow uses trusted inline code, minimal permissions and a shared moderation queue', () => { + assert.match(workflow, /pull_request_target:/); + assert.match(workflow, /types: \[opened, reopened, ready_for_review\]/); + assert.match(workflow, /pull-requests: write/); + assert.match(workflow, /issues: read/); + assert.match(workflow, /concurrency:\n group: pr-rate-limit\n cancel-in-progress: false\n queue: max/); + assert.doesNotMatch(workflow, /actions\/checkout|head.sha|secrets\./); +}); + +test('manual recovery evaluates the requested PR and rejects invalid input before API calls', async () => { + const { writes, calls } = await run({ payload: { inputs: { pr_number: '6' } } }); + assert.deepEqual(writes.map((w) => w.kind), ['comment', 'close']); + assert.ok(calls.every((number) => number === 6)); + for (const pr_number of ['', '0', '-1', '1.5', '06', ' 6', '6x', '9007199254740992']) { + await assert.rejects(run({ payload: { inputs: { pr_number } }, readError: true }), /positive integer PR number/); + } + await assert.rejects(run({ payload: {}, readError: true }), /positive integer PR number/); +}); + +test('manual recovery preserves dry run, exemptions, capacity and comment reconciliation', async () => { + const payload = { inputs: { pr_number: '6' } }; + assert.deepEqual((await run({ payload, env: { DRY_RUN: 'true' } })).writes, []); + assert.deepEqual((await run({ payload, permission: 'write' })).writes, []); + assert.deepEqual((await run({ payload, current: pr(6, { created_at: new Date(now - 24 * hour).toISOString() }), history: [] })).writes, []); + const comments = [{ user: { login: 'github-actions[bot]' }, body: '' }]; + assert.deepEqual((await run({ payload, comments })).writes.map((w) => w.kind), ['close']); +}); + +test('privileged action is pinned and manual runs are restricted to the default branch', () => { + assert.match(workflow, /uses: actions\/github-script@[0-9a-f]{40} # v8/); + assert.match(workflow, /workflow_dispatch:/); + assert.match(workflow, /github.event_name != 'workflow_dispatch' \|\|/); + assert.ok(workflow.includes("github.ref == format('refs/heads/{0}', github.event.repository.default_branch)")); +}); + +test('open-PR limit prevents reopening an old backlog', async () => { + const current = pr(6, { created_at: new Date(now - 48 * hour).toISOString() }); + const history = Array.from({ length: 5 }, (_, i) => pr(i + 1, { draft: true })); + const { writes } = await run({ current, history }); + assert.deepEqual(writes.map((w) => w.kind), ['comment', 'close']); + assert.match(writes[0].body, /currently have 6 open PRs/); + assert.match(writes[0].body, /ask a maintainer to reopen this PR when fewer than 5 of your other PRs are open/); + assert.doesNotMatch(writes[0].body, /cooldown ends/); + assert.deepEqual((await run({ current, history: history.slice(0, 4) })).writes, []); +}); + +test('old reopened PR is allowed when capacity becomes available during evaluation', async () => { + const current = pr(6, { created_at: new Date(now - 48 * hour).toISOString() }); + assert.deepEqual((await run({ current, openHistory: [pr(1), pr(2)] })).writes, []); +}); + +test('open-PR limit applies even when recent submissions are below five', async () => { + const history = Array.from({ length: 5 }, (_, i) => pr(i + 1, { created_at: new Date(now - 48 * hour).toISOString() })); + const { writes } = await run({ history }); + assert.match(writes[0].body, /currently have 6 open PRs/); + assert.doesNotMatch(writes[0].body, /cooldown ends/); +}); + +test('a reopened backlog of old PRs stays subject to the open cap during manual recovery', async () => { + const history = Array.from({ length: 100 }, (_, i) => pr(i + 1, { created_at: new Date(now - 48 * hour).toISOString() })); + const current = history[99]; + const { writes } = await run({ current, pages: [history, []], openHistory: history, payload: { inputs: { pr_number: '100' } } }); + assert.match(writes[0].body, /currently have 100 open PRs/); + assert.equal(writes[1].pull_number, 100); +}); + +test('closed submissions never consume capacity, regardless of timestamps', async () => { + const history = Array.from({ length: 30 }, (_, i) => pr(i + 1, { state: 'closed', created_at: 'invalid' })); + assert.deepEqual((await run({ history })).writes, []); +}); +test('permission denials and incomplete history warn and skip', async () => { + for (const options of [ + { permissionError: true, permissionStatus: 403 }, + { permissionError: true, permissionStatus: 404 }, + { pages: Array.from({ length: 20 }, () => Array.from({ length: 100 }, () => pr(1))) }, + ]) { + const result = await run(options); + assert.deepEqual(result.writes, []); + assert.equal(result.warnings.length, 1); + } +}); +test('configured open cap is enforced and invalid configuration skips all API calls', async () => { + assert.deepEqual((await run({ env: { MAX_OPEN: '6' } })).writes, []); + const { writes } = await run({ env: { MAX_OPEN: '3' } }); + assert.match(writes[0].body, /limit: 3/); + for (const value of ['0', '-1', '1.5', 'bad', 'Infinity', '9007199254740992']) { + const result = await run({ env: { MAX_OPEN: value } }); + assert.deepEqual(result.calls, []); + assert.deepEqual(result.writes, []); + assert.equal(result.warnings.length, 1); + } +}); +test('script extraction stops at a subsequent step and rejects ambiguous blocks', () => { + assert.equal(extractScript(workflow + '\n - run: echo later\n'), script); + assert.throws(() => extractScript(''), /exactly one/); + assert.throws(() => extractScript(workflow + ' script: |\n'), /exactly one/); +}); diff --git a/.github/workflows/pr-rate-limit.yml b/.github/workflows/pr-rate-limit.yml new file mode 100644 index 000000000000..4e81a74c9d16 --- /dev/null +++ b/.github/workflows/pr-rate-limit.yml @@ -0,0 +1,160 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +name: Contributor Open PR Limit + +on: + pull_request_target: + types: [opened, reopened, ready_for_review] + workflow_dispatch: + inputs: + pr_number: + description: PR number to reevaluate after a failed moderation run + required: true + type: string + +permissions: + contents: read + pull-requests: write + issues: read + +# Serialize all moderation, including manual recovery, so separate PRs cannot +# close against the same capacity snapshot. queue: max retains up to 100 pending +# runs; monitor canceled runs and manually reevaluate affected PRs on overflow. +concurrency: + group: pr-rate-limit + cancel-in-progress: false + queue: max + +jobs: + rate-limit: + if: >- + github.repository == 'NVIDIA/TensorRT-LLM' && + (github.event_name != 'workflow_dispatch' || + github.ref == format('refs/heads/{0}', github.event.repository.default_branch)) + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + # No checkout or execution of PR code in this privileged workflow. + # Administrators can set PR_RATE_LIMIT_DRY_RUN=true to disable writes. + # PR_RATE_LIMIT_EXEMPT_USERS is a comma-separated list of trusted logins. + - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + env: + DRY_RUN: ${{ vars.PR_RATE_LIMIT_DRY_RUN }} + EXEMPT_USERS: ${{ vars.PR_RATE_LIMIT_EXEMPT_USERS }} + MAX_OPEN: ${{ vars.PR_RATE_LIMIT_MAX_OPEN }} + MAX_OPEN_ESTABLISHED: ${{ vars.PR_RATE_LIMIT_MAX_OPEN_ESTABLISHED }} + with: + script: | + const NEW_LIMIT = Number(process.env.MAX_OPEN?.trim() || '5'); + const ESTABLISHED_LIMIT = Number(process.env.MAX_OPEN_ESTABLISHED?.trim() || '10'); + if (![NEW_LIMIT, ESTABLISHED_LIMIT].every((limit) => Number.isSafeInteger(limit) && limit >= 1)) { + core.warning('Invalid contributor open-PR cap configuration; no moderation performed.'); + return; + } + const EXEMPT_LABEL = 'pr-rate-limit-exempt'; + const MARKER = ''; + const { owner, repo } = context.repo; + const rawNumber = String(context.payload.pull_request?.number ?? context.payload.inputs?.pr_number ?? ''); + const pull_number = Number(rawNumber); + if (!/^[1-9][0-9]*$/.test(rawNumber) || !Number.isSafeInteger(pull_number)) { + throw new Error('A positive integer PR number is required; no moderation performed.'); + } + const getPull = async (number) => (await github.rest.pulls.get({ + owner, repo, pull_number: number, + })).data; + const pull = await getPull(pull_number); + // The open-PR cap applies regardless of creation time or reopening age. + if (pull.state !== 'open') return; + const login = pull.user.login; + const exemptUsers = (process.env.EXEMPT_USERS || '').split(',') + .map((value) => value.trim().toLowerCase()).filter(Boolean); + const hasExemptLabel = (pr) => pr.labels.some((label) => label.name === EXEMPT_LABEL); + if (pull.user.type === 'Bot' || exemptUsers.includes(login.toLowerCase()) || hasExemptLabel(pull)) return; + let permission; + try { + ({ data: permission } = await github.rest.repos.getCollaboratorPermissionLevel({ + owner, repo, username: login, + })); + } catch (error) { + if (![403, 404].includes(error.status)) throw error; + core.warning('Cannot determine contributor permissions (' + error.status + '); no moderation performed.'); + return; + } + if (permission.user?.permissions?.push || ['admin', 'write', 'maintain'].includes(permission.permission)) return; + + // The creator-filtered issues API includes PRs in every state, without + // relying on the eventually consistent search index. Complete the listing + // before making any changes. Excessive history or API failures abort safely. + const readHistory = async (state) => { + const items = new Map(); + for (let page = 1; page <= 20; page += 1) { + const { data } = await github.rest.issues.listForRepo({ + owner, repo, creator: login, state, sort: 'created', + direction: 'desc', per_page: 100, page, + }); + for (const item of data) { + if (item.pull_request && item.user.id === pull.user.id) items.set(item.number, item); + } + if (data.length < 100) return items; + } + core.warning('Contributor history could not be completed within 20 pages; no moderation performed.'); + return null; + }; + const history = await readHistory('all'); + if (!history) return; + // Include the triggering PR even if its listing has not caught up yet. + history.set(pull.number, pull); + let openCount = [...history.values()].filter((item) => item.state === 'open').length; + // A closed PR is not necessarily a contribution: require an actual merge. + // Check history directly rather than trusting author_association or a + // search result that may not yet include a recent merge. + const hasMergedPR = [...history.values()].some((item) => item.pull_request?.merged_at); + const OPEN_LIMIT = hasMergedPR ? ESTABLISHED_LIMIT : NEW_LIMIT; + if (openCount <= OPEN_LIMIT) return; + const comments = await github.paginate(github.rest.issues.listComments, { + owner, repo, issue_number: pull_number, per_page: 100, + }); + // Refresh capacity before writing: another PR may have closed meanwhile. + const openHistory = await readHistory('open'); + if (!openHistory) return; + const current = await getPull(pull_number); + if (current.state !== 'open' || hasExemptLabel(current)) return; + openHistory.set(current.number, current); + openCount = [...openHistory.values()].filter((item) => item.state === 'open').length; + if (openCount <= OPEN_LIMIT) return; + const reason = 'you currently have ' + openCount + + ' open PRs, including this one (limit: ' + OPEN_LIMIT + ')'; + const body = [ + 'Closing this PR because ' + reason + '.', + 'Contributors ' + (hasMergedPR ? 'with merged PRs' : 'with no merged PRs') + + ' in this repository may keep up to ' + OPEN_LIMIT + ' PRs open, including drafts.', + 'Please wait until your existing PRs are reviewed or merged before opening more. ' + + 'Reviewed PRs still count while open. ' + + 'You may ask a maintainer to reopen this PR when fewer than ' + OPEN_LIMIT + ' of your other PRs are open, ' + + 'or ask a maintainer for an exception.', + MARKER, + ].join('\n\n'); + core.info('PR #' + pull_number + ': ' + reason); + if (process.env.DRY_RUN === 'true') { + core.info('Dry run: would comment and close.'); + return; + } + if (!comments.some((comment) => comment.user?.login === 'github-actions[bot]' && comment.body?.includes(MARKER))) { + // Do not blindly retry a comment write: a timeout may follow a successful + // write. A rerun reconciles against the bot-authored marker above. + await github.rest.issues.createComment({ owner, repo, issue_number: pull_number, body }); + } + await github.rest.pulls.update({ owner, repo, pull_number, state: 'closed' }); diff --git a/.github/workflows/precommit-check.yml b/.github/workflows/precommit-check.yml index 3e68e6df598d..17165b6247db 100644 --- a/.github/workflows/precommit-check.yml +++ b/.github/workflows/precommit-check.yml @@ -36,6 +36,9 @@ jobs: - name: Test stale pull request cleanup workflow run: node .github/scripts/cleanup_stale_prs.test.js + - name: Test contributor PR rate limit workflow + run: node --test .github/scripts/pr_rate_limit.test.js + - uses: actions/setup-python@v6 with: python-version: '3.12' diff --git a/AGENTS.md b/AGENTS.md index abc5d75a6bca..449a5ed2270f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -167,6 +167,25 @@ See [CI overview](docs/source/developer-guide/ci-overview.md) for full details. ### Triggering CI +The contributor open-PR caps are implemented in `.github/workflows/pr-rate-limit.yml`; +see `CONTRIBUTING.md` for the default five/ten-open-PR caps based on merge history and exemptions. Its mocked API tests +run with `node --test .github/scripts/pr_rate_limit.test.js` and are registered in Release Checks. + +Administrators can set `PR_RATE_LIMIT_MAX_OPEN` (default five) and +`PR_RATE_LIMIT_MAX_OPEN_ESTABLISHED` (default ten) to positive integers, and exempt trusted +logins through the comma-separated `PR_RATE_LIMIT_EXEMPT_USERS` repository variable. +Set `PR_RATE_LIMIT_DRY_RUN=true` to log decisions without commenting or closing. +Incomplete history, invalid caps and permission-check 403/404 responses warn and skip moderation; +other API failures fail the job. Independent CI jobs are not gated by this workflow. + +All moderation runs share a serial queue, including default-branch manual dispatch. +The queue holds up to 100 pending runs; overflow cancels additional runs. Monitor warnings, +failures and canceled **Contributor Open PR Limit** runs in Actions. After resolving a problem, +rerun the job or choose **Run workflow** on the default branch and enter the affected PR number. +Recovery uses the same exemptions, dry-run setting and cap. It reuses the original bot comment, +whose count reflects the first evaluation. Recovery is manual; there is no scheduled sweep or +instantaneous-cap guarantee while runs are queued or GitHub is unavailable. + CI is triggered by posting comments on the PR. Basic commands: - `/bot run` — trigger the standard CI pipeline - `/bot run --disable-fail-fast` — run all stages even if earlier ones fail (only add when explicitly needed) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 866b13438f08..120d43727cf9 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -118,6 +118,22 @@ For NVIDIA developers, please submit feature or bug fixes to the dedicated bran Meanwhile, please add the "release blocker" label to any PRs that could potentially cause a release delay. +### Open pull request limits + +Contributors with no merged PRs in this repository may keep up to five PRs open at a time. +Contributors with at least one merged PR may keep up to ten open PRs. Both caps include drafts; +a first merge raises the cap instead of removing it. +Please wait until your existing PRs are reviewed or merged before opening more. +Reviewed PRs still count while open; closing or merging a PR frees capacity. +When enforcement is enabled, excess PRs are automatically closed after creation or reopening. +While dry-run mode is enabled, the workflow only logs decisions and does not close PRs. +Closed PRs do not consume capacity, and there is no submission cooldown. +Ask a maintainer to reopen a PR when the number of other open PRs is below your applicable cap. GitHub may prevent authors +from reopening PRs closed by the bot. + +Users with write access and bot accounts are exempt. Maintainers can apply `pr-rate-limit-exempt` +to an individual PR before reopening it, or arrange a trusted-contributor exception. + ### Inactive pull requests TensorRT LLM automatically reviews inactive pull requests each day. This policy applies to both draft and