From 759ef8a45e4127be9a13372a06163b00f22d4005 Mon Sep 17 00:00:00 2001 From: Fathiraz Arthuro Date: Sun, 29 Mar 2026 02:22:29 +0700 Subject: [PATCH 1/9] feat(bulk): add bulkRandomAssign message protocol --- src/lib/messages.ts | 9 +++++++++ 1 file changed, 9 insertions(+) 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 From 420801523779e602330a107b86a29d5f7a2c0216 Mon Sep 17 00:00:00 2001 From: Fathiraz Arthuro Date: Sun, 29 Mar 2026 02:22:43 +0700 Subject: [PATCH 2/9] feat(bulk): implement distribution algorithms --- .../bulk/bulk-random-assign-utils.ts | 89 +++++++++++++++++++ 1 file changed, 89 insertions(+) create mode 100644 src/components/bulk/bulk-random-assign-utils.ts 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..5d8f6b3 --- /dev/null +++ b/src/components/bulk/bulk-random-assign-utils.ts @@ -0,0 +1,89 @@ +/** + * 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) { + // Find assignee with the lowest count + let minCount = Infinity; + let targetAssignee = assignees[0]; + + for (const [assignee, count] of counts.entries()) { + if (count < minCount) { + minCount = count; + targetAssignee = assignee; + } + } + + 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; From 743b6a04476435fedf769fcee46aa21bc5688846 Mon Sep 17 00:00:00 2001 From: Fathiraz Arthuro Date: Sun, 29 Mar 2026 02:23:06 +0700 Subject: [PATCH 3/9] feat(bulk): create random assign modal shell --- .../bulk/bulk-random-assign-modal.tsx | 166 ++++++++++++++++++ 1 file changed, 166 insertions(+) create mode 100644 src/components/bulk/bulk-random-assign-modal.tsx 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..df6d465 --- /dev/null +++ b/src/components/bulk/bulk-random-assign-modal.tsx @@ -0,0 +1,166 @@ +import React, { useState } from 'react' +import { Box, Button, Text } from '@primer/react' +import { ModalStepHeader } from '../ui/modal-step-header' +import { PersonIcon } from '../ui/primitives' +import { Z_MODAL } from '../../lib/z-index' + +type RandomAssignStep = 'ASSIGNEES' | 'PREVIEW' | 'CONFIRM' + +interface Props { + count: number + projectId: string + owner: string + itemIds: string[] + onClose: () => void +} + +export function BulkRandomAssignModal({ count, onClose }: Props) { + const [step, setStep] = useState('ASSIGNEES') + + 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 renderStep = () => { + switch (step) { + case 'ASSIGNEES': + return ( + + Step 1: Select Assignees + {/* Assignee selection will go here in Wave 2 */} + + ) + case 'PREVIEW': + return ( + + Step 2: Preview Assignments + {/* Preview list will go here in Wave 2 */} + + ) + case 'CONFIRM': + return ( + + Step 3: Confirm & Apply + {/* Confirmation summary will go here in Wave 2 */} + + ) + } + } + + 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()} + + + + + + + + + ) +} From 90a2fa2fd2b300c54a4ddf47b321237142ea59e8 Mon Sep 17 00:00:00 2001 From: Fathiraz Arthuro Date: Sun, 29 Mar 2026 02:25:44 +0700 Subject: [PATCH 4/9] feat(bulk): add distribution preview step --- .../bulk/bulk-random-assign-modal.tsx | 77 +++++++++++++++++-- 1 file changed, 71 insertions(+), 6 deletions(-) diff --git a/src/components/bulk/bulk-random-assign-modal.tsx b/src/components/bulk/bulk-random-assign-modal.tsx index df6d465..708969f 100644 --- a/src/components/bulk/bulk-random-assign-modal.tsx +++ b/src/components/bulk/bulk-random-assign-modal.tsx @@ -1,8 +1,14 @@ -import React, { useState } from 'react' -import { Box, Button, Text } from '@primer/react' +import React, { useState, useEffect } from 'react' +import { Box, Button, Text, Select } from '@primer/react' import { ModalStepHeader } from '../ui/modal-step-header' import { PersonIcon } from '../ui/primitives' import { Z_MODAL } from '../../lib/z-index' +import { + distributeBalanced, + distributeRandom, + distributeRoundRobin, + type DistributionStrategy +} from './bulk-random-assign-utils' type RandomAssignStep = 'ASSIGNEES' | 'PREVIEW' | 'CONFIRM' @@ -14,8 +20,28 @@ interface Props { onClose: () => void } -export function BulkRandomAssignModal({ count, onClose }: Props) { +export function BulkRandomAssignModal({ count, onClose, itemIds }: Props) { const [step, setStep] = useState('ASSIGNEES') + const [strategy, setStrategy] = useState('balanced') + const [preview, setPreview] = useState>(new Map()) + const [selectedAssignees] = useState(['Alice', 'Bob']) + + 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') @@ -38,9 +64,48 @@ export function BulkRandomAssignModal({ count, onClose }: Props) { ) case 'PREVIEW': return ( - - Step 2: Preview Assignments - {/* Preview list will go here in Wave 2 */} + + + + Strategy: + + + + + + + Distribution Summary + + {Array.from(preview.entries()) + .map(([assignee, items]) => `${assignee}: ${items.length} items`) + .join(' | ')} + + + + + {Array.from(preview.entries()).map(([assignee, items], index) => ( + 0 ? '1px solid' : 'none', + borderColor: 'border.default', + display: 'flex', + justifyContent: 'space-between' + }} + > + {assignee} + {items.length} items + + ))} + ) case 'CONFIRM': From c4aa7978cf85addb77617ccbd258ddd84a7aae52 Mon Sep 17 00:00:00 2001 From: Fathiraz Arthuro Date: Sun, 29 Mar 2026 02:26:45 +0700 Subject: [PATCH 5/9] feat(bulk): add random assign menu item --- src/components/bulk/bulk-actions-bar.tsx | 38 +++++++++++++++++++++++- 1 file changed, 37 insertions(+), 1 deletion(-) diff --git a/src/components/bulk/bulk-actions-bar.tsx b/src/components/bulk/bulk-actions-bar.tsx index 12c57af..53232b5 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,12 @@ export function BulkActionsBar({ projectId, owner, isOrg, number, getFields }: P } } + async function handleRandomAssign() { + setMenuOpen(false) + if (!await checkToken()) return + setShowRandomAssignModal(true) + } + async function handleBulkClose() { setMenuOpen(false) if (!await checkToken()) return @@ -621,6 +633,17 @@ export function BulkActionsBar({ projectId, owner, isOrg, number, getFields }: P /> )} + {/* Random Assign modal */} + {showRandomAssignModal && ( + setShowRandomAssignModal(false)} + /> + )} + {/* ── Persistent bottom bar ── */} + + + + + + + Random Assign (beta) + + + {shortcut('A')} + + + {count === 1 && ( checkToken().then(ok => ok && setShowDupModal(true))}> From 00c5da28bdd54636f410b2c96042f5fe1bacb99d Mon Sep 17 00:00:00 2001 From: Fathiraz Arthuro Date: Sun, 29 Mar 2026 03:57:30 +0700 Subject: [PATCH 6/9] feat(bulk): add bulk random assign functionality Add the ability to randomly assign multiple project items to team members in a single bulk operation. This feature includes: - Random distribution algorithm for balanced assignment - Visual distribution preview before applying changes - Modal UI for assignee selection and strategy configuration - Background queue processing with rate limiting - GraphQL mutations for clearing and reassigning issue assignees Files changed: - bulk-actions-bar.tsx: Add handleConfirmRandomAssign and wire up modal - bulk-random-assign-modal.tsx: Implement distribution preview and confirmation - background/index.ts: Add bulkRandomAssign message handler - graphql/mutations.ts: Add REMOVE_ASSIGNEES mutation - graphql/queries.ts: Add GET_ISSUE_ASSIGNEES query --- src/components/bulk/bulk-actions-bar.tsx | 30 ++++ .../bulk/bulk-random-assign-modal.tsx | 145 ++++++++++++++++-- src/entries/background/index.ts | 73 ++++++++- src/lib/graphql/mutations.ts | 14 ++ src/lib/graphql/queries.ts | 17 ++ 5 files changed, 263 insertions(+), 16 deletions(-) diff --git a/src/components/bulk/bulk-actions-bar.tsx b/src/components/bulk/bulk-actions-bar.tsx index 53232b5..144a2b9 100644 --- a/src/components/bulk/bulk-actions-bar.tsx +++ b/src/components/bulk/bulk-actions-bar.tsx @@ -377,6 +377,34 @@ export function BulkActionsBar({ projectId, owner, isOrg, number, getFields }: P setShowRandomAssignModal(true) } + async function handleConfirmRandomAssign(assignments: Map) { + 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: 'balanced', + }) + + selectionStore.clear() + } + async function handleBulkClose() { setMenuOpen(false) if (!await checkToken()) return @@ -639,8 +667,10 @@ export function BulkActionsBar({ projectId, owner, isOrg, number, getFields }: P count={count} projectId={projectData?.id || projectId} owner={owner} + repoName={firstRepoName} itemIds={selectionStore.getAll()} onClose={() => setShowRandomAssignModal(false)} + onConfirm={handleConfirmRandomAssign} /> )} diff --git a/src/components/bulk/bulk-random-assign-modal.tsx b/src/components/bulk/bulk-random-assign-modal.tsx index 708969f..c7c096f 100644 --- a/src/components/bulk/bulk-random-assign-modal.tsx +++ b/src/components/bulk/bulk-random-assign-modal.tsx @@ -1,8 +1,9 @@ import React, { useState, useEffect } from 'react' -import { Box, Button, Text, Select } from '@primer/react' +import { Box, Button, Text, Select, TextInput, Checkbox, Avatar, Flash, Spinner } from '@primer/react' import { ModalStepHeader } from '../ui/modal-step-header' -import { PersonIcon } from '../ui/primitives' +import { PersonIcon, SearchIcon } from '../ui/primitives' import { Z_MODAL } from '../../lib/z-index' +import { sendMessage } from '../../lib/messages' import { distributeBalanced, distributeRandom, @@ -10,21 +11,48 @@ import { 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) => void } -export function BulkRandomAssignModal({ count, onClose, itemIds }: Props) { +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] = useState(['Alice', 'Bob']) + const [selectedAssignees, setSelectedAssignees] = useState([]) + const [searchQuery, setSearchQuery] = useState('') + const [assignees, setAssignees] = useState([]) + const [isLoadingAssignees, setIsLoadingAssignees] = useState(false) + + useEffect(() => { + if (!repoName) return + setIsLoadingAssignees(true) + const timer = setTimeout(() => { + sendMessage('searchRepoMetadata', { owner, name: repoName, q: searchQuery, type: 'ASSIGNEES' }) + .then(results => setAssignees(results.map(r => ({ id: r.id, name: r.name, avatarUrl: r.avatarUrl })))) + .finally(() => 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 = @@ -53,13 +81,78 @@ export function BulkRandomAssignModal({ count, onClose, itemIds }: Props) { else if (step === 'CONFIRM') setStep('PREVIEW') } + const idToLogin = Object.fromEntries(assignees.map(a => [a.id, a.name])) + const renderStep = () => { switch (step) { case 'ASSIGNEES': return ( - - Step 1: Select Assignees - {/* Assignee selection will go here in Wave 2 */} + + + 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)} + aria-label={`Select ${assignee.name}`} + /> + {assignee.avatarUrl + ? + : + } + {assignee.name} + + ))} + + + {selectedAssignees.length < 2 && ( + + Please select at least 2 assignees to distribute items. + + )} ) case 'PREVIEW': @@ -84,15 +177,15 @@ export function BulkRandomAssignModal({ count, onClose, itemIds }: Props) { Distribution Summary {Array.from(preview.entries()) - .map(([assignee, items]) => `${assignee}: ${items.length} items`) + .map(([id, items]) => `${idToLogin[id] ?? id}: ${items.length} items`) .join(' | ')} - {Array.from(preview.entries()).map(([assignee, items], index) => ( + {Array.from(preview.entries()).map(([id, items], index) => ( 0 ? '1px solid' : 'none', @@ -101,7 +194,7 @@ export function BulkRandomAssignModal({ count, onClose, itemIds }: Props) { justifyContent: 'space-between' }} > - {assignee} + {idToLogin[id] ?? id} {items.length} items ))} @@ -110,9 +203,30 @@ export function BulkRandomAssignModal({ count, onClose, itemIds }: Props) { ) case 'CONFIRM': return ( - - Step 3: Confirm & Apply - {/* Confirmation summary will go here in Wave 2 */} + + + 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 + + ))} + + ) } @@ -210,7 +324,8 @@ export function BulkRandomAssignModal({ count, onClose, itemIds }: Props) {