diff --git a/docs/code-mirror.md b/docs/code-mirror.md deleted file mode 100644 index 89f65d6..0000000 --- a/docs/code-mirror.md +++ /dev/null @@ -1,110 +0,0 @@ -# CodeMirror Integration - -This document defines how CodeMirror is integrated in @knighted/develop and what constraints must be preserved when changing editor behavior. - -## Scope - -CodeMirror is used for both authoring panels: - -- Component panel (JSX source) -- Styles panel (CSS, CSS Modules, Less, Sass source) - -The integration is CDN-first and must keep textarea fallback behavior. - -## Integration Files - -- `src/cdn.js`: CDN import keys and provider candidates -- `src/editor-codemirror.js`: shared CodeMirror runtime + editor factory -- `src/app.js`: editor initialization, fallback handling, and value wiring -- `src/styles.css`: editor host styling - -## Runtime Model - -The app initializes CodeMirror asynchronously. - -- On success: both textareas are hidden and CodeMirror views are mounted. -- On failure: textareas remain active and the app keeps rendering normally. - -This fallback is required. Editor failures must never block rendering. - -## CDN Rules - -CodeMirror packages are loaded with `importFromCdnWithFallback` and entries in `cdnImportSpecs`. - -### Important: esm.sh specifier strategy - -Use unversioned `esm` specifiers for the CodeMirror package group: - -- `@codemirror/state` -- `@codemirror/view` -- `@codemirror/commands` -- `@codemirror/autocomplete` -- `@codemirror/language` -- `@codemirror/lang-javascript` -- `@codemirror/lang-css` - -Reason: this lets esm.sh resolve one compatible dependency graph. Mixing pinned versions can load multiple `@codemirror/state` instances and trigger: - -- `Unrecognized extension value in extension set ([object Object])` - -Keep `jspmGa` candidates as fallback entries. - -## Editor Behavior Baseline - -`src/editor-codemirror.js` should continue to include these extensions: - -- line numbers -- active line and gutter highlight -- bracket matching -- close brackets -- autocompletion -- syntax highlighting -- history keymap -- default keymap -- completion keymap -- close-bracket keymap -- `indentOnInput` -- tab size and indent unit - -Language mapping should remain: - -- component editor: `javascript-jsx` -- styles editor: - - `css` and `module` -> css language - - `less` -> less language - - `sass` -> sass language - -## App Wiring Requirements - -In `src/app.js`: - -- Keep `getJsxSource()` and `getCssSource()` abstraction so both CodeMirror and textarea fallback paths work. -- Keep `initializeCodeEditors()` non-blocking (`void initializeCodeEditors()`). -- Keep style language reconfiguration on style mode change. -- Keep textarea input listeners in place for fallback mode. - -## Validation Checklist - -When modifying editor integration: - -1. Run `npm run lint`. -2. Run `npm run dev` and verify: - - CodeMirror mounts in both panels. - - Textareas are hidden on success. - - Auto-close and indentation work while typing. - - Style mode change reconfigures language and still renders. - - Fallback path works if a CodeMirror import fails. -3. Run `npm run build` when CDN import keys are changed. - -## Troubleshooting - -If the UI still looks like plain textarea behavior: - -1. Check for `.cm-editor` nodes in devtools. -2. Check whether `textarea.source-textarea--hidden` is present. -3. Check status text for editor fallback message. -4. Hard reload to clear cached CDN module responses. -5. Inspect console for duplicate-state error: - - `Unrecognized extension value in extension set ([object Object])` - -If duplicate-state error returns, first verify `esm` CodeMirror specifiers in `src/cdn.js` are still unversioned for the full package group. diff --git a/docs/contributing.md b/docs/contributing.md deleted file mode 100644 index 875d269..0000000 --- a/docs/contributing.md +++ /dev/null @@ -1,120 +0,0 @@ -# Contributing - -Thanks for contributing to `@knighted/develop`. - -This project is a CDN-first UI component workbench for showcasing `@knighted/jsx` -and `@knighted/css`, so local workflows should preserve browser execution -behavior and avoid bundler-only assumptions in `src/` runtime code. - -## Project docs - -- Type checking notes: `docs/type-checking.md` -- Build and deploy notes: `docs/build-and-deploy.md` -- CodeMirror integration notes: `docs/code-mirror.md` -- Roadmap: `docs/next-steps.md` -- Article draft: `docs/article.md` - -## Prerequisites - -- Node.js `>= 22.22.1` -- npm - -## Install - -```bash -npm install -``` - -## Local development - -Start the local app: - -```bash -npm run dev -``` - -The local server opens `src/index.html`. - -## Build commands - -Build prep + CSS + import map generation: - -```bash -npm run build -``` - -Build with explicit primary CDN modes: - -```bash -npm run build:esm -npm run build:jspm -npm run build:importmap-mode -``` - -Preview generated dist output: - -```bash -npm run preview -``` - -## Validation commands - -Lint source and Playwright files: - -```bash -npm run lint -``` - -Type check TS tooling files: - -```bash -npm run check-types -``` - -## Playwright end-to-end tests - -Install browser binaries once: - -```bash -npx playwright install -``` - -If your environment also needs system deps (for example CI-like Linux -containers): - -```bash -npx playwright install --with-deps -``` - -Run preview-mode E2E suite: - -```bash -npm run test:e2e -``` - -Run dev-mode E2E suite: - -```bash -npm run test:e2e:dev -``` - -Run preview-mode suite headed: - -```bash -npm run test:e2e:headed -``` - -## Contributor checklist - -Before opening a PR: - -1. Run `npm run lint`. -2. Run `npm run build:esm` for runtime/build changes. -3. Run relevant Playwright tests for UI/runtime changes. -4. Update docs when user-facing behavior or workflows change. - -## Scope guidance - -- Keep changes focused to `@knighted/develop`. -- Preserve CDN-first loading and fallback behavior. -- Avoid editing generated output unless explicitly required. diff --git a/docs/dual-build-gh-pages-strategy.md b/docs/dual-build-gh-pages-strategy.md deleted file mode 100644 index bb8ce4d..0000000 --- a/docs/dual-build-gh-pages-strategy.md +++ /dev/null @@ -1,93 +0,0 @@ -# Dual Build GitHub Pages Strategy - -## Purpose - -Document a clean migration strategy for delivering both stable and overhaul UI versions from one repository without adding runtime feature flags to application code. - -## Core Idea - -Build two versions of the site during deployment and publish both under one GitHub Pages branch. - -- Stable site at root path: /index.html -- Overhaul site at next path: /next/index.html - -The URL path acts as the switch. - -- Stable: /develop/ -- Overhaul: /develop/next/ - -## Why This Approach - -1. Keeps runtime code clean. -2. Avoids pervasive if version checks in app modules. -3. Allows side-by-side validation of stable and next UX. -4. Reduces long-term cleanup work versus in-app toggles. - -## Deployment Layout - -Publish a combined artifact to the GitHub Pages branch with this shape: - -- /index.html and root assets from stable branch build -- /next/index.html and next assets from overhaul branch build - -## CI Workflow Design - -A deployment workflow builds both branches in one run and publishes one artifact. - -1. Checkout stable branch into an isolated worktree directory. -2. Install dependencies and build stable output. -3. Copy stable output into publish root. -4. Checkout overhaul branch into a second isolated worktree directory. -5. Install dependencies and build overhaul output. -6. Copy overhaul output into publish root under /next. -7. Deploy combined publish folder to GitHub Pages. - -## Operational Guidance - -1. Run both builds in isolated directories to prevent cross-branch contamination. -2. Keep Node and npm versions pinned consistently in CI. -3. Use workflow concurrency to cancel outdated deploy jobs. -4. Use relative asset URLs so content works under both root and /next paths. -5. Fail the deploy if either build fails. - -## Source Control Model - -- main branch represents stable production UX. -- overhaul branch represents next-generation UX. -- Deploy workflow may trigger on pushes to either branch, but each run should still build both branches for a consistent dual-output artifact. - -## Relationship To App Architecture Work - -This strategy complements the multi-tab and local workspace migration by separating rollout concerns from runtime logic. - -- Runtime implementation remains modular and focused on architecture. -- Deployment controls the exposure of stable versus next. - -## Tradeoffs - -Pros: - -1. Cleaner codebase during migration. -2. Lower risk of runtime toggle regressions. -3. Clear QA and stakeholder review URLs. - -Cons: - -1. Longer deploy times due to dual builds. -2. More CI configuration complexity. -3. Temporary branch coordination requirements. - -## Exit Plan - -After next UI is production-ready: - -1. Promote next code into main. -2. Remove dual-build deployment logic. -3. Publish only root output again. -4. Remove migration-only docs and branch conventions. - -## Suggested Follow-up - -1. Add a deploy workflow implementation doc with exact GitHub Actions YAML and permissions. -2. Add a release checklist for validating both URLs before each deploy. -3. Add ownership notes for stable and next branch review responsibilities. diff --git a/docs/pr-context-storage-matrix.md b/docs/pr-context-storage-matrix.md new file mode 100644 index 0000000..271c467 --- /dev/null +++ b/docs/pr-context-storage-matrix.md @@ -0,0 +1,92 @@ +# PR Context Storage Matrix + +How `@knighted/develop` stores pull request context across browser storage. + +This guide focuses on two storage surfaces: + +1. **IndexedDB (IDB)**: workspace snapshots used by the Workspaces drawer. +2. **localStorage**: repository-scoped PR drawer context metadata. + +## Storage Surfaces + +### IndexedDB + +- Database: `knighted-develop-workspaces` +- Object store: `prWorkspaces` +- Relevant fields in each workspace record: + - `prContextState`: `inactive` | `active` | `disconnected` | `closed` + - `prNumber`: `number | null` + - `prTitle`, `base`, `head` + - `repo` + +### localStorage + +- Key pattern: `knighted:develop:github-pr-config:/` +- Relevant fields in each config: + - `isActivePr`: `boolean` + - `prContextState`: `inactive` | `active` | `disconnected` | `closed` + - `pullRequestNumber`: `number | null` + - `pullRequestUrl`: `string` + - `prTitle`, `baseBranch`, `headBranch` + +## Status Matrix + +Use this matrix as the source of truth when debugging UI/storage mismatch. + +| Scenario | IDB `prContextState` | IDB `prNumber` | localStorage `isActivePr` | localStorage `prContextState` | Notes | +| --------------------------------------------- | -------------------- | --------------------------------- | ------------------------- | ----------------------------- | ----------------------------------------------------------- | +| A. Local workspace only, no PR context | `inactive` | `null` | `false` (or no key) | `inactive` (or no key) | No connected PR context. | +| B. Workspace is for an active, open PR | `active` | PR number | `true` | `active` | Push mode in PR controls. | +| C. Workspace is for a disconnected PR context | `disconnected` | last known PR number if available | `false` | `disconnected` | PR may still be open on GitHub; reconnect can verify later. | +| D. Workspace is for a PR closed on GitHub | `closed` | closed PR number | `false` | `closed` | Historical context retained for debugging/reference. | + +## Why Both IDB And localStorage Exist + +The two stores have different responsibilities: + +1. **IDB (workspace scope)** + - Persists full editor/workspace snapshots. + - Drives Workspaces drawer restore and switching. +2. **localStorage (repository PR scope)** + - Persists PR drawer config and active context metadata for the selected repository. + - Drives Open PR vs Push mode and active-context checks. + +They intentionally overlap on PR metadata so the app can restore workspace context and PR drawer behavior across reloads. + +## Debugging Checklist + +When the UI does not match expected PR state: + +1. Check localStorage key for the selected repository and inspect: + - `isActivePr` + - `prContextState` + - `pullRequestNumber` +2. Check IDB workspace record currently selected/opened and inspect: + - `prContextState` + - `prNumber` + - `repo`, `head`, `prTitle` +3. Compare against the matrix above. +4. If scenario C is expected, remember GitHub-open verification is deferred until reconnect flow is invoked. + +## Console Snippets + +LocalStorage (selected repo key): + +```js +JSON.parse(localStorage.getItem('knighted:develop:github-pr-config:OWNER/REPO') || '{}') +``` + +IndexedDB (all workspace records): + +```js +indexedDB.open('knighted-develop-workspaces').onsuccess = event => { + const db = event.target.result + db.transaction('prWorkspaces').objectStore('prWorkspaces').getAll().onsuccess = e => { + console.log(e.target.result) + } +} +``` + +## Current Limitation + +Reconnect behavior from the Workspaces drawer is not implemented yet. This document defines the storage contract needed to support that workflow reliably. diff --git a/playwright/github-byot-ai.spec.ts b/playwright/github-byot-ai.spec.ts index 1b7da00..ca9e24a 100644 --- a/playwright/github-byot-ai.spec.ts +++ b/playwright/github-byot-ai.spec.ts @@ -1,5 +1,5 @@ import { expect, test } from '@playwright/test' -import { defaultGitHubChatModel } from '../src/modules/github/github-api.js' +import { defaultGitHubChatModel } from '../src/modules/github/api/chat.js' import type { ChatRequestBody, ChatRequestMessage } from './helpers/app-test-helpers.js' import { appEntryPath, diff --git a/playwright/github-pr-drawer.spec.ts b/playwright/github-pr-drawer.spec.ts index e639438..64f5a0f 100644 --- a/playwright/github-pr-drawer.spec.ts +++ b/playwright/github-pr-drawer.spec.ts @@ -1477,7 +1477,11 @@ test('Active PR context updates controls and can be closed from AI controls', as const storedValue = await page.evaluate(() => localStorage.getItem('knighted:develop:github-pr-config:knightedcodemonkey/develop'), ) - expect(storedValue).toBeNull() + expect(storedValue).not.toBeNull() + const parsedStoredValue = JSON.parse(storedValue as string) as Record + expect(parsedStoredValue.isActivePr).toBe(false) + expect(parsedStoredValue.prContextState).toBe('closed') + expect(parsedStoredValue.pullRequestNumber).toBe(2) expect(closePullRequestRequestCount).toBe(1) }) diff --git a/playwright/workspace-tabs.spec.ts b/playwright/workspace-tabs.spec.ts index becfd4f..f63640e 100644 --- a/playwright/workspace-tabs.spec.ts +++ b/playwright/workspace-tabs.spec.ts @@ -108,6 +108,62 @@ const seedSyncedComponentTab = async (page: import('@playwright/test').Page) => }) } +const waitForWorkspaceTabOrderPersistence = async ( + page: import('@playwright/test').Page, + expectedLeadingTabNames: string[], +) => { + await expect + .poll(async () => { + return page.evaluate(async expectedTabNames => { + const request = indexedDB.open('knighted-develop-workspaces') + + const db = await new Promise((resolve, reject) => { + request.onsuccess = () => resolve(request.result) + request.onerror = () => reject(request.error) + request.onblocked = () => reject(new Error('Could not open IndexedDB.')) + }) + + try { + const tx = db.transaction('prWorkspaces', 'readonly') + const store = tx.objectStore('prWorkspaces') + const getAllRequest = store.getAll() + + const records = await new Promise>>( + (resolve, reject) => { + getAllRequest.onsuccess = () => { + const value = Array.isArray(getAllRequest.result) + ? getAllRequest.result + : [] + resolve(value as Array>) + } + getAllRequest.onerror = () => reject(getAllRequest.error) + }, + ) + + return records.some(record => { + const tabs = Array.isArray(record.tabs) ? record.tabs : [] + const tabNames = tabs + .map(tab => { + if (!tab || typeof tab !== 'object') { + return '' + } + + return typeof (tab as { name?: unknown }).name === 'string' + ? ((tab as { name: string }).name ?? '') + : '' + }) + .filter(name => name.length > 0) + + return expectedTabNames.every((name, index) => tabNames[index] === name) + }) + } finally { + db.close() + } + }, expectedLeadingTabNames) + }) + .toBe(true) +} + test('removing active tab selects deterministic adjacent tab', async ({ page }) => { await waitForInitialRender(page) @@ -320,6 +376,9 @@ test('workspace tab drag reorder persists across reload', async ({ page }) => { await expect(orderedTabs.nth(0)).toHaveAccessibleName('Workspace tab module-2.tsx') await expect(orderedTabs.nth(1)).toHaveAccessibleName('Workspace tab App.tsx') + /* Reorder persistence is debounced; wait until IndexedDB reflects the new order. */ + await waitForWorkspaceTabOrderPersistence(page, ['module-2.tsx', 'App.tsx']) + await page.reload() await waitForInitialRender(page) diff --git a/src/app.js b/src/app.js index 636bec1..4dd7511 100644 --- a/src/app.js +++ b/src/app.js @@ -41,13 +41,14 @@ import { createWorkspaceSyncController } from './modules/app-core/workspace-sync import { createWorkspaceTabAddMenuUiController } from './modules/app-core/workspace-tab-add-menu-ui.js' import { createDiagnosticsUiController } from './modules/diagnostics/diagnostics-ui.js' import { createGitHubChatDrawer } from './modules/github/chat-drawer/drawer.js' -import { createGitHubByotControls } from './modules/github/github-byot-controls.js' +import { createGitHubByotControls } from './modules/github/byot-controls.js' import { formatActivePrReference, getActivePrContextSyncKey, -} from './modules/github/github-pr-context.js' -import { createGitHubPrEditorSyncController } from './modules/github/github-pr-editor-sync.js' -import { createGitHubPrDrawer } from './modules/github/github-pr-drawer.js' + parsePullRequestNumberFromUrl, +} from './modules/github/pr/context.js' +import { createGitHubPrEditorSyncController } from './modules/github/pr/editor-sync.js' +import { createGitHubPrDrawer } from './modules/github/pr/drawer/controller/create-controller.js' import { createLayoutThemeController } from './modules/ui/layout-theme.js' import { createLintDiagnosticsController } from './modules/diagnostics/lint-diagnostics.js' import { createPreviewBackgroundController } from './modules/preview/preview-background.js' @@ -395,6 +396,15 @@ const githubAiContextState = { } let workspacePrContextState = 'inactive' +let workspacePrNumber = null + +const toPullRequestNumber = value => { + if (typeof value === 'number' && Number.isFinite(value) && value > 0) { + return value + } + + return null +} let chatDrawerController = { setOpen: () => {}, @@ -505,6 +515,7 @@ const getWorkspaceContextSnapshot = createWorkspaceContextSnapshotGetter({ githubPrTitle, getActivePrContext: () => githubAiContextState.activePrContext, getPrContextState: () => workspacePrContextState, + getPrNumber: () => workspacePrNumber, }) let loadedComponentTabId = 'component' @@ -639,6 +650,8 @@ const { getActiveWorkspaceCreatedAt: () => activeWorkspaceCreatedAt, setActiveWorkspaceRecordId: value => (activeWorkspaceRecordId = value), setActiveWorkspaceCreatedAt: value => (activeWorkspaceCreatedAt = value), + setWorkspacePrContextState: value => (workspacePrContextState = value), + setWorkspacePrNumber: value => (workspacePrNumber = toPullRequestNumber(value)), getCurrentSelectedRepository: getCurrentSelectedRepositoryFullName, getActiveWorkspaceRecordId: () => activeWorkspaceRecordId, setIsApplyingWorkspaceSnapshot: value => (isApplyingWorkspaceSnapshot = value), @@ -736,6 +749,10 @@ const setWorkspacePrContextState = nextState => { workspacePrContextState = nextState.trim() } +const setWorkspacePrNumber = nextValue => { + workspacePrNumber = toPullRequestNumber(nextValue) +} + const persistWorkspacePrContextState = nextState => { setWorkspacePrContextState(nextState) queueWorkspaceSave() @@ -820,16 +837,29 @@ const githubWorkflows = createGitHubWorkflowsSetup({ prContextUi, onPrContextStateChange: activeContext => { if (activeContext?.prTitle) { - setWorkspacePrContextState('active') + const nextPrNumber = + toPullRequestNumber(activeContext.pullRequestNumber) ?? + parsePullRequestNumberFromUrl(activeContext.pullRequestUrl) + const shouldPersistPrContext = + workspacePrContextState !== 'active' || workspacePrNumber !== nextPrNumber + + setWorkspacePrNumber(nextPrNumber) + + if (shouldPersistPrContext) { + persistWorkspacePrContextState('active') + } } else if (workspacePrContextState === 'active') { - setWorkspacePrContextState('inactive') + setWorkspacePrNumber(null) + persistWorkspacePrContextState('inactive') } editedIndicatorVisibilityController.refreshIndicators() }, - onPrContextClosed: () => { + onPrContextClosed: result => { + setWorkspacePrNumber(result?.pullRequestNumber) persistWorkspacePrContextState('closed') }, - onPrContextDisconnected: () => { + onPrContextDisconnected: result => { + setWorkspacePrNumber(result?.pullRequestNumber) persistWorkspacePrContextState('disconnected') }, getTokenForVisibility: () => githubAiContextState.token, diff --git a/src/modules/app-core/app-bindings-startup.js b/src/modules/app-core/app-bindings-startup.js index 99455ab..7615c16 100644 --- a/src/modules/app-core/app-bindings-startup.js +++ b/src/modules/app-core/app-bindings-startup.js @@ -397,9 +397,6 @@ const bindAppEventsAndStart = ({ window.addEventListener('beforeunload', () => { clearToastTimer() diagnosticsFlowController.dispose() - void flushWorkspaceSave().catch(() => { - /* noop */ - }) workspaceSaveController.dispose() void workspaceStorage.close() chatDrawerController.dispose() diff --git a/src/modules/app-core/github-workflows.js b/src/modules/app-core/github-workflows.js index 5d753ac..69fdd09 100644 --- a/src/modules/app-core/github-workflows.js +++ b/src/modules/app-core/github-workflows.js @@ -326,7 +326,7 @@ const initializeGitHubWorkflows = ({ 'neutral', ) if (typeof onPrContextClosed === 'function') { - onPrContextClosed() + onPrContextClosed(result) } showAppToast( reference @@ -370,7 +370,7 @@ const initializeGitHubWorkflows = ({ 'neutral', ) if (typeof onPrContextDisconnected === 'function') { - onPrContextDisconnected() + onPrContextDisconnected(result) } }, }) diff --git a/src/modules/app-core/workspace-context-controller.js b/src/modules/app-core/workspace-context-controller.js index 4411bd0..948477e 100644 --- a/src/modules/app-core/workspace-context-controller.js +++ b/src/modules/app-core/workspace-context-controller.js @@ -5,6 +5,8 @@ const createWorkspaceContextController = ({ getActiveWorkspaceRecordId, setActiveWorkspaceRecordId, setActiveWorkspaceCreatedAt, + setWorkspacePrContextState, + setWorkspacePrNumber, setIsApplyingWorkspaceSnapshot, ensureWorkspaceTabsShape, githubPrBaseBranch, @@ -56,6 +58,22 @@ const createWorkspaceContextController = ({ setActiveWorkspaceRecordId(workspace.id) setActiveWorkspaceCreatedAt(workspace.createdAt ?? null) + if (typeof setWorkspacePrContextState === 'function') { + const nextPrContextState = + typeof workspace.prContextState === 'string' && workspace.prContextState.trim() + ? workspace.prContextState.trim() + : 'inactive' + setWorkspacePrContextState(nextPrContextState) + } + + if (typeof setWorkspacePrNumber === 'function') { + const nextPrNumber = + typeof workspace.prNumber === 'number' && Number.isFinite(workspace.prNumber) + ? workspace.prNumber + : null + setWorkspacePrNumber(nextPrNumber) + } + const nextTabs = ensureWorkspaceTabsShape(workspace.tabs) if (typeof workspace.base === 'string' && githubPrBaseBranch) { githubPrBaseBranch.value = workspace.base diff --git a/src/modules/app-core/workspace-controllers-setup.js b/src/modules/app-core/workspace-controllers-setup.js index 2fb691f..ec09b78 100644 --- a/src/modules/app-core/workspace-controllers-setup.js +++ b/src/modules/app-core/workspace-controllers-setup.js @@ -15,6 +15,8 @@ const createWorkspaceControllersSetup = ({ getActiveWorkspaceCreatedAt, setActiveWorkspaceRecordId, setActiveWorkspaceCreatedAt, + setWorkspacePrContextState, + setWorkspacePrNumber, getCurrentSelectedRepository, getActiveWorkspaceRecordId, setIsApplyingWorkspaceSnapshot, @@ -194,6 +196,8 @@ const createWorkspaceControllersSetup = ({ getActiveWorkspaceRecordId, setActiveWorkspaceRecordId, setActiveWorkspaceCreatedAt, + setWorkspacePrContextState, + setWorkspacePrNumber, setIsApplyingWorkspaceSnapshot, ensureWorkspaceTabsShape, githubPrBaseBranch, diff --git a/src/modules/app-core/workspace-local-helpers.js b/src/modules/app-core/workspace-local-helpers.js index 966def1..bef2f92 100644 --- a/src/modules/app-core/workspace-local-helpers.js +++ b/src/modules/app-core/workspace-local-helpers.js @@ -6,15 +6,22 @@ const createWorkspaceContextSnapshotGetter = githubPrTitle, getActivePrContext, getPrContextState, + getPrNumber, }) => () => { const activePrContext = typeof getActivePrContext === 'function' ? getActivePrContext() : null - const prNumber = + const activePrNumber = typeof activePrContext?.pullRequestNumber === 'number' && Number.isFinite(activePrContext.pullRequestNumber) ? activePrContext.pullRequestNumber : null + const nextPrNumber = typeof getPrNumber === 'function' ? getPrNumber() : null + const persistedPrNumber = + Number.isFinite(nextPrNumber) && Number(nextPrNumber) > 0 + ? Number(nextPrNumber) + : null + const prNumber = activePrNumber ?? persistedPrNumber return { repositoryFullName: getCurrentSelectedRepository(), diff --git a/src/modules/github/api/chat.js b/src/modules/github/api/chat.js new file mode 100644 index 0000000..c0037f5 --- /dev/null +++ b/src/modules/github/api/chat.js @@ -0,0 +1,457 @@ +import { + defaultGitHubChatModel, + githubChatModelOptions, + githubModelsApiUrl, +} from './constants.js' +import { + buildChatRequestHeaders, + parseErrorResponse, + parseRateMetadata, + toApiError, +} from './core.js' + +const normalizeChatMessage = message => { + if (!message || typeof message !== 'object') { + return null + } + + const role = + message.role === 'system' || message.role === 'assistant' ? message.role : 'user' + const content = typeof message.content === 'string' ? message.content.trim() : '' + + if (!content) { + return null + } + + return { role, content } +} + +const normalizeChatMessages = messages => { + if (!Array.isArray(messages)) { + return [] + } + + return messages.map(normalizeChatMessage).filter(Boolean) +} + +const normalizeToolChoice = toolChoice => { + if (toolChoice === 'required' || toolChoice === 'none') { + return toolChoice + } + + return 'auto' +} + +const normalizeToolDefinition = tool => { + if (!tool || typeof tool !== 'object') { + return null + } + + if (tool.type !== 'function') { + return null + } + + const fn = tool.function + if (!fn || typeof fn !== 'object') { + return null + } + + const name = typeof fn.name === 'string' ? fn.name.trim() : '' + if (!name) { + return null + } + + const description = + typeof fn.description === 'string' && fn.description.trim() + ? fn.description.trim() + : undefined + const parameters = + fn.parameters && typeof fn.parameters === 'object' ? fn.parameters : undefined + + return { + type: 'function', + function: { + name, + ...(description ? { description } : {}), + ...(parameters ? { parameters } : {}), + }, + } +} + +const normalizeToolDefinitions = tools => { + if (!Array.isArray(tools)) { + return [] + } + + return tools.map(normalizeToolDefinition).filter(Boolean) +} + +const buildChatBody = ({ model, messages, stream, tools, toolChoice }) => { + const normalizedMessages = normalizeChatMessages(messages) + const normalizedTools = normalizeToolDefinitions(tools) + + const body = { + model, + messages: normalizedMessages, + stream, + } + + if (normalizedTools.length > 0) { + body.tools = normalizedTools + body.tool_choice = normalizeToolChoice(toolChoice) + } + + return body +} + +const normalizeToolCall = toolCall => { + if (!toolCall || typeof toolCall !== 'object') { + return null + } + + const fn = toolCall.function + const name = typeof fn?.name === 'string' ? fn.name.trim() : '' + if (!name) { + return null + } + + const argumentsText = + typeof fn?.arguments === 'string' && fn.arguments.trim() ? fn.arguments : '{}' + + return { + id: typeof toolCall.id === 'string' ? toolCall.id : '', + name, + arguments: argumentsText, + } +} + +const extractToolCallsFromMessage = message => { + if (!message || typeof message !== 'object') { + return [] + } + + if (!Array.isArray(message.tool_calls)) { + return [] + } + + return message.tool_calls.map(normalizeToolCall).filter(Boolean) +} + +const extractContentFromMessage = message => { + if (!message || typeof message !== 'object') { + return '' + } + + if (typeof message.content === 'string') { + return message.content + } + + if (!Array.isArray(message.content)) { + return '' + } + + return message.content + .map(part => { + if (typeof part === 'string') { + return part + } + + if ( + part && + typeof part === 'object' && + part.type === 'text' && + typeof part.text === 'string' + ) { + return part.text + } + + return '' + }) + .join('') +} + +const extractChatCompletionText = body => { + const firstChoice = Array.isArray(body?.choices) ? body.choices[0] : null + + if (!firstChoice || typeof firstChoice !== 'object') { + return '' + } + + const message = firstChoice.message + return extractContentFromMessage(message).trim() +} + +const extractChatCompletionToolCalls = body => { + const firstChoice = Array.isArray(body?.choices) ? body.choices[0] : null + + if (!firstChoice || typeof firstChoice !== 'object') { + return [] + } + + return extractToolCallsFromMessage(firstChoice.message) +} + +const extractStreamingDeltaText = body => { + const firstChoice = Array.isArray(body?.choices) ? body.choices[0] : null + + if (!firstChoice || typeof firstChoice !== 'object') { + return '' + } + + if (typeof firstChoice.delta?.content === 'string') { + return firstChoice.delta.content + } + + return '' +} + +const collectStreamingToolCalls = ({ body, callsByIndex, orderedCalls }) => { + const firstChoice = Array.isArray(body?.choices) ? body.choices[0] : null + const deltas = Array.isArray(firstChoice?.delta?.tool_calls) + ? firstChoice.delta.tool_calls + : [] + + for (const delta of deltas) { + const index = Number.isFinite(delta?.index) ? delta.index : orderedCalls.length + const key = String(index) + const existing = callsByIndex.get(key) ?? { + id: '', + name: '', + arguments: '', + } + + if (typeof delta?.id === 'string' && delta.id.trim()) { + existing.id = delta.id + } + + const fn = delta?.function + if (typeof fn?.name === 'string' && fn.name.trim()) { + existing.name = fn.name + } + + if (typeof fn?.arguments === 'string') { + existing.arguments += fn.arguments + } + + callsByIndex.set(key, existing) + } + + orderedCalls.length = 0 + const sortedEntries = [...callsByIndex.entries()].sort( + (left, right) => Number(left[0]) - Number(right[0]), + ) + + for (const [, call] of sortedEntries) { + const normalizedCall = normalizeToolCall({ + id: call.id, + function: { + name: call.name, + arguments: call.arguments, + }, + }) + + if (normalizedCall) { + orderedCalls.push(normalizedCall) + } + } +} + +const parseSseDataLine = line => { + if (typeof line !== 'string') { + return null + } + + const trimmedLine = line.trim() + if (!trimmedLine.startsWith('data:')) { + return null + } + + const payload = trimmedLine.slice(5).trim() + if (!payload || payload === '[DONE]') { + return null + } + + try { + return JSON.parse(payload) + } catch { + return null + } +} + +const streamGitHubChatCompletion = async ({ + token, + messages, + signal, + onToken, + model = defaultGitHubChatModel, + tools, + toolChoice, +}) => { + if (typeof token !== 'string' || token.trim().length === 0) { + throw new Error('A GitHub token is required to start a chat request.') + } + + const normalizedMessages = normalizeChatMessages(messages) + if (normalizedMessages.length === 0) { + throw new Error('At least one message is required to start a chat request.') + } + + const response = await fetch(githubModelsApiUrl, { + method: 'POST', + headers: buildChatRequestHeaders({ token, stream: true }), + body: JSON.stringify( + buildChatBody({ + model, + messages: normalizedMessages, + stream: true, + tools, + toolChoice, + }), + ), + signal, + }) + + if (!response.ok) { + const { message, rateLimit } = await parseErrorResponse(response) + throw toApiError({ message, rateLimit }) + } + + if (!response.body) { + throw new Error('Streaming response body is not available in this browser.') + } + + const decoder = new TextDecoder() + const reader = response.body.getReader() + let buffered = '' + let combined = '' + let responseModel = '' + const streamingToolCallsByIndex = new Map() + const streamingToolCalls = [] + + while (true) { + // eslint-disable-next-line no-await-in-loop + const { done, value } = await reader.read() + if (done) { + break + } + + buffered += decoder.decode(value, { stream: true }) + const lines = buffered.split('\n') + buffered = lines.pop() ?? '' + + for (const line of lines) { + const body = parseSseDataLine(line) + if (!body) { + continue + } + + if (!responseModel && typeof body.model === 'string') { + responseModel = body.model + } + + collectStreamingToolCalls({ + body, + callsByIndex: streamingToolCallsByIndex, + orderedCalls: streamingToolCalls, + }) + + const chunk = extractStreamingDeltaText(body) + if (!chunk) { + continue + } + + combined += chunk + onToken?.(chunk) + } + } + + if (buffered.trim()) { + const body = parseSseDataLine(buffered) + if (body && !responseModel && typeof body.model === 'string') { + responseModel = body.model + } + if (body) { + collectStreamingToolCalls({ + body, + callsByIndex: streamingToolCallsByIndex, + orderedCalls: streamingToolCalls, + }) + } + const chunk = body ? extractStreamingDeltaText(body) : '' + if (chunk) { + combined += chunk + onToken?.(chunk) + } + } + + if (!combined.trim() && streamingToolCalls.length === 0) { + throw new Error('Streaming response did not include assistant content.') + } + + return { + content: combined, + toolCalls: streamingToolCalls, + model: responseModel || model, + rateLimit: parseRateMetadata({ headers: response.headers, body: null }), + } +} + +const requestGitHubChatCompletion = async ({ + token, + messages, + signal, + model = defaultGitHubChatModel, + tools, + toolChoice, +}) => { + if (typeof token !== 'string' || token.trim().length === 0) { + throw new Error('A GitHub token is required to start a chat request.') + } + + const normalizedMessages = normalizeChatMessages(messages) + if (normalizedMessages.length === 0) { + throw new Error('At least one message is required to start a chat request.') + } + + const response = await fetch(githubModelsApiUrl, { + method: 'POST', + headers: buildChatRequestHeaders({ token, stream: false }), + body: JSON.stringify( + buildChatBody({ + model, + messages: normalizedMessages, + stream: false, + tools, + toolChoice, + }), + ), + signal, + }) + + if (!response.ok) { + const { message, rateLimit } = await parseErrorResponse(response) + throw toApiError({ message, rateLimit }) + } + + const body = await response.json() + const content = extractChatCompletionText(body) + const toolCalls = extractChatCompletionToolCalls(body) + + if (!content && toolCalls.length === 0) { + throw new Error('GitHub chat response did not include assistant content.') + } + + return { + content, + toolCalls, + model: typeof body?.model === 'string' && body.model ? body.model : model, + rateLimit: parseRateMetadata({ headers: response.headers, body }), + } +} + +export { + defaultGitHubChatModel, + githubChatModelOptions, + requestGitHubChatCompletion, + streamGitHubChatCompletion, +} diff --git a/src/modules/github/api/constants.js b/src/modules/github/api/constants.js new file mode 100644 index 0000000..c2d2684 --- /dev/null +++ b/src/modules/github/api/constants.js @@ -0,0 +1,24 @@ +export const githubApiBaseUrl = 'https://api.github.com' +export const githubModelsApiUrl = 'https://models.github.ai/inference/chat/completions' + +export const defaultGitHubChatModel = 'openai/gpt-4.1-mini' + +/* Local model options avoid browser CORS failures when calling catalog endpoints directly. */ +export const githubChatModelOptions = [ + 'openai/gpt-4.1-mini', + 'openai/gpt-4.1', + 'openai/gpt-4.1-nano', + 'openai/gpt-4o', + 'openai/gpt-4o-mini', + 'openai/gpt-5', + 'openai/gpt-5-chat', + 'openai/gpt-5-mini', + 'openai/gpt-5-nano', + 'cohere/cohere-command-r-plus-08-2024', + 'deepseek/deepseek-v3-0324', + 'meta/llama-4-maverick-17b-128e-instruct-fp8', + 'meta/llama-4-scout-17b-16e-instruct', + 'mistral-ai/ministral-3b', + 'mistral-ai/mistral-medium-2505', + 'mistral-ai/mistral-small-2503', +] diff --git a/src/modules/github/api/core.js b/src/modules/github/api/core.js new file mode 100644 index 0000000..27c3ddd --- /dev/null +++ b/src/modules/github/api/core.js @@ -0,0 +1,216 @@ +import { githubApiBaseUrl } from './constants.js' + +const parseNextPageUrlFromLinkHeader = linkHeader => { + if (typeof linkHeader !== 'string' || !linkHeader.trim()) { + return null + } + + const segments = linkHeader.split(',') + + for (const segment of segments) { + const parts = segment + .trim() + .split(';') + .map(part => part.trim()) + if (parts.length < 2) { + continue + } + + const urlPart = parts[0] + const relPart = parts[1] + + if (!urlPart.startsWith('<') || !urlPart.endsWith('>')) { + continue + } + + if (relPart !== 'rel="next"') { + continue + } + + return urlPart.slice(1, -1) + } + + return null +} + +const buildRequestHeaders = token => ({ + Accept: 'application/vnd.github+json', + Authorization: `Bearer ${token}`, + 'X-GitHub-Api-Version': '2022-11-28', +}) + +const buildChatRequestHeaders = ({ token, stream }) => ({ + Accept: stream ? 'text/event-stream' : 'application/json', + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + 'X-GitHub-Api-Version': '2022-11-28', +}) + +const toFiniteNumber = value => { + if (value === null || value === undefined) { + return null + } + + if (typeof value === 'string' && value.trim().length === 0) { + return null + } + + const numberValue = Number(value) + return Number.isFinite(numberValue) ? numberValue : null +} + +const parseRateMetadataFromHeaders = headers => { + if (!headers || typeof headers.get !== 'function') { + return { + remaining: null, + resetEpochSeconds: null, + } + } + + const remaining = + toFiniteNumber(headers.get('x-ratelimit-remaining')) ?? + toFiniteNumber(headers.get('ratelimit-remaining')) + + const resetEpochSeconds = + toFiniteNumber(headers.get('x-ratelimit-reset')) ?? + toFiniteNumber(headers.get('ratelimit-reset')) + + return { + remaining, + resetEpochSeconds, + } +} + +const parseRateMetadataFromBody = body => { + if (!body || typeof body !== 'object') { + return { + remaining: null, + resetEpochSeconds: null, + } + } + + const rateLimit = body.rate_limit ?? body.rateLimit ?? null + + const remaining = + toFiniteNumber(rateLimit?.remaining) ?? toFiniteNumber(body.remaining) ?? null + + const resetEpochSeconds = + toFiniteNumber(rateLimit?.reset) ?? + toFiniteNumber(rateLimit?.reset_epoch_seconds) ?? + toFiniteNumber(rateLimit?.resetEpochSeconds) ?? + toFiniteNumber(body.reset) ?? + null + + return { + remaining, + resetEpochSeconds, + } +} + +const mergeRateMetadata = (primary, fallback) => ({ + remaining: primary.remaining ?? fallback.remaining ?? null, + resetEpochSeconds: primary.resetEpochSeconds ?? fallback.resetEpochSeconds ?? null, +}) + +const parseRateMetadata = ({ headers, body }) => { + const fromHeaders = parseRateMetadataFromHeaders(headers) + const fromBody = parseRateMetadataFromBody(body) + return mergeRateMetadata(fromHeaders, fromBody) +} + +const toApiError = ({ message, rateLimit }) => { + const error = new Error(message) + error.rateLimit = rateLimit + return error +} + +const parseErrorResponse = async response => { + let body = null + + try { + body = await response.json() + } catch { + /* noop */ + } + + const message = + body && typeof body.message === 'string' && body.message.trim() + ? body.message + : `GitHub API request failed with status ${response.status}` + + return { + message, + rateLimit: parseRateMetadata({ headers: response.headers, body }), + } +} + +const fetchJson = async ({ token, url, signal }) => { + const response = await fetch(url, { + method: 'GET', + headers: buildRequestHeaders(token), + signal, + }) + + if (!response.ok) { + const { message, rateLimit } = await parseErrorResponse(response) + throw toApiError({ message, rateLimit }) + } + + return { + data: await response.json(), + nextPageUrl: parseNextPageUrlFromLinkHeader(response.headers.get('link')), + } +} + +const requestGitHubJson = async ({ + token, + url, + method = 'GET', + body, + signal, + allowNotFound = false, +}) => { + const headers = { + ...buildRequestHeaders(token), + ...(body ? { 'Content-Type': 'application/json' } : {}), + } + + const response = await fetch(url, { + method, + headers, + body: body ? JSON.stringify(body) : undefined, + signal, + }) + + if (allowNotFound && response.status === 404) { + return null + } + + if (!response.ok) { + const { message, rateLimit } = await parseErrorResponse(response) + throw toApiError({ message, rateLimit }) + } + + return response.json() +} + +const encodePathForApi = path => + path + .split('/') + .map(segment => encodeURIComponent(segment)) + .join('/') + +const buildRepoApiUrl = ({ owner, repo, path }) => + `${githubApiBaseUrl}/repos/${owner}/${repo}${path}` + +export { + buildChatRequestHeaders, + buildRepoApiUrl, + buildRequestHeaders, + encodePathForApi, + fetchJson, + parseErrorResponse, + parseRateMetadata, + requestGitHubJson, + toApiError, +} diff --git a/src/modules/github/api/editor-content.js b/src/modules/github/api/editor-content.js new file mode 100644 index 0000000..525aa18 --- /dev/null +++ b/src/modules/github/api/editor-content.js @@ -0,0 +1,387 @@ +import { buildRepoApiUrl, requestGitHubJson } from './core.js' +import { createBranchReference, getBranchReferenceSha } from './repository-files.js' +import { createRepositoryPullRequest } from './pull-requests.js' + +const normalizeFileUpdatePath = value => + (typeof value === 'string' ? value.trim() : '').replace(/\\/g, '/').replace(/\/+/g, '/') + +const validateRepositoryRelativeFilePath = value => { + const path = normalizeFileUpdatePath(value) + + if (!path) { + return { ok: false, reason: 'File path is required.' } + } + + if (path.startsWith('/')) { + return { + ok: false, + reason: 'File path must be repository-relative (no leading slash).', + } + } + + if (path.endsWith('/')) { + return { ok: false, reason: 'File path must include a filename (no trailing slash).' } + } + + const segments = path.split('/').filter(Boolean) + if (segments.some(segment => segment === '..')) { + return { ok: false, reason: 'File path cannot include parent directory traversal.' } + } + + if (!/^[A-Za-z0-9._\-/]+$/.test(path)) { + return { + ok: false, + reason: + 'File path contains unsupported characters. Use letters, numbers, ., _, -, and / only.', + } + } + + if (segments.length === 0 || segments.some(segment => segment === '.' || !segment)) { + return { ok: false, reason: 'File path is invalid.' } + } + + return { ok: true, value: path } +} + +const normalizeFileUpdateInput = (file, index) => { + if (!file || typeof file !== 'object') { + throw new Error(`File update at index ${index} must be an object.`) + } + + const validation = validateRepositoryRelativeFilePath(file.path) + if (!validation.ok) { + const rawPath = typeof file.path === 'string' ? file.path : '' + throw new Error( + `Invalid file update path at index ${index}: ${rawPath || '(missing path)'} (${validation.reason})`, + ) + } + + return { + path: validation.value, + content: typeof file.content === 'string' ? file.content : '', + } +} + +const toUniqueFileUpdatesByPath = files => { + if (!Array.isArray(files) || files.length === 0) { + return [] + } + + const updatesByPath = new Map() + for (const [index, file] of files.entries()) { + const normalized = normalizeFileUpdateInput(file, index) + + updatesByPath.set(normalized.path, normalized) + } + + return [...updatesByPath.values()] +} + +const getCommitTreeSha = async ({ token, owner, repo, commitSha, signal }) => { + const response = await requestGitHubJson({ + token, + url: buildRepoApiUrl({ owner, repo, path: `/git/commits/${commitSha}` }), + signal, + }) + + const treeSha = response?.tree?.sha + if (typeof treeSha !== 'string' || !treeSha) { + throw new Error(`Could not resolve tree SHA for commit ${commitSha}.`) + } + + return treeSha +} + +const createRepositoryTree = async ({ + token, + owner, + repo, + baseTreeSha, + files, + signal, +}) => { + const tree = files.map(file => ({ + path: file.path, + mode: '100644', + type: 'blob', + content: file.content, + })) + + const response = await requestGitHubJson({ + token, + url: buildRepoApiUrl({ owner, repo, path: '/git/trees' }), + method: 'POST', + body: { + base_tree: baseTreeSha, + tree, + }, + signal, + }) + + const treeSha = response?.sha + if (typeof treeSha !== 'string' || !treeSha) { + throw new Error('Could not create repository tree for commit.') + } + + return treeSha +} + +const createRepositoryCommit = async ({ + token, + owner, + repo, + message, + treeSha, + parentCommitSha, + signal, +}) => { + const response = await requestGitHubJson({ + token, + url: buildRepoApiUrl({ owner, repo, path: '/git/commits' }), + method: 'POST', + body: { + message, + tree: treeSha, + parents: [parentCommitSha], + }, + signal, + }) + + const commitSha = response?.sha + if (typeof commitSha !== 'string' || !commitSha) { + throw new Error('Could not create repository commit.') + } + + return commitSha +} + +const updateBranchReference = async ({ token, owner, repo, branch, sha, signal }) => { + const ref = encodeURIComponent(`heads/${branch}`) + await requestGitHubJson({ + token, + url: buildRepoApiUrl({ owner, repo, path: `/git/refs/${ref}` }), + method: 'PATCH', + body: { + sha, + force: false, + }, + signal, + }) +} + +const commitFilesToExistingBranchWithGitDatabaseApi = async ({ + token, + owner, + repo, + branch, + files, + commitMessage, + signal, +}) => { + const uniqueFiles = toUniqueFileUpdatesByPath(files) + if (uniqueFiles.length === 0) { + return [] + } + + const headCommitSha = await getBranchReferenceSha({ + token, + owner, + repo, + branch, + signal, + }) + const baseTreeSha = await getCommitTreeSha({ + token, + owner, + repo, + commitSha: headCommitSha, + signal, + }) + const treeSha = await createRepositoryTree({ + token, + owner, + repo, + baseTreeSha, + files: uniqueFiles, + signal, + }) + const commitSha = await createRepositoryCommit({ + token, + owner, + repo, + message: commitMessage, + treeSha, + parentCommitSha: headCommitSha, + signal, + }) + await updateBranchReference({ + token, + owner, + repo, + branch, + sha: commitSha, + signal, + }) + + return uniqueFiles.map(file => ({ + path: file.path, + commitSha, + created: null, + })) +} + +const isReferenceAlreadyExistsError = error => { + if (!(error instanceof Error)) { + return false + } + + const message = error.message.toLowerCase() + return ( + message.includes('reference already exists') || message.includes('already exists') + ) +} + +const createUniqueBranchReference = async ({ + token, + owner, + repo, + headBranch, + baseSha, + signal, + attempt = 0, +}) => { + const candidateBranch = attempt === 0 ? headBranch : `${headBranch}-${attempt + 1}` + + try { + await createBranchReference({ + token, + owner, + repo, + branch: candidateBranch, + sha: baseSha, + signal, + }) + return candidateBranch + } catch (error) { + if (!isReferenceAlreadyExistsError(error)) { + throw error + } + + if (attempt >= 4) { + throw new Error( + `Branch ${headBranch} already exists. Choose another branch name and retry.`, + { + cause: error, + }, + ) + } + + return createUniqueBranchReference({ + token, + owner, + repo, + headBranch, + baseSha, + signal, + attempt: attempt + 1, + }) + } +} + +const createEditorContentPullRequest = async ({ + token, + repository, + baseBranch, + headBranch, + prTitle, + prBody, + fileUpdates, + commitMessage, + signal, +}) => { + const owner = repository?.owner + const repo = repository?.name + + if (typeof owner !== 'string' || !owner || typeof repo !== 'string' || !repo) { + throw new Error('A valid repository selection is required.') + } + + const baseSha = await getBranchReferenceSha({ + token, + owner, + repo, + branch: baseBranch, + signal, + }) + + const nextBranch = await createUniqueBranchReference({ + token, + owner, + repo, + headBranch, + baseSha, + signal, + }) + + const committedFileUpdates = await commitEditorContentToExistingBranch({ + token, + repository, + branch: nextBranch, + fileUpdates, + commitMessage, + signal, + }) + + const pullRequest = await createRepositoryPullRequest({ + token, + owner, + repo, + title: prTitle, + body: prBody, + head: nextBranch, + base: baseBranch, + signal, + }) + + return { + pullRequest, + branch: nextBranch, + fileUpdates: committedFileUpdates, + } +} + +const commitEditorContentToExistingBranch = async ({ + token, + repository, + branch, + fileUpdates, + commitMessage, + signal, +}) => { + const owner = repository?.owner + const repo = repository?.name + + if (typeof owner !== 'string' || !owner || typeof repo !== 'string' || !repo) { + throw new Error('A valid repository selection is required.') + } + + if (typeof branch !== 'string' || !branch.trim()) { + throw new Error('An existing head branch is required.') + } + + if (!Array.isArray(fileUpdates) || fileUpdates.length === 0) { + throw new Error('At least one file update is required.') + } + + return commitFilesToExistingBranchWithGitDatabaseApi({ + token, + owner, + repo, + branch, + files: fileUpdates, + commitMessage, + signal, + }) +} + +export { createEditorContentPullRequest, commitEditorContentToExistingBranch } diff --git a/src/modules/github/api/pull-requests.js b/src/modules/github/api/pull-requests.js new file mode 100644 index 0000000..92f98ed --- /dev/null +++ b/src/modules/github/api/pull-requests.js @@ -0,0 +1,164 @@ +import { buildRepoApiUrl, requestGitHubJson } from './core.js' + +const normalizePullRequestSummary = pullRequest => { + if (!pullRequest || typeof pullRequest !== 'object') { + return null + } + + const number = + typeof pullRequest.number === 'number' && Number.isFinite(pullRequest.number) + ? pullRequest.number + : null + const htmlUrl = typeof pullRequest.html_url === 'string' ? pullRequest.html_url : '' + const title = typeof pullRequest.title === 'string' ? pullRequest.title : '' + const state = typeof pullRequest.state === 'string' ? pullRequest.state : '' + const headRef = typeof pullRequest?.head?.ref === 'string' ? pullRequest.head.ref : '' + const baseRef = typeof pullRequest?.base?.ref === 'string' ? pullRequest.base.ref : '' + + if (!number) { + return null + } + + return { + number, + htmlUrl, + title, + state, + headRef, + baseRef, + isOpen: state.toLowerCase() === 'open', + } +} + +const createRepositoryPullRequest = async ({ + token, + owner, + repo, + title, + body, + head, + base, + signal, +}) => { + const response = await requestGitHubJson({ + token, + url: buildRepoApiUrl({ owner, repo, path: '/pulls' }), + method: 'POST', + body: { + title, + body, + head, + base, + }, + signal, + }) + + return { + number: response?.number, + htmlUrl: typeof response?.html_url === 'string' ? response.html_url : '', + apiUrl: typeof response?.url === 'string' ? response.url : '', + } +} + +const closeRepositoryPullRequest = async ({ + token, + owner, + repo, + pullRequestNumber, + signal, +}) => { + const number = Number(pullRequestNumber) + if (!Number.isFinite(number) || number <= 0) { + throw new Error('A valid pull request number is required to close a pull request.') + } + + const response = await requestGitHubJson({ + token, + url: buildRepoApiUrl({ owner, repo, path: `/pulls/${number}` }), + method: 'PATCH', + body: { + state: 'closed', + }, + signal, + }) + + return normalizePullRequestSummary(response) +} + +const getRepositoryPullRequest = async ({ + token, + owner, + repo, + pullRequestNumber, + signal, +}) => { + const number = Number(pullRequestNumber) + if (!Number.isFinite(number) || number <= 0) { + return null + } + + const response = await requestGitHubJson({ + token, + url: buildRepoApiUrl({ owner, repo, path: `/pulls/${number}` }), + signal, + allowNotFound: true, + }) + + if (!response) { + return null + } + + return normalizePullRequestSummary(response) +} + +const findOpenRepositoryPullRequestByHead = async ({ + token, + owner, + repo, + headOwner, + headBranch, + baseBranch, + signal, +}) => { + const normalizedHeadBranch = typeof headBranch === 'string' ? headBranch.trim() : '' + if (!normalizedHeadBranch) { + return null + } + + const normalizedHeadOwner = + typeof headOwner === 'string' && headOwner.trim() ? headOwner.trim() : owner + const query = new URLSearchParams({ + state: 'open', + head: `${normalizedHeadOwner}:${normalizedHeadBranch}`, + per_page: '20', + }) + + if (typeof baseBranch === 'string' && baseBranch.trim()) { + query.set('base', baseBranch.trim()) + } + + const response = await requestGitHubJson({ + token, + url: buildRepoApiUrl({ owner, repo, path: `/pulls?${query.toString()}` }), + signal, + }) + + if (!Array.isArray(response) || response.length === 0) { + return null + } + + const normalized = response.map(normalizePullRequestSummary).filter(Boolean) + + const exactBranchMatch = normalized.find( + pullRequest => pullRequest.headRef === normalizedHeadBranch, + ) + + return exactBranchMatch ?? normalized[0] ?? null +} + +export { + closeRepositoryPullRequest, + createRepositoryPullRequest, + findOpenRepositoryPullRequestByHead, + getRepositoryPullRequest, +} diff --git a/src/modules/github/api/repositories.js b/src/modules/github/api/repositories.js new file mode 100644 index 0000000..6fd5261 --- /dev/null +++ b/src/modules/github/api/repositories.js @@ -0,0 +1,140 @@ +import { githubApiBaseUrl } from './constants.js' +import { fetchJson } from './core.js' + +const normalizeRepo = repo => { + const owner = repo?.owner?.login + const name = repo?.name + const fullName = repo?.full_name + + if ( + typeof owner !== 'string' || + typeof name !== 'string' || + typeof fullName !== 'string' + ) { + return null + } + + return { + id: repo.id, + owner, + name, + fullName, + defaultBranch: typeof repo.default_branch === 'string' ? repo.default_branch : 'main', + permissions: repo.permissions ?? {}, + htmlUrl: typeof repo.html_url === 'string' ? repo.html_url : null, + } +} + +const hasWritePermission = permissions => Boolean(permissions && permissions.push) + +const normalizeBranchName = branch => { + if (!branch || typeof branch !== 'object') { + return null + } + + return typeof branch.name === 'string' && branch.name.trim() ? branch.name : null +} + +const listReposPage = async ({ token, url, signal }) => { + const { data, nextPageUrl } = await fetchJson({ token, url, signal }) + + if (!Array.isArray(data)) { + throw new Error('Unexpected response while loading repositories from GitHub.') + } + + return { + repos: data.map(normalizeRepo).filter(Boolean), + nextPageUrl, + } +} + +const listBranchesPage = async ({ token, url, signal }) => { + const { data, nextPageUrl } = await fetchJson({ token, url, signal }) + + if (!Array.isArray(data)) { + throw new Error('Unexpected response while loading repository branches from GitHub.') + } + + return { + branches: data.map(normalizeBranchName).filter(Boolean), + nextPageUrl, + } +} + +const listWritableRepositories = async ({ token, signal }) => { + if (typeof token !== 'string' || token.trim().length === 0) { + throw new Error('A GitHub token is required to load repositories.') + } + + const writableRepos = [] + const dedupeById = new Set() + let nextPageUrl = `${githubApiBaseUrl}/user/repos?sort=updated&per_page=100` + let remainingPageBudget = 10 + + while (nextPageUrl && remainingPageBudget > 0) { + /* GitHub pagination is cursor-like via Link headers, so each request depends on the previous page. */ + // eslint-disable-next-line no-await-in-loop + const page = await listReposPage({ token, url: nextPageUrl, signal }) + for (const repo of page.repos) { + if (!hasWritePermission(repo.permissions) || dedupeById.has(repo.id)) { + continue + } + dedupeById.add(repo.id) + writableRepos.push(repo) + } + + nextPageUrl = page.nextPageUrl + remainingPageBudget -= 1 + } + + writableRepos.sort((left, right) => left.fullName.localeCompare(right.fullName)) + + return writableRepos +} + +const listRepositoryBranches = async ({ token, owner, repo, signal }) => { + if (typeof token !== 'string' || token.trim().length === 0) { + throw new Error('A GitHub token is required to load branches.') + } + + const normalizedOwner = typeof owner === 'string' ? owner.trim() : '' + const normalizedRepo = typeof repo === 'string' ? repo.trim() : '' + + if (!normalizedOwner || !normalizedRepo) { + throw new Error('A valid repository owner/name is required to load branches.') + } + + const branches = [] + const dedupe = new Set() + const collectBranchesByPage = async ({ url, remainingPageBudget }) => { + if (!url || remainingPageBudget <= 0) { + return + } + + const page = await listBranchesPage({ token, url, signal }) + + for (const name of page.branches) { + if (dedupe.has(name)) { + continue + } + + dedupe.add(name) + branches.push(name) + } + + await collectBranchesByPage({ + url: page.nextPageUrl, + remainingPageBudget: remainingPageBudget - 1, + }) + } + + await collectBranchesByPage({ + url: `${githubApiBaseUrl}/repos/${normalizedOwner}/${normalizedRepo}/branches?per_page=100`, + remainingPageBudget: 5, + }) + + branches.sort((left, right) => left.localeCompare(right)) + return branches +} + +export { listRepositoryBranches, listWritableRepositories } diff --git a/src/modules/github/api/repository-files.js b/src/modules/github/api/repository-files.js new file mode 100644 index 0000000..f9b4b3e --- /dev/null +++ b/src/modules/github/api/repository-files.js @@ -0,0 +1,210 @@ +import { buildRepoApiUrl, encodePathForApi, requestGitHubJson } from './core.js' + +const fromUtf8Base64 = value => { + const normalizedValue = typeof value === 'string' ? value.replace(/\s+/g, '') : '' + if (!normalizedValue) { + return '' + } + + const decodedBinary = atob(normalizedValue) + const bytes = Uint8Array.from(decodedBinary, character => character.charCodeAt(0)) + const decoder = new TextDecoder() + return decoder.decode(bytes) +} + +const toUtf8Base64 = value => { + const encoder = new TextEncoder() + const bytes = encoder.encode(value) + const chunkSize = 0x8000 + const chunks = [] + + for (let offset = 0; offset < bytes.length; offset += chunkSize) { + const chunk = bytes.subarray(offset, offset + chunkSize) + chunks.push(String.fromCharCode(...chunk)) + } + + return btoa(chunks.join('')) +} + +const isMissingShaForExistingFileError = error => { + if (!(error instanceof Error)) { + return false + } + + const message = error.message.toLowerCase() + return ( + message.includes('sha') && + (message.includes('already exists') || + message.includes('must be supplied') || + message.includes("wasn't supplied") || + message.includes('not supplied')) + ) +} + +const getBranchReferenceSha = async ({ token, owner, repo, branch, signal }) => { + const ref = encodeURIComponent(`heads/${branch}`) + const response = await requestGitHubJson({ + token, + url: buildRepoApiUrl({ owner, repo, path: `/git/ref/${ref}` }), + signal, + }) + + const sha = response?.object?.sha + if (typeof sha !== 'string' || !sha) { + throw new Error(`Could not resolve SHA for ${owner}/${repo}@${branch}`) + } + + return sha +} + +const createBranchReference = async ({ token, owner, repo, branch, sha, signal }) => { + const response = await requestGitHubJson({ + token, + url: buildRepoApiUrl({ owner, repo, path: '/git/refs' }), + method: 'POST', + body: { + ref: `refs/heads/${branch}`, + sha, + }, + signal, + }) + + const createdRef = response?.ref + if (typeof createdRef !== 'string' || !createdRef) { + throw new Error(`Could not create branch ${branch} in ${owner}/${repo}`) + } + + return createdRef +} + +const getRepositoryFileMetadata = async ({ token, owner, repo, path, ref, signal }) => { + const encodedPath = encodePathForApi(path) + const query = ref ? `?ref=${encodeURIComponent(ref)}` : '' + const response = await requestGitHubJson({ + token, + url: buildRepoApiUrl({ owner, repo, path: `/contents/${encodedPath}${query}` }), + signal, + allowNotFound: true, + }) + + if (!response) { + return null + } + + return { + sha: typeof response.sha === 'string' ? response.sha : null, + } +} + +const getRepositoryFileContent = async ({ token, owner, repo, path, ref, signal }) => { + const encodedPath = encodePathForApi(path) + const query = ref ? `?ref=${encodeURIComponent(ref)}` : '' + const response = await requestGitHubJson({ + token, + url: buildRepoApiUrl({ owner, repo, path: `/contents/${encodedPath}${query}` }), + signal, + allowNotFound: true, + }) + + if (!response) { + return null + } + + return { + path, + sha: typeof response.sha === 'string' ? response.sha : null, + content: fromUtf8Base64(typeof response.content === 'string' ? response.content : ''), + } +} + +const upsertRepositoryFile = async ({ + token, + owner, + repo, + branch, + path, + content, + message, + signal, +}) => { + const encodedPath = encodePathForApi(path) + const existingFile = await getRepositoryFileMetadata({ + token, + owner, + repo, + path, + ref: branch, + signal, + }) + + const baseBody = { + message, + content: toUtf8Base64(content), + branch, + } + + const requestBody = existingFile?.sha + ? { + ...baseBody, + sha: existingFile.sha, + } + : baseBody + + try { + const response = await requestGitHubJson({ + token, + url: buildRepoApiUrl({ owner, repo, path: `/contents/${encodedPath}` }), + method: 'PUT', + body: requestBody, + signal, + }) + + return { + path, + commitSha: typeof response?.commit?.sha === 'string' ? response.commit.sha : null, + created: !existingFile?.sha, + } + } catch (error) { + if (!isMissingShaForExistingFileError(error) || existingFile?.sha) { + throw error + } + + const latestFile = await getRepositoryFileMetadata({ + token, + owner, + repo, + path, + ref: branch, + signal, + }) + + if (!latestFile?.sha) { + throw error + } + + const response = await requestGitHubJson({ + token, + url: buildRepoApiUrl({ owner, repo, path: `/contents/${encodedPath}` }), + method: 'PUT', + body: { + ...baseBody, + sha: latestFile.sha, + }, + signal, + }) + + return { + path, + commitSha: typeof response?.commit?.sha === 'string' ? response.commit.sha : null, + created: false, + } + } +} + +export { + createBranchReference, + getBranchReferenceSha, + getRepositoryFileContent, + getRepositoryFileMetadata, + upsertRepositoryFile, +} diff --git a/src/modules/github/github-byot-controls.js b/src/modules/github/byot-controls.js similarity index 98% rename from src/modules/github/github-byot-controls.js rename to src/modules/github/byot-controls.js index 0f494b5..67022a2 100644 --- a/src/modules/github/github-byot-controls.js +++ b/src/modules/github/byot-controls.js @@ -3,9 +3,9 @@ import { loadGitHubToken, maskGitHubToken, saveGitHubToken, -} from './github-token-store.js' -import { listWritableRepositories } from './github-api.js' -import { findRepositoryWithActivePrContext } from './github-pr-drawer.js' +} from './token-store.js' +import { listWritableRepositories } from './api/repositories.js' +import { findRepositoryWithActivePrContext } from './pr/drawer/config.js' const selectedRepositoryStorageKey = 'knighted:develop:github-repository' diff --git a/src/modules/github/chat-drawer/chat-utils.js b/src/modules/github/chat-drawer/chat-utils.js index 7a98cfa..dd39ebe 100644 --- a/src/modules/github/chat-drawer/chat-utils.js +++ b/src/modules/github/chat-drawer/chat-utils.js @@ -1,4 +1,4 @@ -import { defaultGitHubChatModel } from '../github-api.js' +import { defaultGitHubChatModel } from '../api/chat.js' export const toChatText = value => { if (typeof value !== 'string') { diff --git a/src/modules/github/chat-drawer/drawer.js b/src/modules/github/chat-drawer/drawer.js index f70b579..bb79a6d 100644 --- a/src/modules/github/chat-drawer/drawer.js +++ b/src/modules/github/chat-drawer/drawer.js @@ -3,7 +3,7 @@ import { githubChatModelOptions, requestGitHubChatCompletion, streamGitHubChatCompletion, -} from '../github-api.js' +} from '../api/chat.js' import { formatModelAccessErrorMessage, isModelAccessError, diff --git a/src/modules/github/github-api.js b/src/modules/github/github-api.js deleted file mode 100644 index 95ebf40..0000000 --- a/src/modules/github/github-api.js +++ /dev/null @@ -1,1559 +0,0 @@ -const githubApiBaseUrl = 'https://api.github.com' -const githubModelsApiUrl = 'https://models.github.ai/inference/chat/completions' - -export const defaultGitHubChatModel = 'openai/gpt-4.1-mini' - -/* Local model options avoid browser CORS failures when calling catalog endpoints directly. */ -export const githubChatModelOptions = [ - 'openai/gpt-4.1-mini', - 'openai/gpt-4.1', - 'openai/gpt-4.1-nano', - 'openai/gpt-4o', - 'openai/gpt-4o-mini', - 'openai/gpt-5', - 'openai/gpt-5-chat', - 'openai/gpt-5-mini', - 'openai/gpt-5-nano', - 'cohere/cohere-command-r-plus-08-2024', - 'deepseek/deepseek-v3-0324', - 'meta/llama-4-maverick-17b-128e-instruct-fp8', - 'meta/llama-4-scout-17b-16e-instruct', - 'mistral-ai/ministral-3b', - 'mistral-ai/mistral-medium-2505', - 'mistral-ai/mistral-small-2503', -] - -const parseNextPageUrlFromLinkHeader = linkHeader => { - if (typeof linkHeader !== 'string' || !linkHeader.trim()) { - return null - } - - const segments = linkHeader.split(',') - - for (const segment of segments) { - const parts = segment - .trim() - .split(';') - .map(part => part.trim()) - if (parts.length < 2) { - continue - } - - const urlPart = parts[0] - const relPart = parts[1] - - if (!urlPart.startsWith('<') || !urlPart.endsWith('>')) { - continue - } - - if (relPart !== 'rel="next"') { - continue - } - - return urlPart.slice(1, -1) - } - - return null -} - -const normalizeRepo = repo => { - const owner = repo?.owner?.login - const name = repo?.name - const fullName = repo?.full_name - - if ( - typeof owner !== 'string' || - typeof name !== 'string' || - typeof fullName !== 'string' - ) { - return null - } - - return { - id: repo.id, - owner, - name, - fullName, - defaultBranch: typeof repo.default_branch === 'string' ? repo.default_branch : 'main', - permissions: repo.permissions ?? {}, - htmlUrl: typeof repo.html_url === 'string' ? repo.html_url : null, - } -} - -const hasWritePermission = permissions => Boolean(permissions && permissions.push) - -const normalizeBranchName = branch => { - if (!branch || typeof branch !== 'object') { - return null - } - - return typeof branch.name === 'string' && branch.name.trim() ? branch.name : null -} - -const buildRequestHeaders = token => ({ - Accept: 'application/vnd.github+json', - Authorization: `Bearer ${token}`, - 'X-GitHub-Api-Version': '2022-11-28', -}) - -const buildChatRequestHeaders = ({ token, stream }) => ({ - Accept: stream ? 'text/event-stream' : 'application/json', - Authorization: `Bearer ${token}`, - 'Content-Type': 'application/json', - 'X-GitHub-Api-Version': '2022-11-28', -}) - -const toFiniteNumber = value => { - if (value === null || value === undefined) { - return null - } - - if (typeof value === 'string' && value.trim().length === 0) { - return null - } - - const numberValue = Number(value) - return Number.isFinite(numberValue) ? numberValue : null -} - -const fromUtf8Base64 = value => { - const normalizedValue = typeof value === 'string' ? value.replace(/\s+/g, '') : '' - if (!normalizedValue) { - return '' - } - - const decodedBinary = atob(normalizedValue) - const bytes = Uint8Array.from(decodedBinary, character => character.charCodeAt(0)) - const decoder = new TextDecoder() - return decoder.decode(bytes) -} - -const parseRateMetadataFromHeaders = headers => { - if (!headers || typeof headers.get !== 'function') { - return { - remaining: null, - resetEpochSeconds: null, - } - } - - const remaining = - toFiniteNumber(headers.get('x-ratelimit-remaining')) ?? - toFiniteNumber(headers.get('ratelimit-remaining')) - - const resetEpochSeconds = - toFiniteNumber(headers.get('x-ratelimit-reset')) ?? - toFiniteNumber(headers.get('ratelimit-reset')) - - return { - remaining, - resetEpochSeconds, - } -} - -const parseRateMetadataFromBody = body => { - if (!body || typeof body !== 'object') { - return { - remaining: null, - resetEpochSeconds: null, - } - } - - const rateLimit = body.rate_limit ?? body.rateLimit ?? null - - const remaining = - toFiniteNumber(rateLimit?.remaining) ?? toFiniteNumber(body.remaining) ?? null - - const resetEpochSeconds = - toFiniteNumber(rateLimit?.reset) ?? - toFiniteNumber(rateLimit?.reset_epoch_seconds) ?? - toFiniteNumber(rateLimit?.resetEpochSeconds) ?? - toFiniteNumber(body.reset) ?? - null - - return { - remaining, - resetEpochSeconds, - } -} - -const mergeRateMetadata = (primary, fallback) => ({ - remaining: primary.remaining ?? fallback.remaining ?? null, - resetEpochSeconds: primary.resetEpochSeconds ?? fallback.resetEpochSeconds ?? null, -}) - -const parseRateMetadata = ({ headers, body }) => { - const fromHeaders = parseRateMetadataFromHeaders(headers) - const fromBody = parseRateMetadataFromBody(body) - return mergeRateMetadata(fromHeaders, fromBody) -} - -const toApiError = ({ message, rateLimit }) => { - const error = new Error(message) - error.rateLimit = rateLimit - return error -} - -const normalizeChatMessage = message => { - if (!message || typeof message !== 'object') { - return null - } - - const role = - message.role === 'system' || message.role === 'assistant' ? message.role : 'user' - const content = typeof message.content === 'string' ? message.content.trim() : '' - - if (!content) { - return null - } - - return { role, content } -} - -const normalizeChatMessages = messages => { - if (!Array.isArray(messages)) { - return [] - } - - return messages.map(normalizeChatMessage).filter(Boolean) -} - -const normalizeToolChoice = toolChoice => { - if (toolChoice === 'required' || toolChoice === 'none') { - return toolChoice - } - - return 'auto' -} - -const normalizeToolDefinition = tool => { - if (!tool || typeof tool !== 'object') { - return null - } - - if (tool.type !== 'function') { - return null - } - - const fn = tool.function - if (!fn || typeof fn !== 'object') { - return null - } - - const name = typeof fn.name === 'string' ? fn.name.trim() : '' - if (!name) { - return null - } - - const description = - typeof fn.description === 'string' && fn.description.trim() - ? fn.description.trim() - : undefined - const parameters = - fn.parameters && typeof fn.parameters === 'object' ? fn.parameters : undefined - - return { - type: 'function', - function: { - name, - ...(description ? { description } : {}), - ...(parameters ? { parameters } : {}), - }, - } -} - -const normalizeToolDefinitions = tools => { - if (!Array.isArray(tools)) { - return [] - } - - return tools.map(normalizeToolDefinition).filter(Boolean) -} - -const buildChatBody = ({ model, messages, stream, tools, toolChoice }) => { - const normalizedMessages = normalizeChatMessages(messages) - const normalizedTools = normalizeToolDefinitions(tools) - - const body = { - model, - messages: normalizedMessages, - stream, - } - - if (normalizedTools.length > 0) { - body.tools = normalizedTools - body.tool_choice = normalizeToolChoice(toolChoice) - } - - return body -} - -const normalizeToolCall = toolCall => { - if (!toolCall || typeof toolCall !== 'object') { - return null - } - - const fn = toolCall.function - const name = typeof fn?.name === 'string' ? fn.name.trim() : '' - if (!name) { - return null - } - - const argumentsText = - typeof fn?.arguments === 'string' && fn.arguments.trim() ? fn.arguments : '{}' - - return { - id: typeof toolCall.id === 'string' ? toolCall.id : '', - name, - arguments: argumentsText, - } -} - -const extractToolCallsFromMessage = message => { - if (!message || typeof message !== 'object') { - return [] - } - - if (!Array.isArray(message.tool_calls)) { - return [] - } - - return message.tool_calls.map(normalizeToolCall).filter(Boolean) -} - -const extractContentFromMessage = message => { - if (!message || typeof message !== 'object') { - return '' - } - - if (typeof message.content === 'string') { - return message.content - } - - if (!Array.isArray(message.content)) { - return '' - } - - return message.content - .map(part => { - if (typeof part === 'string') { - return part - } - - if ( - part && - typeof part === 'object' && - part.type === 'text' && - typeof part.text === 'string' - ) { - return part.text - } - - return '' - }) - .join('') -} - -const extractChatCompletionText = body => { - const firstChoice = Array.isArray(body?.choices) ? body.choices[0] : null - - if (!firstChoice || typeof firstChoice !== 'object') { - return '' - } - - const message = firstChoice.message - return extractContentFromMessage(message).trim() -} - -const extractChatCompletionToolCalls = body => { - const firstChoice = Array.isArray(body?.choices) ? body.choices[0] : null - - if (!firstChoice || typeof firstChoice !== 'object') { - return [] - } - - return extractToolCallsFromMessage(firstChoice.message) -} - -const extractStreamingDeltaText = body => { - const firstChoice = Array.isArray(body?.choices) ? body.choices[0] : null - - if (!firstChoice || typeof firstChoice !== 'object') { - return '' - } - - if (typeof firstChoice.delta?.content === 'string') { - return firstChoice.delta.content - } - - return '' -} - -const collectStreamingToolCalls = ({ body, callsByIndex, orderedCalls }) => { - const firstChoice = Array.isArray(body?.choices) ? body.choices[0] : null - const deltas = Array.isArray(firstChoice?.delta?.tool_calls) - ? firstChoice.delta.tool_calls - : [] - - for (const delta of deltas) { - const index = Number.isFinite(delta?.index) ? delta.index : orderedCalls.length - const key = String(index) - const existing = callsByIndex.get(key) ?? { - id: '', - name: '', - arguments: '', - } - - if (typeof delta?.id === 'string' && delta.id.trim()) { - existing.id = delta.id - } - - const fn = delta?.function - if (typeof fn?.name === 'string' && fn.name.trim()) { - existing.name = fn.name - } - - if (typeof fn?.arguments === 'string') { - existing.arguments += fn.arguments - } - - callsByIndex.set(key, existing) - } - - orderedCalls.length = 0 - const sortedEntries = [...callsByIndex.entries()].sort( - (left, right) => Number(left[0]) - Number(right[0]), - ) - - for (const [, call] of sortedEntries) { - const normalizedCall = normalizeToolCall({ - id: call.id, - function: { - name: call.name, - arguments: call.arguments, - }, - }) - - if (normalizedCall) { - orderedCalls.push(normalizedCall) - } - } -} - -const parseSseDataLine = line => { - if (typeof line !== 'string') { - return null - } - - const trimmedLine = line.trim() - if (!trimmedLine.startsWith('data:')) { - return null - } - - const payload = trimmedLine.slice(5).trim() - if (!payload || payload === '[DONE]') { - return null - } - - try { - return JSON.parse(payload) - } catch { - return null - } -} - -const parseErrorResponse = async response => { - let body = null - - try { - body = await response.json() - } catch { - /* noop */ - } - - const message = - body && typeof body.message === 'string' && body.message.trim() - ? body.message - : `GitHub API request failed with status ${response.status}` - - return { - message, - rateLimit: parseRateMetadata({ headers: response.headers, body }), - } -} - -const fetchJson = async ({ token, url, signal }) => { - const response = await fetch(url, { - method: 'GET', - headers: buildRequestHeaders(token), - signal, - }) - - if (!response.ok) { - const { message, rateLimit } = await parseErrorResponse(response) - throw toApiError({ message, rateLimit }) - } - - return { - data: await response.json(), - nextPageUrl: parseNextPageUrlFromLinkHeader(response.headers.get('link')), - } -} - -const listReposPage = async ({ token, url, signal }) => { - const { data, nextPageUrl } = await fetchJson({ token, url, signal }) - - if (!Array.isArray(data)) { - throw new Error('Unexpected response while loading repositories from GitHub.') - } - - return { - repos: data.map(normalizeRepo).filter(Boolean), - nextPageUrl, - } -} - -const listBranchesPage = async ({ token, url, signal }) => { - const { data, nextPageUrl } = await fetchJson({ token, url, signal }) - - if (!Array.isArray(data)) { - throw new Error('Unexpected response while loading repository branches from GitHub.') - } - - return { - branches: data.map(normalizeBranchName).filter(Boolean), - nextPageUrl, - } -} - -export const listWritableRepositories = async ({ token, signal }) => { - if (typeof token !== 'string' || token.trim().length === 0) { - throw new Error('A GitHub token is required to load repositories.') - } - - const writableRepos = [] - const dedupeById = new Set() - let nextPageUrl = `${githubApiBaseUrl}/user/repos?sort=updated&per_page=100` - let remainingPageBudget = 10 - - while (nextPageUrl && remainingPageBudget > 0) { - /* GitHub pagination is cursor-like via Link headers, so each request depends on the previous page. */ - // eslint-disable-next-line no-await-in-loop - const page = await listReposPage({ token, url: nextPageUrl, signal }) - for (const repo of page.repos) { - if (!hasWritePermission(repo.permissions) || dedupeById.has(repo.id)) { - continue - } - dedupeById.add(repo.id) - writableRepos.push(repo) - } - - nextPageUrl = page.nextPageUrl - remainingPageBudget -= 1 - } - - writableRepos.sort((left, right) => left.fullName.localeCompare(right.fullName)) - - return writableRepos -} - -export const listRepositoryBranches = async ({ token, owner, repo, signal }) => { - if (typeof token !== 'string' || token.trim().length === 0) { - throw new Error('A GitHub token is required to load branches.') - } - - const normalizedOwner = typeof owner === 'string' ? owner.trim() : '' - const normalizedRepo = typeof repo === 'string' ? repo.trim() : '' - - if (!normalizedOwner || !normalizedRepo) { - throw new Error('A valid repository owner/name is required to load branches.') - } - - const branches = [] - const dedupe = new Set() - const collectBranchesByPage = async ({ url, remainingPageBudget }) => { - if (!url || remainingPageBudget <= 0) { - return - } - - const page = await listBranchesPage({ token, url, signal }) - - for (const name of page.branches) { - if (dedupe.has(name)) { - continue - } - - dedupe.add(name) - branches.push(name) - } - - await collectBranchesByPage({ - url: page.nextPageUrl, - remainingPageBudget: remainingPageBudget - 1, - }) - } - - await collectBranchesByPage({ - url: `${githubApiBaseUrl}/repos/${normalizedOwner}/${normalizedRepo}/branches?per_page=100`, - remainingPageBudget: 5, - }) - - branches.sort((left, right) => left.localeCompare(right)) - return branches -} - -export const streamGitHubChatCompletion = async ({ - token, - messages, - signal, - onToken, - model = defaultGitHubChatModel, - tools, - toolChoice, -}) => { - if (typeof token !== 'string' || token.trim().length === 0) { - throw new Error('A GitHub token is required to start a chat request.') - } - - const normalizedMessages = normalizeChatMessages(messages) - if (normalizedMessages.length === 0) { - throw new Error('At least one message is required to start a chat request.') - } - - const response = await fetch(githubModelsApiUrl, { - method: 'POST', - headers: buildChatRequestHeaders({ token, stream: true }), - body: JSON.stringify( - buildChatBody({ - model, - messages: normalizedMessages, - stream: true, - tools, - toolChoice, - }), - ), - signal, - }) - - if (!response.ok) { - const { message, rateLimit } = await parseErrorResponse(response) - throw toApiError({ message, rateLimit }) - } - - if (!response.body) { - throw new Error('Streaming response body is not available in this browser.') - } - - const decoder = new TextDecoder() - const reader = response.body.getReader() - let buffered = '' - let combined = '' - let responseModel = '' - const streamingToolCallsByIndex = new Map() - const streamingToolCalls = [] - - while (true) { - // eslint-disable-next-line no-await-in-loop - const { done, value } = await reader.read() - if (done) { - break - } - - buffered += decoder.decode(value, { stream: true }) - const lines = buffered.split('\n') - buffered = lines.pop() ?? '' - - for (const line of lines) { - const body = parseSseDataLine(line) - if (!body) { - continue - } - - if (!responseModel && typeof body.model === 'string') { - responseModel = body.model - } - - collectStreamingToolCalls({ - body, - callsByIndex: streamingToolCallsByIndex, - orderedCalls: streamingToolCalls, - }) - - const chunk = extractStreamingDeltaText(body) - if (!chunk) { - continue - } - - combined += chunk - onToken?.(chunk) - } - } - - if (buffered.trim()) { - const body = parseSseDataLine(buffered) - if (body && !responseModel && typeof body.model === 'string') { - responseModel = body.model - } - if (body) { - collectStreamingToolCalls({ - body, - callsByIndex: streamingToolCallsByIndex, - orderedCalls: streamingToolCalls, - }) - } - const chunk = body ? extractStreamingDeltaText(body) : '' - if (chunk) { - combined += chunk - onToken?.(chunk) - } - } - - if (!combined.trim() && streamingToolCalls.length === 0) { - throw new Error('Streaming response did not include assistant content.') - } - - return { - content: combined, - toolCalls: streamingToolCalls, - model: responseModel || model, - rateLimit: parseRateMetadata({ headers: response.headers, body: null }), - } -} - -export const requestGitHubChatCompletion = async ({ - token, - messages, - signal, - model = defaultGitHubChatModel, - tools, - toolChoice, -}) => { - if (typeof token !== 'string' || token.trim().length === 0) { - throw new Error('A GitHub token is required to start a chat request.') - } - - const normalizedMessages = normalizeChatMessages(messages) - if (normalizedMessages.length === 0) { - throw new Error('At least one message is required to start a chat request.') - } - - const response = await fetch(githubModelsApiUrl, { - method: 'POST', - headers: buildChatRequestHeaders({ token, stream: false }), - body: JSON.stringify( - buildChatBody({ - model, - messages: normalizedMessages, - stream: false, - tools, - toolChoice, - }), - ), - signal, - }) - - if (!response.ok) { - const { message, rateLimit } = await parseErrorResponse(response) - throw toApiError({ message, rateLimit }) - } - - const body = await response.json() - const content = extractChatCompletionText(body) - const toolCalls = extractChatCompletionToolCalls(body) - - if (!content && toolCalls.length === 0) { - throw new Error('GitHub chat response did not include assistant content.') - } - - return { - content, - toolCalls, - model: typeof body?.model === 'string' && body.model ? body.model : model, - rateLimit: parseRateMetadata({ headers: response.headers, body }), - } -} - -const encodePathForApi = path => - path - .split('/') - .map(segment => encodeURIComponent(segment)) - .join('/') - -const requestGitHubJson = async ({ - token, - url, - method = 'GET', - body, - signal, - allowNotFound = false, -}) => { - const headers = { - ...buildRequestHeaders(token), - ...(body ? { 'Content-Type': 'application/json' } : {}), - } - - const response = await fetch(url, { - method, - headers, - body: body ? JSON.stringify(body) : undefined, - signal, - }) - - if (allowNotFound && response.status === 404) { - return null - } - - if (!response.ok) { - const { message, rateLimit } = await parseErrorResponse(response) - throw toApiError({ message, rateLimit }) - } - - return response.json() -} - -export const getBranchReferenceSha = async ({ token, owner, repo, branch, signal }) => { - const ref = encodeURIComponent(`heads/${branch}`) - const response = await requestGitHubJson({ - token, - url: `${githubApiBaseUrl}/repos/${owner}/${repo}/git/ref/${ref}`, - signal, - }) - - const sha = response?.object?.sha - if (typeof sha !== 'string' || !sha) { - throw new Error(`Could not resolve SHA for ${owner}/${repo}@${branch}`) - } - - return sha -} - -export const createBranchReference = async ({ - token, - owner, - repo, - branch, - sha, - signal, -}) => { - const response = await requestGitHubJson({ - token, - url: `${githubApiBaseUrl}/repos/${owner}/${repo}/git/refs`, - method: 'POST', - body: { - ref: `refs/heads/${branch}`, - sha, - }, - signal, - }) - - const createdRef = response?.ref - if (typeof createdRef !== 'string' || !createdRef) { - throw new Error(`Could not create branch ${branch} in ${owner}/${repo}`) - } - - return createdRef -} - -export const getRepositoryFileMetadata = async ({ - token, - owner, - repo, - path, - ref, - signal, -}) => { - const encodedPath = encodePathForApi(path) - const query = ref ? `?ref=${encodeURIComponent(ref)}` : '' - const response = await requestGitHubJson({ - token, - url: `${githubApiBaseUrl}/repos/${owner}/${repo}/contents/${encodedPath}${query}`, - signal, - allowNotFound: true, - }) - - if (!response) { - return null - } - - return { - sha: typeof response.sha === 'string' ? response.sha : null, - } -} - -export const getRepositoryFileContent = async ({ - token, - owner, - repo, - path, - ref, - signal, -}) => { - const encodedPath = encodePathForApi(path) - const query = ref ? `?ref=${encodeURIComponent(ref)}` : '' - const response = await requestGitHubJson({ - token, - url: `${githubApiBaseUrl}/repos/${owner}/${repo}/contents/${encodedPath}${query}`, - signal, - allowNotFound: true, - }) - - if (!response) { - return null - } - - return { - path, - sha: typeof response.sha === 'string' ? response.sha : null, - content: fromUtf8Base64(typeof response.content === 'string' ? response.content : ''), - } -} - -const toUtf8Base64 = value => { - const encoder = new TextEncoder() - const bytes = encoder.encode(value) - const chunkSize = 0x8000 - const chunks = [] - - for (let offset = 0; offset < bytes.length; offset += chunkSize) { - const chunk = bytes.subarray(offset, offset + chunkSize) - chunks.push(String.fromCharCode(...chunk)) - } - - return btoa(chunks.join('')) -} - -const isMissingShaForExistingFileError = error => { - if (!(error instanceof Error)) { - return false - } - - const message = error.message.toLowerCase() - return ( - message.includes('sha') && - (message.includes('already exists') || - message.includes('must be supplied') || - message.includes("wasn't supplied") || - message.includes('not supplied')) - ) -} - -export const upsertRepositoryFile = async ({ - token, - owner, - repo, - branch, - path, - content, - message, - signal, -}) => { - const encodedPath = encodePathForApi(path) - const existingFile = await getRepositoryFileMetadata({ - token, - owner, - repo, - path, - ref: branch, - signal, - }) - - const baseBody = { - message, - content: toUtf8Base64(content), - branch, - } - - const requestBody = existingFile?.sha - ? { - ...baseBody, - sha: existingFile.sha, - } - : baseBody - - try { - const response = await requestGitHubJson({ - token, - url: `${githubApiBaseUrl}/repos/${owner}/${repo}/contents/${encodedPath}`, - method: 'PUT', - body: requestBody, - signal, - }) - - return { - path, - commitSha: typeof response?.commit?.sha === 'string' ? response.commit.sha : null, - created: !existingFile?.sha, - } - } catch (error) { - if (!isMissingShaForExistingFileError(error) || existingFile?.sha) { - throw error - } - - const latestFile = await getRepositoryFileMetadata({ - token, - owner, - repo, - path, - ref: branch, - signal, - }) - - if (!latestFile?.sha) { - throw error - } - - const response = await requestGitHubJson({ - token, - url: `${githubApiBaseUrl}/repos/${owner}/${repo}/contents/${encodedPath}`, - method: 'PUT', - body: { - ...baseBody, - sha: latestFile.sha, - }, - signal, - }) - - return { - path, - commitSha: typeof response?.commit?.sha === 'string' ? response.commit.sha : null, - created: false, - } - } -} - -const normalizeFileUpdatePath = value => - (typeof value === 'string' ? value.trim() : '').replace(/\\/g, '/').replace(/\/+/g, '/') - -const validateRepositoryRelativeFilePath = value => { - const path = normalizeFileUpdatePath(value) - - if (!path) { - return { ok: false, reason: 'File path is required.' } - } - - if (path.startsWith('/')) { - return { - ok: false, - reason: 'File path must be repository-relative (no leading slash).', - } - } - - if (path.endsWith('/')) { - return { ok: false, reason: 'File path must include a filename (no trailing slash).' } - } - - const segments = path.split('/').filter(Boolean) - if (segments.some(segment => segment === '..')) { - return { ok: false, reason: 'File path cannot include parent directory traversal.' } - } - - if (!/^[A-Za-z0-9._\-/]+$/.test(path)) { - return { - ok: false, - reason: - 'File path contains unsupported characters. Use letters, numbers, ., _, -, and / only.', - } - } - - if (segments.length === 0 || segments.some(segment => segment === '.' || !segment)) { - return { ok: false, reason: 'File path is invalid.' } - } - - return { ok: true, value: path } -} - -const normalizeFileUpdateInput = (file, index) => { - if (!file || typeof file !== 'object') { - throw new Error(`File update at index ${index} must be an object.`) - } - - const validation = validateRepositoryRelativeFilePath(file.path) - if (!validation.ok) { - const rawPath = typeof file.path === 'string' ? file.path : '' - throw new Error( - `Invalid file update path at index ${index}: ${rawPath || '(missing path)'} (${validation.reason})`, - ) - } - - return { - path: validation.value, - content: typeof file.content === 'string' ? file.content : '', - } -} - -const toUniqueFileUpdatesByPath = files => { - if (!Array.isArray(files) || files.length === 0) { - return [] - } - - const updatesByPath = new Map() - for (const [index, file] of files.entries()) { - const normalized = normalizeFileUpdateInput(file, index) - - updatesByPath.set(normalized.path, normalized) - } - - return [...updatesByPath.values()] -} - -const getCommitTreeSha = async ({ token, owner, repo, commitSha, signal }) => { - const response = await requestGitHubJson({ - token, - url: `${githubApiBaseUrl}/repos/${owner}/${repo}/git/commits/${commitSha}`, - signal, - }) - - const treeSha = response?.tree?.sha - if (typeof treeSha !== 'string' || !treeSha) { - throw new Error(`Could not resolve tree SHA for commit ${commitSha}.`) - } - - return treeSha -} - -const createRepositoryTree = async ({ - token, - owner, - repo, - baseTreeSha, - files, - signal, -}) => { - const tree = files.map(file => ({ - path: file.path, - mode: '100644', - type: 'blob', - content: file.content, - })) - - const response = await requestGitHubJson({ - token, - url: `${githubApiBaseUrl}/repos/${owner}/${repo}/git/trees`, - method: 'POST', - body: { - base_tree: baseTreeSha, - tree, - }, - signal, - }) - - const treeSha = response?.sha - if (typeof treeSha !== 'string' || !treeSha) { - throw new Error('Could not create repository tree for commit.') - } - - return treeSha -} - -const createRepositoryCommit = async ({ - token, - owner, - repo, - message, - treeSha, - parentCommitSha, - signal, -}) => { - const response = await requestGitHubJson({ - token, - url: `${githubApiBaseUrl}/repos/${owner}/${repo}/git/commits`, - method: 'POST', - body: { - message, - tree: treeSha, - parents: [parentCommitSha], - }, - signal, - }) - - const commitSha = response?.sha - if (typeof commitSha !== 'string' || !commitSha) { - throw new Error('Could not create repository commit.') - } - - return commitSha -} - -const updateBranchReference = async ({ token, owner, repo, branch, sha, signal }) => { - const ref = encodeURIComponent(`heads/${branch}`) - await requestGitHubJson({ - token, - url: `${githubApiBaseUrl}/repos/${owner}/${repo}/git/refs/${ref}`, - method: 'PATCH', - body: { - sha, - force: false, - }, - signal, - }) -} - -const commitFilesToExistingBranchWithGitDatabaseApi = async ({ - token, - owner, - repo, - branch, - files, - commitMessage, - signal, -}) => { - const uniqueFiles = toUniqueFileUpdatesByPath(files) - if (uniqueFiles.length === 0) { - return [] - } - - const headCommitSha = await getBranchReferenceSha({ - token, - owner, - repo, - branch, - signal, - }) - const baseTreeSha = await getCommitTreeSha({ - token, - owner, - repo, - commitSha: headCommitSha, - signal, - }) - const treeSha = await createRepositoryTree({ - token, - owner, - repo, - baseTreeSha, - files: uniqueFiles, - signal, - }) - const commitSha = await createRepositoryCommit({ - token, - owner, - repo, - message: commitMessage, - treeSha, - parentCommitSha: headCommitSha, - signal, - }) - await updateBranchReference({ - token, - owner, - repo, - branch, - sha: commitSha, - signal, - }) - - return uniqueFiles.map(file => ({ - path: file.path, - commitSha, - created: null, - })) -} - -export const createRepositoryPullRequest = async ({ - token, - owner, - repo, - title, - body, - head, - base, - signal, -}) => { - const response = await requestGitHubJson({ - token, - url: `${githubApiBaseUrl}/repos/${owner}/${repo}/pulls`, - method: 'POST', - body: { - title, - body, - head, - base, - }, - signal, - }) - - return { - number: response?.number, - htmlUrl: typeof response?.html_url === 'string' ? response.html_url : '', - apiUrl: typeof response?.url === 'string' ? response.url : '', - } -} - -export const closeRepositoryPullRequest = async ({ - token, - owner, - repo, - pullRequestNumber, - signal, -}) => { - const number = Number(pullRequestNumber) - if (!Number.isFinite(number) || number <= 0) { - throw new Error('A valid pull request number is required to close a pull request.') - } - - const response = await requestGitHubJson({ - token, - url: `${githubApiBaseUrl}/repos/${owner}/${repo}/pulls/${number}`, - method: 'PATCH', - body: { - state: 'closed', - }, - signal, - }) - - return normalizePullRequestSummary(response) -} - -const normalizePullRequestSummary = pullRequest => { - if (!pullRequest || typeof pullRequest !== 'object') { - return null - } - - const number = - typeof pullRequest.number === 'number' && Number.isFinite(pullRequest.number) - ? pullRequest.number - : null - const htmlUrl = typeof pullRequest.html_url === 'string' ? pullRequest.html_url : '' - const title = typeof pullRequest.title === 'string' ? pullRequest.title : '' - const state = typeof pullRequest.state === 'string' ? pullRequest.state : '' - const headRef = typeof pullRequest?.head?.ref === 'string' ? pullRequest.head.ref : '' - const baseRef = typeof pullRequest?.base?.ref === 'string' ? pullRequest.base.ref : '' - - if (!number) { - return null - } - - return { - number, - htmlUrl, - title, - state, - headRef, - baseRef, - isOpen: state.toLowerCase() === 'open', - } -} - -export const getRepositoryPullRequest = async ({ - token, - owner, - repo, - pullRequestNumber, - signal, -}) => { - const number = Number(pullRequestNumber) - if (!Number.isFinite(number) || number <= 0) { - return null - } - - const response = await requestGitHubJson({ - token, - url: `${githubApiBaseUrl}/repos/${owner}/${repo}/pulls/${number}`, - signal, - allowNotFound: true, - }) - - if (!response) { - return null - } - - return normalizePullRequestSummary(response) -} - -export const findOpenRepositoryPullRequestByHead = async ({ - token, - owner, - repo, - headOwner, - headBranch, - baseBranch, - signal, -}) => { - const normalizedHeadBranch = typeof headBranch === 'string' ? headBranch.trim() : '' - if (!normalizedHeadBranch) { - return null - } - - const normalizedHeadOwner = - typeof headOwner === 'string' && headOwner.trim() ? headOwner.trim() : owner - const query = new URLSearchParams({ - state: 'open', - head: `${normalizedHeadOwner}:${normalizedHeadBranch}`, - per_page: '20', - }) - - if (typeof baseBranch === 'string' && baseBranch.trim()) { - query.set('base', baseBranch.trim()) - } - - const response = await requestGitHubJson({ - token, - url: `${githubApiBaseUrl}/repos/${owner}/${repo}/pulls?${query.toString()}`, - signal, - }) - - if (!Array.isArray(response) || response.length === 0) { - return null - } - - const normalized = response.map(normalizePullRequestSummary).filter(Boolean) - - const exactBranchMatch = normalized.find( - pullRequest => pullRequest.headRef === normalizedHeadBranch, - ) - - return exactBranchMatch ?? normalized[0] ?? null -} - -const isReferenceAlreadyExistsError = error => { - if (!(error instanceof Error)) { - return false - } - - const message = error.message.toLowerCase() - return ( - message.includes('reference already exists') || message.includes('already exists') - ) -} - -const createUniqueBranchReference = async ({ - token, - owner, - repo, - headBranch, - baseSha, - signal, - attempt = 0, -}) => { - const candidateBranch = attempt === 0 ? headBranch : `${headBranch}-${attempt + 1}` - - try { - await createBranchReference({ - token, - owner, - repo, - branch: candidateBranch, - sha: baseSha, - signal, - }) - return candidateBranch - } catch (error) { - if (!isReferenceAlreadyExistsError(error)) { - throw error - } - - if (attempt >= 4) { - throw new Error( - `Branch ${headBranch} already exists. Choose another branch name and retry.`, - { - cause: error, - }, - ) - } - - return createUniqueBranchReference({ - token, - owner, - repo, - headBranch, - baseSha, - signal, - attempt: attempt + 1, - }) - } -} - -export const createEditorContentPullRequest = async ({ - token, - repository, - baseBranch, - headBranch, - prTitle, - prBody, - fileUpdates, - commitMessage, - signal, -}) => { - const owner = repository?.owner - const repo = repository?.name - - if (typeof owner !== 'string' || !owner || typeof repo !== 'string' || !repo) { - throw new Error('A valid repository selection is required.') - } - - const baseSha = await getBranchReferenceSha({ - token, - owner, - repo, - branch: baseBranch, - signal, - }) - - const nextBranch = await createUniqueBranchReference({ - token, - owner, - repo, - headBranch, - baseSha, - signal, - }) - - const committedFileUpdates = await commitEditorContentToExistingBranch({ - token, - repository, - branch: nextBranch, - fileUpdates, - commitMessage, - signal, - }) - - const pullRequest = await createRepositoryPullRequest({ - token, - owner, - repo, - title: prTitle, - body: prBody, - head: nextBranch, - base: baseBranch, - signal, - }) - - return { - pullRequest, - branch: nextBranch, - fileUpdates: committedFileUpdates, - } -} - -export const commitEditorContentToExistingBranch = async ({ - token, - repository, - branch, - fileUpdates, - commitMessage, - signal, -}) => { - const owner = repository?.owner - const repo = repository?.name - - if (typeof owner !== 'string' || !owner || typeof repo !== 'string' || !repo) { - throw new Error('A valid repository selection is required.') - } - - if (typeof branch !== 'string' || !branch.trim()) { - throw new Error('An existing head branch is required.') - } - - if (!Array.isArray(fileUpdates) || fileUpdates.length === 0) { - throw new Error('At least one file update is required.') - } - - return commitFilesToExistingBranchWithGitDatabaseApi({ - token, - owner, - repo, - branch, - files: fileUpdates, - commitMessage, - signal, - }) -} diff --git a/src/modules/github/github-pr-drawer.js b/src/modules/github/github-pr-drawer.js deleted file mode 100644 index 77e26b9..0000000 --- a/src/modules/github/github-pr-drawer.js +++ /dev/null @@ -1,1749 +0,0 @@ -import { - closeRepositoryPullRequest, - commitEditorContentToExistingBranch, - createEditorContentPullRequest, - findOpenRepositoryPullRequestByHead, - getRepositoryPullRequest, - listRepositoryBranches, -} from './github-api.js' -import { - formatActivePrReference, - parsePullRequestNumberFromUrl, -} from './github-pr-context.js' -import { - isFunctionLikeDeclaration, - isFunctionLikeVariableInitializer, -} from '../preview/jsx-top-level-declarations.js' - -const prConfigStoragePrefix = 'knighted:develop:github-pr-config:' - -const defaultCommitMessage = 'chore: sync editor updates from @knighted/develop' - -const supportedRenderModes = new Set(['dom', 'react']) -const supportedStyleModes = new Set(['css', 'module', 'less', 'sass']) - -const toSafeText = value => (typeof value === 'string' ? value.trim() : '') - -const toPullRequestNumber = value => { - if (typeof value === 'number' && Number.isFinite(value) && value > 0) { - return value - } - - return null -} - -const normalizeRenderMode = value => { - const mode = toSafeText(value).toLowerCase() - return supportedRenderModes.has(mode) ? mode : 'dom' -} - -const normalizeStyleMode = value => { - const mode = toSafeText(value).toLowerCase() - return supportedStyleModes.has(mode) ? mode : 'css' -} - -const getRepositoryPrConfigStorageKey = repositoryFullName => - `${prConfigStoragePrefix}${repositoryFullName}` - -const pruneRepositoryPrConfigs = repositoryFullName => { - if (typeof repositoryFullName !== 'string' || !repositoryFullName.trim()) { - return - } - - const activeStorageKey = getRepositoryPrConfigStorageKey(repositoryFullName) - - try { - const keysToRemove = [] - - for (let index = 0; index < localStorage.length; index += 1) { - const key = localStorage.key(index) - if (!key || !key.startsWith(prConfigStoragePrefix)) { - continue - } - - if (key !== activeStorageKey) { - keysToRemove.push(key) - } - } - - for (const key of keysToRemove) { - localStorage.removeItem(key) - } - } catch { - /* noop */ - } -} - -const readRepositoryPrConfig = repositoryFullName => { - if (typeof repositoryFullName !== 'string' || !repositoryFullName.trim()) { - return {} - } - - try { - const value = localStorage.getItem( - getRepositoryPrConfigStorageKey(repositoryFullName), - ) - if (!value) { - return {} - } - - const parsed = JSON.parse(value) - return parsed && typeof parsed === 'object' ? parsed : {} - } catch { - return {} - } -} - -const saveRepositoryPrConfig = ({ repositoryFullName, config }) => { - if (typeof repositoryFullName !== 'string' || !repositoryFullName.trim()) { - return - } - - try { - const activeStorageKey = getRepositoryPrConfigStorageKey(repositoryFullName) - - localStorage.setItem(activeStorageKey, JSON.stringify(config)) - - pruneRepositoryPrConfigs(repositoryFullName) - } catch { - /* noop */ - } -} - -const sanitizeRepositoryPrConfig = config => { - const source = config && typeof config === 'object' ? config : {} - const pullRequestUrl = toSafeText(source.pullRequestUrl) - const fallbackPullRequestNumber = parsePullRequestNumberFromUrl(pullRequestUrl) - const pullRequestNumber = - toPullRequestNumber(source.pullRequestNumber) ?? fallbackPullRequestNumber - - return { - baseBranch: toSafeText(source.baseBranch), - headBranch: sanitizeBranchPart(source.headBranch), - prTitle: toSafeText(source.prTitle), - prBody: typeof source.prBody === 'string' ? source.prBody.trim() : '', - renderMode: normalizeRenderMode(source.renderMode), - styleMode: normalizeStyleMode(source.styleMode), - isActivePr: source.isActivePr === true, - pullRequestNumber, - pullRequestUrl, - } -} - -const removeRepositoryPrConfig = repositoryFullName => { - if (typeof repositoryFullName !== 'string' || !repositoryFullName.trim()) { - return - } - - try { - localStorage.removeItem(getRepositoryPrConfigStorageKey(repositoryFullName)) - } catch { - /* noop */ - } -} - -const getActiveRepositoryPrContext = repositoryFullName => { - const savedConfig = readRepositoryPrConfig(repositoryFullName) - - if (savedConfig?.isActivePr !== true) { - return null - } - - const headBranch = sanitizeBranchPart(savedConfig.headBranch) - const prTitle = toSafeText(savedConfig.prTitle) - const baseBranch = toSafeText(savedConfig.baseBranch) - - if (!headBranch || !prTitle) { - return null - } - - return { - headBranch, - renderMode: normalizeRenderMode(savedConfig.renderMode), - styleMode: normalizeStyleMode(savedConfig.styleMode), - prTitle, - prBody: typeof savedConfig.prBody === 'string' ? savedConfig.prBody : '', - baseBranch, - pullRequestNumber: - typeof savedConfig.pullRequestNumber === 'number' && - Number.isFinite(savedConfig.pullRequestNumber) - ? savedConfig.pullRequestNumber - : parsePullRequestNumberFromUrl(savedConfig.pullRequestUrl), - pullRequestUrl: - typeof savedConfig.pullRequestUrl === 'string' ? savedConfig.pullRequestUrl : '', - repositoryFullName, - } -} - -export const findRepositoryWithActivePrContext = repositories => { - if (!Array.isArray(repositories) || repositories.length === 0) { - return null - } - - for (const repository of repositories) { - const repositoryFullName = toSafeText(repository?.fullName) - - if (!repositoryFullName) { - continue - } - - if (getActiveRepositoryPrContext(repositoryFullName)) { - return repositoryFullName - } - } - - return null -} - -const normalizeFilePath = value => - toSafeText(value).replace(/\\/g, '/').replace(/\/+/g, '/') - -const validateFilePath = value => { - const path = normalizeFilePath(value) - - if (!path) { - return { ok: false, reason: 'File path is required.' } - } - - if (path.startsWith('/')) { - return { - ok: false, - reason: 'File path must be repository-relative (no leading slash).', - } - } - - if (path.endsWith('/')) { - return { ok: false, reason: 'File path must include a filename (no trailing slash).' } - } - - const segments = path.split('/').filter(Boolean) - - if (segments.some(segment => segment === '..')) { - return { ok: false, reason: 'File path cannot include parent directory traversal.' } - } - - if (!/^[A-Za-z0-9._\-/]+$/.test(path)) { - return { - ok: false, - reason: - 'File path contains unsupported characters. Use letters, numbers, ., _, -, and / only.', - } - } - - if (segments.length === 0 || segments.some(segment => segment === '.' || !segment)) { - return { ok: false, reason: 'File path is invalid.' } - } - - return { ok: true, value: path } -} - -const normalizeFileCommits = fileCommits => { - if (!Array.isArray(fileCommits)) { - return { - fileCommits: [], - invalidPaths: [], - } - } - - const dedupedByPath = new Map() - const invalidPathsByKey = new Map() - - for (const item of fileCommits) { - const pathValidation = validateFilePath(item?.path) - if (!pathValidation.ok) { - const rawPath = toSafeText(item?.path) - const tabLabel = toSafeText(item?.tabLabel) - const displayPath = rawPath || '(missing path)' - const key = `${displayPath}|${pathValidation.reason}` - - if (!invalidPathsByKey.has(key)) { - invalidPathsByKey.set(key, { - path: displayPath, - tabLabel, - reason: pathValidation.reason, - }) - } - - continue - } - - dedupedByPath.set(pathValidation.value, { - path: pathValidation.value, - content: typeof item?.content === 'string' ? item.content : '', - tabLabel: toSafeText(item?.tabLabel), - isEntry: item?.isEntry === true, - }) - } - - return { - fileCommits: [...dedupedByPath.values()], - invalidPaths: [...invalidPathsByKey.values()], - } -} - -const ensureTrailingNewline = value => { - if (typeof value !== 'string' || value.length === 0 || value.endsWith('\n')) { - return value - } - - return `${value}\n` -} - -const sanitizeBranchPart = value => { - const trimmed = toSafeText(value) - if (!trimmed) { - return '' - } - - return trimmed - .replace(/[^A-Za-z0-9._/-]/g, '-') - .replace(/\/+/g, '/') - .replace(/-{2,}/g, '-') - .replace(/^[-/.]+|[-/.]+$/g, '') -} - -const createBranchEntropySuffix = () => Math.random().toString(36).slice(2, 6) - -const isAutoGeneratedHeadBranch = value => { - const branch = sanitizeBranchPart(value) - if (!branch) { - return false - } - - return /^feat\/component-[a-z0-9]{4}(?:-\d+)?$/.test(branch) -} - -const createDefaultBranchName = () => { - const entropy = createBranchEntropySuffix() - return `feat/component-${entropy}` -} - -const buildSummary = ({ - repository, - baseBranch, - headBranch, - fileCommits, - prTitle, - commitMessage, - actionType, -}) => { - const repositoryLabel = toSafeText(repository?.fullName) || 'No repository selected' - const isPushCommit = actionType === 'push-commit' - - const lines = [ - `Repository: ${repositoryLabel}`, - `Head branch: ${headBranch}`, - `PR title: ${prTitle}`, - `Commit message: ${commitMessage}`, - ] - - if (Array.isArray(fileCommits) && fileCommits.length > 0) { - lines.push('Files to commit:') - for (const fileCommit of fileCommits) { - const path = toSafeText(fileCommit?.path) - if (!path) { - continue - } - - const tabLabel = toSafeText(fileCommit?.tabLabel) - lines.push(tabLabel ? `- ${tabLabel} -> ${path}` : `- ${path}`) - } - } - - if (!isPushCommit) { - lines.splice(1, 0, `Base branch: ${baseBranch}`) - } - - lines.push('') - lines.push( - isPushCommit - ? 'Proceed with committing editor content to the active pull request branch?' - : 'Proceed with creating commits and opening this pull request?', - ) - - return lines.join('\n') -} - -const toBranchCacheKey = repository => { - const owner = toSafeText(repository?.owner) - const name = toSafeText(repository?.name) - if (!owner || !name) { - return '' - } - - return `${owner}/${name}` -} - -const createSelectOption = ({ value, label, selected = false, disabled = false }) => { - const option = document.createElement('option') - option.value = value - option.textContent = label - option.selected = selected - option.disabled = disabled - return option -} - -const mergeBranchOptions = ({ preferredBranch, branchNames }) => { - const dedupe = new Set() - const result = [] - - const pushBranch = branch => { - const safeBranch = toSafeText(branch) - if (!safeBranch || dedupe.has(safeBranch)) { - return - } - - dedupe.add(safeBranch) - result.push(safeBranch) - } - - pushBranch(preferredBranch) - - if (Array.isArray(branchNames)) { - for (const branchName of branchNames) { - pushBranch(branchName) - } - } - - return result -} - -const mergeWhitespaceAroundRemoval = value => value.replace(/\n{3,}/g, '\n\n') - -const isSourceRange = value => - Array.isArray(value) && - value.length === 2 && - Number.isInteger(value[0]) && - Number.isInteger(value[1]) - -const isRemovableAppDeclaration = declaration => { - if (!declaration || declaration.name !== 'App') { - return false - } - - if (!isFunctionLikeDeclaration(declaration)) { - return false - } - - if (declaration.kind !== 'variable') { - return true - } - - return isFunctionLikeVariableInitializer(declaration) -} - -const removeRanges = ({ source, ranges }) => { - const sortedRanges = ranges.slice().sort((first, second) => second[0] - first[0]) - let output = source - - for (const [start, end] of sortedRanges) { - if (start < 0 || end < start || end > output.length) { - continue - } - - output = `${output.slice(0, start)}${output.slice(end)}` - } - - return output -} - -const stripTopLevelAppWrapper = async ({ source, getTopLevelDeclarations }) => { - if (typeof source !== 'string' || !source.trim()) { - return '' - } - - if (typeof getTopLevelDeclarations !== 'function') { - return source - } - - try { - const declarations = await getTopLevelDeclarations(source) - - if (!Array.isArray(declarations)) { - return source - } - - const ranges = declarations - .filter(isRemovableAppDeclaration) - .map(declaration => declaration.statementRange) - .filter(isSourceRange) - - if (ranges.length === 0) { - return source - } - - return mergeWhitespaceAroundRemoval(removeRanges({ source, ranges })) - } catch { - return source - } -} - -export const createGitHubPrDrawer = ({ - toggleButton, - drawer, - closeButton, - repositorySelect, - baseBranchInput, - headBranchInput, - prTitleInput, - prBodyInput, - commitMessageInput, - includeAppWrapperToggle, - submitButton, - titleNode, - statusNode, - getToken, - getSelectedRepository, - getWritableRepositories, - setSelectedRepository, - getFileCommits, - getEditorSyncTargets, - getTopLevelDeclarations, - getRenderMode, - getStyleMode, - getDrawerSide, - confirmBeforeSubmit, - onPullRequestOpened, - onPullRequestCommitPushed, - onActivePrContextChange, - onSyncActivePrEditorContent, - onRestoreRenderMode, - onRestoreStyleMode, -}) => { - let open = false - let submitting = false - let pendingAbortController = null - let pendingBranchesAbortController = null - let pendingContextVerifyAbortController = null - let pendingActiveContentSyncAbortController = null - let pendingBranchesRequestKey = '' - let pendingBranchesPromise = null - let pendingContextVerifyRequestKey = '' - let pendingContextVerifyPromise = null - let lastSyncedRepositoryFullName = '' - let lastActiveContentSyncKey = '' - const baseBranchesByRepository = new Map() - - const getSelectedRepositoryObject = () => getSelectedRepository?.() ?? null - - const getRepositoryFullName = repository => - typeof repository?.fullName === 'string' ? repository.fullName : '' - - const getCurrentActivePrContext = () => { - const repository = getSelectedRepositoryObject() - const repositoryFullName = getRepositoryFullName(repository) - if (!repositoryFullName) { - return null - } - - return getActiveRepositoryPrContext(repositoryFullName) - } - - const syncModeFields = () => { - const isPushCommitMode = Boolean(getCurrentActivePrContext()) - - if (repositorySelect instanceof HTMLSelectElement) { - repositorySelect.disabled = submitting || isPushCommitMode - } - - if (baseBranchInput instanceof HTMLSelectElement) { - baseBranchInput.disabled = submitting || isPushCommitMode - } - - if (baseBranchInput instanceof HTMLInputElement) { - baseBranchInput.readOnly = isPushCommitMode - baseBranchInput.disabled = submitting - } - - if (headBranchInput instanceof HTMLInputElement) { - headBranchInput.readOnly = isPushCommitMode - headBranchInput.disabled = submitting - } - - if (prTitleInput instanceof HTMLInputElement) { - prTitleInput.required = !isPushCommitMode - prTitleInput.readOnly = isPushCommitMode - prTitleInput.disabled = submitting - } - - const prBodyField = prBodyInput?.closest('.github-pr-field') - if (prBodyField instanceof HTMLElement) { - prBodyField.hidden = isPushCommitMode - } - - if (prBodyInput instanceof HTMLTextAreaElement) { - prBodyInput.required = false - prBodyInput.disabled = submitting || isPushCommitMode - } - - if (includeAppWrapperToggle instanceof HTMLInputElement) { - includeAppWrapperToggle.disabled = submitting - } - - if (commitMessageInput instanceof HTMLInputElement) { - commitMessageInput.required = false - commitMessageInput.readOnly = false - commitMessageInput.disabled = submitting - } - } - - const setSubmitButtonLabel = ({ isPending = false } = {}) => { - if (!(submitButton instanceof HTMLButtonElement)) { - return - } - - const activeContext = getCurrentActivePrContext() - const isPushCommitMode = Boolean(activeContext) - - if (drawer instanceof HTMLElement) { - drawer.dataset.mode = isPushCommitMode ? 'push' : 'open' - } - - if (isPending) { - submitButton.textContent = isPushCommitMode ? 'Pushing commit...' : 'Opening PR...' - if (titleNode instanceof HTMLElement) { - titleNode.textContent = isPushCommitMode ? 'Push Commit' : 'Open Pull Request' - } - syncModeFields() - return - } - - submitButton.textContent = isPushCommitMode ? 'Push commit' : 'Open PR' - - if (titleNode instanceof HTMLElement) { - titleNode.textContent = isPushCommitMode ? 'Push Commit' : 'Open Pull Request' - } - - syncModeFields() - } - - const emitRenderModeRestore = activeContext => { - if (typeof onRestoreRenderMode !== 'function') { - return - } - - if (!activeContext) { - return - } - - const mode = normalizeRenderMode(activeContext?.renderMode) - onRestoreRenderMode(mode) - } - - const emitStyleModeRestore = activeContext => { - if (typeof onRestoreStyleMode !== 'function') { - return - } - - if (!activeContext) { - return - } - - const mode = normalizeStyleMode(activeContext?.styleMode) - onRestoreStyleMode(mode) - } - - const emitActivePrContextChange = () => { - if (typeof onActivePrContextChange !== 'function') { - return - } - - const activeContext = getCurrentActivePrContext() - onActivePrContextChange(activeContext) - emitRenderModeRestore(activeContext) - emitStyleModeRestore(activeContext) - } - - const setStatus = (text, level = 'neutral') => { - if (!statusNode) { - return - } - - statusNode.textContent = text - statusNode.dataset.level = level - } - - const setPendingState = isPending => { - submitting = isPending - - if (submitButton instanceof HTMLButtonElement) { - submitButton.disabled = isPending - submitButton.setAttribute('aria-busy', isPending ? 'true' : 'false') - submitButton.classList.toggle('render-button--loading', isPending) - setSubmitButtonLabel({ isPending }) - } - - for (const input of [ - repositorySelect, - baseBranchInput, - headBranchInput, - prTitleInput, - prBodyInput, - commitMessageInput, - includeAppWrapperToggle, - ]) { - if ( - input instanceof HTMLInputElement || - input instanceof HTMLSelectElement || - input instanceof HTMLTextAreaElement - ) { - input.disabled = isPending - } - } - - syncModeFields() - } - - const getFormValues = () => { - return { - baseBranch: toSafeText(baseBranchInput?.value), - headBranch: toSafeText(headBranchInput?.value), - prTitle: toSafeText(prTitleInput?.value), - prBody: typeof prBodyInput?.value === 'string' ? prBodyInput.value.trim() : '', - commitMessage: toSafeText(commitMessageInput?.value), - } - } - - const abortPendingBranchesRequest = () => { - pendingBranchesAbortController?.abort() - pendingBranchesAbortController = null - } - - const abortPendingContextVerifyRequest = () => { - pendingContextVerifyAbortController?.abort() - pendingContextVerifyAbortController = null - pendingContextVerifyRequestKey = '' - pendingContextVerifyPromise = null - } - - const abortPendingActiveContentSyncRequest = () => { - pendingActiveContentSyncAbortController?.abort() - pendingActiveContentSyncAbortController = null - } - - const syncActivePrEditorContent = async () => { - if (typeof onSyncActivePrEditorContent !== 'function') { - return - } - - const repository = getSelectedRepositoryObject() - const repositoryFullName = getRepositoryFullName(repository) - const token = toSafeText(getToken?.()) - const activeContext = getCurrentActivePrContext() - - if (!repositoryFullName || !token || !activeContext) { - lastActiveContentSyncKey = '' - abortPendingActiveContentSyncRequest() - return - } - - const syncTargets = - typeof getEditorSyncTargets === 'function' ? getEditorSyncTargets() : null - const tabSyncTargets = Array.isArray(syncTargets?.tabTargets) - ? syncTargets.tabTargets - : [] - const componentSyncPath = toSafeText( - tabSyncTargets.find(target => toSafeText(target?.kind) === 'component')?.path, - ) - const stylesSyncPath = toSafeText( - tabSyncTargets.find(target => toSafeText(target?.kind) === 'styles')?.path, - ) - - if (!componentSyncPath || !stylesSyncPath) { - lastActiveContentSyncKey = '' - abortPendingActiveContentSyncRequest() - return - } - - const syncKey = [ - repositoryFullName, - activeContext.headBranch, - componentSyncPath, - stylesSyncPath, - String(activeContext.pullRequestNumber ?? ''), - ].join('|') - - if (syncKey === lastActiveContentSyncKey) { - return - } - - abortPendingActiveContentSyncRequest() - const abortController = new AbortController() - pendingActiveContentSyncAbortController = abortController - - try { - await onSyncActivePrEditorContent({ - token, - repository, - activeContext, - syncTargets: { - tabTargets: [ - { kind: 'component', path: componentSyncPath }, - { kind: 'styles', path: stylesSyncPath }, - ], - }, - signal: abortController.signal, - }) - - if (pendingActiveContentSyncAbortController !== abortController) { - return - } - - lastActiveContentSyncKey = syncKey - } catch { - if (abortController.signal.aborted) { - return - } - } finally { - if (pendingActiveContentSyncAbortController === abortController) { - pendingActiveContentSyncAbortController = null - } - } - } - - const verifyActivePullRequestContext = async () => { - const repository = getSelectedRepositoryObject() - const repositoryFullName = getRepositoryFullName(repository) - const owner = toSafeText(repository?.owner) - const repo = toSafeText(repository?.name) - const token = toSafeText(getToken?.()) - - if (!repositoryFullName || !owner || !repo || !token) { - return - } - - const savedConfig = readRepositoryPrConfig(repositoryFullName) - if (savedConfig?.isActivePr !== true) { - return - } - - const pullRequestNumberFromConfig = - typeof savedConfig.pullRequestNumber === 'number' && - Number.isFinite(savedConfig.pullRequestNumber) - ? savedConfig.pullRequestNumber - : parsePullRequestNumberFromUrl(savedConfig.pullRequestUrl) - const headBranch = sanitizeBranchPart(savedConfig.headBranch) - - if (!pullRequestNumberFromConfig && !headBranch) { - return - } - - const requestKey = [ - repositoryFullName, - String(pullRequestNumberFromConfig || ''), - headBranch, - toSafeText(savedConfig.baseBranch), - ].join('|') - - if (pendingContextVerifyPromise && pendingContextVerifyRequestKey === requestKey) { - await pendingContextVerifyPromise - return - } - - abortPendingContextVerifyRequest() - const abortController = new AbortController() - pendingContextVerifyAbortController = abortController - - const runVerifyRequest = async () => { - try { - let resolvedPullRequest = null - let pullRequestClosedByNumber = false - - if (pullRequestNumberFromConfig) { - const pullRequest = await getRepositoryPullRequest({ - token, - owner, - repo, - pullRequestNumber: pullRequestNumberFromConfig, - signal: abortController.signal, - }) - - if (pullRequest?.isOpen) { - resolvedPullRequest = pullRequest - } else if (pullRequest) { - pullRequestClosedByNumber = true - } - } - - if (!resolvedPullRequest && !pullRequestClosedByNumber) { - resolvedPullRequest = await findOpenRepositoryPullRequestByHead({ - token, - owner, - repo, - headOwner: owner, - headBranch, - baseBranch: toSafeText(savedConfig.baseBranch), - signal: abortController.signal, - }) - } - - if (pendingContextVerifyAbortController !== abortController) { - return - } - - if (resolvedPullRequest?.isOpen) { - const normalizedSavedConfig = sanitizeRepositoryPrConfig(savedConfig) - const nextHeadBranch = - sanitizeBranchPart(resolvedPullRequest.headRef) || headBranch - const nextBaseBranch = - toSafeText(resolvedPullRequest.baseRef) || toSafeText(savedConfig.baseBranch) - - saveRepositoryPrConfig({ - repositoryFullName, - config: { - ...normalizedSavedConfig, - isActivePr: true, - headBranch: nextHeadBranch, - baseBranch: nextBaseBranch, - pullRequestNumber: resolvedPullRequest.number, - pullRequestUrl: resolvedPullRequest.htmlUrl, - prTitle: - toSafeText(savedConfig.prTitle) || toSafeText(resolvedPullRequest.title), - }, - }) - syncFormForRepository({ resetBranch: true }) - setSubmitButtonLabel() - emitActivePrContextChange() - void syncActivePrEditorContent() - return - } - - saveRepositoryPrConfig({ - repositoryFullName, - config: { - ...sanitizeRepositoryPrConfig(savedConfig), - isActivePr: false, - }, - }) - setSubmitButtonLabel() - emitActivePrContextChange() - lastActiveContentSyncKey = '' - abortPendingActiveContentSyncRequest() - setStatus( - 'Saved pull request context is not open on GitHub. Open PR mode restored.', - 'neutral', - ) - } catch (error) { - if (abortController.signal.aborted) { - return - } - - const message = - error instanceof Error ? error.message : 'Failed to verify pull request state.' - setStatus(`Could not verify saved pull request state: ${message}`, 'error') - } finally { - if (pendingContextVerifyAbortController === abortController) { - pendingContextVerifyAbortController = null - } - } - } - - const requestPromise = runVerifyRequest() - pendingContextVerifyRequestKey = requestKey - pendingContextVerifyPromise = requestPromise - - try { - await requestPromise - } finally { - if (pendingContextVerifyPromise === requestPromise) { - pendingContextVerifyPromise = null - pendingContextVerifyRequestKey = '' - } - } - } - - const renderBaseBranchOptions = ({ preferredBranch, branchNames, loading = false }) => { - const baseBranch = toSafeText(preferredBranch) || 'main' - - if (baseBranchInput instanceof HTMLInputElement) { - baseBranchInput.value = baseBranch - return - } - - if (!(baseBranchInput instanceof HTMLSelectElement)) { - return - } - - if (loading) { - baseBranchInput.replaceChildren( - createSelectOption({ - value: baseBranch, - label: 'Loading branches...', - }), - ) - baseBranchInput.value = baseBranch - baseBranchInput.disabled = true - return - } - - const mergedOptions = mergeBranchOptions({ preferredBranch: baseBranch, branchNames }) - - const options = mergedOptions.map(branchName => - createSelectOption({ - value: branchName, - label: branchName, - selected: branchName === baseBranch, - }), - ) - - const isPushCommitMode = Boolean(getCurrentActivePrContext()) - - baseBranchInput.replaceChildren(...options) - baseBranchInput.disabled = submitting || isPushCommitMode - baseBranchInput.value = baseBranch - } - - const getPreferredBaseBranchForRepository = repository => { - const repositoryFullName = getRepositoryFullName(repository) - const savedConfig = readRepositoryPrConfig(repositoryFullName) - return ( - toSafeText(savedConfig.baseBranch) || - toSafeText(repository?.defaultBranch) || - 'main' - ) - } - - const loadBaseBranchesForSelectedRepository = async ({ preferredBranch }) => { - const repository = getSelectedRepositoryObject() - const cacheKey = toBranchCacheKey(repository) - const nextPreferredBranch = - toSafeText(preferredBranch) || getPreferredBaseBranchForRepository(repository) - const requestKey = `${cacheKey}:${nextPreferredBranch}` - - if (!cacheKey) { - renderBaseBranchOptions({ preferredBranch: nextPreferredBranch, branchNames: [] }) - return - } - - const token = toSafeText(getToken?.()) - if (!token) { - renderBaseBranchOptions({ preferredBranch: nextPreferredBranch, branchNames: [] }) - return - } - - const cachedBranches = baseBranchesByRepository.get(cacheKey) - if (Array.isArray(cachedBranches) && cachedBranches.length > 0) { - renderBaseBranchOptions({ - preferredBranch: nextPreferredBranch, - branchNames: cachedBranches, - }) - return - } - - if (pendingBranchesPromise && pendingBranchesRequestKey === requestKey) { - await pendingBranchesPromise - return - } - - abortPendingBranchesRequest() - renderBaseBranchOptions({ preferredBranch: nextPreferredBranch, loading: true }) - - const abortController = new AbortController() - pendingBranchesAbortController = abortController - - const runBranchRequest = async () => { - const branches = await listRepositoryBranches({ - token, - owner: repository.owner, - repo: repository.name, - signal: abortController.signal, - }) - - if (pendingBranchesAbortController !== abortController) { - return - } - - baseBranchesByRepository.set(cacheKey, branches) - renderBaseBranchOptions({ - preferredBranch: nextPreferredBranch, - branchNames: branches, - }) - } - - const requestPromise = runBranchRequest() - - pendingBranchesRequestKey = requestKey - pendingBranchesPromise = requestPromise - - try { - await requestPromise - } catch { - if (abortController.signal.aborted) { - return - } - - renderBaseBranchOptions({ preferredBranch: nextPreferredBranch, branchNames: [] }) - } finally { - if (pendingBranchesAbortController === abortController) { - pendingBranchesAbortController = null - } - - if (pendingBranchesPromise === requestPromise) { - pendingBranchesPromise = null - pendingBranchesRequestKey = '' - } - } - } - - const syncRepositorySelect = ({ repositories, selectedRepository }) => { - if (!(repositorySelect instanceof HTMLSelectElement)) { - return - } - - repositorySelect.replaceChildren() - - if (!Array.isArray(repositories) || repositories.length === 0) { - const option = document.createElement('option') - option.value = '' - option.textContent = 'Connect a token to load repositories' - option.selected = true - repositorySelect.append(option) - repositorySelect.disabled = true - return - } - - const selectedFullName = getRepositoryFullName(selectedRepository) - - const options = repositories.map(repo => { - const option = document.createElement('option') - option.value = repo.fullName - option.textContent = repo.fullName - option.selected = repo.fullName === selectedFullName - return option - }) - - repositorySelect.replaceChildren(...options) - repositorySelect.disabled = false - - if (!selectedFullName && repositories[0]) { - repositorySelect.value = repositories[0].fullName - setSelectedRepository?.(repositories[0].fullName) - return - } - - repositorySelect.value = selectedFullName - } - - const syncFormForRepository = ({ resetBranch = false, resetAll = false } = {}) => { - const repository = getSelectedRepositoryObject() - const repositoryFullName = getRepositoryFullName(repository) - const repositoryChanged = - Boolean(repositoryFullName) && repositoryFullName !== lastSyncedRepositoryFullName - const savedConfig = readRepositoryPrConfig(repositoryFullName) - const savedDraftConfig = resetAll ? {} : savedConfig - - const baseBranch = - toSafeText(savedConfig.baseBranch) || - toSafeText(repository?.defaultBranch) || - 'main' - - renderBaseBranchOptions({ preferredBranch: baseBranch, branchNames: [] }) - - if (headBranchInput instanceof HTMLInputElement) { - if ( - resetAll || - resetBranch || - repositoryChanged || - !toSafeText(headBranchInput.value) - ) { - const savedHeadBranch = sanitizeBranchPart(savedDraftConfig.headBranch) - headBranchInput.value = - savedHeadBranch && !isAutoGeneratedHeadBranch(savedHeadBranch) - ? savedHeadBranch - : createDefaultBranchName() - } - } - - if (prTitleInput instanceof HTMLInputElement) { - if (resetAll || repositoryChanged || !toSafeText(prTitleInput.value)) { - prTitleInput.value = toSafeText(savedDraftConfig.prTitle) - } - } - - if (prBodyInput instanceof HTMLTextAreaElement) { - if (resetAll || repositoryChanged || !toSafeText(prBodyInput.value)) { - prBodyInput.value = - typeof savedDraftConfig.prBody === 'string' ? savedDraftConfig.prBody : '' - } - } - - if (commitMessageInput instanceof HTMLInputElement) { - if (resetAll || repositoryChanged || !toSafeText(commitMessageInput.value)) { - commitMessageInput.value = '' - } - } - - if (includeAppWrapperToggle instanceof HTMLInputElement) { - includeAppWrapperToggle.checked = true - } - - lastSyncedRepositoryFullName = repositoryFullName - } - - const persistCurrentConfig = () => { - const repository = getSelectedRepositoryObject() - const repositoryFullName = getRepositoryFullName(repository) - if (!repositoryFullName) { - return - } - - const values = getFormValues() - const currentRenderMode = normalizeRenderMode(getRenderMode?.()) - const currentStyleMode = normalizeStyleMode(getStyleMode?.()) - const existingConfig = readRepositoryPrConfig(repositoryFullName) - const normalizedExistingConfig = sanitizeRepositoryPrConfig(existingConfig) - const isActivePr = existingConfig?.isActivePr === true - - if (isActivePr) { - saveRepositoryPrConfig({ - repositoryFullName, - config: { - ...normalizedExistingConfig, - renderMode: currentRenderMode, - styleMode: currentStyleMode, - isActivePr: true, - pullRequestNumber: existingConfig?.pullRequestNumber, - pullRequestUrl: existingConfig?.pullRequestUrl, - }, - }) - - setSubmitButtonLabel() - emitActivePrContextChange() - return - } - - saveRepositoryPrConfig({ - repositoryFullName, - config: { - baseBranch: values.baseBranch, - headBranch: isAutoGeneratedHeadBranch(values.headBranch) ? '' : values.headBranch, - prTitle: values.prTitle, - prBody: values.prBody, - renderMode: currentRenderMode, - styleMode: currentStyleMode, - isActivePr: false, - pullRequestNumber: existingConfig?.pullRequestNumber, - pullRequestUrl: existingConfig?.pullRequestUrl, - }, - }) - - setSubmitButtonLabel() - emitActivePrContextChange() - } - - const syncRepositories = () => { - const repositories = getWritableRepositories?.() ?? [] - const selectedRepository = getSelectedRepositoryObject() - syncRepositorySelect({ repositories, selectedRepository }) - syncFormForRepository() - setSubmitButtonLabel() - emitActivePrContextChange() - void verifyActivePullRequestContext() - if (!open) { - return - } - - void loadBaseBranchesForSelectedRepository({ - preferredBranch: getFormValues().baseBranch, - }) - } - - const setOpen = nextOpen => { - open = nextOpen === true - - if (!(toggleButton instanceof HTMLButtonElement) || !drawer) { - return - } - - const preferredSide = getDrawerSide?.() === 'left' ? 'left' : 'right' - drawer.classList.toggle('github-pr-drawer--left', preferredSide === 'left') - drawer.classList.toggle('github-pr-drawer--right', preferredSide !== 'left') - - toggleButton.setAttribute('aria-expanded', open ? 'true' : 'false') - drawer.toggleAttribute('hidden', !open) - - if (open) { - const repositories = getWritableRepositories?.() ?? [] - const selectedRepository = getSelectedRepositoryObject() - syncRepositorySelect({ repositories, selectedRepository }) - syncFormForRepository() - setSubmitButtonLabel() - void loadBaseBranchesForSelectedRepository({ - preferredBranch: getFormValues().baseBranch, - }) - repositorySelect?.focus() - return - } - - abortPendingBranchesRequest() - } - - const runSubmit = async () => { - const repository = getSelectedRepositoryObject() - const repositoryLabel = getRepositoryFullName(repository) - const token = getToken?.() - const activeContext = getCurrentActivePrContext() - const isPushCommitMode = Boolean(activeContext) - - if (!toSafeText(token)) { - setStatus( - isPushCommitMode - ? 'Add a GitHub token before pushing a commit.' - : 'Add a GitHub token before opening a pull request.', - 'error', - ) - return - } - - if (!repositoryLabel) { - setStatus( - isPushCommitMode - ? 'Select a writable repository before pushing a commit.' - : 'Select a writable repository before opening a pull request.', - 'error', - ) - return - } - - const values = getFormValues() - const targetBaseBranch = isPushCommitMode - ? toSafeText(activeContext?.baseBranch) - : values.baseBranch - const targetHeadBranch = isPushCommitMode - ? sanitizeBranchPart(activeContext?.headBranch) - : sanitizeBranchPart(values.headBranch) - const targetPrTitle = isPushCommitMode - ? toSafeText(activeContext?.prTitle) - : values.prTitle - const targetPrBody = isPushCommitMode - ? typeof activeContext?.prBody === 'string' - ? activeContext.prBody - : '' - : values.prBody - const currentRenderMode = normalizeRenderMode(getRenderMode?.()) - const currentStyleMode = normalizeStyleMode(getStyleMode?.()) - const targetCommitMessage = values.commitMessage || defaultCommitMessage - - if ( - !isPushCommitMode && - prTitleInput instanceof HTMLInputElement && - !prTitleInput.checkValidity() - ) { - prTitleInput.reportValidity() - return - } - - const includeAppWrapper = - includeAppWrapperToggle instanceof HTMLInputElement - ? includeAppWrapperToggle.checked - : false - - const { fileCommits: normalizedFileCommits, invalidPaths } = normalizeFileCommits( - typeof getFileCommits === 'function' - ? getFileCommits({ includeAllWorkspaceFiles: !isPushCommitMode }) - : [], - ) - - if (invalidPaths.length > 0) { - const maxInvalidPathsInMessage = 3 - const invalidPathDetails = invalidPaths - .slice(0, maxInvalidPathsInMessage) - .map(entry => { - const sourceLabel = entry.tabLabel ? `${entry.tabLabel}: ` : '' - return `${sourceLabel}${entry.path} (${entry.reason})` - }) - .join('; ') - const remainingCount = invalidPaths.length - maxInvalidPathsInMessage - const remainingSummary = remainingCount > 0 ? ` (+${remainingCount} more)` : '' - - setStatus( - `Commit blocked: invalid workspace file path${invalidPaths.length === 1 ? '' : 's'}. ${invalidPathDetails}${remainingSummary}`, - 'error', - ) - return - } - - if (normalizedFileCommits.length === 0) { - setStatus( - isPushCommitMode - ? 'No local editor changes to push.' - : 'No workspace files are available to commit.', - isPushCommitMode ? 'neutral' : 'error', - ) - return - } - - if (!isPushCommitMode && !targetBaseBranch) { - setStatus('Base branch is required.', 'error') - return - } - - if (!targetHeadBranch) { - setStatus( - isPushCommitMode - ? 'Active pull request context is missing a head branch. Close the context and open a new pull request.' - : 'Head branch name is required.', - 'error', - ) - return - } - - if (!targetPrTitle) { - setStatus( - isPushCommitMode - ? 'Active pull request context is missing a title. Close the context and open a new pull request.' - : 'Pull request title is required.', - 'error', - ) - return - } - - const summary = buildSummary({ - repository, - baseBranch: targetBaseBranch, - headBranch: targetHeadBranch, - fileCommits: normalizedFileCommits, - prTitle: targetPrTitle, - commitMessage: targetCommitMessage, - actionType: isPushCommitMode ? 'push-commit' : 'open-pr', - }) - - const fileUpdates = await Promise.all( - normalizedFileCommits.map(async fileCommit => { - const shouldStripEntryWrapper = !includeAppWrapper && fileCommit.isEntry - const nextContent = shouldStripEntryWrapper - ? await stripTopLevelAppWrapper({ - source: fileCommit.content, - getTopLevelDeclarations, - }) - : fileCommit.content - const content = ensureTrailingNewline(nextContent) - - return { - path: fileCommit.path, - content, - } - }), - ) - - const submitRequest = () => { - pendingAbortController?.abort() - const abortController = new AbortController() - pendingAbortController = abortController - - setPendingState(true) - setStatus( - isPushCommitMode - ? 'Committing editor files to active pull request branch...' - : 'Creating branch, committing editor files, and opening pull request...', - 'pending', - ) - - const runRequest = isPushCommitMode - ? commitEditorContentToExistingBranch({ - token, - repository, - branch: targetHeadBranch, - fileUpdates, - commitMessage: targetCommitMessage, - signal: abortController.signal, - }) - : createEditorContentPullRequest({ - token, - repository, - baseBranch: targetBaseBranch, - headBranch: targetHeadBranch, - prTitle: targetPrTitle, - prBody: targetPrBody, - fileUpdates, - commitMessage: targetCommitMessage, - signal: abortController.signal, - }) - - void Promise.resolve(runRequest) - .then(result => { - if (isPushCommitMode) { - const compactPullRequestReference = formatActivePrReference(activeContext) - const pullRequestUrl = toSafeText(activeContext?.pullRequestUrl) - const pullRequestTitle = toSafeText(activeContext?.prTitle) - const pullRequestReference = - compactPullRequestReference || - pullRequestUrl || - (pullRequestTitle ? `PR: ${pullRequestTitle}` : '') - - setStatus( - pullRequestReference - ? `Commit pushed to ${targetHeadBranch} (${pullRequestReference}).` - : `Commit pushed to ${targetHeadBranch}.`, - 'ok', - ) - onPullRequestCommitPushed?.({ - branch: targetHeadBranch, - fileUpdates: Array.isArray(result) ? result : [], - }) - setOpen(false) - return - } - - saveRepositoryPrConfig({ - repositoryFullName: repositoryLabel, - config: { - renderMode: currentRenderMode, - styleMode: currentStyleMode, - baseBranch: targetBaseBranch, - headBranch: targetHeadBranch, - prTitle: targetPrTitle, - prBody: targetPrBody, - isActivePr: true, - pullRequestNumber: result.pullRequest.number, - pullRequestUrl: result.pullRequest.htmlUrl, - }, - }) - - emitActivePrContextChange() - setSubmitButtonLabel() - - const url = result.pullRequest.htmlUrl - setStatus( - url ? `Pull request opened: ${url}` : 'Pull request opened successfully.', - 'ok', - ) - onPullRequestOpened?.({ - url, - pullRequestNumber: result.pullRequest.number, - branch: targetHeadBranch, - fileUpdates: Array.isArray(result.fileUpdates) ? result.fileUpdates : [], - }) - setOpen(false) - }) - .catch(error => { - if (abortController.signal.aborted) { - return - } - - const fallbackMessage = isPushCommitMode - ? 'Failed to push commit.' - : 'Failed to open pull request.' - const message = error instanceof Error ? error.message : fallbackMessage - setStatus( - isPushCommitMode - ? `Push commit failed: ${message}` - : `Open PR failed: ${message}`, - 'error', - ) - }) - .finally(() => { - if (pendingAbortController === abortController) { - pendingAbortController = null - } - setPendingState(false) - }) - } - - if (typeof confirmBeforeSubmit === 'function') { - confirmBeforeSubmit({ - title: isPushCommitMode - ? 'Push commit to active pull request branch?' - : 'Open pull request with editor content?', - copy: summary, - confirmButtonText: isPushCommitMode ? 'Push commit' : 'Open PR', - onConfirm: submitRequest, - }) - return - } - - submitRequest() - } - - toggleButton?.addEventListener('click', () => { - setOpen(!open) - }) - - closeButton?.addEventListener('click', () => { - setOpen(false) - }) - - repositorySelect?.addEventListener('change', () => { - if (!(repositorySelect instanceof HTMLSelectElement)) { - return - } - - const repositoryFullName = toSafeText(repositorySelect.value) - if (!repositoryFullName) { - return - } - - setSelectedRepository?.(repositoryFullName) - syncFormForRepository({ resetBranch: true }) - setSubmitButtonLabel() - emitActivePrContextChange() - void verifyActivePullRequestContext() - void loadBaseBranchesForSelectedRepository({ - preferredBranch: getFormValues().baseBranch, - }) - }) - - baseBranchInput?.addEventListener('change', persistCurrentConfig) - baseBranchInput?.addEventListener('blur', persistCurrentConfig) - headBranchInput?.addEventListener('blur', persistCurrentConfig) - prTitleInput?.addEventListener('blur', persistCurrentConfig) - prBodyInput?.addEventListener('blur', persistCurrentConfig) - - submitButton?.addEventListener('click', () => { - if (submitting) { - return - } - - void runSubmit() - }) - - syncRepositories() - - return { - setOpen, - isOpen: () => open, - getActivePrContext: () => getCurrentActivePrContext(), - disconnectActivePrContext: () => { - const repository = getSelectedRepositoryObject() - const repositoryFullName = getRepositoryFullName(repository) - if (!repositoryFullName) { - return { reference: '' } - } - - const savedConfig = readRepositoryPrConfig(repositoryFullName) - const normalizedSavedConfig = sanitizeRepositoryPrConfig(savedConfig) - const previousActiveContext = - savedConfig?.isActivePr === true - ? { - repositoryFullName, - pullRequestNumber: - typeof savedConfig.pullRequestNumber === 'number' && - Number.isFinite(savedConfig.pullRequestNumber) - ? savedConfig.pullRequestNumber - : parsePullRequestNumberFromUrl(savedConfig.pullRequestUrl), - } - : null - - if (Object.keys(savedConfig).length > 0) { - saveRepositoryPrConfig({ - repositoryFullName, - config: { - ...normalizedSavedConfig, - isActivePr: false, - }, - }) - } - - lastActiveContentSyncKey = '' - abortPendingActiveContentSyncRequest() - setSubmitButtonLabel() - emitActivePrContextChange() - - return { - reference: formatActivePrReference(previousActiveContext), - } - }, - clearActivePrContext: () => { - const repository = getSelectedRepositoryObject() - const repositoryFullName = getRepositoryFullName(repository) - if (!repositoryFullName) { - return - } - - removeRepositoryPrConfig(repositoryFullName) - lastActiveContentSyncKey = '' - abortPendingActiveContentSyncRequest() - syncFormForRepository({ resetAll: true, resetBranch: true }) - setSubmitButtonLabel() - emitActivePrContextChange() - }, - closeActivePullRequestOnGitHub: async () => { - const repository = getSelectedRepositoryObject() - const repositoryFullName = getRepositoryFullName(repository) - const token = toSafeText(getToken?.()) - const activeContext = getCurrentActivePrContext() - const pullRequestNumber = - activeContext?.pullRequestNumber ?? - parsePullRequestNumberFromUrl(activeContext?.pullRequestUrl) - - if (!repositoryFullName || !repository?.owner || !repository?.name) { - throw new Error('Select a repository before closing pull request context.') - } - - if (!token) { - throw new Error('Add a GitHub token before closing a pull request.') - } - - if (!pullRequestNumber) { - throw new Error('Active pull request context is missing pull request metadata.') - } - - setStatus('Closing pull request on GitHub...', 'pending') - - await closeRepositoryPullRequest({ - token, - owner: repository.owner, - repo: repository.name, - pullRequestNumber, - }) - - removeRepositoryPrConfig(repositoryFullName) - syncFormForRepository({ resetAll: true, resetBranch: true }) - setSubmitButtonLabel() - emitActivePrContextChange() - - const closedReference = formatActivePrReference({ - repositoryFullName, - pullRequestNumber, - }) - setStatus( - closedReference - ? `Closed pull request ${closedReference}.` - : `Closed pull request #${pullRequestNumber}.`, - 'ok', - ) - - return { pullRequestNumber, reference: closedReference } - }, - setToken: token => { - const hasToken = typeof token === 'string' && token.trim().length > 0 - if (toggleButton instanceof HTMLButtonElement) { - toggleButton.disabled = !hasToken - } - - setSubmitButtonLabel() - emitActivePrContextChange() - void verifyActivePullRequestContext() - - if (!hasToken) { - abortPendingContextVerifyRequest() - abortPendingActiveContentSyncRequest() - lastActiveContentSyncKey = '' - abortPendingBranchesRequest() - baseBranchesByRepository.clear() - setOpen(false) - renderBaseBranchOptions({ preferredBranch: 'main', branchNames: [] }) - return - } - - if (!open) { - return - } - - void loadBaseBranchesForSelectedRepository({ - preferredBranch: getFormValues().baseBranch, - }) - }, - setSelectedRepository: () => { - syncRepositories() - }, - syncRepositories, - dispose: () => { - pendingAbortController?.abort() - pendingAbortController = null - abortPendingContextVerifyRequest() - abortPendingActiveContentSyncRequest() - abortPendingBranchesRequest() - }, - } -} diff --git a/src/modules/github/github-pr-context.js b/src/modules/github/pr/context.js similarity index 100% rename from src/modules/github/github-pr-context.js rename to src/modules/github/pr/context.js diff --git a/src/modules/github/pr/drawer/branches.js b/src/modules/github/pr/drawer/branches.js new file mode 100644 index 0000000..a14053d --- /dev/null +++ b/src/modules/github/pr/drawer/branches.js @@ -0,0 +1,69 @@ +import { sanitizeBranchPart, toSafeText } from './common.js' + +const createBranchEntropySuffix = () => Math.random().toString(36).slice(2, 6) + +const isAutoGeneratedHeadBranch = value => { + const branch = sanitizeBranchPart(value) + if (!branch) { + return false + } + + return /^feat\/component-[a-z0-9]{4}(?:-\d+)?$/.test(branch) +} + +const createDefaultBranchName = () => { + const entropy = createBranchEntropySuffix() + return `feat/component-${entropy}` +} + +const toBranchCacheKey = repository => { + const owner = toSafeText(repository?.owner) + const name = toSafeText(repository?.name) + if (!owner || !name) { + return '' + } + + return `${owner}/${name}` +} + +const createSelectOption = ({ value, label, selected = false, disabled = false }) => { + const option = document.createElement('option') + option.value = value + option.textContent = label + option.selected = selected + option.disabled = disabled + return option +} + +const mergeBranchOptions = ({ preferredBranch, branchNames }) => { + const dedupe = new Set() + const result = [] + + const pushBranch = branch => { + const safeBranch = toSafeText(branch) + if (!safeBranch || dedupe.has(safeBranch)) { + return + } + + dedupe.add(safeBranch) + result.push(safeBranch) + } + + pushBranch(preferredBranch) + + if (Array.isArray(branchNames)) { + for (const branchName of branchNames) { + pushBranch(branchName) + } + } + + return result +} + +export { + createDefaultBranchName, + createSelectOption, + isAutoGeneratedHeadBranch, + mergeBranchOptions, + toBranchCacheKey, +} diff --git a/src/modules/github/pr/drawer/common.js b/src/modules/github/pr/drawer/common.js new file mode 100644 index 0000000..4578483 --- /dev/null +++ b/src/modules/github/pr/drawer/common.js @@ -0,0 +1,53 @@ +const defaultCommitMessage = 'chore: sync editor updates from @knighted/develop' + +const supportedRenderModes = new Set(['dom', 'react']) +const supportedStyleModes = new Set(['css', 'module', 'less', 'sass']) +const supportedPrContextStates = new Set(['inactive', 'active', 'disconnected', 'closed']) + +const toSafeText = value => (typeof value === 'string' ? value.trim() : '') + +const toPullRequestNumber = value => { + if (typeof value === 'number' && Number.isFinite(value) && value > 0) { + return value + } + + return null +} + +const normalizeRenderMode = value => { + const mode = toSafeText(value).toLowerCase() + return supportedRenderModes.has(mode) ? mode : 'dom' +} + +const normalizeStyleMode = value => { + const mode = toSafeText(value).toLowerCase() + return supportedStyleModes.has(mode) ? mode : 'css' +} + +const normalizePrContextState = value => { + const state = toSafeText(value).toLowerCase() + return supportedPrContextStates.has(state) ? state : 'inactive' +} + +const sanitizeBranchPart = value => { + const trimmed = toSafeText(value) + if (!trimmed) { + return '' + } + + return trimmed + .replace(/[^A-Za-z0-9._/-]/g, '-') + .replace(/\/+/g, '/') + .replace(/-{2,}/g, '-') + .replace(/^[-/.]+|[-/.]+$/g, '') +} + +export { + defaultCommitMessage, + normalizePrContextState, + normalizeRenderMode, + normalizeStyleMode, + sanitizeBranchPart, + toPullRequestNumber, + toSafeText, +} diff --git a/src/modules/github/pr/drawer/config.js b/src/modules/github/pr/drawer/config.js new file mode 100644 index 0000000..2cbf01a --- /dev/null +++ b/src/modules/github/pr/drawer/config.js @@ -0,0 +1,183 @@ +import { parsePullRequestNumberFromUrl } from '../context.js' +import { + normalizePrContextState, + normalizeRenderMode, + normalizeStyleMode, + sanitizeBranchPart, + toPullRequestNumber, + toSafeText, +} from './common.js' + +const prConfigStoragePrefix = 'knighted:develop:github-pr-config:' + +const getRepositoryPrConfigStorageKey = repositoryFullName => + `${prConfigStoragePrefix}${repositoryFullName}` + +const pruneRepositoryPrConfigs = repositoryFullName => { + if (typeof repositoryFullName !== 'string' || !repositoryFullName.trim()) { + return + } + + const activeStorageKey = getRepositoryPrConfigStorageKey(repositoryFullName) + + try { + const keysToRemove = [] + + for (let index = 0; index < localStorage.length; index += 1) { + const key = localStorage.key(index) + if (!key || !key.startsWith(prConfigStoragePrefix)) { + continue + } + + if (key !== activeStorageKey) { + keysToRemove.push(key) + } + } + + for (const key of keysToRemove) { + localStorage.removeItem(key) + } + } catch { + /* noop */ + } +} + +const readRepositoryPrConfig = repositoryFullName => { + if (typeof repositoryFullName !== 'string' || !repositoryFullName.trim()) { + return {} + } + + try { + const value = localStorage.getItem( + getRepositoryPrConfigStorageKey(repositoryFullName), + ) + if (!value) { + return {} + } + + const parsed = JSON.parse(value) + return parsed && typeof parsed === 'object' ? parsed : {} + } catch { + return {} + } +} + +const saveRepositoryPrConfig = ({ repositoryFullName, config }) => { + if (typeof repositoryFullName !== 'string' || !repositoryFullName.trim()) { + return + } + + try { + const activeStorageKey = getRepositoryPrConfigStorageKey(repositoryFullName) + + localStorage.setItem(activeStorageKey, JSON.stringify(config)) + + pruneRepositoryPrConfigs(repositoryFullName) + } catch { + /* noop */ + } +} + +const sanitizeRepositoryPrConfig = config => { + const source = config && typeof config === 'object' ? config : {} + const pullRequestUrl = toSafeText(source.pullRequestUrl) + const fallbackPullRequestNumber = parsePullRequestNumberFromUrl(pullRequestUrl) + const pullRequestNumber = + toPullRequestNumber(source.pullRequestNumber) ?? fallbackPullRequestNumber + const isActivePr = source.isActivePr === true + const normalizedPrContextState = normalizePrContextState(source.prContextState) + const prContextState = isActivePr + ? 'active' + : normalizedPrContextState === 'active' + ? 'inactive' + : normalizedPrContextState + + return { + baseBranch: toSafeText(source.baseBranch), + headBranch: sanitizeBranchPart(source.headBranch), + prTitle: toSafeText(source.prTitle), + prBody: typeof source.prBody === 'string' ? source.prBody.trim() : '', + renderMode: normalizeRenderMode(source.renderMode), + styleMode: normalizeStyleMode(source.styleMode), + isActivePr, + prContextState, + pullRequestNumber, + pullRequestUrl, + } +} + +const removeRepositoryPrConfig = repositoryFullName => { + if (typeof repositoryFullName !== 'string' || !repositoryFullName.trim()) { + return + } + + try { + localStorage.removeItem(getRepositoryPrConfigStorageKey(repositoryFullName)) + } catch { + /* noop */ + } +} + +const getActiveRepositoryPrContext = repositoryFullName => { + const savedConfig = sanitizeRepositoryPrConfig( + readRepositoryPrConfig(repositoryFullName), + ) + + if (savedConfig?.isActivePr !== true) { + return null + } + + const headBranch = sanitizeBranchPart(savedConfig.headBranch) + const prTitle = toSafeText(savedConfig.prTitle) + const baseBranch = toSafeText(savedConfig.baseBranch) + + if (!headBranch || !prTitle) { + return null + } + + return { + headBranch, + renderMode: normalizeRenderMode(savedConfig.renderMode), + styleMode: normalizeStyleMode(savedConfig.styleMode), + prTitle, + prBody: typeof savedConfig.prBody === 'string' ? savedConfig.prBody : '', + baseBranch, + pullRequestNumber: + typeof savedConfig.pullRequestNumber === 'number' && + Number.isFinite(savedConfig.pullRequestNumber) + ? savedConfig.pullRequestNumber + : parsePullRequestNumberFromUrl(savedConfig.pullRequestUrl), + pullRequestUrl: + typeof savedConfig.pullRequestUrl === 'string' ? savedConfig.pullRequestUrl : '', + repositoryFullName, + } +} + +const findRepositoryWithActivePrContext = repositories => { + if (!Array.isArray(repositories) || repositories.length === 0) { + return null + } + + for (const repository of repositories) { + const repositoryFullName = toSafeText(repository?.fullName) + + if (!repositoryFullName) { + continue + } + + if (getActiveRepositoryPrContext(repositoryFullName)) { + return repositoryFullName + } + } + + return null +} + +export { + findRepositoryWithActivePrContext, + getActiveRepositoryPrContext, + readRepositoryPrConfig, + removeRepositoryPrConfig, + sanitizeRepositoryPrConfig, + saveRepositoryPrConfig, +} diff --git a/src/modules/github/pr/drawer/controller/context-sync.js b/src/modules/github/pr/drawer/controller/context-sync.js new file mode 100644 index 0000000..faf2e4c --- /dev/null +++ b/src/modules/github/pr/drawer/controller/context-sync.js @@ -0,0 +1,276 @@ +export const createContextSyncHandlers = ({ + state, + getSelectedRepositoryObject, + getRepositoryFullName, + getToken, + getEditorSyncTargets, + onSyncActivePrEditorContent, + getCurrentActivePrContext, + syncFormForRepository, + setSubmitButtonLabel, + emitActivePrContextChange, + setStatus, + toSafeText, + sanitizeBranchPart, + parsePullRequestNumberFromUrl, + readRepositoryPrConfig, + saveRepositoryPrConfig, + sanitizeRepositoryPrConfig, + getRepositoryPullRequest, + findOpenRepositoryPullRequestByHead, +}) => { + const abortPendingContextVerifyRequest = () => { + state.pendingContextVerifyAbortController?.abort() + state.pendingContextVerifyAbortController = null + state.pendingContextVerifyRequestKey = '' + state.pendingContextVerifyPromise = null + } + + const abortPendingActiveContentSyncRequest = () => { + state.pendingActiveContentSyncAbortController?.abort() + state.pendingActiveContentSyncAbortController = null + } + + const syncActivePrEditorContent = async () => { + if (typeof onSyncActivePrEditorContent !== 'function') { + return + } + + const repository = getSelectedRepositoryObject() + const repositoryFullName = getRepositoryFullName(repository) + const token = toSafeText(getToken?.()) + const activeContext = getCurrentActivePrContext() + + if (!repositoryFullName || !token || !activeContext) { + state.lastActiveContentSyncKey = '' + abortPendingActiveContentSyncRequest() + return + } + + const syncTargets = + typeof getEditorSyncTargets === 'function' ? getEditorSyncTargets() : null + const tabSyncTargets = Array.isArray(syncTargets?.tabTargets) + ? syncTargets.tabTargets + : [] + const componentSyncPath = toSafeText( + tabSyncTargets.find(target => toSafeText(target?.kind) === 'component')?.path, + ) + const stylesSyncPath = toSafeText( + tabSyncTargets.find(target => toSafeText(target?.kind) === 'styles')?.path, + ) + + if (!componentSyncPath || !stylesSyncPath) { + state.lastActiveContentSyncKey = '' + abortPendingActiveContentSyncRequest() + return + } + + const syncKey = [ + repositoryFullName, + activeContext.headBranch, + componentSyncPath, + stylesSyncPath, + String(activeContext.pullRequestNumber ?? ''), + ].join('|') + + if (syncKey === state.lastActiveContentSyncKey) { + return + } + + abortPendingActiveContentSyncRequest() + const abortController = new AbortController() + state.pendingActiveContentSyncAbortController = abortController + + try { + await onSyncActivePrEditorContent({ + token, + repository, + activeContext, + syncTargets: { + tabTargets: [ + { kind: 'component', path: componentSyncPath }, + { kind: 'styles', path: stylesSyncPath }, + ], + }, + signal: abortController.signal, + }) + + if (state.pendingActiveContentSyncAbortController !== abortController) { + return + } + + state.lastActiveContentSyncKey = syncKey + } catch { + if (abortController.signal.aborted) { + return + } + } finally { + if (state.pendingActiveContentSyncAbortController === abortController) { + state.pendingActiveContentSyncAbortController = null + } + } + } + + const verifyActivePullRequestContext = async () => { + const repository = getSelectedRepositoryObject() + const repositoryFullName = getRepositoryFullName(repository) + const owner = toSafeText(repository?.owner) + const repo = toSafeText(repository?.name) + const token = toSafeText(getToken?.()) + + if (!repositoryFullName || !owner || !repo || !token) { + return + } + + const savedConfig = readRepositoryPrConfig(repositoryFullName) + if (savedConfig?.isActivePr !== true) { + return + } + + const pullRequestNumberFromConfig = + typeof savedConfig.pullRequestNumber === 'number' && + Number.isFinite(savedConfig.pullRequestNumber) + ? savedConfig.pullRequestNumber + : parsePullRequestNumberFromUrl(savedConfig.pullRequestUrl) + const headBranch = sanitizeBranchPart(savedConfig.headBranch) + + if (!pullRequestNumberFromConfig && !headBranch) { + return + } + + const requestKey = [ + repositoryFullName, + String(pullRequestNumberFromConfig || ''), + headBranch, + toSafeText(savedConfig.baseBranch), + ].join('|') + + if ( + state.pendingContextVerifyPromise && + state.pendingContextVerifyRequestKey === requestKey + ) { + await state.pendingContextVerifyPromise + return + } + + abortPendingContextVerifyRequest() + const abortController = new AbortController() + state.pendingContextVerifyAbortController = abortController + + const runVerifyRequest = async () => { + try { + let resolvedPullRequest = null + let pullRequestClosedByNumber = false + + if (pullRequestNumberFromConfig) { + const pullRequest = await getRepositoryPullRequest({ + token, + owner, + repo, + pullRequestNumber: pullRequestNumberFromConfig, + signal: abortController.signal, + }) + + if (pullRequest?.isOpen) { + resolvedPullRequest = pullRequest + } else if (pullRequest) { + pullRequestClosedByNumber = true + } + } + + if (!resolvedPullRequest && !pullRequestClosedByNumber) { + resolvedPullRequest = await findOpenRepositoryPullRequestByHead({ + token, + owner, + repo, + headOwner: owner, + headBranch, + baseBranch: toSafeText(savedConfig.baseBranch), + signal: abortController.signal, + }) + } + + if (state.pendingContextVerifyAbortController !== abortController) { + return + } + + if (resolvedPullRequest?.isOpen) { + const normalizedSavedConfig = sanitizeRepositoryPrConfig(savedConfig) + const nextHeadBranch = + sanitizeBranchPart(resolvedPullRequest.headRef) || headBranch + const nextBaseBranch = + toSafeText(resolvedPullRequest.baseRef) || toSafeText(savedConfig.baseBranch) + + saveRepositoryPrConfig({ + repositoryFullName, + config: { + ...normalizedSavedConfig, + isActivePr: true, + prContextState: 'active', + headBranch: nextHeadBranch, + baseBranch: nextBaseBranch, + pullRequestNumber: resolvedPullRequest.number, + pullRequestUrl: resolvedPullRequest.htmlUrl, + prTitle: + toSafeText(savedConfig.prTitle) || toSafeText(resolvedPullRequest.title), + }, + }) + syncFormForRepository({ resetBranch: true }) + setSubmitButtonLabel() + emitActivePrContextChange() + void syncActivePrEditorContent() + return + } + + saveRepositoryPrConfig({ + repositoryFullName, + config: { + ...sanitizeRepositoryPrConfig(savedConfig), + isActivePr: false, + prContextState: 'closed', + }, + }) + setSubmitButtonLabel() + emitActivePrContextChange() + state.lastActiveContentSyncKey = '' + abortPendingActiveContentSyncRequest() + setStatus( + 'Saved pull request context is not open on GitHub. Open PR mode restored.', + 'neutral', + ) + } catch (error) { + if (abortController.signal.aborted) { + return + } + + const message = + error instanceof Error ? error.message : 'Failed to verify pull request state.' + setStatus(`Could not verify saved pull request state: ${message}`, 'error') + } finally { + if (state.pendingContextVerifyAbortController === abortController) { + state.pendingContextVerifyAbortController = null + } + } + } + + const requestPromise = runVerifyRequest() + state.pendingContextVerifyRequestKey = requestKey + state.pendingContextVerifyPromise = requestPromise + + try { + await requestPromise + } finally { + if (state.pendingContextVerifyPromise === requestPromise) { + state.pendingContextVerifyPromise = null + state.pendingContextVerifyRequestKey = '' + } + } + } + + return { + abortPendingActiveContentSyncRequest, + abortPendingContextVerifyRequest, + syncActivePrEditorContent, + verifyActivePullRequestContext, + } +} diff --git a/src/modules/github/pr/drawer/controller/create-controller.js b/src/modules/github/pr/drawer/controller/create-controller.js new file mode 100644 index 0000000..e8ef52c --- /dev/null +++ b/src/modules/github/pr/drawer/controller/create-controller.js @@ -0,0 +1,295 @@ +import { + commitEditorContentToExistingBranch, + createEditorContentPullRequest, +} from '../../../api/editor-content.js' +import { listRepositoryBranches } from '../../../api/repositories.js' +import { + closeRepositoryPullRequest, + findOpenRepositoryPullRequestByHead, + getRepositoryPullRequest, +} from '../../../api/pull-requests.js' +import { formatActivePrReference, parsePullRequestNumberFromUrl } from '../../context.js' +import { + createDefaultBranchName, + createSelectOption, + isAutoGeneratedHeadBranch, + mergeBranchOptions, + toBranchCacheKey, +} from '../branches.js' +import { + getActiveRepositoryPrContext, + readRepositoryPrConfig, + removeRepositoryPrConfig, + sanitizeRepositoryPrConfig, + saveRepositoryPrConfig, +} from '../config.js' +import { + defaultCommitMessage, + normalizeRenderMode, + normalizeStyleMode, + sanitizeBranchPart, + toSafeText, +} from '../common.js' +import { ensureTrailingNewline, normalizeFileCommits } from '../file-commits.js' +import { stripTopLevelAppWrapper } from '../source-transform.js' +import { buildSummary } from '../summary.js' +import { createContextSyncHandlers } from './context-sync.js' +import { bindControllerEvents } from './events.js' +import { createPublicActions } from './public-actions.js' +import { createRepositoryFormHandlers } from './repository-form.js' +import { createRunSubmit } from './run-submit.js' +import { createUiStateHandlers } from './ui-state.js' + +export const createGitHubPrDrawer = ({ + toggleButton, + drawer, + closeButton, + repositorySelect, + baseBranchInput, + headBranchInput, + prTitleInput, + prBodyInput, + commitMessageInput, + includeAppWrapperToggle, + submitButton, + titleNode, + statusNode, + getToken, + getSelectedRepository, + getWritableRepositories, + setSelectedRepository, + getFileCommits, + getEditorSyncTargets, + getTopLevelDeclarations, + getRenderMode, + getStyleMode, + getDrawerSide, + confirmBeforeSubmit, + onPullRequestOpened, + onPullRequestCommitPushed, + onActivePrContextChange, + onSyncActivePrEditorContent, + onRestoreRenderMode, + onRestoreStyleMode, +}) => { + const state = { + open: false, + submitting: false, + pendingAbortController: null, + pendingBranchesAbortController: null, + pendingContextVerifyAbortController: null, + pendingActiveContentSyncAbortController: null, + pendingBranchesRequestKey: '', + pendingBranchesPromise: null, + pendingContextVerifyRequestKey: '', + pendingContextVerifyPromise: null, + lastSyncedRepositoryFullName: '', + lastActiveContentSyncKey: '', + baseBranchesByRepository: new Map(), + } + + const getSelectedRepositoryObject = () => getSelectedRepository?.() ?? null + + const getRepositoryFullName = repository => + typeof repository?.fullName === 'string' ? repository.fullName : '' + + const getCurrentActivePrContext = () => { + const repository = getSelectedRepositoryObject() + const repositoryFullName = getRepositoryFullName(repository) + if (!repositoryFullName) { + return null + } + + return getActiveRepositoryPrContext(repositoryFullName) + } + + const uiHandlers = createUiStateHandlers({ + state, + repositorySelect, + baseBranchInput, + headBranchInput, + prTitleInput, + prBodyInput, + commitMessageInput, + includeAppWrapperToggle, + submitButton, + titleNode, + statusNode, + drawer, + onActivePrContextChange, + onRestoreRenderMode, + onRestoreStyleMode, + normalizeRenderMode, + normalizeStyleMode, + toSafeText, + getCurrentActivePrContext, + }) + + let syncFormForRepository = () => {} + + const contextHandlers = createContextSyncHandlers({ + state, + getSelectedRepositoryObject, + getRepositoryFullName, + getToken, + getEditorSyncTargets, + onSyncActivePrEditorContent, + getCurrentActivePrContext, + syncFormForRepository: options => syncFormForRepository(options), + setSubmitButtonLabel: uiHandlers.setSubmitButtonLabel, + emitActivePrContextChange: uiHandlers.emitActivePrContextChange, + setStatus: uiHandlers.setStatus, + toSafeText, + sanitizeBranchPart, + parsePullRequestNumberFromUrl, + readRepositoryPrConfig, + saveRepositoryPrConfig, + sanitizeRepositoryPrConfig, + getRepositoryPullRequest, + findOpenRepositoryPullRequestByHead, + }) + + const repositoryFormHandlers = createRepositoryFormHandlers({ + state, + toggleButton, + drawer, + repositorySelect, + baseBranchInput, + headBranchInput, + prTitleInput, + prBodyInput, + commitMessageInput, + includeAppWrapperToggle, + getDrawerSide, + getToken, + getWritableRepositories, + setSelectedRepository, + getSelectedRepositoryObject, + getRepositoryFullName, + getCurrentActivePrContext, + getFormValues: uiHandlers.getFormValues, + setSubmitButtonLabel: uiHandlers.setSubmitButtonLabel, + emitActivePrContextChange: uiHandlers.emitActivePrContextChange, + verifyActivePullRequestContext: contextHandlers.verifyActivePullRequestContext, + getRenderMode, + getStyleMode, + toSafeText, + sanitizeBranchPart, + isAutoGeneratedHeadBranch, + createDefaultBranchName, + createSelectOption, + mergeBranchOptions, + toBranchCacheKey, + normalizeRenderMode, + normalizeStyleMode, + readRepositoryPrConfig, + sanitizeRepositoryPrConfig, + saveRepositoryPrConfig, + listRepositoryBranches, + }) + + syncFormForRepository = repositoryFormHandlers.syncFormForRepository + + const runSubmit = createRunSubmit({ + state, + getSelectedRepositoryObject, + getRepositoryFullName, + getToken, + getCurrentActivePrContext, + getFormValues: uiHandlers.getFormValues, + getRenderMode, + getStyleMode, + prTitleInput, + includeAppWrapperToggle, + getFileCommits, + getTopLevelDeclarations, + confirmBeforeSubmit, + onPullRequestOpened, + onPullRequestCommitPushed, + setStatus: uiHandlers.setStatus, + setPendingState: uiHandlers.setPendingState, + setOpen: repositoryFormHandlers.setOpen, + setSubmitButtonLabel: uiHandlers.setSubmitButtonLabel, + emitActivePrContextChange: uiHandlers.emitActivePrContextChange, + defaultCommitMessage, + normalizeRenderMode, + normalizeStyleMode, + normalizeFileCommits, + toSafeText, + sanitizeBranchPart, + buildSummary, + stripTopLevelAppWrapper, + ensureTrailingNewline, + commitEditorContentToExistingBranch, + createEditorContentPullRequest, + formatActivePrReference, + saveRepositoryPrConfig, + }) + + bindControllerEvents({ + state, + toggleButton, + closeButton, + repositorySelect, + baseBranchInput, + headBranchInput, + prTitleInput, + prBodyInput, + submitButton, + setOpen: repositoryFormHandlers.setOpen, + runSubmit, + persistCurrentConfig: repositoryFormHandlers.persistCurrentConfig, + setSelectedRepository, + setSubmitButtonLabel: uiHandlers.setSubmitButtonLabel, + emitActivePrContextChange: uiHandlers.emitActivePrContextChange, + verifyActivePullRequestContext: contextHandlers.verifyActivePullRequestContext, + syncFormForRepository: repositoryFormHandlers.syncFormForRepository, + loadBaseBranchesForSelectedRepository: + repositoryFormHandlers.loadBaseBranchesForSelectedRepository, + getFormValues: uiHandlers.getFormValues, + toSafeText, + }) + + repositoryFormHandlers.syncRepositories() + + const publicActions = createPublicActions({ + state, + toggleButton, + getSelectedRepositoryObject, + getRepositoryFullName, + getToken, + getCurrentActivePrContext, + getFormValues: uiHandlers.getFormValues, + setStatus: uiHandlers.setStatus, + setOpen: repositoryFormHandlers.setOpen, + setSubmitButtonLabel: uiHandlers.setSubmitButtonLabel, + emitActivePrContextChange: uiHandlers.emitActivePrContextChange, + syncFormForRepository: repositoryFormHandlers.syncFormForRepository, + verifyActivePullRequestContext: contextHandlers.verifyActivePullRequestContext, + loadBaseBranchesForSelectedRepository: + repositoryFormHandlers.loadBaseBranchesForSelectedRepository, + renderBaseBranchOptions: repositoryFormHandlers.renderBaseBranchOptions, + syncRepositories: repositoryFormHandlers.syncRepositories, + abortPendingBranchesRequest: repositoryFormHandlers.abortPendingBranchesRequest, + abortPendingContextVerifyRequest: contextHandlers.abortPendingContextVerifyRequest, + abortPendingActiveContentSyncRequest: + contextHandlers.abortPendingActiveContentSyncRequest, + closeRepositoryPullRequest, + formatActivePrReference, + parsePullRequestNumberFromUrl, + readRepositoryPrConfig, + saveRepositoryPrConfig, + sanitizeRepositoryPrConfig, + removeRepositoryPrConfig, + sanitizeBranchPart, + toSafeText, + }) + + return { + setOpen: repositoryFormHandlers.setOpen, + isOpen: () => state.open, + getActivePrContext: () => getCurrentActivePrContext(), + ...publicActions, + syncRepositories: repositoryFormHandlers.syncRepositories, + } +} diff --git a/src/modules/github/pr/drawer/controller/events.js b/src/modules/github/pr/drawer/controller/events.js new file mode 100644 index 0000000..7adc306 --- /dev/null +++ b/src/modules/github/pr/drawer/controller/events.js @@ -0,0 +1,64 @@ +export const bindControllerEvents = ({ + state, + toggleButton, + closeButton, + repositorySelect, + baseBranchInput, + headBranchInput, + prTitleInput, + prBodyInput, + submitButton, + setOpen, + runSubmit, + persistCurrentConfig, + setSelectedRepository, + setSubmitButtonLabel, + emitActivePrContextChange, + verifyActivePullRequestContext, + syncFormForRepository, + loadBaseBranchesForSelectedRepository, + getFormValues, + toSafeText, +}) => { + toggleButton?.addEventListener('click', () => { + setOpen(!state.open) + }) + + closeButton?.addEventListener('click', () => { + setOpen(false) + }) + + repositorySelect?.addEventListener('change', () => { + if (!(repositorySelect instanceof HTMLSelectElement)) { + return + } + + const repositoryFullName = toSafeText(repositorySelect.value) + if (!repositoryFullName) { + return + } + + setSelectedRepository?.(repositoryFullName) + syncFormForRepository({ resetBranch: true }) + setSubmitButtonLabel() + emitActivePrContextChange() + void verifyActivePullRequestContext() + void loadBaseBranchesForSelectedRepository({ + preferredBranch: getFormValues().baseBranch, + }) + }) + + baseBranchInput?.addEventListener('change', persistCurrentConfig) + baseBranchInput?.addEventListener('blur', persistCurrentConfig) + headBranchInput?.addEventListener('blur', persistCurrentConfig) + prTitleInput?.addEventListener('blur', persistCurrentConfig) + prBodyInput?.addEventListener('blur', persistCurrentConfig) + + submitButton?.addEventListener('click', () => { + if (state.submitting) { + return + } + + void runSubmit() + }) +} diff --git a/src/modules/github/pr/drawer/controller/public-actions.js b/src/modules/github/pr/drawer/controller/public-actions.js new file mode 100644 index 0000000..473ad09 --- /dev/null +++ b/src/modules/github/pr/drawer/controller/public-actions.js @@ -0,0 +1,201 @@ +export const createPublicActions = ({ + state, + toggleButton, + getSelectedRepositoryObject, + getRepositoryFullName, + getToken, + getCurrentActivePrContext, + getFormValues, + setStatus, + setOpen, + setSubmitButtonLabel, + emitActivePrContextChange, + syncFormForRepository, + verifyActivePullRequestContext, + loadBaseBranchesForSelectedRepository, + renderBaseBranchOptions, + syncRepositories, + abortPendingBranchesRequest, + abortPendingContextVerifyRequest, + abortPendingActiveContentSyncRequest, + closeRepositoryPullRequest, + formatActivePrReference, + parsePullRequestNumberFromUrl, + readRepositoryPrConfig, + saveRepositoryPrConfig, + sanitizeRepositoryPrConfig, + removeRepositoryPrConfig, + sanitizeBranchPart, + toSafeText, +}) => { + return { + disconnectActivePrContext: () => { + const repository = getSelectedRepositoryObject() + const repositoryFullName = getRepositoryFullName(repository) + if (!repositoryFullName) { + return { reference: '' } + } + + const savedConfig = readRepositoryPrConfig(repositoryFullName) + const normalizedSavedConfig = sanitizeRepositoryPrConfig(savedConfig) + const previousActiveContext = + savedConfig?.isActivePr === true + ? { + repositoryFullName, + pullRequestNumber: + typeof savedConfig.pullRequestNumber === 'number' && + Number.isFinite(savedConfig.pullRequestNumber) + ? savedConfig.pullRequestNumber + : parsePullRequestNumberFromUrl(savedConfig.pullRequestUrl), + } + : null + + if (Object.keys(savedConfig).length > 0) { + saveRepositoryPrConfig({ + repositoryFullName, + config: { + ...normalizedSavedConfig, + isActivePr: false, + prContextState: 'disconnected', + }, + }) + } + + state.lastActiveContentSyncKey = '' + abortPendingActiveContentSyncRequest() + setSubmitButtonLabel() + emitActivePrContextChange() + + return { + reference: formatActivePrReference(previousActiveContext), + pullRequestNumber: + typeof previousActiveContext?.pullRequestNumber === 'number' && + Number.isFinite(previousActiveContext.pullRequestNumber) + ? previousActiveContext.pullRequestNumber + : null, + } + }, + clearActivePrContext: () => { + const repository = getSelectedRepositoryObject() + const repositoryFullName = getRepositoryFullName(repository) + if (!repositoryFullName) { + return + } + + removeRepositoryPrConfig(repositoryFullName) + state.lastActiveContentSyncKey = '' + abortPendingActiveContentSyncRequest() + syncFormForRepository({ resetAll: true, resetBranch: true }) + setSubmitButtonLabel() + emitActivePrContextChange() + }, + closeActivePullRequestOnGitHub: async () => { + const repository = getSelectedRepositoryObject() + const repositoryFullName = getRepositoryFullName(repository) + const token = toSafeText(getToken?.()) + const activeContext = getCurrentActivePrContext() + const pullRequestNumber = + activeContext?.pullRequestNumber ?? + parsePullRequestNumberFromUrl(activeContext?.pullRequestUrl) + + if (!repositoryFullName || !repository?.owner || !repository?.name) { + throw new Error('Select a repository before closing pull request context.') + } + + if (!token) { + throw new Error('Add a GitHub token before closing a pull request.') + } + + if (!pullRequestNumber) { + throw new Error('Active pull request context is missing pull request metadata.') + } + + setStatus('Closing pull request on GitHub...', 'pending') + + await closeRepositoryPullRequest({ + token, + owner: repository.owner, + repo: repository.name, + pullRequestNumber, + }) + + const savedConfig = sanitizeRepositoryPrConfig( + readRepositoryPrConfig(repositoryFullName), + ) + saveRepositoryPrConfig({ + repositoryFullName, + config: { + ...savedConfig, + baseBranch: toSafeText(activeContext?.baseBranch) || savedConfig.baseBranch, + headBranch: + sanitizeBranchPart(activeContext?.headBranch) || savedConfig.headBranch, + prTitle: toSafeText(activeContext?.prTitle) || savedConfig.prTitle, + prBody: + typeof activeContext?.prBody === 'string' + ? activeContext.prBody + : savedConfig.prBody, + isActivePr: false, + prContextState: 'closed', + pullRequestNumber, + pullRequestUrl: + toSafeText(activeContext?.pullRequestUrl) || savedConfig.pullRequestUrl, + }, + }) + syncFormForRepository({ resetAll: true, resetBranch: true }) + setSubmitButtonLabel() + emitActivePrContextChange() + + const closedReference = formatActivePrReference({ + repositoryFullName, + pullRequestNumber, + }) + setStatus( + closedReference + ? `Closed pull request ${closedReference}.` + : `Closed pull request #${pullRequestNumber}.`, + 'ok', + ) + + return { pullRequestNumber, reference: closedReference } + }, + setToken: token => { + const hasToken = typeof token === 'string' && token.trim().length > 0 + if (toggleButton instanceof HTMLButtonElement) { + toggleButton.disabled = !hasToken + } + + setSubmitButtonLabel() + emitActivePrContextChange() + void verifyActivePullRequestContext() + + if (!hasToken) { + abortPendingContextVerifyRequest() + abortPendingActiveContentSyncRequest() + state.lastActiveContentSyncKey = '' + abortPendingBranchesRequest() + state.baseBranchesByRepository.clear() + setOpen(false) + renderBaseBranchOptions({ preferredBranch: 'main', branchNames: [] }) + return + } + + if (!state.open) { + return + } + + void loadBaseBranchesForSelectedRepository({ + preferredBranch: getFormValues().baseBranch, + }) + }, + setSelectedRepository: () => { + syncRepositories() + }, + dispose: () => { + state.pendingAbortController?.abort() + state.pendingAbortController = null + abortPendingContextVerifyRequest() + abortPendingActiveContentSyncRequest() + abortPendingBranchesRequest() + }, + } +} diff --git a/src/modules/github/pr/drawer/controller/repository-form.js b/src/modules/github/pr/drawer/controller/repository-form.js new file mode 100644 index 0000000..163ab34 --- /dev/null +++ b/src/modules/github/pr/drawer/controller/repository-form.js @@ -0,0 +1,388 @@ +export const createRepositoryFormHandlers = ({ + state, + toggleButton, + drawer, + repositorySelect, + baseBranchInput, + headBranchInput, + prTitleInput, + prBodyInput, + commitMessageInput, + includeAppWrapperToggle, + getDrawerSide, + getToken, + getWritableRepositories, + setSelectedRepository, + getSelectedRepositoryObject, + getRepositoryFullName, + getCurrentActivePrContext, + getFormValues, + setSubmitButtonLabel, + emitActivePrContextChange, + verifyActivePullRequestContext, + getRenderMode, + getStyleMode, + toSafeText, + sanitizeBranchPart, + isAutoGeneratedHeadBranch, + createDefaultBranchName, + createSelectOption, + mergeBranchOptions, + toBranchCacheKey, + normalizeRenderMode, + normalizeStyleMode, + readRepositoryPrConfig, + sanitizeRepositoryPrConfig, + saveRepositoryPrConfig, + listRepositoryBranches, +}) => { + const abortPendingBranchesRequest = () => { + state.pendingBranchesAbortController?.abort() + state.pendingBranchesAbortController = null + } + + const renderBaseBranchOptions = ({ preferredBranch, branchNames, loading = false }) => { + const baseBranch = toSafeText(preferredBranch) || 'main' + + if (baseBranchInput instanceof HTMLInputElement) { + baseBranchInput.value = baseBranch + return + } + + if (!(baseBranchInput instanceof HTMLSelectElement)) { + return + } + + if (loading) { + baseBranchInput.replaceChildren( + createSelectOption({ + value: baseBranch, + label: 'Loading branches...', + }), + ) + baseBranchInput.value = baseBranch + baseBranchInput.disabled = true + return + } + + const mergedOptions = mergeBranchOptions({ preferredBranch: baseBranch, branchNames }) + + const options = mergedOptions.map(branchName => + createSelectOption({ + value: branchName, + label: branchName, + selected: branchName === baseBranch, + }), + ) + + const isPushCommitMode = Boolean(getCurrentActivePrContext()) + + baseBranchInput.replaceChildren(...options) + baseBranchInput.disabled = state.submitting || isPushCommitMode + baseBranchInput.value = baseBranch + } + + const getPreferredBaseBranchForRepository = repository => { + const repositoryFullName = getRepositoryFullName(repository) + const savedConfig = readRepositoryPrConfig(repositoryFullName) + return ( + toSafeText(savedConfig.baseBranch) || + toSafeText(repository?.defaultBranch) || + 'main' + ) + } + + const loadBaseBranchesForSelectedRepository = async ({ preferredBranch }) => { + const repository = getSelectedRepositoryObject() + const cacheKey = toBranchCacheKey(repository) + const nextPreferredBranch = + toSafeText(preferredBranch) || getPreferredBaseBranchForRepository(repository) + const requestKey = `${cacheKey}:${nextPreferredBranch}` + + if (!cacheKey) { + renderBaseBranchOptions({ preferredBranch: nextPreferredBranch, branchNames: [] }) + return + } + + const token = toSafeText(getToken?.()) + if (!token) { + renderBaseBranchOptions({ preferredBranch: nextPreferredBranch, branchNames: [] }) + return + } + + const cachedBranches = state.baseBranchesByRepository.get(cacheKey) + if (Array.isArray(cachedBranches) && cachedBranches.length > 0) { + renderBaseBranchOptions({ + preferredBranch: nextPreferredBranch, + branchNames: cachedBranches, + }) + return + } + + if (state.pendingBranchesPromise && state.pendingBranchesRequestKey === requestKey) { + await state.pendingBranchesPromise + return + } + + abortPendingBranchesRequest() + renderBaseBranchOptions({ preferredBranch: nextPreferredBranch, loading: true }) + + const abortController = new AbortController() + state.pendingBranchesAbortController = abortController + + const runBranchRequest = async () => { + const branches = await listRepositoryBranches({ + token, + owner: repository.owner, + repo: repository.name, + signal: abortController.signal, + }) + + if (state.pendingBranchesAbortController !== abortController) { + return + } + + state.baseBranchesByRepository.set(cacheKey, branches) + renderBaseBranchOptions({ + preferredBranch: nextPreferredBranch, + branchNames: branches, + }) + } + + const requestPromise = runBranchRequest() + + state.pendingBranchesRequestKey = requestKey + state.pendingBranchesPromise = requestPromise + + try { + await requestPromise + } catch { + if (abortController.signal.aborted) { + return + } + + renderBaseBranchOptions({ preferredBranch: nextPreferredBranch, branchNames: [] }) + } finally { + if (state.pendingBranchesAbortController === abortController) { + state.pendingBranchesAbortController = null + } + + if (state.pendingBranchesPromise === requestPromise) { + state.pendingBranchesPromise = null + state.pendingBranchesRequestKey = '' + } + } + } + + const syncRepositorySelect = ({ repositories, selectedRepository }) => { + if (!(repositorySelect instanceof HTMLSelectElement)) { + return + } + + repositorySelect.replaceChildren() + + if (!Array.isArray(repositories) || repositories.length === 0) { + const option = document.createElement('option') + option.value = '' + option.textContent = 'Connect a token to load repositories' + option.selected = true + repositorySelect.append(option) + repositorySelect.disabled = true + return + } + + const selectedFullName = getRepositoryFullName(selectedRepository) + + const options = repositories.map(repo => { + const option = document.createElement('option') + option.value = repo.fullName + option.textContent = repo.fullName + option.selected = repo.fullName === selectedFullName + return option + }) + + repositorySelect.replaceChildren(...options) + repositorySelect.disabled = false + + if (!selectedFullName && repositories[0]) { + repositorySelect.value = repositories[0].fullName + setSelectedRepository?.(repositories[0].fullName) + return + } + + repositorySelect.value = selectedFullName + } + + const syncFormForRepository = ({ resetBranch = false, resetAll = false } = {}) => { + const repository = getSelectedRepositoryObject() + const repositoryFullName = getRepositoryFullName(repository) + const repositoryChanged = + Boolean(repositoryFullName) && + repositoryFullName !== state.lastSyncedRepositoryFullName + const savedConfig = readRepositoryPrConfig(repositoryFullName) + const savedDraftConfig = resetAll ? {} : savedConfig + + const baseBranch = + toSafeText(savedConfig.baseBranch) || + toSafeText(repository?.defaultBranch) || + 'main' + + renderBaseBranchOptions({ preferredBranch: baseBranch, branchNames: [] }) + + if (headBranchInput instanceof HTMLInputElement) { + if ( + resetAll || + resetBranch || + repositoryChanged || + !toSafeText(headBranchInput.value) + ) { + const savedHeadBranch = sanitizeBranchPart(savedDraftConfig.headBranch) + headBranchInput.value = + savedHeadBranch && !isAutoGeneratedHeadBranch(savedHeadBranch) + ? savedHeadBranch + : createDefaultBranchName() + } + } + + if (prTitleInput instanceof HTMLInputElement) { + if (resetAll || repositoryChanged || !toSafeText(prTitleInput.value)) { + prTitleInput.value = toSafeText(savedDraftConfig.prTitle) + } + } + + if (prBodyInput instanceof HTMLTextAreaElement) { + if (resetAll || repositoryChanged || !toSafeText(prBodyInput.value)) { + prBodyInput.value = + typeof savedDraftConfig.prBody === 'string' ? savedDraftConfig.prBody : '' + } + } + + if (commitMessageInput instanceof HTMLInputElement) { + if (resetAll || repositoryChanged || !toSafeText(commitMessageInput.value)) { + commitMessageInput.value = '' + } + } + + if (includeAppWrapperToggle instanceof HTMLInputElement) { + includeAppWrapperToggle.checked = true + } + + state.lastSyncedRepositoryFullName = repositoryFullName + } + + const persistCurrentConfig = () => { + const repository = getSelectedRepositoryObject() + const repositoryFullName = getRepositoryFullName(repository) + if (!repositoryFullName) { + return + } + + const values = getFormValues() + const currentRenderMode = normalizeRenderMode(getRenderMode?.()) + const currentStyleMode = normalizeStyleMode(getStyleMode?.()) + const existingConfig = readRepositoryPrConfig(repositoryFullName) + const normalizedExistingConfig = sanitizeRepositoryPrConfig(existingConfig) + const isActivePr = existingConfig?.isActivePr === true + + if (isActivePr) { + saveRepositoryPrConfig({ + repositoryFullName, + config: { + ...normalizedExistingConfig, + renderMode: currentRenderMode, + styleMode: currentStyleMode, + isActivePr: true, + prContextState: 'active', + pullRequestNumber: existingConfig?.pullRequestNumber, + pullRequestUrl: existingConfig?.pullRequestUrl, + }, + }) + + setSubmitButtonLabel() + emitActivePrContextChange() + return + } + + const nextPrContextState = + normalizedExistingConfig.prContextState === 'disconnected' || + normalizedExistingConfig.prContextState === 'closed' + ? normalizedExistingConfig.prContextState + : 'inactive' + + saveRepositoryPrConfig({ + repositoryFullName, + config: { + baseBranch: values.baseBranch, + headBranch: isAutoGeneratedHeadBranch(values.headBranch) ? '' : values.headBranch, + prTitle: values.prTitle, + prBody: values.prBody, + renderMode: currentRenderMode, + styleMode: currentStyleMode, + isActivePr: false, + prContextState: nextPrContextState, + pullRequestNumber: existingConfig?.pullRequestNumber, + pullRequestUrl: existingConfig?.pullRequestUrl, + }, + }) + + setSubmitButtonLabel() + emitActivePrContextChange() + } + + const syncRepositories = () => { + const repositories = getWritableRepositories?.() ?? [] + const selectedRepository = getSelectedRepositoryObject() + syncRepositorySelect({ repositories, selectedRepository }) + syncFormForRepository() + setSubmitButtonLabel() + emitActivePrContextChange() + void verifyActivePullRequestContext() + if (!state.open) { + return + } + + void loadBaseBranchesForSelectedRepository({ + preferredBranch: getFormValues().baseBranch, + }) + } + + const setOpen = nextOpen => { + state.open = nextOpen === true + + if (!(toggleButton instanceof HTMLButtonElement) || !drawer) { + return + } + + const preferredSide = getDrawerSide?.() === 'left' ? 'left' : 'right' + drawer.classList.toggle('github-pr-drawer--left', preferredSide === 'left') + drawer.classList.toggle('github-pr-drawer--right', preferredSide !== 'left') + + toggleButton.setAttribute('aria-expanded', state.open ? 'true' : 'false') + drawer.toggleAttribute('hidden', !state.open) + + if (state.open) { + const repositories = getWritableRepositories?.() ?? [] + const selectedRepository = getSelectedRepositoryObject() + syncRepositorySelect({ repositories, selectedRepository }) + syncFormForRepository() + setSubmitButtonLabel() + void loadBaseBranchesForSelectedRepository({ + preferredBranch: getFormValues().baseBranch, + }) + repositorySelect?.focus() + return + } + + abortPendingBranchesRequest() + } + + return { + abortPendingBranchesRequest, + loadBaseBranchesForSelectedRepository, + persistCurrentConfig, + renderBaseBranchOptions, + setOpen, + syncFormForRepository, + syncRepositories, + } +} diff --git a/src/modules/github/pr/drawer/controller/run-submit.js b/src/modules/github/pr/drawer/controller/run-submit.js new file mode 100644 index 0000000..b17fee7 --- /dev/null +++ b/src/modules/github/pr/drawer/controller/run-submit.js @@ -0,0 +1,313 @@ +export const createRunSubmit = ({ + state, + getSelectedRepositoryObject, + getRepositoryFullName, + getToken, + getCurrentActivePrContext, + getFormValues, + getRenderMode, + getStyleMode, + prTitleInput, + includeAppWrapperToggle, + getFileCommits, + getTopLevelDeclarations, + confirmBeforeSubmit, + onPullRequestOpened, + onPullRequestCommitPushed, + setStatus, + setPendingState, + setOpen, + setSubmitButtonLabel, + emitActivePrContextChange, + defaultCommitMessage, + normalizeRenderMode, + normalizeStyleMode, + normalizeFileCommits, + toSafeText, + sanitizeBranchPart, + buildSummary, + stripTopLevelAppWrapper, + ensureTrailingNewline, + commitEditorContentToExistingBranch, + createEditorContentPullRequest, + formatActivePrReference, + saveRepositoryPrConfig, +}) => { + return async () => { + const repository = getSelectedRepositoryObject() + const repositoryLabel = getRepositoryFullName(repository) + const token = getToken?.() + const activeContext = getCurrentActivePrContext() + const isPushCommitMode = Boolean(activeContext) + + if (!toSafeText(token)) { + setStatus( + isPushCommitMode + ? 'Add a GitHub token before pushing a commit.' + : 'Add a GitHub token before opening a pull request.', + 'error', + ) + return + } + + if (!repositoryLabel) { + setStatus( + isPushCommitMode + ? 'Select a writable repository before pushing a commit.' + : 'Select a writable repository before opening a pull request.', + 'error', + ) + return + } + + const values = getFormValues() + const targetBaseBranch = isPushCommitMode + ? toSafeText(activeContext?.baseBranch) + : values.baseBranch + const targetHeadBranch = isPushCommitMode + ? sanitizeBranchPart(activeContext?.headBranch) + : sanitizeBranchPart(values.headBranch) + const targetPrTitle = isPushCommitMode + ? toSafeText(activeContext?.prTitle) + : values.prTitle + const targetPrBody = isPushCommitMode + ? typeof activeContext?.prBody === 'string' + ? activeContext.prBody + : '' + : values.prBody + const currentRenderMode = normalizeRenderMode(getRenderMode?.()) + const currentStyleMode = normalizeStyleMode(getStyleMode?.()) + const targetCommitMessage = values.commitMessage || defaultCommitMessage + + if ( + !isPushCommitMode && + prTitleInput instanceof HTMLInputElement && + !prTitleInput.checkValidity() + ) { + prTitleInput.reportValidity() + return + } + + const includeAppWrapper = + includeAppWrapperToggle instanceof HTMLInputElement + ? includeAppWrapperToggle.checked + : false + + const { fileCommits: normalizedFileCommits, invalidPaths } = normalizeFileCommits( + typeof getFileCommits === 'function' + ? getFileCommits({ includeAllWorkspaceFiles: !isPushCommitMode }) + : [], + ) + + if (invalidPaths.length > 0) { + const maxInvalidPathsInMessage = 3 + const invalidPathDetails = invalidPaths + .slice(0, maxInvalidPathsInMessage) + .map(entry => { + const sourceLabel = entry.tabLabel ? `${entry.tabLabel}: ` : '' + return `${sourceLabel}${entry.path} (${entry.reason})` + }) + .join('; ') + const remainingCount = invalidPaths.length - maxInvalidPathsInMessage + const remainingSummary = remainingCount > 0 ? ` (+${remainingCount} more)` : '' + + setStatus( + `Commit blocked: invalid workspace file path${invalidPaths.length === 1 ? '' : 's'}. ${invalidPathDetails}${remainingSummary}`, + 'error', + ) + return + } + + if (normalizedFileCommits.length === 0) { + setStatus( + isPushCommitMode + ? 'No local editor changes to push.' + : 'No workspace files are available to commit.', + isPushCommitMode ? 'neutral' : 'error', + ) + return + } + + if (!isPushCommitMode && !targetBaseBranch) { + setStatus('Base branch is required.', 'error') + return + } + + if (!targetHeadBranch) { + setStatus( + isPushCommitMode + ? 'Active pull request context is missing a head branch. Close the context and open a new pull request.' + : 'Head branch name is required.', + 'error', + ) + return + } + + if (!targetPrTitle) { + setStatus( + isPushCommitMode + ? 'Active pull request context is missing a title. Close the context and open a new pull request.' + : 'Pull request title is required.', + 'error', + ) + return + } + + const summary = buildSummary({ + repository, + baseBranch: targetBaseBranch, + headBranch: targetHeadBranch, + fileCommits: normalizedFileCommits, + prTitle: targetPrTitle, + commitMessage: targetCommitMessage, + actionType: isPushCommitMode ? 'push-commit' : 'open-pr', + }) + + const fileUpdates = await Promise.all( + normalizedFileCommits.map(async fileCommit => { + const shouldStripEntryWrapper = !includeAppWrapper && fileCommit.isEntry + const nextContent = shouldStripEntryWrapper + ? await stripTopLevelAppWrapper({ + source: fileCommit.content, + getTopLevelDeclarations, + }) + : fileCommit.content + const content = ensureTrailingNewline(nextContent) + + return { + path: fileCommit.path, + content, + } + }), + ) + + const submitRequest = () => { + state.pendingAbortController?.abort() + const abortController = new AbortController() + state.pendingAbortController = abortController + + setPendingState(true) + setStatus( + isPushCommitMode + ? 'Committing editor files to active pull request branch...' + : 'Creating branch, committing editor files, and opening pull request...', + 'pending', + ) + + const runRequest = isPushCommitMode + ? commitEditorContentToExistingBranch({ + token, + repository, + branch: targetHeadBranch, + fileUpdates, + commitMessage: targetCommitMessage, + signal: abortController.signal, + }) + : createEditorContentPullRequest({ + token, + repository, + baseBranch: targetBaseBranch, + headBranch: targetHeadBranch, + prTitle: targetPrTitle, + prBody: targetPrBody, + fileUpdates, + commitMessage: targetCommitMessage, + signal: abortController.signal, + }) + + void Promise.resolve(runRequest) + .then(result => { + if (isPushCommitMode) { + const compactPullRequestReference = formatActivePrReference(activeContext) + const pullRequestUrl = toSafeText(activeContext?.pullRequestUrl) + const pullRequestTitle = toSafeText(activeContext?.prTitle) + const pullRequestReference = + compactPullRequestReference || + pullRequestUrl || + (pullRequestTitle ? `PR: ${pullRequestTitle}` : '') + + setStatus( + pullRequestReference + ? `Commit pushed to ${targetHeadBranch} (${pullRequestReference}).` + : `Commit pushed to ${targetHeadBranch}.`, + 'ok', + ) + onPullRequestCommitPushed?.({ + branch: targetHeadBranch, + fileUpdates: Array.isArray(result) ? result : [], + }) + setOpen(false) + return + } + + saveRepositoryPrConfig({ + repositoryFullName: repositoryLabel, + config: { + renderMode: currentRenderMode, + styleMode: currentStyleMode, + baseBranch: targetBaseBranch, + headBranch: targetHeadBranch, + prTitle: targetPrTitle, + prBody: targetPrBody, + isActivePr: true, + prContextState: 'active', + pullRequestNumber: result.pullRequest.number, + pullRequestUrl: result.pullRequest.htmlUrl, + }, + }) + + emitActivePrContextChange() + setSubmitButtonLabel() + + const url = result.pullRequest.htmlUrl + setStatus( + url ? `Pull request opened: ${url}` : 'Pull request opened successfully.', + 'ok', + ) + onPullRequestOpened?.({ + url, + pullRequestNumber: result.pullRequest.number, + branch: targetHeadBranch, + fileUpdates: Array.isArray(result.fileUpdates) ? result.fileUpdates : [], + }) + setOpen(false) + }) + .catch(error => { + if (abortController.signal.aborted) { + return + } + + const fallbackMessage = isPushCommitMode + ? 'Failed to push commit.' + : 'Failed to open pull request.' + const message = error instanceof Error ? error.message : fallbackMessage + setStatus( + isPushCommitMode + ? `Push commit failed: ${message}` + : `Open PR failed: ${message}`, + 'error', + ) + }) + .finally(() => { + if (state.pendingAbortController === abortController) { + state.pendingAbortController = null + } + setPendingState(false) + }) + } + + if (typeof confirmBeforeSubmit === 'function') { + confirmBeforeSubmit({ + title: isPushCommitMode + ? 'Push commit to active pull request branch?' + : 'Open pull request with editor content?', + copy: summary, + confirmButtonText: isPushCommitMode ? 'Push commit' : 'Open PR', + onConfirm: submitRequest, + }) + return + } + + submitRequest() + } +} diff --git a/src/modules/github/pr/drawer/controller/ui-state.js b/src/modules/github/pr/drawer/controller/ui-state.js new file mode 100644 index 0000000..6a867f5 --- /dev/null +++ b/src/modules/github/pr/drawer/controller/ui-state.js @@ -0,0 +1,195 @@ +export const createUiStateHandlers = ({ + state, + repositorySelect, + baseBranchInput, + headBranchInput, + prTitleInput, + prBodyInput, + commitMessageInput, + includeAppWrapperToggle, + submitButton, + titleNode, + statusNode, + drawer, + onActivePrContextChange, + onRestoreRenderMode, + onRestoreStyleMode, + normalizeRenderMode, + normalizeStyleMode, + toSafeText, + getCurrentActivePrContext, +}) => { + const syncModeFields = () => { + const isPushCommitMode = Boolean(getCurrentActivePrContext()) + + if (repositorySelect instanceof HTMLSelectElement) { + repositorySelect.disabled = state.submitting || isPushCommitMode + } + + if (baseBranchInput instanceof HTMLSelectElement) { + baseBranchInput.disabled = state.submitting || isPushCommitMode + } + + if (baseBranchInput instanceof HTMLInputElement) { + baseBranchInput.readOnly = isPushCommitMode + baseBranchInput.disabled = state.submitting + } + + if (headBranchInput instanceof HTMLInputElement) { + headBranchInput.readOnly = isPushCommitMode + headBranchInput.disabled = state.submitting + } + + if (prTitleInput instanceof HTMLInputElement) { + prTitleInput.required = !isPushCommitMode + prTitleInput.readOnly = isPushCommitMode + prTitleInput.disabled = state.submitting + } + + const prBodyField = prBodyInput?.closest('.github-pr-field') + if (prBodyField instanceof HTMLElement) { + prBodyField.hidden = isPushCommitMode + } + + if (prBodyInput instanceof HTMLTextAreaElement) { + prBodyInput.required = false + prBodyInput.disabled = state.submitting || isPushCommitMode + } + + if (includeAppWrapperToggle instanceof HTMLInputElement) { + includeAppWrapperToggle.disabled = state.submitting + } + + if (commitMessageInput instanceof HTMLInputElement) { + commitMessageInput.required = false + commitMessageInput.readOnly = false + commitMessageInput.disabled = state.submitting + } + } + + const setSubmitButtonLabel = ({ isPending = false } = {}) => { + if (!(submitButton instanceof HTMLButtonElement)) { + return + } + + const activeContext = getCurrentActivePrContext() + const isPushCommitMode = Boolean(activeContext) + + if (drawer instanceof HTMLElement) { + drawer.dataset.mode = isPushCommitMode ? 'push' : 'open' + } + + if (isPending) { + submitButton.textContent = isPushCommitMode ? 'Pushing commit...' : 'Opening PR...' + if (titleNode instanceof HTMLElement) { + titleNode.textContent = isPushCommitMode ? 'Push Commit' : 'Open Pull Request' + } + syncModeFields() + return + } + + submitButton.textContent = isPushCommitMode ? 'Push commit' : 'Open PR' + + if (titleNode instanceof HTMLElement) { + titleNode.textContent = isPushCommitMode ? 'Push Commit' : 'Open Pull Request' + } + + syncModeFields() + } + + const emitRenderModeRestore = activeContext => { + if (typeof onRestoreRenderMode !== 'function') { + return + } + + if (!activeContext) { + return + } + + const mode = normalizeRenderMode(activeContext?.renderMode) + onRestoreRenderMode(mode) + } + + const emitStyleModeRestore = activeContext => { + if (typeof onRestoreStyleMode !== 'function') { + return + } + + if (!activeContext) { + return + } + + const mode = normalizeStyleMode(activeContext?.styleMode) + onRestoreStyleMode(mode) + } + + const emitActivePrContextChange = () => { + if (typeof onActivePrContextChange !== 'function') { + return + } + + const activeContext = getCurrentActivePrContext() + onActivePrContextChange(activeContext) + emitRenderModeRestore(activeContext) + emitStyleModeRestore(activeContext) + } + + const setStatus = (text, level = 'neutral') => { + if (!statusNode) { + return + } + + statusNode.textContent = text + statusNode.dataset.level = level + } + + const setPendingState = isPending => { + state.submitting = isPending + + if (submitButton instanceof HTMLButtonElement) { + submitButton.disabled = isPending + submitButton.setAttribute('aria-busy', isPending ? 'true' : 'false') + submitButton.classList.toggle('render-button--loading', isPending) + setSubmitButtonLabel({ isPending }) + } + + for (const input of [ + repositorySelect, + baseBranchInput, + headBranchInput, + prTitleInput, + prBodyInput, + commitMessageInput, + includeAppWrapperToggle, + ]) { + if ( + input instanceof HTMLInputElement || + input instanceof HTMLSelectElement || + input instanceof HTMLTextAreaElement + ) { + input.disabled = isPending + } + } + + syncModeFields() + } + + const getFormValues = () => { + return { + baseBranch: toSafeText(baseBranchInput?.value), + headBranch: toSafeText(headBranchInput?.value), + prTitle: toSafeText(prTitleInput?.value), + prBody: typeof prBodyInput?.value === 'string' ? prBodyInput.value.trim() : '', + commitMessage: toSafeText(commitMessageInput?.value), + } + } + + return { + emitActivePrContextChange, + getFormValues, + setPendingState, + setStatus, + setSubmitButtonLabel, + syncModeFields, + } +} diff --git a/src/modules/github/pr/drawer/file-commits.js b/src/modules/github/pr/drawer/file-commits.js new file mode 100644 index 0000000..1bf0ec6 --- /dev/null +++ b/src/modules/github/pr/drawer/file-commits.js @@ -0,0 +1,97 @@ +import { toSafeText } from './common.js' + +const normalizeFilePath = value => + toSafeText(value).replace(/\\/g, '/').replace(/\/+/g, '/') + +const validateFilePath = value => { + const path = normalizeFilePath(value) + + if (!path) { + return { ok: false, reason: 'File path is required.' } + } + + if (path.startsWith('/')) { + return { + ok: false, + reason: 'File path must be repository-relative (no leading slash).', + } + } + + if (path.endsWith('/')) { + return { ok: false, reason: 'File path must include a filename (no trailing slash).' } + } + + const segments = path.split('/').filter(Boolean) + + if (segments.some(segment => segment === '..')) { + return { ok: false, reason: 'File path cannot include parent directory traversal.' } + } + + if (!/^[A-Za-z0-9._\-/]+$/.test(path)) { + return { + ok: false, + reason: + 'File path contains unsupported characters. Use letters, numbers, ., _, -, and / only.', + } + } + + if (segments.length === 0 || segments.some(segment => segment === '.' || !segment)) { + return { ok: false, reason: 'File path is invalid.' } + } + + return { ok: true, value: path } +} + +const normalizeFileCommits = fileCommits => { + if (!Array.isArray(fileCommits)) { + return { + fileCommits: [], + invalidPaths: [], + } + } + + const dedupedByPath = new Map() + const invalidPathsByKey = new Map() + + for (const item of fileCommits) { + const pathValidation = validateFilePath(item?.path) + if (!pathValidation.ok) { + const rawPath = toSafeText(item?.path) + const tabLabel = toSafeText(item?.tabLabel) + const displayPath = rawPath || '(missing path)' + const key = `${displayPath}|${pathValidation.reason}` + + if (!invalidPathsByKey.has(key)) { + invalidPathsByKey.set(key, { + path: displayPath, + tabLabel, + reason: pathValidation.reason, + }) + } + + continue + } + + dedupedByPath.set(pathValidation.value, { + path: pathValidation.value, + content: typeof item?.content === 'string' ? item.content : '', + tabLabel: toSafeText(item?.tabLabel), + isEntry: item?.isEntry === true, + }) + } + + return { + fileCommits: [...dedupedByPath.values()], + invalidPaths: [...invalidPathsByKey.values()], + } +} + +const ensureTrailingNewline = value => { + if (typeof value !== 'string' || value.length === 0 || value.endsWith('\n')) { + return value + } + + return `${value}\n` +} + +export { ensureTrailingNewline, normalizeFileCommits } diff --git a/src/modules/github/pr/drawer/source-transform.js b/src/modules/github/pr/drawer/source-transform.js new file mode 100644 index 0000000..389cf94 --- /dev/null +++ b/src/modules/github/pr/drawer/source-transform.js @@ -0,0 +1,76 @@ +import { + isFunctionLikeDeclaration, + isFunctionLikeVariableInitializer, +} from '../../../preview/jsx-top-level-declarations.js' + +const mergeWhitespaceAroundRemoval = value => value.replace(/\n{3,}/g, '\n\n') + +const isSourceRange = value => + Array.isArray(value) && + value.length === 2 && + Number.isInteger(value[0]) && + Number.isInteger(value[1]) + +const isRemovableAppDeclaration = declaration => { + if (!declaration || declaration.name !== 'App') { + return false + } + + if (!isFunctionLikeDeclaration(declaration)) { + return false + } + + if (declaration.kind !== 'variable') { + return true + } + + return isFunctionLikeVariableInitializer(declaration) +} + +const removeRanges = ({ source, ranges }) => { + const sortedRanges = ranges.slice().sort((first, second) => second[0] - first[0]) + let output = source + + for (const [start, end] of sortedRanges) { + if (start < 0 || end < start || end > output.length) { + continue + } + + output = `${output.slice(0, start)}${output.slice(end)}` + } + + return output +} + +const stripTopLevelAppWrapper = async ({ source, getTopLevelDeclarations }) => { + if (typeof source !== 'string' || !source.trim()) { + return '' + } + + if (typeof getTopLevelDeclarations !== 'function') { + return source + } + + try { + const declarations = await getTopLevelDeclarations(source) + + if (!Array.isArray(declarations)) { + return source + } + + const ranges = declarations + .filter(isRemovableAppDeclaration) + .map(declaration => declaration.statementRange) + .filter(isSourceRange) + + if (ranges.length === 0) { + return source + } + + return mergeWhitespaceAroundRemoval(removeRanges({ source, ranges })) + } catch { + return source + } +} + +export { stripTopLevelAppWrapper } diff --git a/src/modules/github/pr/drawer/summary.js b/src/modules/github/pr/drawer/summary.js new file mode 100644 index 0000000..93f7101 --- /dev/null +++ b/src/modules/github/pr/drawer/summary.js @@ -0,0 +1,49 @@ +import { toSafeText } from './common.js' + +const buildSummary = ({ + repository, + baseBranch, + headBranch, + fileCommits, + prTitle, + commitMessage, + actionType, +}) => { + const repositoryLabel = toSafeText(repository?.fullName) || 'No repository selected' + const isPushCommit = actionType === 'push-commit' + + const lines = [ + `Repository: ${repositoryLabel}`, + `Head branch: ${headBranch}`, + `PR title: ${prTitle}`, + `Commit message: ${commitMessage}`, + ] + + if (Array.isArray(fileCommits) && fileCommits.length > 0) { + lines.push('Files to commit:') + for (const fileCommit of fileCommits) { + const path = toSafeText(fileCommit?.path) + if (!path) { + continue + } + + const tabLabel = toSafeText(fileCommit?.tabLabel) + lines.push(tabLabel ? `- ${tabLabel} -> ${path}` : `- ${path}`) + } + } + + if (!isPushCommit) { + lines.splice(1, 0, `Base branch: ${baseBranch}`) + } + + lines.push('') + lines.push( + isPushCommit + ? 'Proceed with committing editor content to the active pull request branch?' + : 'Proceed with creating commits and opening this pull request?', + ) + + return lines.join('\n') +} + +export { buildSummary } diff --git a/src/modules/github/github-pr-editor-sync.js b/src/modules/github/pr/editor-sync.js similarity index 97% rename from src/modules/github/github-pr-editor-sync.js rename to src/modules/github/pr/editor-sync.js index b9e3358..7828174 100644 --- a/src/modules/github/github-pr-editor-sync.js +++ b/src/modules/github/pr/editor-sync.js @@ -1,4 +1,4 @@ -import { getRepositoryFileContent } from './github-api.js' +import { getRepositoryFileContent } from '../api/repository-files.js' const toSafeText = value => (typeof value === 'string' ? value.trim() : '') diff --git a/src/modules/github/github-token-store.js b/src/modules/github/token-store.js similarity index 100% rename from src/modules/github/github-token-store.js rename to src/modules/github/token-store.js