diff --git a/src/background/bulk-update.ts b/src/background/bulk-update.ts index d31ecb1..2043f2f 100644 --- a/src/background/bulk-update.ts +++ b/src/background/bulk-update.ts @@ -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) { diff --git a/src/background/create-issue.ts b/src/background/create-issue.ts new file mode 100644 index 0000000..e15cd36 --- /dev/null +++ b/src/background/create-issue.ts @@ -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 { + 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 { + 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 { + 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 ?? []), + 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(CLONE_ISSUE, { + repositoryId, + title: data.title, + body: data.body, + 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(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 } + }) +} diff --git a/src/background/rest-helpers.ts b/src/background/rest-helpers.ts index 7d69087..fca7d34 100644 --- a/src/background/rest-helpers.ts +++ b/src/background/rest-helpers.ts @@ -89,7 +89,7 @@ export async function broadcastQueue( reverse?: { messageType: string data: Record - affectedItemIds: readonly string[] + affectedItemIds: string[] label?: string undoWindowMs?: number } @@ -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(fn: () => Promise, tabId?: number): Promise { - 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`). @@ -126,22 +128,24 @@ export async function withRateLimitRetry(fn: () => Promise, 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 } diff --git a/src/entries/background.ts b/src/entries/background.ts index bf93430..af58794 100644 --- a/src/entries/background.ts +++ b/src/entries/background.ts @@ -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() @@ -24,4 +25,5 @@ export default defineBackground(() => { registerSprintHandlers() registerDuplicateHandlers() registerBulkHandlers() + registerCreateIssueHandler() }) diff --git a/src/entries/content.ts b/src/entries/content.ts index 68af7ee..7951997 100644 --- a/src/entries/content.ts +++ b/src/entries/content.ts @@ -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 @@ -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, @@ -64,6 +66,7 @@ export default defineContentScript({ ctx.onInvalidated(() => { cleanupTableEnhancements() cleanupIssueDetail() + cleanupCreateIssueFields() document.removeEventListener('click', handleProjectItemOpen, true) }) }, diff --git a/src/features/create-issue-field-flyout.tsx b/src/features/create-issue-field-flyout.tsx new file mode 100644 index 0000000..ce735f3 --- /dev/null +++ b/src/features/create-issue-field-flyout.tsx @@ -0,0 +1,136 @@ +// "Fields ▾" chip + anchored flyout injected into GitHub's native "Create new +// issue" modal. Lets the user stage the project's own custom fields before +// GitHub creates the issue; values are applied afterward via the existing +// bulkUpdate pipeline (see create-issue-injections.tsx). + +import React, { useEffect, useRef, useState } from 'react' +import { AnchoredOverlay, Box, Button, CounterLabel, Spinner, Text } from '@primer/react' + +import { BULK_BAR_PRIMER_PORTAL_NAME } from '@/lib/primer-shadow-dom-compat' +import { Z_TOOLTIP } from '@/lib/z-index' +import { primerCss } from '@/lib/primer-css-helper' +import { ListCheckIcon } from '@/ui/icons' +import { EDITABLE_PROJECT_FIELD_DATATYPES, type ProjectData } from '@/features/bulk-edit-utils' +import { ValuePicker } from '@/features/bulk-edit-value-picker' +import { canApply } from '@/features/bulk-edit-flyout-helpers' +import { createIssueFieldsStore } from '@/lib/create-issue-fields-store' + +const chipSx = primerCss.chipButton() + +export interface CreateIssueFieldsChipProps { + getFields: () => Promise +} + +function noop(): void {} + +export function CreateIssueFieldsChip({ getFields }: CreateIssueFieldsChipProps) { + const chipRef = useRef(null) + const [open, setOpen] = useState(false) + const [fields, setFields] = useState(null) + const [, forceRender] = useState(0) + + useEffect(() => createIssueFieldsStore.subscribe(() => forceRender((n) => n + 1)), []) + const count = createIssueFieldsStore.count() + const loading = open && fields === null + + useEffect(() => { + if (!open || fields !== null) return + let cancelled = false + getFields().then((data) => { + if (cancelled) return + setFields(data.fields.filter((f) => EDITABLE_PROJECT_FIELD_DATATYPES.has(f.dataType))) + }) + return () => { + cancelled = true + } + }, [open, fields, getFields]) + + return ( + <> + + + setOpen(false)} + anchorRef={chipRef as React.RefObject} + renderAnchor={null} + align="start" + side="outside-bottom" + width="auto" + height="auto" + overlayProps={{ + portalContainerName: BULK_BAR_PRIMER_PORTAL_NAME, + role: 'dialog', + 'aria-label': 'Project fields', + sx: { boxShadow: 'none', pointerEvents: 'auto', zIndex: Z_TOOLTIP }, + }} + > + + {loading && ( + + + + )} + {!loading && (fields?.length ?? 0) === 0 && ( + No editable fields on this project. + )} + {!loading && + (fields ?? []).map((field) => { + const staged = createIssueFieldsStore.snapshot().get(field.id) + return ( + + canApply(next) + ? createIssueFieldsStore.set(field, next) + : createIssueFieldsStore.remove(field.id) + } + metaQuery="" + setMetaQuery={noop} + metaResults={[]} + metaLoading={false} + /> + ) + })} + + + + ) +} diff --git a/src/features/create-issue-injections.tsx b/src/features/create-issue-injections.tsx new file mode 100644 index 0000000..c1c6e61 --- /dev/null +++ b/src/features/create-issue-injections.tsx @@ -0,0 +1,433 @@ +// SPA-aware injector: mounts the "Fields ▾" chip into GitHub's native +// "Create new issue" modal and intercepts the native Create button — RGP +// creates the issue itself (createIssue → addProjectV2ItemById → per-field +// updateProjectV2ItemFieldValue), reading ids from the API responses instead +// of racing to find the new board row. +// Cloned from `issue-detail-injections.tsx`'s observer/history-patch pattern. + +import React from 'react' +import type { ContentScriptContext } from 'wxt/utils/content-script-context' +import type { ProjectData } from '@/features/bulk-edit-utils' +import type { CreateIssueWithFieldsMessageData } from '@/lib/messages' +import { createFeatureUi, type FeatureUi } from '@/lib/shadow-ui-factory' +import { CreateIssueFieldsChip } from '@/features/create-issue-field-flyout' +import { createIssueFieldsStore } from '@/lib/create-issue-fields-store' +import { + BULK_EDIT_CONCURRENT_MESSAGE, + BULK_EDIT_DISPATCH_FAILED_MESSAGE, + canApply, + serializeValue, +} from '@/features/bulk-edit-flyout-helpers' +import { sendMessage } from '@/lib/messages' +import { queueStore } from '@/lib/queue-store' +import { toastStore } from '@/lib/toast-store' +import { logger } from '@/lib/debug-logger' + +const CHIP_TESTID = 'rgp-create-issue-fields-chip' +const CREATE_BUTTON_SELECTOR = '[data-testid="create-issue-button"]' +const CREATE_MORE_SELECTOR = '[data-testid="create-more-check"]' + +const MODAL_SELECTORS = [ + '[data-component="Dialog"][class*="CreateIssueDialogContainer"]', + 'div[role="dialog"]:has([data-testid="create-issue-button"])', +] + +const FOOTER_SELECTORS = [ + '[class*="MetadataFooterContainer"]', + '[data-testid="create-issue-footer"]', +] + +const TITLE_INPUT_SELECTORS = [ + 'input[name="issue_title"]', + 'input[name="issue[title]"]', + '[data-testid="issue-title-input"]', + 'input[aria-label="Title"]', + 'input[aria-label="Add a title"]', +] + +const BODY_INPUT_SELECTORS = ['textarea[aria-label="Markdown value"]'] + +// The dialog's metadata sidebar renders the currently-picked assignees/labels +// as tokens once selected via their own pickers. Best-effort read: if none of +// these match, we simply omit assignees/labels from the create payload rather +// than failing the create. +const ASSIGNEES_CONTAINER_SELECTORS = ['[data-testid="assignees-select-menu"]'] +const LABELS_CONTAINER_SELECTORS = ['[data-testid="labels-select-menu"]'] + +// The board's native "+ Add item" omnibar row that (when typed into) spawns +// this dialog with its text as the initial title. Closing the dialog through +// our intercepted Create button skips GitHub's own handler, which normally +// clears this input — so we clear it ourselves. +const OMNIBAR_INPUT_SELECTORS = ['[class*="omnibarInput"]'] + +// After a "Create more" cycle, GitHub's dialog considers its form dirty and, +// on Close, shows this native confirmation instead of closing directly. RGP's +// own Close click doesn't know about it, so the dialog appears stuck. +const CONFIRM_DISCARD_DIALOG_SELECTOR = '[data-component="ConfirmationDialog"]' +const CONFIRM_DISCARD_BUTTON_SELECTOR = 'button[data-variant="danger"]' +const CONFIRM_DISCARD_TEXT_PATTERN = /discard/i + +// The board re-renders once the new item lands (the background's +// create→attach→field-update chain, several seconds after dispatch), and that +// re-render restores the omnibar's original draft text. Re-clear it on every +// mutation `check()` sees for this long after a create, rather than guessing +// a fixed delay. +const OMNIBAR_WATCH_MS = 15000 + +// No repo-picker exists inside the dialog — the repo is fixed before it opens +// (via a `#repo` hashtag in the board's inline combobox) and only surfaces +// inside the dialog as static heading text: "Create new issue in owner/repo". +const REPO_HEADING_SELECTORS = ['h1'] +const REPO_HEADING_PATTERN = /in\s+([^/\s]+)\/([^/\s]+)\s*$/i + +function findDialog(): Element | null { + for (const sel of MODAL_SELECTORS) { + try { + const el = document.querySelector(sel) + if (el) return el + } catch { + // selector may not be valid in this browser; skip + } + } + return null +} + +function findFooter(dialog: Element): Element | null { + for (const sel of FOOTER_SELECTORS) { + try { + const el = dialog.querySelector(sel) + if (el) return el + } catch { + // skip + } + } + return null +} + +function readInput( + dialog: Element, + selectors: string[], +): HTMLInputElement | HTMLTextAreaElement | null { + for (const sel of selectors) { + const el = dialog.querySelector(sel) + if (el) return el + } + return null +} + +function readTitle(dialog: Element): string { + return readInput(dialog, TITLE_INPUT_SELECTORS)?.value.trim() ?? '' +} + +function readBody(dialog: Element): string { + return readInput(dialog, BODY_INPUT_SELECTORS)?.value ?? '' +} + +function readCreateMore(dialog: Element): boolean { + return dialog.querySelector(CREATE_MORE_SELECTOR)?.checked ?? false +} + +function readAssignees(dialog: Element): string[] { + for (const sel of ASSIGNEES_CONTAINER_SELECTORS) { + const container = dialog.querySelector(sel) + if (!container) continue + const logins = Array.from(container.querySelectorAll('img[alt]')) + .map((img) => img.alt.replace(/^@/, '').trim()) + .filter(Boolean) + if (logins.length) return logins + } + return [] +} + +function readLabels(dialog: Element): string[] { + for (const sel of LABELS_CONTAINER_SELECTORS) { + const container = dialog.querySelector(sel) + if (!container) continue + const names = Array.from(container.querySelectorAll('[data-testid="label-token"]')) + .map((el) => el.textContent?.trim() ?? '') + .filter(Boolean) + if (names.length) return names + } + return [] +} + +function readRepo(dialog: Element): { owner: string; name: string } | null { + for (const sel of REPO_HEADING_SELECTORS) { + const el = dialog.querySelector(sel) + const match = el?.textContent?.match(REPO_HEADING_PATTERN) + if (match) return { owner: match[1], name: match[2] } + } + return null +} + +// Sets the value via the native setter so React's change tracking sees the +// update, then dispatches an `input` event so controlled inputs re-render. +function clearInput(el: HTMLInputElement | HTMLTextAreaElement): void { + const proto = + el instanceof HTMLTextAreaElement ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype + const setter = Object.getOwnPropertyDescriptor(proto, 'value')?.set + setter?.call(el, '') + el.dispatchEvent(new Event('input', { bubbles: true })) +} + +function buildCreatePayload( + dialog: Element, + projectId: string, +): CreateIssueWithFieldsMessageData | null { + const title = readTitle(dialog) + if (!title) return null + const repo = readRepo(dialog) + if (!repo) { + logger.warn('[rgp:cs] create-issue: could not read repo from dialog heading') + return null + } + + const updates: CreateIssueWithFieldsMessageData['updates'] = [] + const fieldMeta: NonNullable = {} + for (const { field, value } of createIssueFieldsStore.snapshot().values()) { + if (!canApply(value)) continue + const payload = serializeValue(value) + if (payload === null) continue + updates.push({ fieldId: field.id, value: { ...payload, dataType: field.dataType } }) + fieldMeta[field.id] = { + name: field.name, + options: field.options, + iterations: field.configuration?.iterations, + } + } + + const assignees = readAssignees(dialog) + const labels = readLabels(dialog) + + return { + projectId, + repoOwner: repo.owner, + repoName: repo.name, + title, + body: readBody(dialog), + createMore: readCreateMore(dialog), + updates, + fieldMeta, + ...(assignees.length ? { assignees } : {}), + ...(labels.length ? { labels } : {}), + } +} + +export function setupCreateIssueFieldInjector( + ctx: ContentScriptContext, + getFields: () => Promise, +): () => void { + let currentDialog: Element | null = null + let currentUi: FeatureUi | null = null + let mounting = false + let rafId: number | null = null + let intercepting = false + let omnibarWatchUntil: number | null = null + let omnibarSeedText: string | null = null + + function readOmnibarValue(): string { + for (const sel of OMNIBAR_INPUT_SELECTORS) { + const el = document.querySelector(sel) + if (el) return el.value + } + return '' + } + + function clearOmnibar(): void { + for (const sel of OMNIBAR_INPUT_SELECTORS) { + document.querySelectorAll(sel).forEach(clearInput) + } + } + + // Same as clearOmnibar, but only touches inputs whose value still matches + // the draft text captured at dispatch time — a value the user has since + // typed over is left alone instead of being wiped by the re-render watch. + function clearOmnibarIfUnchanged(seed: string): void { + for (const sel of OMNIBAR_INPUT_SELECTORS) { + document.querySelectorAll(sel).forEach((el) => { + if (el.value === seed) clearInput(el) + }) + } + } + + // GitHub's "Discard changes?" confirmation (if our Close click triggers one) + // mounts asynchronously — it isn't in the DOM yet on the same tick as the + // click. Poll a few animation frames instead of checking once. + async function dismissDiscardConfirm(timeoutMs = 5000): Promise { + const deadline = Date.now() + timeoutMs + while (Date.now() < deadline) { + const dialog = document.querySelector(CONFIRM_DISCARD_DIALOG_SELECTOR) + if (dialog && CONFIRM_DISCARD_TEXT_PATTERN.test(dialog.textContent ?? '')) { + const btn = dialog.querySelector(CONFIRM_DISCARD_BUTTON_SELECTOR) + if (btn) { + btn.click() + return + } + } + await new Promise((resolve) => requestAnimationFrame(resolve)) + } + } + + async function handleCreate(dialog: Element): Promise { + if (intercepting) return + intercepting = true + try { + const project = await getFields() + const payload = buildCreatePayload(dialog, project.id) + if (!payload) { + toastStore.show({ type: 'error', message: BULK_EDIT_DISPATCH_FAILED_MESSAGE }) + return + } + + if (queueStore.getActiveCount() >= 3) { + toastStore.show({ type: 'warning', message: BULK_EDIT_CONCURRENT_MESSAGE }) + return + } + + logger.log('[rgp:cs] create-issue: dispatching createIssueWithFields', payload.title) + const result = await sendMessage('createIssueWithFields', payload) + if (!result.ok) { + toastStore.show({ type: 'error', message: BULK_EDIT_DISPATCH_FAILED_MESSAGE }) + return + } + + // Clear title + body before closing (or before staging the next issue) so + // GitHub's dirty-form guard never sees unsaved content and pops its own + // "Discard changes?" confirmation on close. + const titleEl = readInput(dialog, TITLE_INPUT_SELECTORS) + const bodyEl = readInput(dialog, BODY_INPUT_SELECTORS) + if (titleEl) clearInput(titleEl) + if (bodyEl) clearInput(bodyEl) + + if (!payload.createMore) { + createIssueFieldsStore.clearAll() + omnibarSeedText = readOmnibarValue() + dialog.querySelector('[data-component="Dialog.CloseButton"]')?.click() + // Fallback in case some other field still marks the form dirty. + await dismissDiscardConfirm() + clearOmnibar() + // The board re-renders once the new item lands (async, seconds later), + // and that re-render restores the omnibar's original draft text once. + // check() re-clears it on every subsequent mutation until this expires, + // but only if the user hasn't since typed a new draft into it. + omnibarWatchUntil = Date.now() + OMNIBAR_WATCH_MS + } + } catch { + toastStore.show({ type: 'error', message: BULK_EDIT_DISPATCH_FAILED_MESSAGE }) + } finally { + intercepting = false + } + } + + async function mountChip(dialog: Element): Promise { + if (mounting || currentUi) return + const footer = findFooter(dialog) + if (!footer) return + if (footer.querySelector(`[data-testid="${CHIP_TESTID}"]`)) return + + mounting = true + try { + const ui = await createFeatureUi(ctx, { + name: 'create-issue-fields', + component: , + anchor: footer, + append: 'last', + portalCompat: true, + }) + ui.mount() + currentUi = ui + } finally { + mounting = false + } + } + + function unmountChip(): void { + currentUi?.destroy() + currentUi = null + } + + // Unmount the chip on a dialog-node swap (GitHub re-rendering the modal + // while it stays open). Staged fields must survive this — only a genuine + // dialog close should wipe them. + function unmountForSwap(): void { + unmountChip() + currentDialog = null + } + + function teardownForDialog(): void { + unmountForSwap() + createIssueFieldsStore.clearAll() + } + + const scheduleCheck = () => { + if (rafId !== null) return + rafId = requestAnimationFrame(() => { + rafId = null + check() + }) + } + + function check(): void { + if (omnibarWatchUntil !== null) { + if (Date.now() > omnibarWatchUntil) { + omnibarWatchUntil = null + omnibarSeedText = null + } else if (omnibarSeedText !== null) { + clearOmnibarIfUnchanged(omnibarSeedText) + } + } + + const dialog = findDialog() + + if (dialog && dialog !== currentDialog) { + if (currentDialog) unmountForSwap() + currentDialog = dialog + void mountChip(dialog) + } else if (!dialog && currentDialog) { + teardownForDialog() + } else if (dialog && !currentUi && !mounting) { + // GitHub re-rendered the footer row without swapping the dialog itself + void mountChip(dialog) + } + } + + const handleDialogClick = (e: Event) => { + if (!currentDialog) return + const target = e.target as Element + if (!target.closest?.(CREATE_BUTTON_SELECTOR)) return + if (!readTitle(currentDialog)) return // let native validation handle empty title + e.preventDefault() + e.stopImmediatePropagation() + void handleCreate(currentDialog) + } + + const handleDialogKeydown = (e: KeyboardEvent) => { + if (!currentDialog) return + if (e.key !== 'Enter' || !(e.metaKey || e.ctrlKey)) return + if (!currentDialog.contains(e.target as Node)) return + if (!readTitle(currentDialog)) return + e.preventDefault() + e.stopImmediatePropagation() + void handleCreate(currentDialog) + } + + const observer = new MutationObserver(scheduleCheck) + observer.observe(document.body, { + childList: true, + subtree: true, + attributes: true, + attributeFilter: ['style', 'class', 'hidden', 'open'], + }) + + document.addEventListener('click', handleDialogClick, true) + document.addEventListener('keydown', handleDialogKeydown, true) + scheduleCheck() + + return () => { + observer.disconnect() + document.removeEventListener('click', handleDialogClick, true) + document.removeEventListener('keydown', handleDialogKeydown, true) + if (rafId !== null) window.cancelAnimationFrame(rafId) + teardownForDialog() + } +} diff --git a/src/lib/create-issue-fields-store.test.ts b/src/lib/create-issue-fields-store.test.ts new file mode 100644 index 0000000..569fcd8 --- /dev/null +++ b/src/lib/create-issue-fields-store.test.ts @@ -0,0 +1,69 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { createIssueFieldsStore } from '@/lib/create-issue-fields-store' +import type { ProjectField } from '@/features/bulk-edit-utils' + +const textField: ProjectField = { id: 'f1', name: 'Priority', dataType: 'TEXT' } +const numberField: ProjectField = { id: 'f2', name: 'Points', dataType: 'NUMBER' } + +beforeEach(() => { + createIssueFieldsStore.clearAll() +}) + +describe('createIssueFieldsStore', () => { + it('set stages a field value', () => { + createIssueFieldsStore.set(textField, { kind: 'text', text: 'hi' }) + expect(createIssueFieldsStore.count()).toBe(1) + expect(createIssueFieldsStore.snapshot().get('f1')).toEqual({ + field: textField, + value: { kind: 'text', text: 'hi' }, + }) + }) + + it('set overwrites an existing value for the same field', () => { + createIssueFieldsStore.set(textField, { kind: 'text', text: 'a' }) + createIssueFieldsStore.set(textField, { kind: 'text', text: 'b' }) + expect(createIssueFieldsStore.count()).toBe(1) + expect(createIssueFieldsStore.snapshot().get('f1')?.value).toEqual({ kind: 'text', text: 'b' }) + }) + + it('remove clears a single staged field', () => { + createIssueFieldsStore.set(textField, { kind: 'text', text: 'a' }) + createIssueFieldsStore.set(numberField, { kind: 'number', number: 3 }) + createIssueFieldsStore.remove('f1') + expect(createIssueFieldsStore.count()).toBe(1) + expect(createIssueFieldsStore.snapshot().has('f1')).toBe(false) + }) + + it('clearAll removes every staged field', () => { + createIssueFieldsStore.set(textField, { kind: 'text', text: 'a' }) + createIssueFieldsStore.set(numberField, { kind: 'number', number: 3 }) + createIssueFieldsStore.clearAll() + expect(createIssueFieldsStore.count()).toBe(0) + }) + + it('subscribe fires on set/remove/clearAll and not after unsubscribe', () => { + const listener = vi.fn() + const unsub = createIssueFieldsStore.subscribe(listener) + + createIssueFieldsStore.set(textField, { kind: 'text', text: 'a' }) + expect(listener).toHaveBeenCalledTimes(1) + + createIssueFieldsStore.remove('f1') + expect(listener).toHaveBeenCalledTimes(2) + + createIssueFieldsStore.set(textField, { kind: 'text', text: 'a' }) + createIssueFieldsStore.clearAll() + expect(listener).toHaveBeenCalledTimes(4) + + unsub() + createIssueFieldsStore.set(numberField, { kind: 'number', number: 1 }) + expect(listener).toHaveBeenCalledTimes(4) + }) + + it('remove on a missing field is a no-op (no notification)', () => { + const listener = vi.fn() + createIssueFieldsStore.subscribe(listener) + createIssueFieldsStore.remove('does-not-exist') + expect(listener).not.toHaveBeenCalled() + }) +}) diff --git a/src/lib/create-issue-fields-store.ts b/src/lib/create-issue-fields-store.ts new file mode 100644 index 0000000..c6cf944 --- /dev/null +++ b/src/lib/create-issue-fields-store.ts @@ -0,0 +1,51 @@ +import type { ProjectField } from '@/features/bulk-edit-utils' +import type { FieldValue } from '@/features/bulk-edit-flyout-helpers' + +type Listener = () => void + +export interface StagedField { + field: ProjectField + value: FieldValue +} + +let current: ReadonlyMap = new Map() + +const listeners = new Set() + +function setState(next: ReadonlyMap): void { + current = next + listeners.forEach((fn) => fn()) +} + +export const createIssueFieldsStore = { + set(field: ProjectField, value: FieldValue): void { + const next = new Map(current) + next.set(field.id, { field, value }) + setState(next) + }, + + remove(fieldId: string): void { + if (!current.has(fieldId)) return + const next = new Map(current) + next.delete(fieldId) + setState(next) + }, + + clearAll(): void { + if (current.size === 0) return + setState(new Map()) + }, + + snapshot(): ReadonlyMap { + return current + }, + + count(): number { + return current.size + }, + + subscribe(fn: Listener): () => void { + listeners.add(fn) + return () => listeners.delete(fn) + }, +} diff --git a/src/lib/errors.ts b/src/lib/errors.ts index 52f2405..1105b1e 100644 --- a/src/lib/errors.ts +++ b/src/lib/errors.ts @@ -76,7 +76,12 @@ export class GithubGraphQLError extends Data.TaggedError('GithubGraphQLError')<{ export class GithubNetworkError extends Data.TaggedError('GithubNetworkError')<{ readonly cause: unknown -}> {} +}> { + constructor(args: { readonly cause: unknown }) { + super(args) + redefineMessage(this, args.cause instanceof Error ? args.cause.message : String(args.cause)) + } +} export class GithubDecodeError extends Data.TaggedError('GithubDecodeError')<{ readonly message: string diff --git a/src/lib/github-project.test.ts b/src/lib/github-project.test.ts index 7bcb0f4..392f520 100644 --- a/src/lib/github-project.test.ts +++ b/src/lib/github-project.test.ts @@ -8,9 +8,9 @@ import { extractProjectContext, fetchProjectFields } from '@/lib/github-project' describe('extractProjectContext', () => { it('extracts org project context', () => { - const ctx = extractProjectContext('/orgs/kitabisa/projects/58') + const ctx = extractProjectContext('/orgs/octocat/projects/58') expect(ctx).toEqual({ - owner: 'kitabisa', + owner: 'octocat', number: 58, isOrg: true, projectId: 'org-project-58', diff --git a/src/lib/messages.ts b/src/lib/messages.ts index f331285..ee63492 100644 --- a/src/lib/messages.ts +++ b/src/lib/messages.ts @@ -1,6 +1,5 @@ import { defineExtensionMessaging } from '@webext-core/messaging' -import type { ExcludeCondition, SprintSettings } from '@/lib/storage' -import type { PatErrorType } from '@/lib/errors' +import type { ProtocolMapFromSchemas } from '@/lib/schemas-messages' export interface IssueRelationshipData { nodeId?: string @@ -98,6 +97,22 @@ export interface BulkUpdateMessageData { > } +/** Payload for `createIssueWithFields` — RGP creates the issue itself (native + * Create button intercepted), then attaches it to the project and applies + * staged custom-field values, reading ids straight from the API responses. */ +export interface CreateIssueWithFieldsMessageData { + projectId: string + repoOwner: string + repoName: string + title: string + body: string + createMore: boolean + updates: { fieldId: string; value: unknown }[] + fieldMeta?: BulkUpdateMessageData['fieldMeta'] + assignees?: string[] + labels?: string[] +} + export interface ItemPreviewData { resolvedItemId: string issueNumber: number @@ -187,221 +202,7 @@ export interface BulkRandomAssignData { strategy: 'balanced' | 'random' | 'round-robin' } -interface ProtocolMap { - duplicateItem(data: { itemId: string; projectId: string; plan?: DuplicateItemPlan }): { - accepted: boolean - } - getItemPreview(data: { - itemId: string - owner: string - number: number - isOrg: boolean - }): ItemPreviewData - openOptions(data: {}): void - getPatStatus(data: {}): { hasPat: boolean } - validatePat(data: { - token: string - }): - | { valid: true; user: string } - | { valid: false; errorType?: PatErrorType; errorMessage?: string } - searchRepoMetadata(data: { - owner: string - name: string - q: string - type: 'ASSIGNEES' | 'LABELS' | 'MILESTONES' | 'ISSUE_TYPES' - }): { id: string; name: string; color?: string; avatarUrl?: string; description?: string }[] - searchRelationshipIssues(data: { - q: string - owner?: string - repoName?: string - }): IssueSearchResultData[] - validateBulkRelationshipUpdates(data: { - itemIds: string[] - projectId: string - relationships: BulkEditRelationshipsUpdate - }): BulkRelationshipValidationResult - searchTransferTargets(data: { - owner: string - q: string - firstItemId?: string - projectId?: string - /** §10.4 — restrict listing to repos owned by the source `owner`. Default 'all'. */ - scope?: 'owner-only' | 'all' - /** §10.3 — when true, BG returns ineligible repos tagged via `eligibility` instead of filtering. */ - includeIneligible?: boolean - }): { - id: string - name: string - nameWithOwner: string - isPrivate: boolean - description: string | null - /** §10.3 — per-row availability for transfer; `undefined` means eligibility was not computed. */ - eligibility?: 'ok' | 'archived' | 'issues-disabled' - }[] - /** - * §10.7 — per-source-item pre-flight eligibility for a transfer destination. - * Returns one row per requested itemId so the modal can render a Flash - * warning listing ineligible titles and proceed with the eligible subset. - */ - validateTransferEligibility(data: { - itemIds: string[] - projectId: string - targetRepoOwner: string - targetRepoName: string - }): Array<{ - domId: string - eligible: boolean - reason?: 'pull-request' | 'same-repo' | 'unresolved' - title?: string - }> - bulkUpdate(data: BulkUpdateMessageData): BulkUpdateDispatchResult - bulkClose(data: { - itemIds: string[] - 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 }): void - bulkTransfer(data: { - itemIds: string[] - projectId: string - targetRepoOwner: string - targetRepoName: string - }): void - bulkLock(data: { - itemIds: string[] - projectId: string - lockReason: 'OFF_TOPIC' | 'TOO_HEATED' | 'RESOLVED' | 'SPAM' | null - }): void - bulkUnlock(data: { itemIds: string[]; projectId: string }): void - bulkPin(data: { itemIds: string[]; projectId: string }): void - bulkUnpin(data: { itemIds: string[]; projectId: string }): void - bulkDelete(data: { itemIds: string[]; projectId: string }): void - getProjectFields(data: { owner: string; number: number; isOrg: boolean }): { - id: string - title: string - fields: { - id: string - name: string - dataType: string - options?: { id: string; name: string; color?: string }[] - configuration?: { - iterations: { id: string; title: string; startDate: string; duration: number }[] - } - }[] - } - getSprintStatus(data: { projectId: string; owner: string; number: number; isOrg: boolean }): { - hasSettings: boolean - activeSprint: SprintInfo | null - nearestUpcoming: SprintInfo | null - acknowledgedSprint: SprintInfo | null - iterationFieldId: string | null - settings: SprintSettings | null - } - saveSprintSettings(data: { projectId: string; settings: SprintSettings }): { ok: boolean } - acknowledgeUpcomingSprint(data: { projectId: string; iterationId: string }): { ok: boolean } - getSprintProgress(data: { - projectId: string - owner: string - number: number - isOrg: boolean - iterationId: string - sprintStartDate: string - settings: SprintSettings - }): SprintProgressData - endSprint(data: { - projectId: string - owner: string - number: number - isOrg: boolean - sprintFieldId: string - activeIterationId: string - nextIterationId: string - doneFieldId: string - doneFieldType: 'SINGLE_SELECT' | 'TEXT' - doneOptionId: string - doneOptionValue: string - notStartedOptionId?: string - excludeConditions: ExcludeCondition[] - }): void | { error: string } - getItemTitles(data: { itemIds: string[]; projectId: string }): Array<{ - domId: string - issueNodeId: string - title: string - typename: 'Issue' | 'PullRequest' - }> - bulkRename(data: { - itemIds: string[] - projectId: string - renames: Array<{ - domId: string - issueNodeId: string - newTitle: string - typename: 'Issue' | 'PullRequest' - }> - }): void - getReorderContext(data: { - itemIds: string[] - projectId: string - owner: string - number: number - isOrg: boolean - allDomIds?: string[] - }): { - projectId: string - /** All project items in current view order */ - allOrderedItems: Array<{ memexItemId: number; nodeId: string; title: string }> - /** The selected items with their DOM IDs and resolved memex item IDs */ - selectedItems: Array<{ domId: string; memexItemId: number; nodeId: string; title: string }> - } - bulkReorder(data: { - projectId: string - reorderOps: Array<{ - nodeId: string - previousNodeId: string | null - }> - label?: string - }): void - bulkReorderByPosition(data: { - selectedDomIds: string[] - insertAfterDomId: string - projectId: string - owner: string - number: number - isOrg: boolean - label?: string - allDomIds?: string[] - }): void - getHierarchyData(data: { - itemId: string - owner: string - number: number - isOrg: boolean - }): HierarchyData - cancelProcess(data: { processId: string }): void - queueStateUpdate(data: { - total: number - completed: number - paused: boolean - retryAfter?: number - status?: string - detail?: string - processId?: string - label?: string - failedItems?: Array<{ id: string; title: string; error: string }> - retryContext?: { messageType: string; data: Record } - reverse?: { - messageType: string - data: Record - affectedItemIds: readonly string[] - label?: string - undoWindowMs?: number - } - }): void -} - -const _messaging = defineExtensionMessaging() +const _messaging = defineExtensionMessaging() export const onMessage = _messaging.onMessage // wrap sendMessage with SW reconnect retry logic diff --git a/src/lib/project-table-dom.ts b/src/lib/project-table-dom.ts index 196f6eb..fa15673 100644 --- a/src/lib/project-table-dom.ts +++ b/src/lib/project-table-dom.ts @@ -53,6 +53,23 @@ export function getAllInjectedItemIds(): ProjectItemDomId[] { return ids } +/** + * Collect item IDs from GitHub's native rows, keyed off `data-hovercard-subject-tag`. + * Present the moment GitHub renders a row — no dependency on RGP's own + * `data-rgp-cb` injection, which is stamped asynchronously and separately. + * Used by the create-issue capture watcher, which must see a brand-new row + * before injection stamps it. + */ +export function getAllNativeItemIds(): ProjectItemDomId[] { + const rows = document.querySelectorAll('[role="row"][data-hovercard-subject-tag]') + const ids: ProjectItemDomId[] = [] + for (const row of rows) { + const id = extractItemId(row) + if (id) ids.push(id) + } + return ids +} + /** * Read the displayed title for a given row. Falls back to the row's primary * link text. Returns `null` if neither can be located. diff --git a/src/lib/schemas-messages.ts b/src/lib/schemas-messages.ts index 0c155f7..40b3662 100644 --- a/src/lib/schemas-messages.ts +++ b/src/lib/schemas-messages.ts @@ -6,12 +6,12 @@ import { PatErrorType } from '@/lib/schemas-errors' /** * Schemas for every entry in `ProtocolMap` (src/lib/messages.ts). * - * The plain TypeScript `interface` declarations in messages.ts remain the - * authoritative wire types so we don't have to rewrite all call sites at - * once; these schemas mirror them and are used by background message - * handlers to: - * - decode untrusted incoming payloads (`Schema.decodeUnknown(input)`), - * - encode handler return values (`Schema.encode(output)`). + * **These schemas are the single source of truth** for the message contract. + * The derived types (`ProtocolMapFromSchemas`, input/output type aliases) are + * re-exported from messages.ts so that `@webext-core/messaging` consumes + * schema-derived types end-to-end. Background handlers use the schemas to: + * - decode untrusted incoming payloads (`Schema.decodeUnknownSync(input)`), + * - encode handler return values (`Schema.encodeSync(output)`). * * Where a payload contains a value already validated upstream (e.g. `value: * Record` for arbitrary field updates), `Schema.Unknown` @@ -59,6 +59,68 @@ const SubIssueData = Schema.Struct({ state: Schema.Literal('OPEN', 'CLOSED'), }) +const DuplicateItemPlanRelationshipSection = Schema.Struct({ + enabled: Schema.Boolean, + issue: Schema.optional(IssueRelationshipData), +}) + +const DuplicateItemPlan = Schema.Struct({ + title: Schema.Struct({ enabled: Schema.Boolean, value: Schema.String }), + body: Schema.Struct({ enabled: Schema.Boolean, value: Schema.String }), + assignees: Schema.Struct({ enabled: Schema.Boolean, ids: Schema.Array(Schema.String) }), + labels: Schema.Struct({ enabled: Schema.Boolean, ids: Schema.Array(Schema.String) }), + issueType: Schema.Struct({ + enabled: Schema.Boolean, + id: Schema.optional(Schema.String), + name: Schema.optional(Schema.String), + }), + fieldValues: Schema.Array( + Schema.Struct({ + fieldId: Schema.String, + enabled: Schema.Boolean, + value: Schema.Record({ key: Schema.String, value: Schema.Unknown }), + }), + ), + relationships: Schema.Struct({ + parent: DuplicateItemPlanRelationshipSection, + blockedBy: Schema.Struct({ + enabled: Schema.Boolean, + issues: Schema.Array(IssueRelationshipData), + }), + blocking: Schema.Struct({ + enabled: Schema.Boolean, + issues: Schema.Array(IssueRelationshipData), + }), + }), +}) +export type DuplicateItemPlan = Schema.Schema.Type + +// Shared shape for BulkUpdate/createIssueWithFields inputs. +const BulkUpdateDispatchResult = Schema.Union( + Schema.Struct({ ok: Schema.Literal(true) }), + Schema.Struct({ ok: Schema.Literal(false), reason: Schema.Literal('concurrent') }), +) + +const FieldMetaValue = Schema.Struct({ + name: Schema.String, + options: Schema.optional(Schema.Array(Schema.Struct({ id: Schema.String, name: Schema.String }))), + iterations: Schema.optional( + Schema.Array( + Schema.Struct({ + id: Schema.String, + title: Schema.String, + startDate: Schema.String, + duration: Schema.Number, + }), + ), + ), +}) + +const BulkUpdateFieldUpdate = Schema.Struct({ + fieldId: Schema.String, + value: Schema.Unknown, +}) + const PreviewFieldEntry = Schema.Struct({ fieldId: Schema.String, fieldName: Schema.String, @@ -187,7 +249,7 @@ export const Messages = { input: Schema.Struct({ itemId: Schema.String, projectId: Schema.String, - plan: Schema.optional(Schema.Unknown), + plan: Schema.optional(DuplicateItemPlan), }), output: Schema.Struct({ accepted: Schema.Boolean }), }, @@ -286,34 +348,26 @@ export const Messages = { input: Schema.Struct({ itemIds: Schema.Array(Schema.String), projectId: Schema.String, - updates: Schema.Array(Schema.Struct({ fieldId: Schema.String, value: Schema.Unknown })), + updates: Schema.Array(BulkUpdateFieldUpdate), relationships: Schema.optional(BulkEditRelationshipsUpdate), - fieldMeta: Schema.optional( - Schema.Record({ - key: Schema.String, - value: Schema.Struct({ - name: Schema.String, - options: Schema.optional( - Schema.Array(Schema.Struct({ id: Schema.String, name: Schema.String })), - ), - iterations: Schema.optional( - Schema.Array( - Schema.Struct({ - id: Schema.String, - title: Schema.String, - startDate: Schema.String, - duration: Schema.Number, - }), - ), - ), - }), - }), - ), + fieldMeta: Schema.optional(Schema.Record({ key: Schema.String, value: FieldMetaValue })), }), - output: Schema.Union( - Schema.Struct({ ok: Schema.Literal(true) }), - Schema.Struct({ ok: Schema.Literal(false), reason: Schema.Literal('concurrent') }), - ), + output: BulkUpdateDispatchResult, + }, + createIssueWithFields: { + input: Schema.Struct({ + projectId: Schema.String, + repoOwner: Schema.String, + repoName: Schema.String, + title: Schema.String, + body: Schema.String, + createMore: Schema.Boolean, + updates: Schema.Array(BulkUpdateFieldUpdate), + fieldMeta: Schema.optional(Schema.Record({ key: Schema.String, value: FieldMetaValue })), + assignees: Schema.optional(Schema.Array(Schema.String)), + labels: Schema.optional(Schema.Array(Schema.String)), + }), + output: BulkUpdateDispatchResult, }, bulkClose: { input: Schema.Struct({ @@ -592,3 +646,43 @@ export const Messages = { output: Schema.Void, }, } as const + +// ─── Derived ProtocolMap ──────────────────────────────────────────────────── + +/** + * Extracts the plain TypeScript type from a Schema value declaration. + * `typeof Messages[key].input` is `{ input: Schema }` — this unwraps to `X`. + */ +type SchemaInput = T extends { input: Schema.Schema } ? I : never +type SchemaOutput = T extends { output: Schema.Schema } ? O : never + +/** + * Effect `Schema.Struct`/`Schema.Array` produce deeply `readonly` types, but + * `@webext-core/messaging`'s `ProtocolMap` and its call sites expect plain + * mutable data. Strips `readonly` recursively so the schema-derived map is a + * drop-in for the hand-written one it replaces. + */ +type DeepMutable = T extends readonly (infer U)[] + ? DeepMutable[] + : T extends object + ? { -readonly [K in keyof T]: DeepMutable } + : T + +/** + * Derives the `ProtocolMap` interface from the `Messages` schema record. + * + * Each key `K` becomes `(data: SchemaInput) => SchemaOutput`, + * matching the function-signature syntax expected by `@webext-core/messaging`. + */ +export type ProtocolMapFromSchemas = { + [K in keyof typeof Messages]: ( + data: DeepMutable>, + ) => DeepMutable> +} + +/** + * Convenience type aliases — re-exported from messages.ts so call sites can + * import `XxxData` etc. without knowing about the schema layer. + */ +export type MessageInput = SchemaInput<(typeof Messages)[K]> +export type MessageOutput = SchemaOutput<(typeof Messages)[K]> diff --git a/src/lib/shadow-ui-factory.tsx b/src/lib/shadow-ui-factory.tsx index 82b61a5..9f51e9f 100644 --- a/src/lib/shadow-ui-factory.tsx +++ b/src/lib/shadow-ui-factory.tsx @@ -82,6 +82,13 @@ export async function createFeatureUi( anchor: opts.anchor ?? document.body, append: opts.append ?? 'last', onMount(container, shadow) { + if (opts.anchor) { + const host = (container.getRootNode() as ShadowRoot).host as HTMLElement + host.style.display = 'inline-flex' + host.style.alignItems = 'center' + host.style.verticalAlign = 'middle' + } + styleHost = document.createElement('div') container.parentElement!.insertBefore(styleHost, container) diff --git a/wxt.config.ts b/wxt.config.ts index 1cbdea6..ca486b6 100644 --- a/wxt.config.ts +++ b/wxt.config.ts @@ -10,7 +10,7 @@ export default defineConfig({ entrypointsDir: 'entries', modules: ['@wxt-dev/module-react'], webExt: { - startUrls: ['https://github.com/orgs/kitabisa/projects/58/views/8'], + startUrls: process.env.WXT_DEV_START_URL ? [process.env.WXT_DEV_START_URL] : [], }, vite: () => ({ server: {