diff --git a/src/components/bulk/bulk-actions-bar.tsx b/src/components/bulk/bulk-actions-bar.tsx index 12c57af..e54d507 100644 --- a/src/components/bulk/bulk-actions-bar.tsx +++ b/src/components/bulk/bulk-actions-bar.tsx @@ -18,6 +18,7 @@ import { BulkPinModal } from './bulk-pin-modal' import { BulkUnpinModal } from './bulk-unpin-modal' import { BulkRenameModal } from './bulk-rename-modal' import { BulkMoveModal, type ReorderOp } from './bulk-move-modal' +import { BulkRandomAssignModal } from './bulk-random-assign-modal' import { ArrowRightIcon, CircleSlashIcon, @@ -27,6 +28,7 @@ import { LockIcon, MoveIcon, PencilIcon, + PersonIcon, PinIcon, SyncIcon, TrashIcon, @@ -123,8 +125,9 @@ export function BulkActionsBar({ projectId, owner, isOrg, number, getFields }: P const [showTransferModal, setShowTransferModal] = useState(false) const [showRenameModal, setShowRenameModal] = useState(false) const [showMoveModal, setShowMoveModal] = useState(false) + const [showRandomAssignModal, setShowRandomAssignModal] = useState(false) const anyModalOpen = showCloseModal || showOpenModal || showDeleteModal || - showLockModal || showPinModal || showUnpinModal || showTransferModal || showDupModal || showRenameModal || showMoveModal + showLockModal || showPinModal || showUnpinModal || showTransferModal || showDupModal || showRenameModal || showMoveModal || showRandomAssignModal // Selection subscription useEffect(() => { @@ -149,6 +152,7 @@ export function BulkActionsBar({ projectId, owner, isOrg, number, getFields }: P setShowTransferModal(false) setShowRenameModal(false) setShowMoveModal(false) + setShowRandomAssignModal(false) } }) }, []) @@ -191,6 +195,7 @@ export function BulkActionsBar({ projectId, owner, isOrg, number, getFields }: P setShowTransferModal(false) setShowRenameModal(false) setShowMoveModal(false) + setShowRandomAssignModal(false) } else if (step === 'CLOSED' && selectionStore.count() > 0) { selectionStore.clear() } @@ -224,6 +229,7 @@ export function BulkActionsBar({ projectId, owner, isOrg, number, getFields }: P const bulkShortcutsRef = useRef void) | undefined>>({}) bulkShortcutsRef.current = { 'Shift+KeyE': () => { setMenuOpen(false); handleFieldSelectionOpen() }, + 'Shift+KeyA': handleRandomAssign, 'Shift+KeyX': handleBulkClose, 'Shift+KeyO': handleBulkOpen, 'Shift+KeyL': handleLock, @@ -365,6 +371,40 @@ export function BulkActionsBar({ projectId, owner, isOrg, number, getFields }: P } } + async function handleRandomAssign() { + setMenuOpen(false) + if (!await checkToken()) return + setShowRandomAssignModal(true) + } + + async function handleConfirmRandomAssign(assignments: Map, strategy: import('./bulk-random-assign-utils').DistributionStrategy) { + const itemIds = selectionStore.getAll() + setShowRandomAssignModal(false) + + const assignmentsArray: Array<{ itemId: string; assigneeIds: string[] }> = [] + const itemToAssignees = new Map() + + for (const [assigneeId, assignedItemIds] of assignments.entries()) { + for (const itemId of assignedItemIds) { + const existing = itemToAssignees.get(itemId) || [] + itemToAssignees.set(itemId, [...existing, assigneeId]) + } + } + + for (const [itemId, assigneeIds] of itemToAssignees.entries()) { + assignmentsArray.push({ itemId, assigneeIds }) + } + + sendMessage('bulkRandomAssign', { + itemIds, + projectId: projectData?.id || projectId, + assignments: assignmentsArray, + strategy, + }) + + selectionStore.clear() + } + async function handleBulkClose() { setMenuOpen(false) if (!await checkToken()) return @@ -621,6 +661,19 @@ export function BulkActionsBar({ projectId, owner, isOrg, number, getFields }: P /> )} + {/* Random Assign modal */} + {showRandomAssignModal && ( + setShowRandomAssignModal(false)} + onConfirm={handleConfirmRandomAssign} + /> + )} + {/* ── Persistent bottom bar ── */} + + + + + + + Random Assign (beta) + + + {shortcut('A')} + + + {count === 1 && ( checkToken().then(ok => ok && setShowDupModal(true))}> diff --git a/src/components/bulk/bulk-random-assign-modal.tsx b/src/components/bulk/bulk-random-assign-modal.tsx new file mode 100644 index 0000000..d6705d9 --- /dev/null +++ b/src/components/bulk/bulk-random-assign-modal.tsx @@ -0,0 +1,358 @@ +import React, { useState, useEffect, useRef } from 'react' +import { Box, Button, Text, Select, TextInput, Checkbox, Avatar, Flash, Spinner } from '@primer/react' +import { ModalStepHeader } from '../ui/modal-step-header' +import { PersonIcon, SearchIcon } from '../ui/primitives' +import { Z_MODAL } from '../../lib/z-index' +import { sendMessage } from '../../lib/messages' +import { + distributeBalanced, + distributeRandom, + distributeRoundRobin, + type DistributionStrategy +} from './bulk-random-assign-utils' + +type Assignee = { id: string; name: string; avatarUrl?: string } + +type RandomAssignStep = 'ASSIGNEES' | 'PREVIEW' | 'CONFIRM' + +interface Props { + count: number + projectId: string + owner: string + repoName: string + itemIds: string[] + onClose: () => void + onConfirm?: (assignments: Map, strategy: DistributionStrategy) => void +} + +export function BulkRandomAssignModal({ count, onClose, itemIds, onConfirm, owner, repoName }: Props) { + const [step, setStep] = useState('ASSIGNEES') + const [strategy, setStrategy] = useState('balanced') + const [preview, setPreview] = useState>(new Map()) + const [selectedAssignees, setSelectedAssignees] = useState([]) + const [searchQuery, setSearchQuery] = useState('') + const [assignees, setAssignees] = useState([]) + const [isLoadingAssignees, setIsLoadingAssignees] = useState(false) + const latestRequestIdRef = useRef(0) + + useEffect(() => { + if (!repoName) return + setIsLoadingAssignees(true) + const requestId = Date.now() + latestRequestIdRef.current = requestId + const timer = setTimeout(() => { + sendMessage('searchRepoMetadata', { owner, name: repoName, q: searchQuery, type: 'ASSIGNEES' }) + .then(results => { + if (requestId === latestRequestIdRef.current) { + setAssignees(results.map(r => ({ id: r.id, name: r.name, avatarUrl: r.avatarUrl }))) + } + }) + .finally(() => { + if (requestId === latestRequestIdRef.current) { + setIsLoadingAssignees(false) + } + }) + }, searchQuery ? 300 : 0) + return () => clearTimeout(timer) + }, [owner, repoName, searchQuery]) + + const toggleAssignee = (id: string) => { + setSelectedAssignees(prev => + prev.includes(id) ? prev.filter(x => x !== id) : [...prev, id] + ) + } + + const selectAll = () => setSelectedAssignees(assignees.map(a => a.id)) + const deselectAll = () => setSelectedAssignees([]) + + const generatePreview = () => { + const distributionFn = + strategy === 'balanced' + ? distributeBalanced + : strategy === 'random' + ? distributeRandom + : distributeRoundRobin + const result = distributionFn(itemIds, selectedAssignees) + setPreview(result) + } + + useEffect(() => { + if (step === 'PREVIEW') { + generatePreview() + } + }, [step, strategy]) + + const handleNext = () => { + if (step === 'ASSIGNEES') setStep('PREVIEW') + else if (step === 'PREVIEW') setStep('CONFIRM') + } + + const handleBack = () => { + if (step === 'PREVIEW') setStep('ASSIGNEES') + else if (step === 'CONFIRM') setStep('PREVIEW') + } + + const idToLogin = Object.fromEntries(assignees.map(a => [a.id, a.name])) + + const renderStep = () => { + switch (step) { + case 'ASSIGNEES': + return ( + + + setSearchQuery(e.target.value)} + sx={{ width: '100%', maxWidth: '300px' }} + /> + + + + + + + + {isLoadingAssignees && assignees.length === 0 && ( + + + + )} + {!isLoadingAssignees && assignees.length === 0 && ( + + {searchQuery ? `No assignees found matching "${searchQuery}"` : 'No assignees found'} + + )} + {assignees.map((assignee, index) => ( + 0 ? '1px solid' : 'none', + borderColor: 'border.default', + cursor: 'pointer', + '&:hover': { bg: 'canvas.subtle' } + }} + onClick={() => toggleAssignee(assignee.id)} + > + toggleAssignee(assignee.id)} + onClick={(e) => e.stopPropagation()} + aria-label={`Select ${assignee.name}`} + /> + {assignee.avatarUrl + ? + : + } + {assignee.name} + + ))} + + + {selectedAssignees.length < 2 && ( + + Please select at least 2 assignees to distribute items. + + )} + + ) + case 'PREVIEW': + return ( + + + + Strategy: + + + + + + + Distribution Summary + + {Array.from(preview.entries()) + .map(([id, items]) => `${idToLogin[id] ?? id}: ${items.length} items`) + .join(' | ')} + + + + + {Array.from(preview.entries()).map(([id, items], index) => ( + 0 ? '1px solid' : 'none', + borderColor: 'border.default', + display: 'flex', + justifyContent: 'space-between' + }} + > + {idToLogin[id] ?? id} + {items.length} items + + ))} + + + ) + case 'CONFIRM': + return ( + + + Sequential Execution Warning + + To comply with GitHub API rate limits, these {count} assignments will be processed sequentially. + Please keep this window open until the process completes. + + + + + Assignment Summary + + Assigning {count} items to {selectedAssignees.length} assignees using the {strategy} strategy. + + + + {Array.from(preview.entries()).map(([id, items]) => ( + + {idToLogin[id] ?? id} + {items.length} items + + ))} + + + + ) + } + } + + const getStepInfo = () => { + switch (step) { + case 'ASSIGNEES': + return { title: 'Select Assignees', step: 1, subtitle: `Choose users to randomly assign to ${count} items.` } + case 'PREVIEW': + return { title: 'Preview Assignments', step: 2, subtitle: 'Review the random distribution before applying.' } + case 'CONFIRM': + return { title: 'Confirm & Apply', step: 3, subtitle: 'Finalize and execute the bulk assignment.' } + } + } + + const { title, step: stepNum, subtitle } = getStepInfo() + + return ( + { + e.stopPropagation() + if (e.key === 'Escape') onClose() + }} + onKeyUp={(e: React.KeyboardEvent) => e.stopPropagation()} + > + + } + step={stepNum} + totalSteps={3} + onClose={onClose} + onBack={step !== 'ASSIGNEES' ? handleBack : undefined} + /> + + + {renderStep()} + + + + + + + + + ) +} diff --git a/src/components/bulk/bulk-random-assign-utils.ts b/src/components/bulk/bulk-random-assign-utils.ts new file mode 100644 index 0000000..a81c050 --- /dev/null +++ b/src/components/bulk/bulk-random-assign-utils.ts @@ -0,0 +1,93 @@ +/** + * Shuffles an array in place using the Fisher-Yates algorithm. + * @param array - The array to shuffle. + * @returns The shuffled array. + */ +function shuffle(array: T[]): T[] { + const shuffled = [...array]; + for (let i = shuffled.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]]; + } + return shuffled; +} + +/** + * Distributes items to assignees in a balanced way, ensuring each assignee + * gets a similar number of items. + * @param items - The IDs of the items to distribute. + * @param assignees - The IDs of the assignees. + * @returns A map of assignee ID to item IDs. + */ +export function distributeBalanced(items: string[], assignees: string[]): Map { + const distribution = new Map(assignees.map(a => [a, []])); + if (assignees.length === 0 || items.length === 0) return distribution; + + const shuffledItems = shuffle(items); + const counts = new Map(assignees.map(a => [a, 0])); + + for (const item of shuffledItems) { + let minCount = Infinity; + const candidates: string[] = []; + + for (const [assignee, count] of counts.entries()) { + if (count < minCount) { + minCount = count; + candidates.length = 0; + candidates.push(assignee); + } else if (count === minCount) { + candidates.push(assignee); + } + } + + const targetAssignee = candidates[Math.floor(Math.random() * candidates.length)]; + + distribution.get(targetAssignee)!.push(item); + counts.set(targetAssignee, minCount + 1); + } + + return distribution; +} + +/** + * Distributes items to assignees randomly. + * @param items - The IDs of the items to distribute. + * @param assignees - The IDs of the assignees. + * @returns A map of assignee ID to item IDs. + */ +export function distributeRandom(items: string[], assignees: string[]): Map { + const distribution = new Map(assignees.map(a => [a, []])); + if (assignees.length === 0 || items.length === 0) return distribution; + + const shuffledItems = shuffle(items); + + for (const item of shuffledItems) { + const randomIndex = Math.floor(Math.random() * assignees.length); + const assignee = assignees[randomIndex]; + distribution.get(assignee)!.push(item); + } + + return distribution; +} + +/** + * Distributes items to assignees in a round-robin fashion. + * @param items - The IDs of the items to distribute. + * @param assignees - The IDs of the assignees. + * @returns A map of assignee ID to item IDs. + */ +export function distributeRoundRobin(items: string[], assignees: string[]): Map { + const distribution = new Map(assignees.map(a => [a, []])); + if (assignees.length === 0 || items.length === 0) return distribution; + + items.forEach((item, index) => { + const assignee = assignees[index % assignees.length]; + distribution.get(assignee)!.push(item); + }); + + return distribution; +} + +export type DistributionStrategy = 'balanced' | 'random' | 'round-robin'; + +export type DistributionFunction = (items: string[], assignees: string[]) => Map; diff --git a/src/entries/background/index.ts b/src/entries/background/index.ts index 1994d40..05c5c34 100644 --- a/src/entries/background/index.ts +++ b/src/entries/background/index.ts @@ -8,6 +8,7 @@ import { UPDATE_PROJECT_FIELD, ADD_SUB_ISSUE, ADD_ASSIGNEES, + REMOVE_ASSIGNEES, ADD_LABELS, UPDATE_ISSUE_MILESTONE, UPDATE_ISSUE_TYPE, @@ -24,7 +25,7 @@ import { UPDATE_PR_BODY, ADD_COMMENT, } from '../../lib/graphql/mutations' -import { VALIDATE_TOKEN, GET_REPO_ASSIGNEES, GET_REPO_LABELS, GET_REPO_MILESTONES, GET_REPO_ISSUE_TYPES, SEARCH_OWNER_REPOS, GET_VIEWER_TOP_REPOS, GET_VIEWER_REPOS_PAGE, GET_POSSIBLE_TRANSFER_REPOS, GET_PROJECT_ITEMS_FOR_RENAME, GET_PROJECT_ITEMS_FOR_REORDER, UPDATE_PROJECT_ITEM_POSITION } from '../../lib/graphql/queries' +import { VALIDATE_TOKEN, GET_REPO_ASSIGNEES, GET_REPO_LABELS, GET_REPO_MILESTONES, GET_REPO_ISSUE_TYPES, SEARCH_OWNER_REPOS, GET_VIEWER_TOP_REPOS, GET_VIEWER_REPOS_PAGE, GET_POSSIBLE_TRANSFER_REPOS, GET_PROJECT_ITEMS_FOR_RENAME, GET_PROJECT_ITEMS_FOR_REORDER, UPDATE_PROJECT_ITEM_POSITION, GET_ISSUE_ASSIGNEES } from '../../lib/graphql/queries' import { processQueue, cancelQueue, sleep } from '../../lib/queue' import { patStorage, usernameStorage, allSprintSettingsStorage } from '../../lib/storage' import { GET_PROJECT_ITEMS_WITH_FIELDS } from '../../lib/graphql/queries' @@ -1123,6 +1124,77 @@ export default defineBackground(() => { } }) + onMessage('bulkRandomAssign', async ({ data, sender }) => { + logger.log('[rgp:bg] bulkRandomAssign received', { itemCount: data.itemIds.length, strategy: data.strategy }) + + if (activeBulkCount >= MAX_CONCURRENT_BULK) { + console.warn('[rgp:bg] max concurrent bulk reached, rejecting bulkRandomAssign') + return + } + + activeBulkCount++ + const processId = `assign-${Date.now()}-${Math.random().toString(36).slice(2, 7)}` + const label = `Random assign · ${data.itemIds.length} item${data.itemIds.length !== 1 ? 's' : ''}` + const tabId = sender.tab?.id + + try { + await broadcastQueue({ total: data.itemIds.length, completed: 0, paused: false, status: 'Resolving items...', processId, label }, tabId) + const resolvedItems = await resolveProjectItemIds(data.itemIds, data.projectId, tabId) + + if (resolvedItems.length === 0) { + console.error('[rgp:bg] no valid items resolved for bulkRandomAssign, aborting') + await broadcastQueue({ total: 0, completed: 0, paused: false, status: 'No valid items found', processId, label }, tabId) + return + } + + const itemToIssueMap = new Map(resolvedItems.map(r => [r.domId, r.issueNodeId])) + const tasks: import('../../lib/queue').QueueTask[] = [] + + for (const assignment of data.assignments) { + const issueNodeId = itemToIssueMap.get(assignment.itemId) + if (issueNodeId && assignment.assigneeIds.length > 0) { + tasks.push({ + id: `assign-${assignment.itemId}`, + detail: 'Clearing and reassigning…', + run: async () => { + const res = await gql<{ node: { assignees?: { nodes: { id: string }[] } } }>( + GET_ISSUE_ASSIGNEES, { id: issueNodeId } + ) + const currentIds = res.node?.assignees?.nodes?.map(n => n.id) ?? [] + if (currentIds.length > 0) { + await gql(REMOVE_ASSIGNEES, { assignableId: issueNodeId, assigneeIds: currentIds }) + await sleep(1000) + } + await gql(ADD_ASSIGNEES, { + assignableId: issueNodeId, + assigneeIds: assignment.assigneeIds, + }) + await sleep(1000) + }, + }) + } + } + + await processQueue(tasks, async state => { + await broadcastQueue({ + total: state.total, + completed: state.completed, + paused: state.paused, + retryAfter: state.retryAfter, + status: state.completed < tasks.length + ? `Clearing and reassigning item ${state.completed + 1} of ${tasks.length}…` + : `Reassigned ${tasks.length} item${tasks.length !== 1 ? 's' : ''}…`, + processId, + label, + }, tabId) + }, processId) + + await broadcastQueue({ total: 0, completed: 0, paused: false, status: 'Done!', processId, label }, tabId) + } finally { + activeBulkCount-- + } + }) + onMessage('getReorderContext', async ({ data }) => { logger.log('[rgp:bg] getReorderContext received', { itemCount: data.itemIds.length }) const { project } = await getProjectFieldsData(data.owner, data.number, data.isOrg) diff --git a/src/lib/graphql/mutations.ts b/src/lib/graphql/mutations.ts index 3f0adeb..a92af76 100644 --- a/src/lib/graphql/mutations.ts +++ b/src/lib/graphql/mutations.ts @@ -73,6 +73,20 @@ export const ADD_ASSIGNEES = ` } ` +export const REMOVE_ASSIGNEES = ` + mutation RemoveAssignees($assignableId: ID!, $assigneeIds: [ID!]!) { + removeAssigneesFromAssignable(input: { + assignableId: $assignableId + assigneeIds: $assigneeIds + }) { + assignable { + ... on Issue { id } + ... on PullRequest { id } + } + } + } +` + export const ADD_LABELS = ` mutation AddLabels($labelableId: ID!, $labelIds: [ID!]!) { addLabelsToLabelable(input: { diff --git a/src/lib/graphql/queries.ts b/src/lib/graphql/queries.ts index 417f1a9..d706796 100644 --- a/src/lib/graphql/queries.ts +++ b/src/lib/graphql/queries.ts @@ -424,6 +424,23 @@ export const UPDATE_PROJECT_ITEM_POSITION = ` } ` +export const GET_ISSUE_ASSIGNEES = ` + query GetIssueAssignees($id: ID!) { + node(id: $id) { + ... on Issue { + assignees(first: 25) { + nodes { id } + } + } + ... on PullRequest { + assignees(first: 25) { + nodes { id } + } + } + } + } +` + export const GET_REPO_ISSUE_TYPES = ` query GetRepoIssueTypes($owner: String!, $name: String!, $cursor: String) { repository(owner: $owner, name: $name) { diff --git a/src/lib/messages.ts b/src/lib/messages.ts index 6a0a5ac..bb70e9e 100644 --- a/src/lib/messages.ts +++ b/src/lib/messages.ts @@ -140,6 +140,13 @@ export interface HierarchyData { blocking: IssueRelationshipData[] } +export interface BulkRandomAssignData { + itemIds: string[] + projectId: string + assignments: Array<{ itemId: string; assigneeIds: string[] }> + strategy: 'balanced' | 'random' | 'round-robin' +} + interface ProtocolMap { duplicateItem(data: { itemId: string @@ -183,6 +190,8 @@ interface ProtocolMap { projectId: string reason: 'COMPLETED' | 'NOT_PLANNED' }): void + /** Assigns multiple items to users based on a strategy */ + bulkRandomAssign(data: BulkRandomAssignData): void bulkOpen(data: { itemIds: string[] projectId: string