Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions .github/actionlint.yaml
Original file line number Diff line number Diff line change
@@ -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'
91 changes: 86 additions & 5 deletions .github/workflows/assign.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:

Expand All @@ -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.
Expand Down Expand Up @@ -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,
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.
} 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({
Expand Down
4 changes: 2 additions & 2 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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

Expand Down
Loading