diff --git a/.github/actionlint.yaml b/.github/actionlint.yaml new file mode 100644 index 000000000..31bb34185 --- /dev/null +++ b/.github/actionlint.yaml @@ -0,0 +1,23 @@ +# 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. + +paths: + .github/workflows/assign.yaml: + ignore: + # actionlint <= 1.7.12 does not know the documented concurrency.queue + # key (rhysd/actionlint#654) https://github.com/rhysd/actionlint/pull/654. + # Drop this once the pinned release does. + - 'unexpected key "queue" for "concurrency" section' diff --git a/.github/workflows/assign.yaml b/.github/workflows/assign.yaml index ceed14c5d..36e8490e0 100644 --- a/.github/workflows/assign.yaml +++ b/.github/workflows/assign.yaml @@ -24,6 +24,12 @@ # user at a time. /unassign only ever removes the commenter and is refused # when they are not currently assigned, so nobody can drop another's claim. # +# Work-in-progress limit (CONTRIBUTING.md#work-in-progress-limit): /assign is +# refused when the target already has MAX_ASSIGNED_ISSUES open issues assigned +# in this repo. Issues whose type is in EXEMPT_ISSUE_TYPES (Epic, Initiative) +# neither count toward the limit nor are blocked by it, since their sub-issues +# are claimed and counted individually. +# # issue_comment always runs in the base-repo context with the base-repo # token, so there is no fork/pwn-request exposure here: the job only needs # `issues: write` and never checks out untrusted code. GitHub's addAssignees @@ -40,9 +46,12 @@ on: permissions: contents: read -concurrency: - group: ${{ github.workflow }}-${{ github.event.issue.number }} - cancel-in-progress: false +env: + # Keep in step with the limit stated in CONTRIBUTING.md and .claude/CLAUDE.md. + MAX_ASSIGNED_ISSUES: 3 + # Comma-separated issue type names that neither count toward the limit nor + # are blocked by it. + EXEMPT_ISSUE_TYPES: Epic,Initiative jobs: @@ -53,6 +62,15 @@ jobs: !github.event.issue.pull_request && (startsWith(github.event.comment.body, '/assign') || startsWith(github.event.comment.body, '/unassign')) + # One repo-wide queue, not one per issue: the limit check and the + # assignment must not interleave with an /assign on another issue, or two + # concurrent claims could both pass the count. Job-level so only command + # comments queue here, and queue: max because the default keeps a single + # pending run and cancels the one it replaces, which would drop a command. + concurrency: + group: ${{ github.workflow }} + cancel-in-progress: false + queue: max runs-on: ubuntu-latest permissions: # Needed to update issue assignees and post status comments back. @@ -144,14 +162,77 @@ jobs: } const targets = [target]; + const exemptTypes = (process.env.EXEMPT_ISSUE_TYPES || '') + .split(',') + .map(t => t.trim()) + .filter(Boolean); + if (!exemptTypes.includes(freshIssue.type?.name)) { + const rawLimit = (process.env.MAX_ASSIGNED_ISSUES || '').trim(); + const limit = Number(rawLimit); + if (!/^[1-9][0-9]*$/.test(rawLimit) || !Number.isSafeInteger(limit)) { + core.setFailed(`MAX_ASSIGNED_ISSUES must be a positive integer, got: ${process.env.MAX_ASSIGNED_ISSUES}`); + return; + } + // The assignee filter answers 422 for an unknown login and reads + // the literal "none" as "unassigned". 422 also covers other + // failures, so skip the count only once a user lookup confirms the + // login does not exist: addAssignees below already reports a user + // it cannot assign. Any other failure aborts rather than count zero. + // Matching each item's assignees against the target + // (case-insensitively, like the filter) neutralizes "none". + // listForRepo returns pull requests too; only issues count. + let open = []; + try { + open = await github.paginate(github.rest.issues.listForRepo, { + owner: context.repo.owner, + repo: context.repo.repo, + assignee: target, + state: 'open', + per_page: 100, + }); + } catch (err) { + if (err.status !== 422) throw err; + const userExists = await github.rest.users.getByUsername({ username: target }) + .then(() => true, lookupErr => { + if (lookupErr.status !== 404) throw lookupErr; + return false; + }); + if (userExists) throw err; + core.info(`${target} is not a GitHub user; skipping the limit check`); + } + const targetLower = target.toLowerCase(); + const assigned = open.filter(i => + !i.pull_request && + !exemptTypes.includes(i.type?.name) && + (i.assignees || []).some(a => a.login.toLowerCase() === targetLower)); + if (assigned.length >= limit) { + core.info(`${target} has ${assigned.length} assigned issues (limit ${limit}); refusing`); + await github.rest.issues.createComment({ + ...common, + body: [ + `@${target} already has ${assigned.length} open issues assigned in this repository, and the work-in-progress limit is ${limit}:`, + '', + ...assigned.map(i => `- #${i.number}`), + '', + 'Get one of them merged or closed, or comment `/unassign` on one to release it, then try again. ' + + (exemptTypes.length ? `Issues typed ${exemptTypes.join(' or ')} do not count. ` : '') + + `See [CONTRIBUTING.md](${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/blob/HEAD/CONTRIBUTING.md#work-in-progress-limit).`, + ].join('\n'), + }); + return; + } + } + const { data: issue } = await github.rest.issues.addAssignees({ ...common, assignees: targets, }); // GitHub drops non-assignable users without erroring, so surface them. - const got = new Set(issue.assignees.map(a => a.login)); - const skipped = targets.filter(u => !got.has(u)); + // Logins are case-insensitive, and the response carries the + // canonical casing, which may differ from what was typed. + const got = new Set(issue.assignees.map(a => a.login.toLowerCase())); + const skipped = targets.filter(u => !got.has(u.toLowerCase())); if (skipped.length) { core.warning(`Could not assign: ${skipped.join(', ')}`); await github.rest.issues.createComment({ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a2c896ee5..6c6e35fcb 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -40,7 +40,7 @@ Claim an issue by commenting on it, and a bot will handle the assignment: - `/assign @user`: assign one other person (only if the issue is unassigned). - `/unassign`: release your claim on the issue. -We use a single-owner model: an issue is assigned to at most one person. `/assign` is refused while the issue already has an assignee, and `/unassign` only ever removes your own claim, so nobody can drop someone else's. GitHub only lets you assign the commenter/self, someone who has commented on the issue, a user with write access, or an org member with read access; if a requested user cannot be assigned, the bot replies to say so. +We use a single-owner model: an issue is assigned to at most one person. `/assign` is refused while the issue already has an assignee, and `/unassign` only ever removes your own claim, so nobody can drop someone else's. GitHub only lets you assign the commenter/self, someone who has commented on the issue, a user with write access, or an org member with read access; if a requested user cannot be assigned, the bot replies to say so. `/assign` is also refused when the person being assigned is already at the [work-in-progress limit](#work-in-progress-limit) for issues, and the bot lists the issues they hold. Claiming an Epic or Initiative is never refused, since those do not count toward the limit. Maintainers with write access can also assign themselves from the issue sidebar. The rule is the same either way: assigned first, then work. @@ -50,7 +50,7 @@ If you stop working on an issue, `/unassign` so someone else can pick it up. Each contributor may have at most **three open pull requests** and at most **three assigned issues** at a time. This applies to every contributor, maintainers included. Draft pull requests count toward the limit; pull requests opened by Renovate and Dependabot do not. Issues whose type is Epic or Initiative do not count either: they track a body of work across many pull requests, so whoever owns one claims its sub-issues individually and those count. -At the limit, get something merged or closed, or release an issue with `/unassign`, before you take on more. Pull requests opened past the limit may be closed, and issues claimed past it may be unassigned. A few changes that someone is actively driving land sooner than a long queue that neither the author nor the reviewers can keep up with, and a short queue keeps the changes that matter easy to find. +At the limit, get something merged or closed, or release an issue with `/unassign`, before you take on more. Pull requests opened past the limit may be closed. The `/assign` bot will not assign an issue past the limit, except an Epic or Initiative, and an issue assigned past it from the sidebar may be unassigned. A few changes that someone is actively driving land sooner than a long queue that neither the author nor the reviewers can keep up with, and a short queue keeps the changes that matter easy to find. ## Pull Requests