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
77 changes: 77 additions & 0 deletions src/background/__tests__/bulk-update-dispatch.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'

const hoisted = vi.hoisted(() => ({
isBulkFull: vi.fn(() => false),
acquireBulk: vi.fn(),
releaseBulk: vi.fn(),
handlers: new Map<
string,
(msg: { data: unknown; sender: { tab?: { id?: number } } }) => Promise<unknown>
>(),
}))

vi.mock('@/lib/debug-logger', () => ({
logger: { log: () => {}, warn: () => {}, error: () => {}, info: () => {} },
}))

vi.mock('@/background/concurrency', () => ({
isBulkFull: hoisted.isBulkFull,
acquireBulk: hoisted.acquireBulk,
releaseBulk: hoisted.releaseBulk,
}))

vi.mock('@/lib/messages', () => ({
onMessage: (type: string, handler: (typeof hoisted.handlers) extends Map<string, infer H> ? H : never) => {
hoisted.handlers.set(type, handler)
},
}))

vi.mock('@/background/cache', () => ({ takeCachedResolvedItems: vi.fn() }))
vi.mock('@/background/rest-helpers', () => ({ broadcastQueue: vi.fn(async () => {}) }))
vi.mock('@/background/relationship-helpers', () => ({ buildBulkRelationshipTasks: vi.fn(() => []) }))
vi.mock('@/background/project-helpers', () => ({ resolveProjectItemIds: vi.fn(async () => []) }))
vi.mock('@/lib/queue', () => ({ processQueue: vi.fn(async () => {}), sleep: vi.fn() }))
vi.mock('@/lib/graphql-client', () => ({ gql: vi.fn() }))

import { registerBulkUpdateHandler } from '@/background/bulk-update'

describe('bulkUpdate dispatch', () => {
beforeEach(() => {
hoisted.handlers.clear()
hoisted.isBulkFull.mockReset()
hoisted.acquireBulk.mockReset()
hoisted.releaseBulk.mockReset()
hoisted.isBulkFull.mockReturnValue(false)
registerBulkUpdateHandler()
})

it('returns concurrent rejection without acquiring when bulk is full', async () => {
hoisted.isBulkFull.mockReturnValue(true)
const handler = hoisted.handlers.get('bulkUpdate')
expect(handler).toBeDefined()

const result = await handler!({
data: { itemIds: ['a'], projectId: 'p', updates: [] },
sender: { tab: { id: 1 } },
})

expect(result).toEqual({ ok: false, reason: 'concurrent' })
expect(hoisted.acquireBulk).not.toHaveBeenCalled()
expect(hoisted.releaseBulk).not.toHaveBeenCalled()
})

it('returns ok and releases bulk slot after background work', async () => {
const handler = hoisted.handlers.get('bulkUpdate')!
const result = await handler({
data: { itemIds: ['a'], projectId: 'p', updates: [] },
sender: { tab: { id: 2 } },
})

expect(result).toEqual({ ok: true })
expect(hoisted.acquireBulk).toHaveBeenCalledTimes(1)

await vi.waitFor(() => {
expect(hoisted.releaseBulk).toHaveBeenCalledTimes(1)
})
})
})
103 changes: 103 additions & 0 deletions src/background/__tests__/transfer-eligibility.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import { describe, expect, it } from 'vitest'

import type { ResolvedItemWithTitle } from '@/background/types'
import {
classifyTransferEligibilityRows,
unresolvedTransferEligibilityRows,
} from '@/background/transfer-eligibility'
import {
decodeIssueNodeId,
decodeProjectItemDomId,
decodeProjectItemId,
decodeRepoName,
decodeRepoOwner,
} from '@/lib/schemas-decode'

function resolvedItem(
domId: string,
opts: {
typename: 'Issue' | 'PullRequest'
repoOwner: string
repoName: string
title?: string
},
): ResolvedItemWithTitle {
return {
domId: decodeProjectItemDomId(domId),
issueNodeId: decodeIssueNodeId('I_issue'),
projectItemId: decodeProjectItemId('PVT_item'),
repoOwner: decodeRepoOwner(opts.repoOwner),
repoName: decodeRepoName(opts.repoName),
title: opts.title ?? 'Example',
typename: opts.typename,
}
}

describe('unresolvedTransferEligibilityRows', () => {
it('returns one unresolved row per item id in order', () => {
const rows = unresolvedTransferEligibilityRows(['issue:1', 'issue:2'])
expect(rows).toEqual([
{ domId: 'issue:1', eligible: false, reason: 'unresolved' },
{ domId: 'issue:2', eligible: false, reason: 'unresolved' },
])
})
})

describe('classifyTransferEligibilityRows', () => {
const targetOwner = 'acme'
const targetName = 'dest'

it('marks missing resolved items as unresolved', () => {
const rows = classifyTransferEligibilityRows(
['issue:1', 'issue:2'],
[resolvedItem('issue:1', { typename: 'Issue', repoOwner: 'acme', repoName: 'src' })],
targetOwner,
targetName,
)
expect(rows[0]).toMatchObject({ domId: 'issue:1', eligible: true })
expect(rows[1]).toEqual({ domId: 'issue:2', eligible: false, reason: 'unresolved' })
})

it('marks pull requests ineligible', () => {
const rows = classifyTransferEligibilityRows(
['issue:9'],
[resolvedItem('issue:9', { typename: 'PullRequest', repoOwner: 'acme', repoName: 'src' })],
targetOwner,
targetName,
)
expect(rows[0]).toMatchObject({
domId: 'issue:9',
eligible: false,
reason: 'pull-request',
title: 'Example',
})
})

it('marks same-repo items ineligible', () => {
const rows = classifyTransferEligibilityRows(
['issue:3'],
[resolvedItem('issue:3', { typename: 'Issue', repoOwner: 'Acme', repoName: 'Dest' })],
targetOwner,
targetName,
)
expect(rows[0]).toMatchObject({
domId: 'issue:3',
eligible: false,
reason: 'same-repo',
})
})

it('marks cross-repo issues eligible', () => {
const rows = classifyTransferEligibilityRows(
['issue:4'],
[resolvedItem('issue:4', { typename: 'Issue', repoOwner: 'acme', repoName: 'source' })],
targetOwner,
targetName,
)
expect(rows[0]).toEqual({
domId: 'issue:4',
eligible: true,
title: 'Example',
})
})
})
92 changes: 90 additions & 2 deletions src/background/bulk-state.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
// bulk state-change handlers: close, open, delete, lock, pin, unpin.
// bulk state-change handlers: close, open, delete, lock, unlock, pin, unpin.

import { onMessage } from '@/lib/messages'
import { gql } from '@/lib/graphql-client'
import {
CLOSE_ISSUE,
REOPEN_ISSUE,
LOCK_ISSUE,
UNLOCK_ISSUE,
PIN_ISSUE,
UNPIN_ISSUE,
DELETE_PROJECT_ITEM,
Expand Down Expand Up @@ -34,6 +35,8 @@ export function registerBulkStateHandlers(): void {
const processId = `close-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`
const label = `Bulk close · ${data.itemIds.length} item${data.itemIds.length !== 1 ? 's' : ''}`
const tabId = sender.tab?.id
let lastFailedTaskIds = new Set<string>()
let lastCompleted = 0

try {
await broadcastQueue(
Expand Down Expand Up @@ -65,6 +68,8 @@ export function registerBulkStateHandlers(): void {
await processQueue(
tasks,
async (state) => {
lastFailedTaskIds = new Set((state.failedItems ?? []).map((f) => f.id))
lastCompleted = state.completed
await broadcastQueue(
{
total: state.total,
Expand All @@ -85,8 +90,26 @@ export function registerBulkStateHandlers(): void {
processId,
)

// §4.9 — reverse hint for Undo (Close → Reopen). Only items that were
// actually processed and not failed are offered for Undo. Items beyond
// `lastCompleted` are unprocessed (e.g. queue cancelled mid-run) and
// must not appear in the Undo target list.
const succeededDomIds = resolvedItems
.slice(0, lastCompleted)
.map((r) => r.domId)
.filter((domId) => !lastFailedTaskIds.has(`close-${domId}`))
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
const reverse =
succeededDomIds.length > 0
? {
messageType: 'bulkOpen',
data: { projectId: data.projectId },
affectedItemIds: succeededDomIds,
label: `Undo close (${succeededDomIds.length})`,
}
: undefined

await broadcastQueue(
{ total: 0, completed: 0, paused: false, status: 'Done!', processId, label },
{ total: 0, completed: 0, paused: false, status: 'Done!', processId, label, reverse },
tabId,
)
} finally {
Expand Down Expand Up @@ -306,6 +329,71 @@ export function registerBulkStateHandlers(): void {
}
})

onMessage('bulkUnlock', async ({ data, sender }) => {
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
logger.log('[rgp:bg] bulkUnlock received', { itemCount: data.itemIds.length })
if (isBulkFull()) {
console.warn('[rgp:bg] max concurrent bulk reached, rejecting bulkUnlock')
return
}
acquireBulk()
const processId = `unlock-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`
const label = `Unlock · ${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 bulkUnlock, aborting')
return
}
const tasks: QueueTask[] = resolvedItems.map(({ domId, issueNodeId }) => ({
id: `unlock-${domId}`,
run: async () => {
await gql(UNLOCK_ISSUE, { lockableId: issueNodeId })
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 < resolvedItems.length
? `Unlocking item ${state.completed + 1} of ${resolvedItems.length}…`
: `Unlocking ${resolvedItems.length} item${resolvedItems.length !== 1 ? 's' : ''}…`,
processId,
label,
failedItems: state.failedItems,
},
tabId,
)
},
processId,
)
await broadcastQueue(
{ total: 0, completed: 0, paused: false, status: 'Done!', processId, label },
tabId,
)
} finally {
releaseBulk()
}
})

onMessage('bulkPin', async ({ data, sender }) => {
logger.log('[rgp:bg] bulkPin received', { itemCount: data.itemIds.length })
if (isBulkFull()) {
Expand Down
Loading
Loading