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
19 changes: 18 additions & 1 deletion src/background/bulk-update.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,8 +53,25 @@ export async function runBulkUpdate(
const cachedResolvedItems = data.relationships
? takeCachedResolvedItems(data.projectId, data.itemIds)
: undefined
const resolvedItems =
let resolvedItems =
cachedResolvedItems ?? (await resolveProjectItemIds(data.itemIds, data.projectId, tabId))

// ponytail: a just-created issue's project item lands in the project's `items`
// connection asynchronously (~100ms after the board row renders), so the first
// resolve can come back empty/partial. Re-run the same idempotent read until it
// appears. Reads only → anti-abuse safe. Attempt ceiling is the tuning knob.
if (!cachedResolvedItems && resolvedItems.length < data.itemIds.length) {
const MAX_RESOLVE_ATTEMPTS = 6 // 1.5s backoff ⇒ ≈9s ceiling; bump if QA still races
for (let attempt = 1; attempt <= MAX_RESOLVE_ATTEMPTS; attempt++) {
await sleep(1500)
try {
resolvedItems = await resolveProjectItemIds(data.itemIds, data.projectId, tabId)
} catch (err) {
logger.warn('[rgp:bg] resolution retry threw, will retry', { attempt, err })
}
if (resolvedItems.length >= data.itemIds.length) break
}
}
logger.log('[rgp:bg] resolved item IDs', resolvedItems)

if (resolvedItems.length === 0) {
Expand Down
206 changes: 206 additions & 0 deletions src/background/create-issue.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,206 @@
// ─── Create-issue handler ─────────────────────────────────────────────────────
// RGP owns the native "Create issue" flow: create → attach to project → apply
// staged custom fields, reading ids straight from the API responses (no DOM
// diffing, no network capture). Trimmed clone of duplicate-handlers.ts's
// create→attach→set-fields task sequence.

import { onMessage } from '@/lib/messages'
import type { CreateIssueWithFieldsMessageData } from '@/lib/messages'
import { gql } from '@/lib/graphql-client'
import { CLONE_ISSUE, ATTACH_TO_PROJECT, UPDATE_PROJECT_FIELD } from '@/lib/graphql-mutations'
import { GET_REPO_ASSIGNEES, GET_REPO_LABELS } from '@/lib/graphql-queries'
import { processQueue, sleep } from '@/lib/queue'
import type { QueueTask } from '@/lib/queue'
import { logger } from '@/lib/debug-logger'

import { isBulkFull, acquireBulk, releaseBulk } from '@/background/concurrency'
import { broadcastQueue, withRateLimitRetry } from '@/background/rest-helpers'
import { getRepositoryId } from '@/background/project-helpers'

// Resolves the dialog's assignee logins / label names to node ids before
// create. Best-effort: names that don't resolve are dropped rather than
// failing the create.
async function resolveAssigneeIds(
owner: string,
name: string,
logins: string[],
): Promise<string[]> {
if (logins.length === 0) return []
// Query per login with the login itself as the search filter — an unfiltered
// (q: '') query only returns the repo's first 20 assignable users, silently
// dropping any selected login that doesn't sort into that page.
const results = await Promise.all(
logins.map((login) =>
gql<{
repository: { assignableUsers: { nodes: { id: string; login: string }[] } }
}>(GET_REPO_ASSIGNEES, { owner, name, q: login }),
),
)
return results
.map(
(result, i) =>
result.repository?.assignableUsers?.nodes?.find((u) => u.login === logins[i])?.id,
)
.filter((id): id is string => Boolean(id))
}

async function resolveLabelIds(
owner: string,
name: string,
labelNames: string[],
): Promise<string[]> {
if (labelNames.length === 0) return []
// Same fix as resolveAssigneeIds: query per label name so selections beyond
// the first 20 unfiltered results aren't silently dropped.
const results = await Promise.all(
labelNames.map((labelName) =>
gql<{
repository: { labels: { nodes: { id: string; name: string }[] } }
}>(GET_REPO_LABELS, { owner, name, q: labelName }),
),
)
return results
.map((result, i) => result.repository?.labels?.nodes?.find((l) => l.name === labelNames[i])?.id)
.filter((id): id is string => Boolean(id))
}

function toFieldValue(value: unknown): Record<string, unknown> {
const { singleSelectOptionId, iterationId, text, date, number: num } = value as any
if (singleSelectOptionId) return { singleSelectOptionId }
if (iterationId) return { iterationId }
if (date !== undefined) return { date }
if (num !== undefined && num !== null) return { number: num }
return { text }
}

async function runCreateIssue(data: CreateIssueWithFieldsMessageData, tabId?: number) {
if (isBulkFull()) {
logger.warn('[rgp:bg] max concurrent bulk operations reached, rejecting create-issue')
return
}

acquireBulk()
const processId = `create-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`
const totalSteps = 2 + data.updates.length
const label = `Create issue · ${data.title}`

await broadcastQueue(
{ total: totalSteps, completed: 0, paused: false, status: 'Creating issue…', processId, label },
tabId,
)

try {
let newIssueId = ''
let newItemId = ''

const tasks: QueueTask[] = [
{
id: 'create-issue',
detail: data.title,
run: async () => {
const repositoryId = await getRepositoryId(data.repoOwner, data.repoName)
const [assigneeIds, labelIds] = await Promise.all([
resolveAssigneeIds(data.repoOwner, data.repoName, data.assignees ?? []),
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
resolveLabelIds(data.repoOwner, data.repoName, data.labels ?? []),
])
logger.log('[rgp:bg] creating issue', { repositoryId, title: data.title })
interface CreateResult {
createIssue: { issue: { id: string; databaseId: number; number: number } }
}
// Deliberately NOT wrapped in withRateLimitRetry — a blind retry of a
// create mutation risks creating a duplicate issue.
const result = await gql<CreateResult>(CLONE_ISSUE, {
repositoryId,
title: data.title,
body: data.body,
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
assigneeIds,
labelIds,
})
newIssueId = result.createIssue.issue.id
await sleep(1000)
},
},
{
id: 'attach-project',
detail: data.repoName,
run: async () => {
logger.log('[rgp:bg] attaching to project', { newIssueId, projectId: data.projectId })
interface AttachResult {
addProjectV2ItemById: { item: { id: string } }
}
const result = await withRateLimitRetry(
() =>
gql<AttachResult>(ATTACH_TO_PROJECT, {
projectId: data.projectId,
contentId: newIssueId,
}),
tabId,
)
newItemId = result.addProjectV2ItemById.item.id
await sleep(1000)
},
},
...data.updates.map((update) => ({
id: `field-${update.fieldId}`,
detail: data.fieldMeta?.[update.fieldId]?.name ?? update.fieldId,
run: async () => {
await withRateLimitRetry(
() =>
gql(UPDATE_PROJECT_FIELD, {
projectId: data.projectId,
itemId: newItemId,
fieldId: update.fieldId,
value: toFieldValue(update.value),
}),
tabId,
)
await sleep(1000)
},
})),
]

await processQueue(
tasks,
async (state) => {
await broadcastQueue(
{
total: totalSteps,
completed: state.completed,
paused: state.paused,
retryAfter: state.retryAfter,
status: state.completed === 0 ? 'Creating issue…' : 'Applying custom fields…',
detail: state.detail,
processId,
label,
failedItems: state.failedItems,
},
tabId,
)
},
processId,
)

await broadcastQueue(
{ total: 0, completed: 0, paused: false, status: 'Done!', processId, label },
tabId,
)
logger.log('[rgp:bg] create-issue complete', { processId })
} finally {
releaseBulk()
}
}

export function registerCreateIssueHandler(): void {
onMessage('createIssueWithFields', ({ data, sender }) => {
logger.log('[rgp:bg] createIssueWithFields received', {
title: data.title,
projectId: data.projectId,
})
const tabId = sender.tab?.id
if (isBulkFull()) {
return { ok: false, reason: 'concurrent' as const }
}
void runCreateIssue(data, tabId)
return { ok: true as const }
})
}
28 changes: 16 additions & 12 deletions src/background/rest-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ export async function broadcastQueue(
reverse?: {
messageType: string
data: Record<string, unknown>
affectedItemIds: readonly string[]
affectedItemIds: string[]
label?: string
undoWindowMs?: number
}
Expand All @@ -110,13 +110,15 @@ export async function broadcastQueue(
// retrying in N seconds" UI broadcast for any *remaining* rate-limit error
// that escapes the service's own retries. We keep one extra attempt so the
// UI gets to show the pause; if the call still fails we surface the error.
// ponytail: only ever wraps idempotent read queries (item resolution) — safe
// to retry. Do NOT wrap a mutation with this; blind retry would double-write.
export async function withRateLimitRetry<T>(fn: () => Promise<T>, tabId?: number): Promise<T> {
let lastErr: unknown
for (let attempt = 0; attempt < 2; attempt++) {
const MAX_RATE_LIMIT_PAUSES = 2
let rateLimitPauses = 0
for (;;) {
try {
return await fn()
} catch (err) {
lastErr = err
// accept either the new tagged `GithubRateLimitError` or the legacy
// `{ status, retryAfter }` shape (still used by direct fetch callers
// such as `validatePat`).
Expand All @@ -126,22 +128,24 @@ export async function withRateLimitRetry<T>(fn: () => Promise<T>, tabId?: number
retryAfter?: number
}
const isRateLimit = e._tag === 'GithubRateLimitError' || e.status === 403 || e.status === 429
if (isRateLimit) {
if (isRateLimit && rateLimitPauses < MAX_RATE_LIMIT_PAUSES) {
rateLimitPauses++
const retryAfter = e.retryAfter ?? 60
logger.warn('[rgp:bg] rate limited, broadcasting pause', {
retryAfter,
attempt: attempt + 1,
maxAttempts: 2,
attempt: rateLimitPauses,
maxAttempts: MAX_RATE_LIMIT_PAUSES,
})
logger.verbose(`⏸ paused ${retryAfter}s — attempt ${attempt + 1}/2`)
logger.verbose(
`⏸ paused ${retryAfter}s — attempt ${rateLimitPauses}/${MAX_RATE_LIMIT_PAUSES}`,
)
await broadcastQueue({ total: 0, completed: 0, paused: true, retryAfter }, tabId)
await sleep(retryAfter * 1000)
await broadcastQueue({ total: 0, completed: 0, paused: false }, tabId)
} else {
logger.error('[rgp:bg] task failed permanently', err)
throw err
continue
}
logger.error('[rgp:bg] task failed permanently', err)
throw err
}
}
throw lastErr
}
2 changes: 2 additions & 0 deletions src/entries/background.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { registerHierarchyHandlers } from '@/background/hierarchy-handlers'
import { registerSprintHandlers } from '@/background/sprint-handlers'
import { registerDuplicateHandlers } from '@/background/duplicate-handlers'
import { registerBulkHandlers } from '@/background/bulk-handlers'
import { registerCreateIssueHandler } from '@/background/create-issue'

export default defineBackground(() => {
initDebugLogger()
Expand All @@ -24,4 +25,5 @@ export default defineBackground(() => {
registerSprintHandlers()
registerDuplicateHandlers()
registerBulkHandlers()
registerCreateIssueHandler()
})
3 changes: 3 additions & 0 deletions src/entries/content.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
} from '@/features/table-enhancements'
import { createHierarchyChipInjector } from '@/features/hierarchy-injections'
import { setupIssueDetailInjector } from '@/features/issue-detail-injections'
import { setupCreateIssueFieldInjector } from '@/features/create-issue-injections'
import { selectionStore } from '@/lib/selection-store'
import { logger, initDebugLogger } from '@/lib/debug-logger'
// eager-load the ManagedRuntime so the content script shares one runtime
Expand Down Expand Up @@ -55,6 +56,7 @@ export default defineContentScript({
const injectSprintHeaders = createSprintHeaderInjector(ctx, projectContext, getFields)
const injectHierarchyChips = createHierarchyChipInjector(projectContext)
const cleanupIssueDetail = setupIssueDetailInjector(projectContext)
const cleanupCreateIssueFields = setupCreateIssueFieldInjector(ctx, getFields)
const cleanupTableEnhancements = setupTableEnhancements([
injectSprintHeaders,
injectStatusBarSprintButton,
Expand All @@ -64,6 +66,7 @@ export default defineContentScript({
ctx.onInvalidated(() => {
cleanupTableEnhancements()
cleanupIssueDetail()
cleanupCreateIssueFields()
document.removeEventListener('click', handleProjectItemOpen, true)
})
},
Expand Down
Loading
Loading