diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4df2f85..907997c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -112,7 +112,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v6 with: - node-version: '20' + node-version: '24' cache: 'npm' - name: Install Dependencies diff --git a/README.md b/README.md index 11b70ac..2ee259f 100644 --- a/README.md +++ b/README.md @@ -5,9 +5,8 @@ # FigGit [![CI Status](https://github.com/findyourexit/figgit/workflows/CI/badge.svg)](https://github.com/findyourexit/figgit/actions) -[![Test Coverage](https://img.shields.io/badge/coverage-94.51%25-brightgreen)](https://github.com/findyourexit/figgit) [![TypeScript](https://img.shields.io/badge/TypeScript-5.4-blue)](https://www.typescriptlang.org/) -![Node](https://img.shields.io/badge/node-%3E%3D20-brightgreen) +![Node](https://img.shields.io/badge/node-%3E%3D24-brightgreen) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) @@ -125,7 +124,7 @@ Example DTCG export: "com.figma": { "exportedAt": "2025-01-15T10:30:00.000Z", "fileName": "Design System", - "pluginVersion": "0.2.0", + "pluginVersion": "0.3.1", "collectionsCount": 3, "variablesCount": 42, "contentHash": "sha256:a1b2c3..." @@ -174,7 +173,7 @@ Example Figma-native export (per collection): "contentHash": "c19f6a...", "exportedAt": "2025-12-03T23:21:11.219Z", "fileName": "Katalyst Design System", - "pluginVersion": "0.2.0", + "pluginVersion": "0.3.1", "exportFormat": "figma-native", "exportType": "perCollection", "collectionId": "889:34", @@ -231,7 +230,7 @@ Use **single-file** export when you want one consolidated JSON, or switch to **p ### Requirements - **Figma Desktop** (plugin API not available in browser version) -- **Node.js 20+** (for building the plugin) +- **Node.js 24+** (for building the plugin) - **GitHub Repository** with write access - **GitHub Personal Access Token** (fine-grained or classic with `repo` scope) @@ -357,7 +356,6 @@ Enable dry run to: - Test configuration without committing - Preview what would be committed - Verify diff calculation -- Preserve last known hash state ### Automatic Branch Creation diff --git a/package.json b/package.json index be0bbfd..b235bc5 100644 --- a/package.json +++ b/package.json @@ -4,6 +4,9 @@ "description": "A Figma plugin for exporting variables to GitHub.", "author": "Tom Larcher ", "license": "MIT", + "engines": { + "node": ">=24" + }, "keywords": [ "figma", "figma-plugin", diff --git a/scripts/build.mjs b/scripts/build.mjs deleted file mode 100644 index f158c05..0000000 --- a/scripts/build.mjs +++ /dev/null @@ -1,78 +0,0 @@ -import { build, context } from 'esbuild'; -import { readFileSync, writeFileSync } from 'fs'; -import path from 'path'; - -const isWatch = process.argv.includes('--watch'); - -const outdir = 'dist'; - -const shared = { - bundle: true, - sourcemap: !isWatch, - minify: !isWatch, - target: 'es2017', - logLevel: 'info', -}; - -async function buildAll() { - await build({ - ...shared, - entryPoints: ['src/plugin.ts'], - outfile: path.join(outdir, 'plugin.js'), - platform: 'browser', - minify: false, // Don't minify plugin code - Figma's parser is strict - }); - await build({ - ...shared, - entryPoints: ['src/ui/index.tsx'], - outfile: path.join(outdir, 'ui.js'), - platform: 'browser', - target: 'es2015', // Lower target for UI to ensure browser compatibility - write: true, // Write the bundle file - }); - - // Read the generated JS to inline it - const uiJs = readFileSync(path.join(outdir, 'ui.js'), 'utf8'); - - // Inline JavaScript directly in HTML to avoid CSP issues - const html = ` - - - -FigGit - - - -
- - - -`; - writeFileSync(path.join(outdir, 'ui.html'), html, 'utf8'); -} - -async function run() { - if (!isWatch) { - await buildAll(); - return; - } - const ctx = await context({ - ...shared, - entryPoints: ['src/plugin.ts', 'src/ui/index.tsx'], - outdir, - platform: 'browser', - }); - await ctx.watch(); - // Rebuild HTML & CSS on change (simple approach: watch manually not added here) - console.log('Watching...'); -} - -run(); diff --git a/src/export/buildDtcgJson.ts b/src/export/buildDtcgJson.ts index f891a94..f21c345 100644 --- a/src/export/buildDtcgJson.ts +++ b/src/export/buildDtcgJson.ts @@ -24,9 +24,7 @@ import { import { stableStringify } from '../util/stableStringify'; import { sha256 } from './hash'; import { normalizeModeValue } from './valueNormalization'; - -/** Plugin version - can be replaced during build via define if desired */ -const PLUGIN_VERSION = '0.2.0'; +import { PLUGIN_VERSION } from '../constants'; /** * Minimal shape for a collection mode to avoid implicit any. diff --git a/src/github/githubClient.ts b/src/github/githubClient.ts index c36a489..1e49427 100644 --- a/src/github/githubClient.ts +++ b/src/github/githubClient.ts @@ -29,20 +29,6 @@ interface GitHubFile { html_url: string; } -/** - * Result of a file upsert operation. - */ -export interface UpsertResult { - /** True if file was created or updated */ - updated: boolean; - /** True if operation was skipped due to identical content */ - skipped: boolean; - /** GitHub HTML URL to view the file */ - url?: string; - /** Git commit SHA if file was updated */ - commitSha?: string; -} - export interface FileCommitPayload { /** File path within the repository */ path: string; @@ -60,6 +46,13 @@ export interface CommitFilesOptions { commitMessage: string; files: FileCommitPayload[]; baseBranch?: string; + /** + * Pre-fetched embedded content hashes keyed by repo-relative path. When a + * path is present, its hash is used for change detection instead of issuing + * another contents request. Only supply hashes that are valid for the commit + * target branch. + */ + knownContentHashes?: Record; } export interface CommitFilesResult { @@ -70,30 +63,6 @@ export interface CommitFilesResult { commitSha?: string; } -/** - * Options for upserting a file to GitHub. - */ -export interface GitHubUpsertOptions { - /** GitHub username or organization name */ - owner: string; - /** Repository name */ - repo: string; - /** Target branch name */ - branch: string; - /** File path within repository (e.g., 'folder/filename.json') */ - path: string; - /** File content as raw string (will be Base64-encoded) */ - content: string; - /** GitHub Personal Access Token */ - token: string; - /** Commit message for the change */ - commitMessage: string; - /** SHA-256 content hash for change detection (not Git blob hash) */ - currentHash: string; - /** Optional base branch to create target branch from when missing */ - baseBranch?: string; -} - /** * Makes an authenticated GitHub API request with automatic retry. * @@ -121,10 +90,17 @@ async function ghFetch(url: string, token: string, init: RequestInit = {}) { init.headers = { ...headers, ...(init.headers as Record) }; - // Retry network requests with exponential backoff + // Retry network requests with exponential backoff. return withRetry( async () => { - return await fetch(url, init); + const res = await fetch(url, init); + // Throw on transient statuses so withRetry retries them with backoff. + // Non-transient statuses (including 401/403/404/409/422) are returned + // so callers can inspect and handle them directly. + if (isTransientStatus(res.status)) { + throw new Error(`GitHub request failed with transient status ${res.status}`); + } + return res; }, { maxAttempts: 3, @@ -133,6 +109,14 @@ async function ghFetch(url: string, token: string, init: RequestInit = {}) { ); } +/** + * Returns true for HTTP statuses worth retrying: rate limiting (429) and + * transient server errors (5xx). + */ +function isTransientStatus(status: number): boolean { + return status === 429 || (status >= 500 && status <= 599); +} + export async function branchExists( owner: string, repo: string, @@ -246,7 +230,7 @@ async function getExistingFile( * @param str - String to encode * @returns Base64-encoded string */ -function toBase64(str: string): string { +export function toBase64(str: string): string { const base64Chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'; // Convert UTF-16 string to UTF-8 bytes @@ -413,127 +397,9 @@ function extractEmbeddedHashFromJsonContent(content: string): string | undefined return undefined; } -/** - * Creates or updates a file in a GitHub repository. - * - * Features: - * - Automatically creates branch if it doesn't exist - * - Detects if content has changed using content hash - * - Skips commit if content is identical (idempotent) - * - Automatically retries once on 409 conflicts - * - Handles create and update in one operation - * - * Change detection: - * 1. Fetches existing file (if any) - * 2. Checks embedded meta.contentHash against current hash - * 3. Skips commit if hashes match - * 4. Otherwise, creates/updates file - * - * @param options - File upsert configuration - * @returns Result indicating whether file was updated or skipped - * @throws Error if unable to create/update file - */ -export async function upsertFile(options: GitHubUpsertOptions): Promise { - const { owner, repo, branch, path, content, token, commitMessage, currentHash, baseBranch } = - options; - - // Ensure target branch exists (create from default branch if needed) - await ensureBranch(owner, repo, branch, token, baseBranch); - - // Check if file already exists - const existing = await getExistingFile(owner, repo, branch, path, token); - - if (existing) { - try { - const decoded = fromBase64(existing.content); - const embeddedHash = extractEmbeddedHashFromJsonContent(decoded); - - // If hashes match, content is identical - skip commit - if (embeddedHash && embeddedHash === currentHash) { - return { updated: false, skipped: true, url: existing.html_url }; - } - - // If no embedded hash (legacy file), proceed with update - // The hash will be added after first update - } catch { - // Ignore Base64 decoding errors - proceed with update - } - } - - // Prepare commit payload - const body = { - message: commitMessage, - content: toBase64(content), - branch, - sha: existing ? existing.sha : undefined, // SHA required for updates - }; - - /** - * Attempts to write the file to GitHub. - * - * Handles 409 conflicts by refetching and retrying once. - * This handles race conditions where another process updated the file. - * - * @param prevExisting - Previously fetched file metadata - * @param attempt - Attempt number (0 = first try, 1 = retry) - * @returns Upsert result - */ - async function attemptWrite( - prevExisting: GitHubFile | null, - attempt: number - ): Promise { - const putRes = await ghFetch( - `https://api.github.com/repos/${owner}/${repo}/contents/${encodeURIComponent(path)}`, - token, - { - method: 'PUT', - body: JSON.stringify({ ...body, sha: prevExisting ? prevExisting.sha : body.sha }), - } - ); - - // Handle 409 conflict (file was updated by someone else) - if (putRes.status === 409 && attempt === 0) { - // Refetch latest version and retry once - const latest = await getExistingFile(owner, repo, branch, path, token); - - if (latest) { - // Before retrying, check if content is still identical - try { - const decoded = fromBase64(latest.content); - const embedded = extractEmbeddedHashFromJsonContent(decoded); - - if (embedded && embedded === currentHash) { - // Content matches - someone else already committed the same change - return { updated: false, skipped: true, url: latest.html_url }; - } - } catch { - // Ignore errors - proceed with retry - } - } - - // Retry with latest SHA - return attemptWrite(latest, 1); - } - - if (!putRes.ok) { - throw new Error(`Failed to write file: ${putRes.status}`); - } - - const result = await putRes.json(); - return { - updated: true, - skipped: false, - url: result.content?.html_url, - commitSha: result.commit?.sha, - }; - } - - // Start the write attempt - return attemptWrite(existing, 0); -} - export async function commitFiles(options: CommitFilesOptions): Promise { - const { owner, repo, branch, token, commitMessage, files, baseBranch } = options; + const { owner, repo, branch, token, commitMessage, files, baseBranch, knownContentHashes } = + options; if (!files.length) { return { updated: false, skipped: true, updatedPaths: [] }; @@ -541,33 +407,86 @@ export async function commitFiles(options: CommitFilesOptions): Promise; + // Blobs are content-addressed and independent of the branch head, so they are + // created once and reused across conflict retries. + const treeEntries: Array<{ path: string; mode: string; type: string; sha: string }> = []; for (const file of filesToUpdate) { const blobSha = await createBlob(owner, repo, token, file.content); treeEntries.push({ path: file.path, mode: '100644', type: 'blob', sha: blobSha }); } - const treeSha = await createTree(owner, repo, token, headInfo.treeSha, treeEntries); - const commit = await createCommit(owner, repo, token, commitMessage, treeSha, headInfo.commitSha); - await updateBranchRef(owner, repo, branch, token, commit.sha); + const commitSha = await commitTreeWithConflictRetry( + owner, + repo, + branch, + token, + commitMessage, + treeEntries + ); return { updated: true, skipped: false, updatedPaths: filesToUpdate.map((file) => file.path), - url: `https://github.com/${owner}/${repo}/commit/${commit.sha}`, - commitSha: commit.sha, + url: `https://github.com/${owner}/${repo}/commit/${commitSha}`, + commitSha, }; } +/** + * Creates a tree and commit on the current branch HEAD, then fast-forwards the + * branch ref to the new commit. + * + * If the ref update fails because the branch advanced concurrently (a 409/422 + * conflict), the branch HEAD is re-read and the commit is rebuilt on the new + * HEAD once before giving up. The (content-addressed) blobs are reused. + */ +async function commitTreeWithConflictRetry( + owner: string, + repo: string, + branch: string, + token: string, + commitMessage: string, + treeEntries: Array<{ path: string; mode: string; type: string; sha: string }>, + attempt = 0 +): Promise { + const headInfo = await getHeadInfo(owner, repo, branch, token); + const treeSha = await createTree(owner, repo, token, headInfo.treeSha, treeEntries); + const commit = await createCommit(owner, repo, token, commitMessage, treeSha, headInfo.commitSha); + + try { + await updateBranchRef(owner, repo, branch, token, commit.sha); + return commit.sha; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (attempt === 0 && /\b(409|422)\b/.test(message)) { + return commitTreeWithConflictRetry( + owner, + repo, + branch, + token, + commitMessage, + treeEntries, + attempt + 1 + ); + } + throw error; + } +} + export interface RemoteHashLookupOptions { owner: string; repo: string; @@ -612,22 +531,22 @@ async function filterFilesNeedingUpdate( repo: string, branch: string, token: string, - files: FileCommitPayload[] + files: FileCommitPayload[], + knownContentHashes?: Record ): Promise { const updates: FileCommitPayload[] = []; for (const file of files) { - const existing = await getExistingFile(owner, repo, branch, file.path, token); - if (existing) { - try { - const decoded = fromBase64(existing.content); - const embeddedHash = extractEmbeddedHashFromJsonContent(decoded); - if (embeddedHash && embeddedHash === file.contentHash) { - continue; - } - } catch { - // Ignore parse errors and include file for update - } + const embeddedHash = await resolveEmbeddedHash( + owner, + repo, + branch, + token, + file.path, + knownContentHashes + ); + if (embeddedHash && embeddedHash === file.contentHash) { + continue; } updates.push(file); } @@ -635,6 +554,32 @@ async function filterFilesNeedingUpdate( return updates; } +/** + * Resolves the embedded content hash for a path, preferring a pre-fetched hash + * to avoid a redundant contents request. + */ +async function resolveEmbeddedHash( + owner: string, + repo: string, + branch: string, + token: string, + path: string, + knownContentHashes?: Record +): Promise { + if (knownContentHashes && Object.prototype.hasOwnProperty.call(knownContentHashes, path)) { + return knownContentHashes[path]; + } + + const existing = await getExistingFile(owner, repo, branch, path, token); + if (!existing) return null; + try { + const decoded = fromBase64(existing.content); + return extractEmbeddedHashFromJsonContent(decoded); + } catch { + return undefined; + } +} + async function getHeadInfo( owner: string, repo: string, @@ -739,6 +684,6 @@ async function updateBranchRef( } ); if (!res.ok) { - throw new Error('Failed to update branch reference'); + throw new Error(`Failed to update branch reference: ${res.status}`); } } diff --git a/src/messaging.ts b/src/messaging.ts index bb288ff..8616624 100644 --- a/src/messaging.ts +++ b/src/messaging.ts @@ -2,7 +2,7 @@ * Message type definitions for communication between UI and Plugin. * * This module defines the message protocol for postMessage-based communication - * between the React UI (running in an iframe) and the plugin code (running in + * between the Preact UI (running in an iframe) and the plugin code (running in * the Figma sandbox). All messages are fully typed for compile-time safety. * * Communication is bidirectional: @@ -24,7 +24,6 @@ import type { ExportBundle, ExportFormat, ExportType } from './types/export'; * - VALIDATE_TOKEN: Test if stored token is valid * - COMMIT_REQUEST: Commit exported JSON to GitHub * - FETCH_REMOTE_EXPORT: Fetch existing JSON from GitHub for diff - * - COPY_TO_CLIPBOARD: Copy text to system clipboard * - PING: Simple connectivity test */ export type UIToPluginMessage = @@ -41,7 +40,6 @@ export type UIToPluginMessage = commitPrefix: string; } | { type: 'FETCH_REMOTE_EXPORT'; files: string[] } - | { type: 'COPY_TO_CLIPBOARD'; text: string } // Copy text to clipboard | { type: 'NOTIFY'; level: 'info' | 'error'; message: string } // Display notification in Figma UI | { type: 'PING' }; @@ -96,10 +94,6 @@ export interface PersistedSettings { commitPrefix?: string; /** Dry run mode - test without actually committing */ dryRun?: boolean; - /** Last known content hash for change detection */ - lastHash?: string; - /** Map of repo-relative file paths to last known content hashes */ - lastHashes?: Record; /** Preferred export format */ exportFormat?: ExportFormat; /** Export type for figma-native format */ @@ -129,6 +123,5 @@ export function defaultSettings(): PersistedSettings { dryRun: false, exportFormat: 'dtcg', exportType: 'singleFile', - lastHashes: {}, }; } diff --git a/src/plugin.ts b/src/plugin.ts index 5ab13a0..4265bea 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -12,7 +12,7 @@ import { buildExportBundle } from './export/buildExportBundle'; import { SETTINGS_KEY, defaultSettings, UIToPluginMessage, PersistedSettings } from './messaging'; import { commitFiles, fromBase64, branchExists, getRemoteFileHashes } from './github/githubClient'; import { stableStringify } from './util/stableStringify'; -import { buildRepoPath } from './util/path'; +import { resolveBaseBranch } from './util/branchPlan'; import { validateAllSettings } from './util/validation'; type CommitRequestMessage = Extract; @@ -122,25 +122,30 @@ async function handleCommitRequest(msg: CommitRequestMessage) { return; } - const storedHashes = getLastHashMap(settings); - const owner = settings.owner.trim(); const repo = settings.repo.trim(); const targetBranch = settings.branch.trim(); - const fallbackBranch = settings.defaultBranch?.trim() || targetBranch; + // When no default branch is configured, `baseBranch` is undefined so the + // GitHub client falls back to the repository's actual default branch when + // creating the target branch. + const baseBranch = resolveBaseBranch(settings.defaultBranch); + const configuredDefault = baseBranch ?? ''; const docPaths = Array.from(new Set(exportBundle.documents.map((doc) => doc.relativePath))); const targetExists = await branchExists(owner, repo, targetBranch, token); let diffBranch: string | null = targetBranch; if (!targetExists) { - if (fallbackBranch && fallbackBranch !== targetBranch) { - const fallbackExists = await branchExists(owner, repo, fallbackBranch, token); + if (configuredDefault && configuredDefault !== targetBranch) { + const fallbackExists = await branchExists(owner, repo, configuredDefault, token); if (!fallbackExists) { - throw new Error(`Default branch "${fallbackBranch}" not found in ${owner}/${repo}`); + throw new Error(`Default branch "${configuredDefault}" not found in ${owner}/${repo}`); } - diffBranch = fallbackBranch; + diffBranch = configuredDefault; } else { + // Target branch does not exist yet and no distinct default branch is + // configured. commitFiles will create it from the repository default, + // so there is nothing to diff against on the (missing) target branch. diffBranch = null; } } @@ -187,18 +192,11 @@ async function handleCommitRequest(msg: CommitRequestMessage) { token, commitMessage, files, - baseBranch: fallbackBranch, - }); - - const nextHashes = { ...storedHashes }; - for (const doc of exportBundle.documents) { - nextHashes[doc.relativePath] = doc.contentHash; - } - - await saveSettings({ - ...settings, - lastHashes: nextHashes, - lastHash: exportBundle.summary.contentHash, + baseBranch, + // Reuse the hashes already fetched for the skip decision when they were + // read from the commit target branch, avoiding a second round of + // contents requests inside commitFiles. + knownContentHashes: diffBranch === targetBranch ? remoteHashes : undefined, }); figma.ui.postMessage({ @@ -305,15 +303,6 @@ function formatGitHubError(message: string): string { return message; } -function getLastHashMap(settings: PersistedSettings): Record { - const map = { ...(settings.lastHashes || {}) }; - if (!Object.keys(map).length && settings.lastHash) { - const legacyPath = buildRepoPath(settings.folder, settings.filename); - map[legacyPath] = settings.lastHash; - } - return map; -} - figma.ui.onmessage = async (msg: UIToPluginMessage) => { switch (msg.type) { case 'REQUEST_EXPORT': @@ -361,8 +350,6 @@ figma.ui.onmessage = async (msg: UIToPluginMessage) => { case 'FETCH_REMOTE_EXPORT': await handleFetchRemoteExport(msg.files); break; - case 'COPY_TO_CLIPBOARD': - break; case 'NOTIFY': figma.notify(msg.message, { error: msg.level === 'error' }); break; diff --git a/src/ui/components/preview/DiffViewer.tsx b/src/ui/components/preview/DiffViewer.tsx index beb823f..d7712cf 100644 --- a/src/ui/components/preview/DiffViewer.tsx +++ b/src/ui/components/preview/DiffViewer.tsx @@ -53,6 +53,16 @@ export const DiffViewer: FunctionComponent = () => { fetchRemoteData, ]); + // NOTE: hooks must run unconditionally and in a stable order on every render, + // so this memo is declared before any early return below. + const diffs = useMemo(() => { + if (!exportState.data) return []; + return exportState.data.documents.map((doc) => { + const remote = remoteDataState.files.find((file) => file.path === doc.relativePath); + return computeDocumentDiff(doc, remote); + }); + }, [exportState.data, remoteDataState.files]); + if (!exportState.data) { return ( }> @@ -73,14 +83,6 @@ export const DiffViewer: FunctionComponent = () => { ); } - const diffs = useMemo(() => { - if (!exportState.data) return []; - return exportState.data.documents.map((doc) => { - const remote = remoteDataState.files.find((file) => file.path === doc.relativePath); - return computeDocumentDiff(doc, remote); - }); - }, [exportState.data, remoteDataState.files]); - const hasAnyChanges = diffs.some( (diff) => diff.remoteMissing || diff --git a/src/ui/context/PluginContext.tsx b/src/ui/context/PluginContext.tsx index 577111b..f0dce34 100644 --- a/src/ui/context/PluginContext.tsx +++ b/src/ui/context/PluginContext.tsx @@ -6,12 +6,26 @@ */ import { h, FunctionComponent, createContext } from 'preact'; -import { useContext, useState, useEffect, useCallback } from 'preact/hooks'; +import { useContext, useState, useEffect, useCallback, useRef } from 'preact/hooks'; import { UIToPluginMessage, PluginToUIMessage, PersistedSettings } from '../../messaging'; import { ExportBundle } from '../../types/export'; export type NotificationType = 'success' | 'error' | 'warning' | 'info'; +/** + * Builds a signature of the settings that shape an export. When any of these + * change, the current export bundle is stale and must be regenerated so the + * preview, diff, and commit all use the new format/paths. + */ +function exportSignature(settings: PersistedSettings): string { + return [ + settings.exportFormat || 'dtcg', + settings.exportType || 'singleFile', + settings.filename || '', + settings.folder || '', + ].join('|'); +} + export interface ExportState { loading: boolean; data?: ExportBundle; @@ -97,6 +111,7 @@ export const PluginProvider: FunctionComponent = ({ childre loading: false, files: [], }); + const lastExportSignature = useRef(null); const sendMessage = (message: UIToPluginMessage) => { parent.postMessage({ pluginMessage: message }, '*'); @@ -219,15 +234,26 @@ export const PluginProvider: FunctionComponent = ({ childre return () => window.removeEventListener('message', handleMessage); }, []); - // Auto-export when settings are loaded (eliminates UX friction) + // Auto-export on load and whenever an export-shaping setting changes + // (format, document strategy, filename, folder). A short debounce collapses + // rapid edits (such as typing a filename) into a single export. useEffect(() => { - if (settings && !exportState.loading && !exportState.data && !exportState.error) { - // Start export automatically in the background - setTimeout(() => { - setExportState({ loading: true }); - sendMessage({ type: 'REQUEST_EXPORT' }); - }, 100); - } + if (!settings || exportState.loading) return; + + const signature = exportSignature(settings); + const signatureChanged = lastExportSignature.current !== signature; + // Export when there is no result yet, or when the inputs that shape the + // export have changed since the last export. Do not retry automatically + // after an error unless the inputs changed. + const needsExport = signatureChanged || (!exportState.data && !exportState.error); + if (!needsExport) return; + + const timer = setTimeout(() => { + lastExportSignature.current = signature; + setExportState({ loading: true }); + sendMessage({ type: 'REQUEST_EXPORT' }); + }, 100); + return () => clearTimeout(timer); }, [settings, exportState.data, exportState.loading, exportState.error]); return ( diff --git a/src/util/branchPlan.ts b/src/util/branchPlan.ts new file mode 100644 index 0000000..845359d --- /dev/null +++ b/src/util/branchPlan.ts @@ -0,0 +1,20 @@ +/** + * Branch resolution helpers for commit planning. + */ + +/** + * Resolves the base branch used to create the target branch from. + * + * When the user has not configured an explicit default branch, this returns + * `undefined` so the GitHub client falls back to the repository's actual + * default branch. It must never fall back to the target branch itself: doing + * so makes branch creation impossible when the target branch does not yet + * exist (the client would try to read a ref that is not there). + * + * @param configuredDefaultBranch - The user-configured default branch (optional) + * @returns The trimmed default branch, or `undefined` when none is configured + */ +export function resolveBaseBranch(configuredDefaultBranch: string | undefined): string | undefined { + const trimmed = configuredDefaultBranch?.trim(); + return trimmed ? trimmed : undefined; +} diff --git a/src/util/retry.ts b/src/util/retry.ts index 8a7909d..35c348e 100644 --- a/src/util/retry.ts +++ b/src/util/retry.ts @@ -44,8 +44,9 @@ function defaultShouldRetry(error: Error, _attempt: number): boolean { return true; } - // Retry on 5xx server errors and 429 rate limiting - if (errorMsg.includes('50') || errorMsg.includes('429') || errorMsg.includes('rate limit')) { + // Retry on 5xx server errors and 429 rate limiting. Match whole status + // codes so unrelated numbers (e.g. "50" in a path) do not trigger retries. + if (/\b(5\d{2}|429)\b/.test(errorMsg) || errorMsg.includes('rate limit')) { return true; } diff --git a/tests/base64.test.ts b/tests/base64.test.ts new file mode 100644 index 0000000..bce03c3 --- /dev/null +++ b/tests/base64.test.ts @@ -0,0 +1,31 @@ +import { describe, it, expect } from 'vitest'; +import { toBase64, fromBase64 } from '../src/github/githubClient'; + +describe('base64 codec (sandbox-safe pure JS)', () => { + const cases: Array<{ name: string; input: string }> = [ + { name: 'empty string', input: '' }, + { name: 'ascii', input: 'hello world' }, + { name: 'json', input: JSON.stringify({ a: 1, b: 'two', c: [3, 4] }) }, + { name: '2-byte sequence', input: 'café résumé' }, + { name: '3-byte sequence (CJK)', input: '设计令牌 デザイン' }, + { name: '4-byte sequence (emoji / surrogate pairs)', input: '🎨🚀✨ tokens' }, + { name: 'mixed widths', input: 'a©你🎉z' }, + ]; + + it('encodes to standard base64 that matches the platform encoder for ASCII', () => { + // For ASCII, our encoder must agree with btoa. + expect(toBase64('hello world')).toBe(btoa('hello world')); + }); + + for (const { name, input } of cases) { + it(`round-trips ${name}`, () => { + expect(fromBase64(toBase64(input))).toBe(input); + }); + } + + it('decodes base64 containing whitespace/newlines (as returned by the contents API)', () => { + const encoded = toBase64('the quick brown fox jumps over the lazy dog'); + const chunked = encoded.replace(/(.{8})/g, '$1\n'); + expect(fromBase64(chunked)).toBe('the quick brown fox jumps over the lazy dog'); + }); +}); diff --git a/tests/branchPlan.test.ts b/tests/branchPlan.test.ts new file mode 100644 index 0000000..4fdf2b2 --- /dev/null +++ b/tests/branchPlan.test.ts @@ -0,0 +1,17 @@ +import { describe, it, expect } from 'vitest'; +import { resolveBaseBranch } from '../src/util/branchPlan'; + +describe('resolveBaseBranch', () => { + it('returns undefined when no default branch is configured', () => { + // Regression: a blank default branch must NOT collapse to the target branch. + // It must be undefined so the client uses the repository's real default. + expect(resolveBaseBranch(undefined)).toBeUndefined(); + expect(resolveBaseBranch('')).toBeUndefined(); + expect(resolveBaseBranch(' ')).toBeUndefined(); + }); + + it('returns the trimmed default branch when configured', () => { + expect(resolveBaseBranch('main')).toBe('main'); + expect(resolveBaseBranch(' develop ')).toBe('develop'); + }); +}); diff --git a/tests/dtcgExport.test.ts b/tests/dtcgExport.test.ts index 77e5908..1cecc83 100644 --- a/tests/dtcgExport.test.ts +++ b/tests/dtcgExport.test.ts @@ -11,6 +11,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { buildDtcgJson } from '../src/export/buildDtcgJson'; import type { DtcgRoot } from '../src/shared/dtcg-types'; import { isDtcgToken, isDtcgGroup } from '../src/shared/dtcg-types'; +import { PLUGIN_VERSION } from '../src/constants'; // Type for mock Figma API interface MockFigmaApi { @@ -123,7 +124,7 @@ describe('buildDtcgJson', () => { const metadata = result.$extensions!['com.figma']; expect(metadata).toHaveProperty('exportedAt'); expect(metadata).toHaveProperty('fileName', 'Test Design System'); - expect(metadata).toHaveProperty('pluginVersion'); + expect(metadata).toHaveProperty('pluginVersion', PLUGIN_VERSION); expect(metadata).toHaveProperty('collectionsCount', 2); expect(metadata).toHaveProperty('variablesCount', 3); expect(metadata).toHaveProperty('contentHash'); diff --git a/tests/githubClient.test.ts b/tests/githubClient.test.ts new file mode 100644 index 0000000..5a16337 --- /dev/null +++ b/tests/githubClient.test.ts @@ -0,0 +1,419 @@ +/** + * GitHub client tests. + * + * These tests mock the global `fetch` to exercise the branch-creation and + * commit flow without hitting the network. They guard against regressions in + * the "create the target branch from the repository default" behaviour, which + * is the path used the first time a user pushes to a brand-new branch. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { commitFiles, branchExists } from '../src/github/githubClient'; + +interface MockResponse { + ok: boolean; + status: number; + json: () => Promise; +} + +function jsonResponse(status: number, body: unknown): MockResponse { + return { + ok: status >= 200 && status < 300, + status, + json: async () => body, + }; +} + +/** + * Builds a stateful fetch mock that simulates a repository whose default branch + * is `main`. Additional branches can be pre-seeded via `existingBranches`. + */ +function createFetchMock(options: { + owner: string; + repo: string; + defaultBranch?: string; + existingBranches?: string[]; + existingFiles?: Record; + patchFailuresBeforeSuccess?: number; +}) { + const { owner, repo, defaultBranch = 'main' } = options; + const branches = new Set(options.existingBranches ?? [defaultBranch]); + const files = options.existingFiles ?? {}; + const calls: Array<{ method: string; url: string }> = []; + let patchCalls = 0; + + const base = `https://api.github.com/repos/${owner}/${repo}`; + + const fetchMock = vi.fn(async (url: unknown, init?: RequestInit): Promise => { + const u = String(url); + const method = init?.method ?? 'GET'; + calls.push({ method, url: u }); + + // Branch ref lookup: GET /git/ref/heads/ + const refMatch = u.match(/\/git\/ref\/heads\/(.+)$/); + if (method === 'GET' && refMatch) { + const branch = decodeURIComponent(refMatch[1]); + if (branches.has(branch)) { + return jsonResponse(200, { object: { sha: `commit-${branch}` } }); + } + return jsonResponse(404, {}); + } + + // Repository metadata (exact, no trailing path): GET /repos/owner/repo + if (method === 'GET' && u === base) { + return jsonResponse(200, { default_branch: defaultBranch }); + } + + // Create ref: POST /git/refs + if (method === 'POST' && u.endsWith('/git/refs')) { + const body = JSON.parse(String(init?.body)) as { ref: string }; + const branch = body.ref.replace('refs/heads/', ''); + branches.add(branch); + return jsonResponse(201, { ref: body.ref, object: { sha: `commit-${branch}` } }); + } + + // Contents API (file read): GET /contents/?ref= + if (method === 'GET' && u.includes('/contents/')) { + const pathPart = u.split('/contents/')[1].split('?')[0]; + const path = decodeURIComponent(pathPart); + const file = files[path]; + if (!file) return jsonResponse(404, {}); + return jsonResponse(200, { + sha: file.sha, + content: file.content, + html_url: `https://github.com/${owner}/${repo}/blob/main/${path}`, + }); + } + + // Commit metadata: GET /git/commits/ + if (method === 'GET' && u.includes('/git/commits/')) { + return jsonResponse(200, { tree: { sha: 'tree-head' } }); + } + + // Create blob / tree / commit + if (method === 'POST' && u.endsWith('/git/blobs')) { + return jsonResponse(201, { sha: 'blob-new' }); + } + if (method === 'POST' && u.endsWith('/git/trees')) { + return jsonResponse(201, { sha: 'tree-new' }); + } + if (method === 'POST' && u.endsWith('/git/commits')) { + return jsonResponse(201, { sha: 'commit-new' }); + } + + // Update branch ref: PATCH /git/refs/heads/ + if (method === 'PATCH' && u.includes('/git/refs/heads/')) { + patchCalls += 1; + if (patchCalls <= (options.patchFailuresBeforeSuccess ?? 0)) { + // 422 "Update is not a fast forward": the branch advanced concurrently. + return jsonResponse(422, { message: 'Update is not a fast forward' }); + } + return jsonResponse(200, {}); + } + + throw new Error(`Unhandled request in mock: ${method} ${u}`); + }); + + return { fetchMock, calls, branches }; +} + +describe('githubClient', () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + describe('branchExists', () => { + it('returns true for an existing branch and false for a missing one', async () => { + const { fetchMock } = createFetchMock({ + owner: 'acme', + repo: 'tokens', + existingBranches: ['main'], + }); + vi.stubGlobal('fetch', fetchMock); + + await expect(branchExists('acme', 'tokens', 'main', 't')).resolves.toBe(true); + await expect(branchExists('acme', 'tokens', 'design-tokens', 't')).resolves.toBe(false); + }); + }); + + describe('commitFiles branch creation', () => { + it('creates a missing target branch from the repository default when no base branch is given', async () => { + // Regression test: when the configured "default branch" is blank, the + // plugin passes `baseBranch: undefined`. The client must then look up the + // repository's actual default branch (`main`) and create the target from + // it, rather than failing with "Cannot read base branch ref". + const { fetchMock, calls, branches } = createFetchMock({ + owner: 'acme', + repo: 'tokens', + defaultBranch: 'main', + existingBranches: ['main'], + }); + vi.stubGlobal('fetch', fetchMock); + + const result = await commitFiles({ + owner: 'acme', + repo: 'tokens', + branch: 'design-tokens', + token: 't', + commitMessage: 'chore: export tokens', + files: [{ path: 'tokens/variables.json', content: '{}', contentHash: 'abc' }], + baseBranch: undefined, + }); + + expect(result.updated).toBe(true); + expect(result.skipped).toBe(false); + expect(result.url).toContain('/commit/commit-new'); + + // The repository default branch must have been consulted. + const consultedRepoDefault = calls.some( + (c) => c.method === 'GET' && c.url === 'https://api.github.com/repos/acme/tokens' + ); + expect(consultedRepoDefault).toBe(true); + + // The target branch must have been created. + expect(branches.has('design-tokens')).toBe(true); + const createdRef = calls.some((c) => c.method === 'POST' && c.url.endsWith('/git/refs')); + expect(createdRef).toBe(true); + }); + + it('creates a missing target branch from an explicit base branch without touching repo metadata', async () => { + const { fetchMock, calls, branches } = createFetchMock({ + owner: 'acme', + repo: 'tokens', + defaultBranch: 'main', + existingBranches: ['main', 'develop'], + }); + vi.stubGlobal('fetch', fetchMock); + + const result = await commitFiles({ + owner: 'acme', + repo: 'tokens', + branch: 'design-tokens', + token: 't', + commitMessage: 'chore: export tokens', + files: [{ path: 'variables.json', content: '{}', contentHash: 'abc' }], + baseBranch: 'develop', + }); + + expect(result.updated).toBe(true); + expect(branches.has('design-tokens')).toBe(true); + + // With an explicit base branch, the repository metadata endpoint must NOT + // be hit. + const consultedRepoDefault = calls.some( + (c) => c.method === 'GET' && c.url === 'https://api.github.com/repos/acme/tokens' + ); + expect(consultedRepoDefault).toBe(false); + }); + + it('throws a clear error when an explicit base branch does not exist', async () => { + const { fetchMock } = createFetchMock({ + owner: 'acme', + repo: 'tokens', + existingBranches: ['main'], + }); + vi.stubGlobal('fetch', fetchMock); + + await expect( + commitFiles({ + owner: 'acme', + repo: 'tokens', + branch: 'design-tokens', + token: 't', + commitMessage: 'chore: export tokens', + files: [{ path: 'variables.json', content: '{}', contentHash: 'abc' }], + baseBranch: 'nonexistent', + }) + ).rejects.toThrow(/base branch ref/i); + }); + }); + + describe('transient error retries', () => { + it('retries transient 5xx responses and eventually succeeds', async () => { + vi.useFakeTimers(); + let attempts = 0; + const fetchMock = vi.fn(async () => { + attempts += 1; + if (attempts < 3) return jsonResponse(503, {}); + return jsonResponse(200, { object: { sha: 'commit-main' } }); + }); + vi.stubGlobal('fetch', fetchMock); + + const promise = branchExists('acme', 'tokens', 'main', 't'); + await vi.runAllTimersAsync(); + await expect(promise).resolves.toBe(true); + expect(fetchMock).toHaveBeenCalledTimes(3); + + vi.useRealTimers(); + }); + + it('gives up after the maximum number of attempts on persistent 5xx', async () => { + vi.useFakeTimers(); + const fetchMock = vi.fn(async () => jsonResponse(503, {})); + vi.stubGlobal('fetch', fetchMock); + + const settled = branchExists('acme', 'tokens', 'main', 't').catch((e) => e); + await vi.runAllTimersAsync(); + const result = await settled; + expect(result).toBeInstanceOf(Error); + expect(fetchMock).toHaveBeenCalledTimes(3); + + vi.useRealTimers(); + }); + + it('does not retry non-transient statuses such as 404', async () => { + const fetchMock = vi.fn(async () => jsonResponse(404, {})); + vi.stubGlobal('fetch', fetchMock); + + await expect(branchExists('acme', 'tokens', 'missing', 't')).resolves.toBe(false); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + }); + + describe('commitFiles conflict resolution', () => { + it('rebuilds the commit and retries once when the ref update conflicts', async () => { + const { fetchMock, calls } = createFetchMock({ + owner: 'acme', + repo: 'tokens', + existingBranches: ['main'], + patchFailuresBeforeSuccess: 1, + }); + vi.stubGlobal('fetch', fetchMock); + + const result = await commitFiles({ + owner: 'acme', + repo: 'tokens', + branch: 'main', + token: 't', + commitMessage: 'chore: export tokens', + files: [{ path: 'variables.json', content: '{}', contentHash: 'abc' }], + }); + + expect(result.updated).toBe(true); + + // The ref update was attempted twice (conflict then success)... + const patchCount = calls.filter((c) => c.method === 'PATCH').length; + expect(patchCount).toBe(2); + // ...and the commit was rebuilt on the refreshed HEAD. + const commitCount = calls.filter( + (c) => c.method === 'POST' && c.url.endsWith('/git/commits') + ).length; + expect(commitCount).toBe(2); + }); + + it('surfaces the error when the conflict persists after one retry', async () => { + const { fetchMock, calls } = createFetchMock({ + owner: 'acme', + repo: 'tokens', + existingBranches: ['main'], + patchFailuresBeforeSuccess: 5, + }); + vi.stubGlobal('fetch', fetchMock); + + await expect( + commitFiles({ + owner: 'acme', + repo: 'tokens', + branch: 'main', + token: 't', + commitMessage: 'chore: export tokens', + files: [{ path: 'variables.json', content: '{}', contentHash: 'abc' }], + }) + ).rejects.toThrow(/update branch reference/i); + + // Initial attempt + exactly one retry. + const patchCount = calls.filter((c) => c.method === 'PATCH').length; + expect(patchCount).toBe(2); + }); + }); + + describe('commitFiles known content hashes', () => { + it('uses provided hashes and commits without re-reading file contents', async () => { + const { fetchMock, calls } = createFetchMock({ + owner: 'acme', + repo: 'tokens', + existingBranches: ['main'], + }); + vi.stubGlobal('fetch', fetchMock); + + const result = await commitFiles({ + owner: 'acme', + repo: 'tokens', + branch: 'main', + token: 't', + commitMessage: 'chore: export tokens', + files: [{ path: 'variables.json', content: '{}', contentHash: 'new-hash' }], + knownContentHashes: { 'variables.json': 'old-hash' }, + }); + + expect(result.updated).toBe(true); + const contentReads = calls.filter((c) => c.method === 'GET' && c.url.includes('/contents/')); + expect(contentReads).toHaveLength(0); + }); + + it('skips via provided hashes without any contents request', async () => { + const { fetchMock, calls } = createFetchMock({ + owner: 'acme', + repo: 'tokens', + existingBranches: ['main'], + }); + vi.stubGlobal('fetch', fetchMock); + + const result = await commitFiles({ + owner: 'acme', + repo: 'tokens', + branch: 'main', + token: 't', + commitMessage: 'chore: export tokens', + files: [{ path: 'variables.json', content: '{}', contentHash: 'same-hash' }], + knownContentHashes: { 'variables.json': 'same-hash' }, + }); + + expect(result.skipped).toBe(true); + expect(result.updated).toBe(false); + const contentReads = calls.filter((c) => c.method === 'GET' && c.url.includes('/contents/')); + expect(contentReads).toHaveLength(0); + }); + }); + + describe('commitFiles skip behaviour', () => { + it('skips the commit when the embedded content hash already matches the remote file', async () => { + // The remote file embeds the same contentHash, so no commit should happen. + const remoteContent = JSON.stringify({ contentHash: 'abc', tokens: {} }); + // Base64 of remoteContent using btoa (available in jsdom test env). + const encoded = btoa(remoteContent); + + const { fetchMock, calls } = createFetchMock({ + owner: 'acme', + repo: 'tokens', + existingBranches: ['main'], + existingFiles: { + 'variables.json': { sha: 'file-sha', content: encoded }, + }, + }); + vi.stubGlobal('fetch', fetchMock); + + const result = await commitFiles({ + owner: 'acme', + repo: 'tokens', + branch: 'main', + token: 't', + commitMessage: 'chore: export tokens', + files: [{ path: 'variables.json', content: remoteContent, contentHash: 'abc' }], + }); + + expect(result.skipped).toBe(true); + expect(result.updated).toBe(false); + + // No commit objects should have been created. + const createdCommit = calls.some( + (c) => c.method === 'POST' && c.url.endsWith('/git/commits') + ); + expect(createdCommit).toBe(false); + }); + }); +}); diff --git a/tests/retry.test.ts b/tests/retry.test.ts index 4673550..cecf893 100644 --- a/tests/retry.test.ts +++ b/tests/retry.test.ts @@ -169,6 +169,22 @@ describe('withRetry', () => { expect(fn).toHaveBeenCalledTimes(2); }); + it('should retry on 5xx server errors', async () => { + const fn = vi + .fn() + .mockRejectedValueOnce(new Error('503 Service Unavailable')) + .mockResolvedValue('success'); + + const promise = withRetry(fn, { maxAttempts: 2, initialDelay: 100 }); + + await vi.advanceTimersByTimeAsync(100); + + const result = await promise; + + expect(result).toBe('success'); + expect(fn).toHaveBeenCalledTimes(2); + }); + it('should respect custom shouldRetry', async () => { const fn = vi.fn().mockRejectedValue(new Error('Custom error')); const shouldRetry = vi.fn().mockReturnValue(false);