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
9 changes: 7 additions & 2 deletions src/background/__tests__/bulk-update-dispatch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,14 +21,19 @@ vi.mock('@/background/concurrency', () => ({
}))

vi.mock('@/lib/messages', () => ({
onMessage: (type: string, handler: (typeof hoisted.handlers) extends Map<string, infer H> ? H : never) => {
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/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() }))
Expand Down
5 changes: 1 addition & 4 deletions src/background/bulk-position.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,7 @@

import { onMessage } from '@/lib/messages'
import { gql } from '@/lib/graphql-client'
import {
GET_PROJECT_ITEMS_FOR_REORDER,
UPDATE_PROJECT_ITEM_POSITION,
} from '@/lib/graphql-queries'
import { GET_PROJECT_ITEMS_FOR_REORDER, UPDATE_PROJECT_ITEM_POSITION } from '@/lib/graphql-queries'
import { processQueue, sleep } from '@/lib/queue'
import type { QueueTask } from '@/lib/queue'
import { logger } from '@/lib/debug-logger'
Expand Down
13 changes: 12 additions & 1 deletion src/background/duplicate-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@ async function runDeepDuplicate(
tabId?: number,
plan?: DuplicateItemPlan,
) {
// Defensive backstop: the caller in registerDuplicateHandlers below already
// checks isDuplicateFull() before invoking this (and returns the verdict to
// the sender), so this only matters for other/future callers.
if (isDuplicateFull()) {
console.warn('[rgp:bg] max concurrent duplicates reached, rejecting new request')
return
Expand Down Expand Up @@ -416,6 +419,14 @@ export function registerDuplicateHandlers(): void {
projectId: data.projectId,
})
const tabId = sender.tab?.id
await runDeepDuplicate(data.itemId, data.projectId, tabId, data.plan)
// Decide acceptance synchronously (no await between this check and
// acquireDuplicate() inside runDeepDuplicate) so the verdict returned to
// the sender is truthful, then let the duplication run in the background.
if (isDuplicateFull()) {
console.warn('[rgp:bg] max concurrent duplicates reached, rejecting new request')
return { accepted: false }
}
void runDeepDuplicate(data.itemId, data.projectId, tabId, data.plan)
return { accepted: true }
})
}
12 changes: 10 additions & 2 deletions src/background/project-service.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,15 @@
import { Context, Effect, Layer } from 'effect'

import { listIssueRelationshipsSafe as listIssueRelationshipsSafeAsync, listSubIssuesSafe as listSubIssuesSafeAsync } from '@/background/relationship-helpers'
import { getProjectFieldsData as getProjectFieldsDataAsync, getRepositoryId as getRepositoryIdAsync, resolveProjectItemIds as resolveProjectItemIdsAsync, resolveProjectItemIdsWithTitles as resolveProjectItemIdsWithTitlesAsync } from '@/background/project-helpers'
import {
listIssueRelationshipsSafe as listIssueRelationshipsSafeAsync,
listSubIssuesSafe as listSubIssuesSafeAsync,
} from '@/background/relationship-helpers'
import {
getProjectFieldsData as getProjectFieldsDataAsync,
getRepositoryId as getRepositoryIdAsync,
resolveProjectItemIds as resolveProjectItemIdsAsync,
resolveProjectItemIdsWithTitles as resolveProjectItemIdsWithTitlesAsync,
} from '@/background/project-helpers'
import type { FieldsResultProject, ResolvedItem, ResolvedItemWithTitle } from '@/background/types'
import type { IssueRelationshipData, SubIssueData } from '@/lib/messages'

Expand Down
11 changes: 2 additions & 9 deletions src/background/relationship-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,15 +20,8 @@ import type {
RelationshipSearchIssueNode,
} from '@/background/types'

import {
githubRest,
parseRepoFromUrl,
withRateLimitRetry,
} from '@/background/rest-helpers'
import {
formatIssueReference,
relationshipKey,
} from '@/lib/relationship-utils'
import { githubRest, parseRepoFromUrl, withRateLimitRetry } from '@/background/rest-helpers'
import { formatIssueReference, relationshipKey } from '@/lib/relationship-utils'

export { relationshipKey } from '@/lib/relationship-utils'

Expand Down
20 changes: 12 additions & 8 deletions src/background/sprint-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,11 @@ import { allSprintSettingsStorage } from '@/lib/storage'
import { todayUtc, isActive, nearestUpcoming, iterationEndDate } from '@/lib/sprint-utils'
import { logger } from '@/lib/debug-logger'

import { sprintProgressCache, SPRINT_PROGRESS_CACHE_TTL_MS, pruneExpiredCache } from '@/background/cache'
import {
sprintProgressCache,
SPRINT_PROGRESS_CACHE_TTL_MS,
pruneExpiredCache,
} from '@/background/cache'

import { isSprintEndFull, acquireSprintEnd, releaseSprintEnd } from '@/background/concurrency'

Expand Down Expand Up @@ -46,15 +50,15 @@ export function registerSprintHandlers(): void {
]
const today = todayUtc()

const withEndDate = <T extends Parameters<typeof iterationEndDate>[0]>(
iter: T,
): T & { endDate: string } => ({ ...iter, endDate: iterationEndDate(iter) })

const active = allIters.find((i) => isActive(i, today)) ?? null
const activeSprint: SprintInfo | null = active
? { ...active, endDate: iterationEndDate(active) }
: null
const activeSprint: SprintInfo | null = active ? withEndDate(active) : null

const upcoming = nearestUpcoming(iterField.configuration.iterations ?? [], today)
const nearestUpcomingSprint: SprintInfo | null = upcoming
? { ...upcoming, endDate: iterationEndDate(upcoming) }
: null
const nearestUpcomingSprint: SprintInfo | null = upcoming ? withEndDate(upcoming) : null

// check acknowledged sprint (if any) — clear stale IDs
let acknowledgedSprint: SprintInfo | null = null
Expand All @@ -63,7 +67,7 @@ export function registerSprintHandlers(): void {
(i) => i.id === settings.acknowledgedSprintId,
)
if (ackIter) {
acknowledgedSprint = { ...ackIter, endDate: iterationEndDate(ackIter) }
acknowledgedSprint = withEndDate(ackIter)
} else {
// stale — clear it
const updated = { ...settings, acknowledgedSprintId: undefined }
Expand Down
4 changes: 3 additions & 1 deletion src/background/transfer-eligibility.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@ export interface TransferEligibilityRow {
title?: string
}

export function unresolvedTransferEligibilityRows(itemIds: readonly string[]): TransferEligibilityRow[] {
export function unresolvedTransferEligibilityRows(
itemIds: readonly string[],
): TransferEligibilityRow[] {
return itemIds.map((domId) => ({
domId,
eligible: false,
Expand Down
31 changes: 31 additions & 0 deletions src/features/__tests__/bulk-duplicate-modal.test.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { describe, expect, it } from 'vitest'

import {
drainPendingDuplicates,
isAssigneesEdited,
isFieldEdited,
isLabelsEdited,
Expand Down Expand Up @@ -134,3 +135,33 @@ describe('isFieldEdited', () => {
expect(isFieldEdited({ ...baseIteration, iterationId: undefined }, baseIteration)).toBe(true)
})
})

// §11.9 — race-window guard: `getActiveCount()` only reflects a duplicate
// once the BG's `queueStateUpdate` broadcast lands, one round-trip after the
// fire. `drainPendingDuplicates` tracks fires not yet reflected so rapid
// "Create more" clicks can't slip past the concurrency cap.
describe('drainPendingDuplicates', () => {
it('holds pending steady while the queue count has not moved (still in the lag window)', () => {
expect(drainPendingDuplicates(0, 0, 2)).toBe(2)
})

it('drains pending by the full rise once the queue catches up', () => {
expect(drainPendingDuplicates(0, 2, 2)).toBe(0)
})

it('drains only by the observed rise on a partial catch-up', () => {
expect(drainPendingDuplicates(1, 2, 2)).toBe(1)
})

it('never drains below zero when the rise exceeds pending', () => {
expect(drainPendingDuplicates(0, 5, 1)).toBe(0)
})

it('ignores a drop in active count (no negative rise)', () => {
expect(drainPendingDuplicates(2, 1, 2)).toBe(2)
})

it('clears any phantom once the queue is fully empty', () => {
expect(drainPendingDuplicates(3, 0, 2)).toBe(0)
})
})
Loading
Loading