From eb27a737d33054b2fa26b151922e124a3b335d20 Mon Sep 17 00:00:00 2001 From: Fathiraz Arthuro Date: Sun, 19 Jul 2026 06:48:39 +0700 Subject: [PATCH 1/9] feat(create-issue): stage project custom fields in native Create modal Adds a "Fields" chip to GitHub's native Create new issue modal on a Projects v2 board. Users stage the project's custom fields (Status, Priority, Sprint/Iteration, number, date, text) before creating the issue; once GitHub creates it and the new row appears on the board, the staged values are applied via the existing bulkUpdate pipeline. --- src/entries/content.ts | 3 + src/features/create-issue-field-flyout.tsx | 131 ++++++++++ src/features/create-issue-injections.tsx | 290 +++++++++++++++++++++ src/lib/create-issue-fields-store.test.ts | 69 +++++ src/lib/create-issue-fields-store.ts | 51 ++++ 5 files changed, 544 insertions(+) create mode 100644 src/features/create-issue-field-flyout.tsx create mode 100644 src/features/create-issue-injections.tsx create mode 100644 src/lib/create-issue-fields-store.test.ts create mode 100644 src/lib/create-issue-fields-store.ts diff --git a/src/entries/content.ts b/src/entries/content.ts index 68af7ee..d8da1da 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, projectContext, 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..b53da68 --- /dev/null +++ b/src/features/create-issue-field-flyout.tsx @@ -0,0 +1,131 @@ +// "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 { 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 ( + createIssueFieldsStore.set(field, next)} + 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..ca5a3f2 --- /dev/null +++ b/src/features/create-issue-injections.tsx @@ -0,0 +1,290 @@ +// SPA-aware injector: mounts the "Fields ▾" chip into GitHub's native +// "Create new issue" modal and, on native Create, applies staged custom-field +// values to the newly created board item via the existing bulkUpdate pipeline. +// 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 { ProjectContext } from '@/lib/github-project' +import type { ProjectData } from '@/features/bulk-edit-utils' +import type { BulkUpdateMessageData } 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 { getAllInjectedItemIds, getTitlesForItemIds } from '@/lib/project-table-dom' +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 WATCH_TIMEOUT_MS = 8000 + +/** Selectors tried in order to find the "Create new issue" dialog. */ +const MODAL_SELECTORS = [ + '[data-component="Dialog"][class*="CreateIssueDialogContainer"]', + 'div[role="dialog"]:has([data-testid="create-issue-button"])', +] + +/** Selectors tried in order to find the metadata footer row inside the dialog. */ +const FOOTER_SELECTORS = [ + '[class*="MetadataFooterContainer"]', + '[data-testid="create-issue-footer"]', +] + +/** Selectors tried in order to find the issue title input inside the dialog. */ +const TITLE_INPUT_SELECTORS = [ + 'input[name="issue_title"]', + 'input[name="issue[title]"]', + '[data-testid="issue-title-input"]', + 'input[aria-label="Title"]', +] + +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 readTitle(dialog: Element): string { + for (const sel of TITLE_INPUT_SELECTORS) { + const el = dialog.querySelector(sel) + if (el) return el.value.trim() + } + return '' +} + +function readCreateMore(dialog: Element): boolean { + return dialog.querySelector(CREATE_MORE_SELECTOR)?.checked ?? false +} + +function buildBulkUpdatePayload(itemId: string, projectId: string): BulkUpdateMessageData | null { + const updates: BulkUpdateMessageData['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, + } + } + + if (updates.length === 0) return null + return { itemIds: [itemId], projectId, updates, fieldMeta } +} + +async function dispatchBulkUpdate(payload: BulkUpdateMessageData): Promise { + if (queueStore.getActiveCount() >= 3) { + toastStore.show({ type: 'warning', message: BULK_EDIT_CONCURRENT_MESSAGE }) + return + } + try { + const result = await sendMessage('bulkUpdate', payload) + if (!result.ok) { + toastStore.show({ type: 'error', message: BULK_EDIT_DISPATCH_FAILED_MESSAGE }) + } + } catch { + toastStore.show({ type: 'error', message: BULK_EDIT_DISPATCH_FAILED_MESSAGE }) + } +} + +interface WatchState { + beforeIds: Set + typedTitle: string + createMore: boolean + projectId: string +} + +export function setupCreateIssueFieldInjector( + ctx: ContentScriptContext, + projectContext: ProjectContext, + getFields: () => Promise, +): () => void { + let currentDialog: Element | null = null + let currentUi: FeatureUi | null = null + let mounting = false + let rafId: number | null = null + let watchState: WatchState | null = null + let watchObserver: MutationObserver | null = null + let watchTimeoutId: ReturnType | null = null + + function stopWatcher(): void { + if (watchTimeoutId !== null) { + clearTimeout(watchTimeoutId) + watchTimeoutId = null + } + watchObserver?.disconnect() + watchObserver = null + } + + function finishWatch(matchedId: string | null): void { + const state = watchState + stopWatcher() + watchState = null + if (!state) return + + if (matchedId) { + const payload = buildBulkUpdatePayload(matchedId, state.projectId) + if (payload) void dispatchBulkUpdate(payload) + } else { + // ponytail: capture is view-dependent — a new item created outside the + // current filtered/paginated board view never appears in the injected + // rows we scan, so it can't be matched here. Upgrade path if this bites: + // resolve the new item via an announcement→number→databaseId query + // instead of diffing the DOM. + toastStore.show({ + type: 'warning', + message: "Issue created outside the current board view — custom fields weren't applied.", + }) + } + + if (!state.createMore) createIssueFieldsStore.clearAll() + } + + function checkForNewRow(): void { + if (!watchState) return + const currentIds = getAllInjectedItemIds() + const newIds = currentIds.filter((id) => !watchState!.beforeIds.has(id)) + if (newIds.length === 0) return + + let matchedId: string | null = newIds[0] ?? null + if (watchState.typedTitle) { + const titled = getTitlesForItemIds(newIds) + const match = titled.find((t) => t.title === watchState!.typedTitle) + if (match) matchedId = match.id + } + + finishWatch(matchedId) + } + + function startWatch(dialog: Element): void { + if (createIssueFieldsStore.count() === 0) return + stopWatcher() + + watchState = { + beforeIds: new Set(getAllInjectedItemIds()), + typedTitle: readTitle(dialog), + createMore: readCreateMore(dialog), + projectId: projectContext.projectId, + } + + watchObserver = new MutationObserver(checkForNewRow) + watchObserver.observe(document.body, { childList: true, subtree: true }) + watchTimeoutId = setTimeout(() => finishWatch(null), WATCH_TIMEOUT_MS) + } + + 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 + } + + function teardownForDialog(): void { + unmountChip() + createIssueFieldsStore.clearAll() + stopWatcher() + watchState = null + currentDialog = null + } + + const scheduleCheck = () => { + if (rafId !== null) return + rafId = requestAnimationFrame(() => { + rafId = null + check() + }) + } + + function check(): void { + const dialog = findDialog() + + if (dialog && dialog !== currentDialog) { + if (currentDialog) teardownForDialog() + 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 + logger.log('[rgp:cs] create-issue: Create clicked, starting capture watcher') + startWatch(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) + scheduleCheck() + + return () => { + observer.disconnect() + document.removeEventListener('click', handleDialogClick, 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) + }, +} From a0c0ec7672db05ca3de6c65dacce46ded17b8b3a Mon Sep 17 00:00:00 2001 From: Fathiraz Arthuro Date: Tue, 21 Jul 2026 10:16:39 +0700 Subject: [PATCH 2/9] feat(create-issue): own Create flow via API with staged dynamic fields Intercept the native Create button and create/attach/apply project fields from GraphQL responses instead of racing the board DOM. --- src/background/bulk-update.ts | 19 +- src/background/create-issue.ts | 152 ++++++++++++++ src/background/rest-helpers.ts | 26 ++- src/entries/background.ts | 2 + src/entries/content.ts | 2 +- src/features/create-issue-injections.tsx | 245 ++++++++++++----------- src/lib/messages.ts | 15 ++ src/lib/project-table-dom.ts | 17 ++ src/lib/shadow-ui-factory.tsx | 7 + 9 files changed, 360 insertions(+), 125 deletions(-) create mode 100644 src/background/create-issue.ts 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..5da7a9b --- /dev/null +++ b/src/background/create-issue.ts @@ -0,0 +1,152 @@ +// ─── 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 { 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' + +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) + 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, + }) + 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..b52d930 100644 --- a/src/background/rest-helpers.ts +++ b/src/background/rest-helpers.ts @@ -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 d8da1da..7951997 100644 --- a/src/entries/content.ts +++ b/src/entries/content.ts @@ -56,7 +56,7 @@ export default defineContentScript({ const injectSprintHeaders = createSprintHeaderInjector(ctx, projectContext, getFields) const injectHierarchyChips = createHierarchyChipInjector(projectContext) const cleanupIssueDetail = setupIssueDetailInjector(projectContext) - const cleanupCreateIssueFields = setupCreateIssueFieldInjector(ctx, projectContext, getFields) + const cleanupCreateIssueFields = setupCreateIssueFieldInjector(ctx, getFields) const cleanupTableEnhancements = setupTableEnhancements([ injectSprintHeaders, injectStatusBarSprintButton, diff --git a/src/features/create-issue-injections.tsx b/src/features/create-issue-injections.tsx index ca5a3f2..ba09c9d 100644 --- a/src/features/create-issue-injections.tsx +++ b/src/features/create-issue-injections.tsx @@ -1,17 +1,17 @@ // SPA-aware injector: mounts the "Fields ▾" chip into GitHub's native -// "Create new issue" modal and, on native Create, applies staged custom-field -// values to the newly created board item via the existing bulkUpdate pipeline. +// "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 { ProjectContext } from '@/lib/github-project' import type { ProjectData } from '@/features/bulk-edit-utils' -import type { BulkUpdateMessageData } from '@/lib/messages' +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 { getAllInjectedItemIds, getTitlesForItemIds } from '@/lib/project-table-dom' import { BULK_EDIT_CONCURRENT_MESSAGE, BULK_EDIT_DISPATCH_FAILED_MESSAGE, @@ -26,28 +26,33 @@ 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 WATCH_TIMEOUT_MS = 8000 -/** Selectors tried in order to find the "Create new issue" dialog. */ const MODAL_SELECTORS = [ '[data-component="Dialog"][class*="CreateIssueDialogContainer"]', 'div[role="dialog"]:has([data-testid="create-issue-button"])', ] -/** Selectors tried in order to find the metadata footer row inside the dialog. */ const FOOTER_SELECTORS = [ '[class*="MetadataFooterContainer"]', '[data-testid="create-issue-footer"]', ] -/** Selectors tried in order to find the issue title input inside the dialog. */ 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"]'] + +// 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 { @@ -72,22 +77,62 @@ function findFooter(dialog: Element): Element | null { return null } -function readTitle(dialog: Element): string { - for (const sel of TITLE_INPUT_SELECTORS) { +function readInput( + dialog: Element, + selectors: string[], +): HTMLInputElement | HTMLTextAreaElement | null { + for (const sel of selectors) { const el = dialog.querySelector(sel) - if (el) return el.value.trim() + if (el) return el } - return '' + 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 buildBulkUpdatePayload(itemId: string, projectId: string): BulkUpdateMessageData | null { - const updates: BulkUpdateMessageData['updates'] = [] - const fieldMeta: NonNullable = {} +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) @@ -100,108 +145,65 @@ function buildBulkUpdatePayload(itemId: string, projectId: string): BulkUpdateMe } } - if (updates.length === 0) return null - return { itemIds: [itemId], projectId, updates, fieldMeta } -} - -async function dispatchBulkUpdate(payload: BulkUpdateMessageData): Promise { - if (queueStore.getActiveCount() >= 3) { - toastStore.show({ type: 'warning', message: BULK_EDIT_CONCURRENT_MESSAGE }) - return - } - try { - const result = await sendMessage('bulkUpdate', payload) - if (!result.ok) { - toastStore.show({ type: 'error', message: BULK_EDIT_DISPATCH_FAILED_MESSAGE }) - } - } catch { - toastStore.show({ type: 'error', message: BULK_EDIT_DISPATCH_FAILED_MESSAGE }) + return { + projectId, + repoOwner: repo.owner, + repoName: repo.name, + title, + body: readBody(dialog), + createMore: readCreateMore(dialog), + updates, + fieldMeta, } } -interface WatchState { - beforeIds: Set - typedTitle: string - createMore: boolean - projectId: string -} - export function setupCreateIssueFieldInjector( ctx: ContentScriptContext, - projectContext: ProjectContext, getFields: () => Promise, ): () => void { let currentDialog: Element | null = null let currentUi: FeatureUi | null = null let mounting = false let rafId: number | null = null - let watchState: WatchState | null = null - let watchObserver: MutationObserver | null = null - let watchTimeoutId: ReturnType | null = null - - function stopWatcher(): void { - if (watchTimeoutId !== null) { - clearTimeout(watchTimeoutId) - watchTimeoutId = null - } - watchObserver?.disconnect() - watchObserver = null - } + let intercepting = false - function finishWatch(matchedId: string | null): void { - const state = watchState - stopWatcher() - watchState = null - if (!state) return - - if (matchedId) { - const payload = buildBulkUpdatePayload(matchedId, state.projectId) - if (payload) void dispatchBulkUpdate(payload) - } else { - // ponytail: capture is view-dependent — a new item created outside the - // current filtered/paginated board view never appears in the injected - // rows we scan, so it can't be matched here. Upgrade path if this bites: - // resolve the new item via an announcement→number→databaseId query - // instead of diffing the DOM. - toastStore.show({ - type: 'warning', - message: "Issue created outside the current board view — custom fields weren't applied.", - }) - } - - if (!state.createMore) createIssueFieldsStore.clearAll() - } - - function checkForNewRow(): void { - if (!watchState) return - const currentIds = getAllInjectedItemIds() - const newIds = currentIds.filter((id) => !watchState!.beforeIds.has(id)) - if (newIds.length === 0) return - - let matchedId: string | null = newIds[0] ?? null - if (watchState.typedTitle) { - const titled = getTitlesForItemIds(newIds) - const match = titled.find((t) => t.title === watchState!.typedTitle) - if (match) matchedId = match.id - } - - finishWatch(matchedId) - } - - function startWatch(dialog: Element): void { - if (createIssueFieldsStore.count() === 0) return - stopWatcher() - - watchState = { - beforeIds: new Set(getAllInjectedItemIds()), - typedTitle: readTitle(dialog), - createMore: readCreateMore(dialog), - projectId: projectContext.projectId, + 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 + } + + if (payload.createMore) { + const titleEl = readInput(dialog, TITLE_INPUT_SELECTORS) + const bodyEl = readInput(dialog, BODY_INPUT_SELECTORS) + if (titleEl) clearInput(titleEl) + if (bodyEl) clearInput(bodyEl) + } else { + createIssueFieldsStore.clearAll() + dialog.querySelector('[data-component="Dialog.CloseButton"]')?.click() + } + } catch { + toastStore.show({ type: 'error', message: BULK_EDIT_DISPATCH_FAILED_MESSAGE }) + } finally { + intercepting = false } - - watchObserver = new MutationObserver(checkForNewRow) - watchObserver.observe(document.body, { childList: true, subtree: true }) - watchTimeoutId = setTimeout(() => finishWatch(null), WATCH_TIMEOUT_MS) } async function mountChip(dialog: Element): Promise { @@ -231,14 +233,19 @@ export function setupCreateIssueFieldInjector( currentUi = null } - function teardownForDialog(): void { + // 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() - createIssueFieldsStore.clearAll() - stopWatcher() - watchState = null currentDialog = null } + function teardownForDialog(): void { + unmountForSwap() + createIssueFieldsStore.clearAll() + } + const scheduleCheck = () => { if (rafId !== null) return rafId = requestAnimationFrame(() => { @@ -251,7 +258,7 @@ export function setupCreateIssueFieldInjector( const dialog = findDialog() if (dialog && dialog !== currentDialog) { - if (currentDialog) teardownForDialog() + if (currentDialog) unmountForSwap() currentDialog = dialog void mountChip(dialog) } else if (!dialog && currentDialog) { @@ -266,8 +273,20 @@ export function setupCreateIssueFieldInjector( if (!currentDialog) return const target = e.target as Element if (!target.closest?.(CREATE_BUTTON_SELECTOR)) return - logger.log('[rgp:cs] create-issue: Create clicked, starting capture watcher') - startWatch(currentDialog) + 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) @@ -279,11 +298,13 @@ export function setupCreateIssueFieldInjector( }) 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/messages.ts b/src/lib/messages.ts index f331285..e6ea630 100644 --- a/src/lib/messages.ts +++ b/src/lib/messages.ts @@ -98,6 +98,20 @@ 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'] +} + export interface ItemPreviewData { resolvedItemId: string issueNumber: number @@ -255,6 +269,7 @@ interface ProtocolMap { title?: string }> bulkUpdate(data: BulkUpdateMessageData): BulkUpdateDispatchResult + createIssueWithFields(data: CreateIssueWithFieldsMessageData): BulkUpdateDispatchResult bulkClose(data: { itemIds: string[] projectId: string 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/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) From f1bff9c0f78a77aad5db05d331351014edff170a Mon Sep 17 00:00:00 2001 From: Fathiraz Arthuro Date: Tue, 21 Jul 2026 13:23:33 +0700 Subject: [PATCH 3/9] fix(schemas): resolve two drift bugs in message schema record 1. createIssueWithFields was missing entirely from the Messages record (35 ProtocolMap keys vs 34 schema entries). Added with correct input/output shapes matching the ProtocolMap definition. 2. duplicateItem.plan was downgraded to Schema.Unknown, losing all type safety for the 7-section DuplicateItemPlan structure. Replaced with a proper DuplicateItemPlan schema struct. Also refactored bulkUpdate to use shared BulkUpdateFieldUpdate, FieldMetaValue, and BulkUpdateDispatchResult structs (deduplication). --- src/lib/schemas-messages.ts | 106 +++++++++++++++++++++++++++--------- 1 file changed, 79 insertions(+), 27 deletions(-) diff --git a/src/lib/schemas-messages.ts b/src/lib/schemas-messages.ts index 0c155f7..043b865 100644 --- a/src/lib/schemas-messages.ts +++ b/src/lib/schemas-messages.ts @@ -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,24 @@ 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 })), + }), + output: BulkUpdateDispatchResult, }, bulkClose: { input: Schema.Struct({ From 47bf1ef58ad942a2817e83ac7ee2ef83bbca730a Mon Sep 17 00:00:00 2001 From: Fathiraz Arthuro Date: Tue, 21 Jul 2026 13:28:21 +0700 Subject: [PATCH 4/9] refactor(schemas): add derived ProtocolMap type from Messages record ProtocolMapFromSchemas maps each schema entry to a function signature (data: Input) => Output, matching the shape @webext-core/messaging expects. MessageInput and MessageOutput provide per-key type aliases for call-site convenience. Updated module header to document schemas as single source of truth. --- src/lib/schemas-messages.ts | 40 +++++++++++++++++++++++++++++++------ 1 file changed, 34 insertions(+), 6 deletions(-) diff --git a/src/lib/schemas-messages.ts b/src/lib/schemas-messages.ts index 043b865..e83aa20 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` @@ -644,3 +644,31 @@ 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 + +/** + * 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: SchemaInput<(typeof Messages)[K]>, + ) => SchemaOutput<(typeof Messages)[K]> +} + +/** + * 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]> From 939777cb805670335f8767fcedfcdba3aa0eed0f Mon Sep 17 00:00:00 2001 From: Fathiraz Arthuro Date: Wed, 22 Jul 2026 09:09:52 +0700 Subject: [PATCH 5/9] fix(create-issue): clear dirty form before close, dismiss discard-confirm, re-clear omnibar GitHub's own dirty-form guard was popping a native "Discard changes?" confirmation when RGP's Close click ran on an unsaved title/body, leaving the dialog stuck open. Clear title+body before close (or before staging the next issue on Create more), poll for and click through the discard confirmation as a fallback, and re-clear the board's inline omnibar input that gets restored once the new item's async create/attach/field-update chain lands and re-renders the row. --- src/features/create-issue-injections.tsx | 71 ++++++++++++++++++++++-- 1 file changed, 65 insertions(+), 6 deletions(-) diff --git a/src/features/create-issue-injections.tsx b/src/features/create-issue-injections.tsx index ba09c9d..1f359db 100644 --- a/src/features/create-issue-injections.tsx +++ b/src/features/create-issue-injections.tsx @@ -47,6 +47,25 @@ const TITLE_INPUT_SELECTORS = [ const BODY_INPUT_SELECTORS = ['textarea[aria-label="Markdown value"]'] +// 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_SELECTOR = + '[data-component="ConfirmationDialog"] button[data-variant="danger"]' + +// 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". @@ -166,6 +185,28 @@ export function setupCreateIssueFieldInjector( let mounting = false let rafId: number | null = null let intercepting = false + let omnibarWatchUntil: number | null = null + + function clearOmnibar(): void { + for (const sel of OMNIBAR_INPUT_SELECTORS) { + document.querySelectorAll(sel).forEach(clearInput) + } + } + + // 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 btn = document.querySelector(CONFIRM_DISCARD_SELECTOR) + if (btn) { + btn.click() + return + } + await new Promise((resolve) => requestAnimationFrame(resolve)) + } + } async function handleCreate(dialog: Element): Promise { if (intercepting) return @@ -190,14 +231,24 @@ export function setupCreateIssueFieldInjector( return } - if (payload.createMore) { - const titleEl = readInput(dialog, TITLE_INPUT_SELECTORS) - const bodyEl = readInput(dialog, BODY_INPUT_SELECTORS) - if (titleEl) clearInput(titleEl) - if (bodyEl) clearInput(bodyEl) - } else { + // 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() 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. + omnibarWatchUntil = Date.now() + OMNIBAR_WATCH_MS } } catch { toastStore.show({ type: 'error', message: BULK_EDIT_DISPATCH_FAILED_MESSAGE }) @@ -255,6 +306,14 @@ export function setupCreateIssueFieldInjector( } function check(): void { + if (omnibarWatchUntil !== null) { + if (Date.now() > omnibarWatchUntil) { + omnibarWatchUntil = null + } else { + clearOmnibar() + } + } + const dialog = findDialog() if (dialog && dialog !== currentDialog) { From 6642088a275e2363b372c6b7ca760f5b7e953908 Mon Sep 17 00:00:00 2001 From: Fathiraz Arthuro Date: Wed, 22 Jul 2026 09:22:35 +0700 Subject: [PATCH 6/9] chore: remove hardcoded org references, make project-agnostic - wxt.config.ts: startUrls now reads WXT_DEV_START_URL env var instead of a hardcoded org board - github-project.test.ts: replace kitabisa org fixture with octocat placeholder --- src/lib/github-project.test.ts | 4 ++-- wxt.config.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) 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/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: { From edc36921080657562041c1ef2ac8bd05281227b0 Mon Sep 17 00:00:00 2001 From: Fathiraz Arthuro Date: Wed, 22 Jul 2026 10:18:05 +0700 Subject: [PATCH 7/9] fix(create-issue): address cubic-dev-ai review comments on PR #53 - gate the fields flyout badge/store on canApply so a staged-then-cleared value (or an unselectable radio) no longer overcounts - carry the native dialog's assignees/labels into the create payload and resolve them to ids before createIssue, instead of silently dropping them - require the discard-confirmation dialog's text to match /discard/i before clicking its danger button, so an unrelated confirmation is never dismissed - value-gate the omnibar re-clear during the post-create watch window so a freshly-typed draft isn't wiped - add a compile-time key-set assertion between the hand-written ProtocolMap and the schema-derived ProtocolMapFromSchemas so the two can't drift --- src/background/create-issue.ts | 38 ++++++++++ src/features/create-issue-field-flyout.tsx | 7 +- src/features/create-issue-injections.tsx | 81 +++++++++++++++++++--- src/lib/messages.ts | 20 ++++++ src/lib/schemas-messages.ts | 2 + 5 files changed, 138 insertions(+), 10 deletions(-) diff --git a/src/background/create-issue.ts b/src/background/create-issue.ts index 5da7a9b..64742df 100644 --- a/src/background/create-issue.ts +++ b/src/background/create-issue.ts @@ -8,6 +8,7 @@ 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' @@ -16,6 +17,37 @@ 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 [] + const result = await gql<{ + repository: { assignableUsers: { nodes: { id: string; login: string }[] } } + }>(GET_REPO_ASSIGNEES, { owner, name, q: '' }) + const byLogin = new Map( + (result.repository?.assignableUsers?.nodes || []).map((u) => [u.login, u.id]), + ) + return logins.map((login) => byLogin.get(login)).filter((id): id is string => Boolean(id)) +} + +async function resolveLabelIds( + owner: string, + name: string, + labelNames: string[], +): Promise { + if (labelNames.length === 0) return [] + const result = await gql<{ + repository: { labels: { nodes: { id: string; name: string }[] } } + }>(GET_REPO_LABELS, { owner, name, q: '' }) + const byName = new Map((result.repository?.labels?.nodes || []).map((l) => [l.name, l.id])) + return labelNames.map((n) => byName.get(n)).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 } @@ -51,6 +83,10 @@ async function runCreateIssue(data: CreateIssueWithFieldsMessageData, tabId?: nu 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 } } @@ -61,6 +97,8 @@ async function runCreateIssue(data: CreateIssueWithFieldsMessageData, tabId?: nu repositoryId, title: data.title, body: data.body, + assigneeIds, + labelIds, }) newIssueId = result.createIssue.issue.id await sleep(1000) diff --git a/src/features/create-issue-field-flyout.tsx b/src/features/create-issue-field-flyout.tsx index b53da68..ce735f3 100644 --- a/src/features/create-issue-field-flyout.tsx +++ b/src/features/create-issue-field-flyout.tsx @@ -12,6 +12,7 @@ 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() @@ -116,7 +117,11 @@ export function CreateIssueFieldsChip({ getFields }: CreateIssueFieldsChipProps) key={field.id} field={field} value={staged?.value ?? null} - onChange={(next) => createIssueFieldsStore.set(field, next)} + onChange={(next) => + canApply(next) + ? createIssueFieldsStore.set(field, next) + : createIssueFieldsStore.remove(field.id) + } metaQuery="" setMetaQuery={noop} metaResults={[]} diff --git a/src/features/create-issue-injections.tsx b/src/features/create-issue-injections.tsx index 1f359db..c1c6e61 100644 --- a/src/features/create-issue-injections.tsx +++ b/src/features/create-issue-injections.tsx @@ -47,6 +47,13 @@ const TITLE_INPUT_SELECTORS = [ 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 @@ -56,8 +63,9 @@ 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_SELECTOR = - '[data-component="ConfirmationDialog"] button[data-variant="danger"]' +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 @@ -119,6 +127,30 @@ 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) @@ -164,6 +196,9 @@ function buildCreatePayload( } } + const assignees = readAssignees(dialog) + const labels = readLabels(dialog) + return { projectId, repoOwner: repo.owner, @@ -173,6 +208,8 @@ function buildCreatePayload( createMore: readCreateMore(dialog), updates, fieldMeta, + ...(assignees.length ? { assignees } : {}), + ...(labels.length ? { labels } : {}), } } @@ -186,6 +223,15 @@ export function setupCreateIssueFieldInjector( 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) { @@ -193,16 +239,30 @@ export function setupCreateIssueFieldInjector( } } + // 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 btn = document.querySelector(CONFIRM_DISCARD_SELECTOR) - if (btn) { - btn.click() - return + 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)) } @@ -241,13 +301,15 @@ export function setupCreateIssueFieldInjector( 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. + // 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 { @@ -309,8 +371,9 @@ export function setupCreateIssueFieldInjector( if (omnibarWatchUntil !== null) { if (Date.now() > omnibarWatchUntil) { omnibarWatchUntil = null - } else { - clearOmnibar() + omnibarSeedText = null + } else if (omnibarSeedText !== null) { + clearOmnibarIfUnchanged(omnibarSeedText) } } diff --git a/src/lib/messages.ts b/src/lib/messages.ts index e6ea630..175035d 100644 --- a/src/lib/messages.ts +++ b/src/lib/messages.ts @@ -1,6 +1,7 @@ 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 @@ -110,6 +111,8 @@ export interface CreateIssueWithFieldsMessageData { createMore: boolean updates: { fieldId: string; value: unknown }[] fieldMeta?: BulkUpdateMessageData['fieldMeta'] + assignees?: string[] + labels?: string[] } export interface ItemPreviewData { @@ -416,6 +419,23 @@ interface ProtocolMap { }): void } +// Compile-time drift guard: `ProtocolMap` above is hand-written (kept, rather +// than replaced by `ProtocolMapFromSchemas`, because the schema record's +// `Schema.Array` fields are `readonly T[]` and swapping the messaging generic +// cascades ~40 readonly/mutable mismatches into unrelated call sites). This +// fails typecheck if the two maps' key sets diverge, so schemas-messages.ts +// stays the source of truth for which messages exist without forcing every +// caller onto `readonly` arrays. +type _KeysEqual = [keyof A] extends [keyof B] + ? [keyof B] extends [keyof A] + ? true + : false + : false +type _AssertProtocolMapMatchesSchema = + _KeysEqual extends true ? true : never + +const _protocolMapMatchesSchema: _AssertProtocolMapMatchesSchema = true + const _messaging = defineExtensionMessaging() export const onMessage = _messaging.onMessage diff --git a/src/lib/schemas-messages.ts b/src/lib/schemas-messages.ts index e83aa20..5868918 100644 --- a/src/lib/schemas-messages.ts +++ b/src/lib/schemas-messages.ts @@ -364,6 +364,8 @@ export const Messages = { 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, }, From b077b83f0b22c0a62eef7e4aba29ce4ba34c8ac9 Mon Sep 17 00:00:00 2001 From: Fathiraz Arthuro Date: Fri, 24 Jul 2026 14:19:12 +0700 Subject: [PATCH 8/9] fix(errors): surface GraphQL cause message on GithubGraphQLError --- src/lib/errors.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) 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 From bf4acb7a8490144dd81acebf2cdeb99c9c42d0fd Mon Sep 17 00:00:00 2001 From: Fathiraz Arthuro Date: Fri, 24 Jul 2026 17:03:26 +0700 Subject: [PATCH 9/9] fix(create-issue): resolve assignees/labels beyond first-20 page resolveAssigneeIds/resolveLabelIds queried GET_REPO_ASSIGNEES/GET_REPO_LABELS with an empty filter, so only the repo's first 20 results were available for matching selected logins/names. Query per selected value with it as the search filter instead, so selections outside that page resolve correctly. Also derives the ProtocolMap message-contract type from the schemas-messages.ts Schema record instead of a hand-written duplicate, so payload/return shapes can't drift from the schemas. Adds DeepMutable to normalize the schema-derived (readonly) types to the mutable shape @webext-core/messaging expects. --- src/background/create-issue.ts | 38 ++++-- src/background/rest-helpers.ts | 2 +- src/lib/messages.ts | 236 +-------------------------------- src/lib/schemas-messages.ts | 16 ++- 4 files changed, 43 insertions(+), 249 deletions(-) diff --git a/src/background/create-issue.ts b/src/background/create-issue.ts index 64742df..e15cd36 100644 --- a/src/background/create-issue.ts +++ b/src/background/create-issue.ts @@ -26,13 +26,22 @@ async function resolveAssigneeIds( logins: string[], ): Promise { if (logins.length === 0) return [] - const result = await gql<{ - repository: { assignableUsers: { nodes: { id: string; login: string }[] } } - }>(GET_REPO_ASSIGNEES, { owner, name, q: '' }) - const byLogin = new Map( - (result.repository?.assignableUsers?.nodes || []).map((u) => [u.login, u.id]), + // 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 logins.map((login) => byLogin.get(login)).filter((id): id is string => Boolean(id)) + 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( @@ -41,11 +50,18 @@ async function resolveLabelIds( labelNames: string[], ): Promise { if (labelNames.length === 0) return [] - const result = await gql<{ - repository: { labels: { nodes: { id: string; name: string }[] } } - }>(GET_REPO_LABELS, { owner, name, q: '' }) - const byName = new Map((result.repository?.labels?.nodes || []).map((l) => [l.name, l.id])) - return labelNames.map((n) => byName.get(n)).filter((id): id is string => Boolean(id)) + // 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 { diff --git a/src/background/rest-helpers.ts b/src/background/rest-helpers.ts index b52d930..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 } diff --git a/src/lib/messages.ts b/src/lib/messages.ts index 175035d..ee63492 100644 --- a/src/lib/messages.ts +++ b/src/lib/messages.ts @@ -1,6 +1,4 @@ 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 { @@ -204,239 +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 - createIssueWithFields(data: CreateIssueWithFieldsMessageData): 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 -} - -// Compile-time drift guard: `ProtocolMap` above is hand-written (kept, rather -// than replaced by `ProtocolMapFromSchemas`, because the schema record's -// `Schema.Array` fields are `readonly T[]` and swapping the messaging generic -// cascades ~40 readonly/mutable mismatches into unrelated call sites). This -// fails typecheck if the two maps' key sets diverge, so schemas-messages.ts -// stays the source of truth for which messages exist without forcing every -// caller onto `readonly` arrays. -type _KeysEqual = [keyof A] extends [keyof B] - ? [keyof B] extends [keyof A] - ? true - : false - : false -type _AssertProtocolMapMatchesSchema = - _KeysEqual extends true ? true : never - -const _protocolMapMatchesSchema: _AssertProtocolMapMatchesSchema = true - -const _messaging = defineExtensionMessaging() +const _messaging = defineExtensionMessaging() export const onMessage = _messaging.onMessage // wrap sendMessage with SW reconnect retry logic diff --git a/src/lib/schemas-messages.ts b/src/lib/schemas-messages.ts index 5868918..40b3662 100644 --- a/src/lib/schemas-messages.ts +++ b/src/lib/schemas-messages.ts @@ -656,6 +656,18 @@ export const Messages = { 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. * @@ -664,8 +676,8 @@ type SchemaOutput = T extends { output: Schema.Schema } ? */ export type ProtocolMapFromSchemas = { [K in keyof typeof Messages]: ( - data: SchemaInput<(typeof Messages)[K]>, - ) => SchemaOutput<(typeof Messages)[K]> + data: DeepMutable>, + ) => DeepMutable> } /**