From cbd22346367b6fb30aca54439bf7aec501963460 Mon Sep 17 00:00:00 2001 From: bnz183 Date: Tue, 9 Jun 2026 15:08:19 +0200 Subject: [PATCH 1/2] feat: add github pull request publishing --- apps/studio/e2e/smoke.spec.ts | 19 + apps/studio/server/config.ts | 49 ++ apps/studio/server/demoPublish.ts | 85 ++++ apps/studio/server/index.ts | 4 + apps/studio/server/publish.test.ts | 110 +++++ apps/studio/server/publish.ts | 80 ++- apps/studio/src/App.tsx | 90 +++- apps/studio/src/components/PublishGate.tsx | 174 ++++++- apps/studio/src/index.css | 37 ++ apps/studio/src/lib/prBranch.test.ts | 10 + apps/studio/src/lib/prBranch.ts | 23 + apps/studio/src/lib/publish.ts | 21 +- apps/studio/src/lib/studioConfig.ts | 15 + packages/github-publisher/src/githubApi.ts | 91 ++++ .../src/githubBranchNames.test.ts | 26 + .../github-publisher/src/githubBranchNames.ts | 35 ++ .../github-publisher/src/githubErrors.test.ts | 21 + packages/github-publisher/src/githubErrors.ts | 38 +- .../github-publisher/src/githubPr.test.ts | 326 ++++++++++++ packages/github-publisher/src/githubPr.ts | 463 ++++++++++++++++++ packages/github-publisher/src/index.ts | 25 + .../src/githubPublisherAdapter.test.ts | 139 ++++++ .../publishers/src/githubPublisherAdapter.ts | 58 ++- packages/publishers/src/index.ts | 9 + packages/publishers/src/publishMode.test.ts | 29 ++ packages/publishers/src/publishMode.ts | 28 ++ packages/publishers/src/types.ts | 22 + 27 files changed, 1984 insertions(+), 43 deletions(-) create mode 100644 apps/studio/server/publish.test.ts create mode 100644 apps/studio/src/lib/prBranch.test.ts create mode 100644 apps/studio/src/lib/prBranch.ts create mode 100644 packages/github-publisher/src/githubApi.ts create mode 100644 packages/github-publisher/src/githubBranchNames.test.ts create mode 100644 packages/github-publisher/src/githubBranchNames.ts create mode 100644 packages/github-publisher/src/githubPr.test.ts create mode 100644 packages/github-publisher/src/githubPr.ts create mode 100644 packages/publishers/src/githubPublisherAdapter.test.ts create mode 100644 packages/publishers/src/publishMode.test.ts create mode 100644 packages/publishers/src/publishMode.ts diff --git a/apps/studio/e2e/smoke.spec.ts b/apps/studio/e2e/smoke.spec.ts index 5d17c56..38813bd 100644 --- a/apps/studio/e2e/smoke.spec.ts +++ b/apps/studio/e2e/smoke.spec.ts @@ -77,4 +77,23 @@ test.describe("Studio smoke", () => { await page.getByRole("button", { name: "Simulate publish" }).click(); await expect(page.getByText("Publish simulated")).toBeVisible({ timeout: 10_000 }); }); + + test("publish mode selector renders in demo mode", async ({ page }) => { + await enterDemoMode(page); + await page.getByRole("button", { name: "New post" }).click(); + await postTitleInput(page).fill("Publish mode smoke test"); + await postDescriptionInput(page).fill( + "Summary for publish mode smoke test.", + ); + await page.locator(".writing-canvas__body").fill("# Publish mode\n\nBody content."); + + const modeSelect = page.locator("#publish-mode-select"); + await expect(modeSelect).toBeVisible(); + await modeSelect.selectOption("pull-request"); + await expect(page.getByText("PR branch")).toBeVisible(); + await page.getByRole("button", { name: "Simulate PR publish" }).click(); + await expect(page.getByText("Pull request simulated")).toBeVisible({ + timeout: 10_000, + }); + }); }); diff --git a/apps/studio/server/config.ts b/apps/studio/server/config.ts index 905aab7..5d37ba1 100644 --- a/apps/studio/server/config.ts +++ b/apps/studio/server/config.ts @@ -13,8 +13,10 @@ import { import { isPublisherId, listPublisherIds, + parsePublishMode, supportedPublisherSummary, type PublisherId, + type PublishMode, } from "@sourcedraft/publishers"; export type SupportedAdapter = AdapterId; @@ -30,6 +32,9 @@ export type PublishEnvConfig = { publicMediaPath: string; adapter: SupportedAdapter; publisher: SupportedPublisher; + publishMode: PublishMode; + prBranchPrefix: string; + prDraft: boolean; adapterOptions?: Record; publisherOptions?: Record; categories: string[]; @@ -55,6 +60,44 @@ export function loadProjectConfig(): SourceDraftConfig { return loadSourceDraftConfig(); } +function parseBooleanEnv(value: string | undefined, defaultValue: boolean): boolean { + if (value === undefined) { + return defaultValue; + } + + const normalized = value.trim().toLowerCase(); + if (normalized === "true" || normalized === "1" || normalized === "yes") { + return true; + } + + if (normalized === "false" || normalized === "0" || normalized === "no") { + return false; + } + + return defaultValue; +} + +function resolvePublishModeFromEnv(): PublishMode { + const raw = process.env.SOURCEDRAFT_PUBLISH_MODE?.trim().toLowerCase(); + const parsed = parsePublishMode(raw); + let mode: PublishMode = parsed ?? "direct"; + + if (mode === "pull-request" && parseBooleanEnv(process.env.SOURCEDRAFT_PR_DRAFT, false)) { + mode = "draft-pull-request"; + } + + return mode; +} + +function resolvePrBranchPrefix(): string { + const raw = process.env.SOURCEDRAFT_PR_BRANCH_PREFIX?.trim(); + if (!raw) { + return "sourcedraft/"; + } + + return raw.endsWith("/") ? raw : `${raw}/`; +} + function resolveAdapter(rawAdapter: string): SupportedAdapter | null { if (isAdapterId(rawAdapter)) { return rawAdapter; @@ -355,6 +398,9 @@ export function loadPublishEnv(): PublishEnvResult { ? { ghostDefaultStatus: credentials.ghostDefaultStatus } : {}), categories: project.categories, + publishMode: resolvePublishModeFromEnv(), + prBranchPrefix: resolvePrBranchPrefix(), + prDraft: parseBooleanEnv(process.env.SOURCEDRAFT_PR_DRAFT, false), }, }; } @@ -428,6 +474,9 @@ export function loadPublicConfig(): PublicStudioConfig { publicMediaPath: resolvePublicMediaPath(mediaDir, project), adapter, publisher, + publishMode: resolvePublishModeFromEnv(), + prBranchPrefix: resolvePrBranchPrefix(), + prDraft: parseBooleanEnv(process.env.SOURCEDRAFT_PR_DRAFT, false), ...(project.adapterOptions !== undefined ? { adapterOptions: project.adapterOptions } : {}), diff --git a/apps/studio/server/demoPublish.ts b/apps/studio/server/demoPublish.ts index 88094a7..c25a160 100644 --- a/apps/studio/server/demoPublish.ts +++ b/apps/studio/server/demoPublish.ts @@ -11,8 +11,26 @@ import type { PublishEnvConfig } from "./config.js"; import { summaryFromArticle } from "./demoPosts.js"; import { demoCommitSha, upsertDemoPost } from "./demoStore.js"; import { safePostPath } from "./postPaths.js"; +import { + isPrPublishMode, + parsePublishMode, + publishModeSummary, + type PublishMode, +} from "@sourcedraft/publishers"; import type { PublishRequestBody, PublishResponse } from "./publish.js"; +function demoPrBranch(slug: string, prefix: string): string { + const safePrefix = prefix.endsWith("/") ? prefix : `${prefix}/`; + const segment = slug + .trim() + .toLowerCase() + .replace(/[^a-z0-9._/-]+/gu, "-") + .replace(/-+/gu, "-") + .replace(/^[-./]+|[-./]+$/gu, "") || "post"; + + return `${safePrefix}${segment}`; +} + function renderArticle(article: Article, env: Omit): string { return renderAdapterOutput(env.adapter, article, env.adapterOptions); } @@ -29,6 +47,25 @@ function defaultPostPath( }); } +function resolveDemoPublishMode( + body: PublishRequestBody, + env: Omit, +): { ok: true; mode: PublishMode } | { ok: false; error: string } { + if (body.publishMode !== undefined) { + const parsed = parsePublishMode(body.publishMode); + if (parsed === null) { + return { + ok: false, + error: `Unsupported publish mode. Supported modes: ${publishModeSummary()}.`, + }; + } + + return { ok: true, mode: parsed }; + } + + return { ok: true, mode: env.publishMode }; +} + export async function publishDemoArticle( body: PublishRequestBody, env: Omit, @@ -67,9 +104,56 @@ export async function publishDemoArticle( created = true; } + const publishModeResult = resolveDemoPublishMode(body, env); + if (!publishModeResult.ok) { + return { + status: 400, + body: { + ok: false, + error: publishModeResult.error, + }, + }; + } + + const publishMode = publishModeResult.mode; + if (isPrPublishMode(publishMode) && env.publisher !== "github") { + return { + status: 400, + body: { + ok: false, + error: `Pull request publish mode is only supported for the GitHub publisher. Current publisher: ${env.publisher}.`, + }, + }; + } + const content = renderArticle(article, env); const commitSha = demoCommitSha(); + if (isPrPublishMode(publishMode)) { + const prBranch = demoPrBranch(article.slug, env.prBranchPrefix); + const prNumber = 101; + const owner = env.owner || "demo"; + const repo = env.repo || "sample-posts"; + + return { + status: 200, + body: { + ok: true, + path, + created, + sha: commitSha, + commitSha, + publishMode, + prBranch, + baseBranch: env.branch, + prNumber, + prUrl: `https://github.com/${owner}/${repo}/pull/${prNumber}`, + deployHookNote: + "PR created; deploy hook not triggered until merge.", + }, + }; + } + upsertDemoPost(path, content, { path, ...summaryFromArticle(path, article), @@ -83,6 +167,7 @@ export async function publishDemoArticle( created, sha: commitSha, commitSha, + publishMode: "direct", }, }; } diff --git a/apps/studio/server/index.ts b/apps/studio/server/index.ts index 128a502..7597eeb 100644 --- a/apps/studio/server/index.ts +++ b/apps/studio/server/index.ts @@ -109,6 +109,10 @@ app.get("/api/config", readLimiter, requireAuth, (req, res) => { publicMediaPath: runtime.publicMediaPath, defaultBranch: runtime.branch, categories: runtime.categories, + publishMode: runtime.publishMode, + prBranchPrefix: runtime.prBranchPrefix, + prDraft: runtime.prDraft, + publisher: runtime.publisher, ...(runtime.adapterOptions !== undefined ? { adapterOptions: runtime.adapterOptions } : {}), diff --git a/apps/studio/server/publish.test.ts b/apps/studio/server/publish.test.ts new file mode 100644 index 0000000..efda5b9 --- /dev/null +++ b/apps/studio/server/publish.test.ts @@ -0,0 +1,110 @@ +import assert from "node:assert/strict"; +import { afterEach, describe, it } from "node:test"; +import { publishDemoArticle } from "./demoPublish.js"; +import { loadPublicConfig } from "./config.js"; +import { resetDemoStore } from "./demoStore.js"; + +const originalEnv = { ...process.env }; + +afterEach(() => { + process.env = { ...originalEnv }; +}); + +describe("publish API modes", () => { + it("rejects unsupported publish mode in demo", async () => { + resetDemoStore(); + const runtime = loadPublicConfig(); + const result = await publishDemoArticle( + { + title: "Mode test", + slug: "mode-test", + description: "Validates unsupported publish mode handling.", + pubDate: "2026-06-08", + category: "Guides", + tags: ["demo"], + draft: false, + body: "# Mode test\n\nBody.", + publishMode: "fast-lane", + }, + runtime, + ); + + assert.equal(result.status, 400); + assert.equal(result.body.ok, false); + if (!result.body.ok) { + assert.match(result.body.error, /Unsupported publish mode/); + } + }); + + it("simulates pull-request publish in demo mode", async () => { + resetDemoStore(); + const runtime = loadPublicConfig(); + const result = await publishDemoArticle( + { + title: "PR demo test", + slug: "pr-demo-test", + description: "Validates demo PR publish response shape.", + pubDate: "2026-06-08", + category: "Guides", + tags: ["demo"], + draft: false, + body: "# PR demo test\n\nBody.", + publishMode: "pull-request", + }, + runtime, + ); + + assert.equal(result.status, 200); + assert.equal(result.body.ok, true); + if (result.body.ok) { + assert.equal(result.body.publishMode, "pull-request"); + assert.equal(result.body.prBranch, "sourcedraft/pr-demo-test"); + assert.equal(result.body.baseBranch, runtime.branch); + assert.match(result.body.prUrl ?? "", /\/pull\/101$/u); + assert.equal(result.body.deployHookNote, "PR created; deploy hook not triggered until merge."); + } + }); + + it("rejects PR mode for non-GitHub publishers", async () => { + resetDemoStore(); + const runtime = { + ...loadPublicConfig(), + publisher: "wordpress" as const, + publishMode: "direct" as const, + prBranchPrefix: "sourcedraft/", + prDraft: false, + }; + + const result = await publishDemoArticle( + { + title: "Unsupported PR mode", + slug: "unsupported-pr-mode", + description: "Validates unsupported publisher handling.", + pubDate: "2026-06-08", + category: "Guides", + tags: ["demo"], + draft: false, + body: "# Unsupported\n\nBody.", + publishMode: "pull-request", + }, + runtime, + ); + + assert.equal(result.status, 400); + assert.equal(result.body.ok, false); + if (!result.body.ok) { + assert.match(result.body.error, /only supported for the GitHub publisher/i); + } + }); + + it("loads publish mode from env", () => { + process.env.SOURCEDRAFT_PUBLISH_MODE = "pull-request"; + process.env.SOURCEDRAFT_PR_BRANCH_PREFIX = "custom/"; + process.env.SOURCEDRAFT_PR_DRAFT = "true"; + + const runtime = loadPublicConfig(); + assert.equal(runtime.publishMode, "draft-pull-request"); + assert.equal(runtime.prBranchPrefix, "custom/"); + assert.equal(runtime.prDraft, true); + }); +}); diff --git a/apps/studio/server/publish.ts b/apps/studio/server/publish.ts index ba95701..cb43c6e 100644 --- a/apps/studio/server/publish.ts +++ b/apps/studio/server/publish.ts @@ -8,7 +8,13 @@ import { type Article, type ArticleInput, } from "@sourcedraft/core"; -import type { CmsArticlePayload } from "@sourcedraft/publishers"; +import { + isPrPublishMode, + parsePublishMode, + publishModeSummary, + type CmsArticlePayload, + type PublishMode, +} from "@sourcedraft/publishers"; import type { PublishEnvConfig } from "./config.js"; import { applyDeployHookStrictMode, @@ -23,6 +29,7 @@ export type PublishRequestBody = ArticleInput & { sourcePath?: unknown; /** Remote CMS post id (WordPress post id, Ghost uuid) for updates */ remoteId?: unknown; + publishMode?: unknown; }; export type PublishSuccessResponse = { @@ -32,7 +39,13 @@ export type PublishSuccessResponse = { sha: string; commitSha: string; remoteId?: string; + publishMode?: PublishMode; + prUrl?: string; + prNumber?: number; + prBranch?: string; + baseBranch?: string; deployHook?: DeployHookResult; + deployHookNote?: string; }; export type PublishErrorResponse = { @@ -82,6 +95,25 @@ function toCmsPayload(article: Article): CmsArticlePayload { }; } +function resolvePublishMode( + body: PublishRequestBody, + env: PublishEnvConfig, +): { ok: true; mode: PublishMode } | { ok: false; error: string } { + if (body.publishMode !== undefined) { + const parsed = parsePublishMode(body.publishMode); + if (parsed === null) { + return { + ok: false, + error: `Unsupported publish mode. Supported modes: ${publishModeSummary()}.`, + }; + } + + return { ok: true, mode: parsed }; + } + + return { ok: true, mode: env.publishMode }; +} + function parseRemoteId(value: unknown): string | undefined { if (typeof value === "string" && value.trim().length > 0) { return value.trim(); @@ -130,6 +162,28 @@ export async function publishArticle( path = defaultPostPath(article, env); } + const publishModeResult = resolvePublishMode(body, env); + if (!publishModeResult.ok) { + return { + status: 400, + body: { + ok: false, + error: publishModeResult.error, + }, + }; + } + + const publishMode = publishModeResult.mode; + if (isPrPublishMode(publishMode) && env.publisher !== "github") { + return { + status: 400, + body: { + ok: false, + error: `Pull request publish mode is only supported for the GitHub publisher. Current publisher: ${env.publisher}.`, + }, + }; + } + const content = renderArticle(article, env); const remoteId = parseRemoteId(body.remoteId); const publisher = createPublisherFromEnv(env); @@ -139,6 +193,9 @@ export async function publishArticle( content, message: `Publish: ${article.slug}`, article: toCmsPayload(article), + slug: article.slug, + publishMode, + prBranchPrefix: env.prBranchPrefix, ...(remoteId !== undefined ? { remoteId } : {}), }); @@ -158,6 +215,26 @@ export async function publishArticle( }; } + if (isPrPublishMode(publishMode)) { + return { + status: 200, + body: { + ok: true, + path: result.path, + created: result.created, + sha: result.sha, + commitSha: result.commitSha, + publishMode, + ...(result.prUrl !== undefined ? { prUrl: result.prUrl } : {}), + ...(result.prNumber !== undefined ? { prNumber: result.prNumber } : {}), + ...(result.prBranch !== undefined ? { prBranch: result.prBranch } : {}), + ...(result.baseBranch !== undefined ? { baseBranch: result.baseBranch } : {}), + deployHookNote: + "PR created; deploy hook not triggered until merge.", + }, + }; + } + const deployHookConfig = loadDeployHookConfigFromEnv(); const deployHook = await triggerDeployHook(result.path, deployHookConfig); const strictGate = applyDeployHookStrictMode( @@ -185,6 +262,7 @@ export async function publishArticle( created: result.created, sha: result.sha, commitSha: result.commitSha, + publishMode: result.publishMode ?? "direct", ...(result.remoteId !== undefined ? { remoteId: result.remoteId } : {}), ...(deployHook.triggered ? { deployHook } : {}), }, diff --git a/apps/studio/src/App.tsx b/apps/studio/src/App.tsx index 226a9ca..9209b9a 100644 --- a/apps/studio/src/App.tsx +++ b/apps/studio/src/App.tsx @@ -1,5 +1,6 @@ import { getAdapterPostPath, isAdapterId } from "@sourcedraft/adapters"; import { normalizeArticle, validateArticle } from "@sourcedraft/core"; +import type { PublishMode } from "@sourcedraft/publishers"; import { useCallback, useEffect, useMemo, useState } from "react"; import { AppBar } from "./components/AppBar"; import { AstroMdxPreview } from "./components/AstroMdxPreview"; @@ -26,6 +27,7 @@ import { type ArticleFormState, } from "./lib/articleForm"; import { fetchPost, fetchPosts, type PostSummary } from "./lib/posts"; +import { previewPrBranch } from "./lib/prBranch"; import { publishArticle as publishArticleToGitHub } from "./lib/publish"; import { FALLBACK_STUDIO_CONFIG, @@ -69,6 +71,10 @@ function App() { const [publishing, setPublishing] = useState(false); const [publishError, setPublishError] = useState(null); const [publishSuccess, setPublishSuccess] = useState(null); + const [publishSuccessUrl, setPublishSuccessUrl] = useState( + null, + ); + const [publishMode, setPublishMode] = useState("direct"); const [latestUploadedImagePath, setLatestUploadedImagePath] = useState< string | null >(null); @@ -121,6 +127,7 @@ function App() { } setStudioConfig(config); + setPublishMode(config.publishMode); setDemoMode(config.demoMode === true); setForm((current) => { if (current.title.length > 0 || current.body.length > 0) { @@ -163,6 +170,20 @@ function App() { } }, [articleInput, validation.valid]); + const prBranchPreview = useMemo(() => { + if (!validation.valid || !normalizedArticle) { + return null; + } + + return previewPrBranch(normalizedArticle.slug, studioConfig.prBranchPrefix); + }, [ + validation.valid, + normalizedArticle, + studioConfig.prBranchPrefix, + ]); + + const prModeSupported = studioConfig.publisher === "github"; + const outputPath = useMemo(() => { if (!validation.valid || !normalizedArticle) { return null; @@ -382,9 +403,13 @@ function App() { setPublishing(true); setPublishError(null); setPublishSuccess(null); + setPublishSuccessUrl(null); try { - const result = await publishArticleToGitHub(articleInput, editingPath); + const result = await publishArticleToGitHub(articleInput, { + sourcePath: editingPath, + publishMode, + }); if (!result.ok) { const issueSummary = @@ -396,27 +421,46 @@ function App() { } const action = result.created ? "Created" : "Updated"; - const deployNote = - result.deployHook?.triggered === true - ? ` ${result.deployHook.ok ? "Deploy hook succeeded." : result.deployHook.message}` - : ""; + const mode = result.publishMode ?? publishMode; + + if (mode === "direct") { + const deployNote = + result.deployHook?.triggered === true + ? ` ${result.deployHook.ok ? "Deploy hook succeeded." : result.deployHook.message}` + : ""; + + setPublishSuccess( + `${action} ${result.path} (commit ${result.commitSha.slice(0, 7)}).${deployNote}`, + ); + setEditingPath(result.path); + commitBaseline( + { + form, + editingPath: result.path, + slugAuto, + }, + { + remoteSync: true, + clearLocalDraft: true, + }, + ); + await refreshPosts(); + return; + } + + const prLabel = + typeof result.prNumber === "number" + ? `PR #${result.prNumber}` + : "Pull request"; + const prBranchNote = + result.prBranch !== undefined ? ` on ${result.prBranch}` : ""; setPublishSuccess( - `${action} ${result.path} (commit ${result.commitSha.slice(0, 7)}).${deployNote}`, + `${action} ${result.path}${prBranchNote} (${prLabel}, commit ${result.commitSha.slice(0, 7)}).${result.deployHookNote ? ` ${result.deployHookNote}` : ""}`, ); - setEditingPath(result.path); - commitBaseline( - { - form, - editingPath: result.path, - slugAuto, - }, - { - remoteSync: true, - clearLocalDraft: true, - }, - ); - await refreshPosts(); + if (typeof result.prUrl === "string" && result.prUrl.length > 0) { + setPublishSuccessUrl(result.prUrl); + } } catch { setPublishError( "Could not reach the publish API. Start the dev server and try again.", @@ -526,8 +570,16 @@ function App() { publishing={publishing} publishError={publishError} publishSuccess={publishSuccess} + publishSuccessUrl={publishSuccessUrl} githubReady={githubReady} demoMode={demoMode} + publishMode={publishMode} + defaultPublishMode={studioConfig.publishMode} + baseBranch={studioConfig.defaultBranch} + outputPath={outputPath} + prBranchPreview={prBranchPreview} + prModeSupported={prModeSupported} + onPublishModeChange={setPublishMode} onPublish={handlePublish} /> diff --git a/apps/studio/src/components/PublishGate.tsx b/apps/studio/src/components/PublishGate.tsx index 57b41b1..053b30b 100644 --- a/apps/studio/src/components/PublishGate.tsx +++ b/apps/studio/src/components/PublishGate.tsx @@ -1,13 +1,35 @@ +import type { PublishMode } from "@sourcedraft/publishers"; + type PublishGateProps = { ready: boolean; publishing: boolean; publishError: string | null; publishSuccess: string | null; + publishSuccessUrl: string | null; githubReady: boolean; demoMode: boolean; + publishMode: PublishMode; + defaultPublishMode: PublishMode; + baseBranch: string; + outputPath: string | null; + prBranchPreview: string | null; + prModeSupported: boolean; + onPublishModeChange: (mode: PublishMode) => void; onPublish: () => void; }; +const PUBLISH_MODE_OPTIONS: { value: PublishMode; label: string }[] = [ + { value: "direct", label: "Direct commit" }, + { value: "pull-request", label: "Pull request" }, + { value: "draft-pull-request", label: "Draft pull request" }, +]; + +function publishModeLabel(mode: PublishMode): string { + return ( + PUBLISH_MODE_OPTIONS.find((option) => option.value === mode)?.label ?? mode + ); +} + function disabledReason( ready: boolean, githubReady: boolean, @@ -29,13 +51,70 @@ function disabledReason( return null; } +function publishStatusCopy( + publishing: boolean, + ready: boolean, + demoMode: boolean, + publishMode: PublishMode, +): string { + if (publishing) { + if (demoMode) { + return publishMode === "direct" + ? "Simulating direct publish…" + : "Simulating pull request publish…"; + } + + return publishMode === "direct" + ? "Saving to GitHub…" + : "Creating pull request on GitHub…"; + } + + if (!ready) { + return "Complete required fields to enable publish"; + } + + if (demoMode) { + return publishMode === "direct" + ? "Demo mode will simulate a direct commit publish" + : "Demo mode will simulate a pull request publish"; + } + + return publishMode === "direct" + ? "Your post will be committed to the repository" + : "Your post will be committed to a SourceDraft branch and opened as a pull request"; +} + +function publishButtonLabel( + publishing: boolean, + demoMode: boolean, + publishMode: PublishMode, +): string { + if (publishing) { + return "Publishing…"; + } + + if (demoMode) { + return publishMode === "direct" ? "Simulate publish" : "Simulate PR publish"; + } + + return publishMode === "direct" ? "Publish to GitHub" : "Publish as pull request"; +} + export function PublishGate({ ready, publishing, publishError, publishSuccess, + publishSuccessUrl, githubReady, demoMode, + publishMode, + defaultPublishMode, + baseBranch, + outputPath, + prBranchPreview, + prModeSupported, + onPublishModeChange, onPublish, }: PublishGateProps) { const canPublish = ready && !publishing && (githubReady || demoMode); @@ -49,15 +128,7 @@ export function PublishGate({ Publish

- {publishing - ? demoMode - ? "Simulating publish…" - : "Saving to GitHub…" - : ready - ? demoMode - ? "Demo mode will simulate a successful publish" - : "Your post will be committed to the repository" - : "Complete required fields to enable publish"} + {publishStatusCopy(publishing, ready, demoMode, publishMode)}

+
+ + + {defaultPublishMode !== publishMode && ( +

+ Server default: {publishModeLabel(defaultPublishMode)} +

+ )} +
+ +
+
+
Target branch
+
{baseBranch}
+
+
+
Output path
+
{outputPath ?? "—"}
+
+ {publishMode !== "direct" && ( +
+
PR branch
+
{prBranchPreview ?? "—"}
+
+ )} +
+ {reason && (

{reason}

)} + {!prModeSupported && publishMode !== "direct" && ( +

+ Pull request publish is only available for the GitHub publisher. +

+ )} + {publishError && (

Publish failed

@@ -94,13 +214,31 @@ export function PublishGate({ {publishSuccess && (

- {demoMode ? "Publish simulated" : "Published successfully"} + {demoMode + ? publishMode === "direct" + ? "Publish simulated" + : "Pull request simulated" + : publishMode === "direct" + ? "Published successfully" + : "Pull request created"} +

+

+ {publishSuccessUrl ? ( + + {publishSuccess} + + ) : ( + publishSuccess + )}

-

{publishSuccess}

{demoMode - ? "No GitHub commit was made. Configure GitHub in .env for real publishing." - : "Your site build or CI will pick up the file from the repository."} + ? publishMode === "direct" + ? "No GitHub commit was made. Configure GitHub in .env for real publishing." + : "No GitHub pull request was created. Configure GitHub in .env for real PR publishing." + : publishMode === "direct" + ? "Your site build or CI will pick up the file from the repository." + : "Merge the pull request to update the base branch and trigger your normal deploy flow."}

)} diff --git a/apps/studio/src/index.css b/apps/studio/src/index.css index 2e46f9f..826e06c 100644 --- a/apps/studio/src/index.css +++ b/apps/studio/src/index.css @@ -1141,6 +1141,43 @@ code { min-width: 148px; } +.publish-bar__settings { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px 12px; + padding: 0 16px 12px; +} + +.publish-bar__field { + font-size: 12px; + color: var(--text-muted); +} + +.publish-bar__select { + min-width: 180px; + font-size: 12px; +} + +.publish-bar__details { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); + gap: 8px 16px; + margin: 0; + padding: 0 16px 12px; + font-size: 12px; +} + +.publish-bar__details dt { + color: var(--text-muted); +} + +.publish-bar__details dd { + margin: 2px 0 0; + font-family: var(--font-mono, ui-monospace, monospace); + word-break: break-all; +} + .publish-bar__hint { margin: 0; padding: 0 16px 12px; diff --git a/apps/studio/src/lib/prBranch.test.ts b/apps/studio/src/lib/prBranch.test.ts new file mode 100644 index 0000000..b186e7b --- /dev/null +++ b/apps/studio/src/lib/prBranch.test.ts @@ -0,0 +1,10 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { previewPrBranch } from "./prBranch.js"; + +describe("previewPrBranch", () => { + it("builds a stable PR branch preview", () => { + assert.equal(previewPrBranch("my-post", "sourcedraft/"), "sourcedraft/my-post"); + assert.equal(previewPrBranch("My Post!", "custom/"), "custom/my-post"); + }); +}); diff --git a/apps/studio/src/lib/prBranch.ts b/apps/studio/src/lib/prBranch.ts new file mode 100644 index 0000000..f74144f --- /dev/null +++ b/apps/studio/src/lib/prBranch.ts @@ -0,0 +1,23 @@ +function sanitizeBranchSegment(value: string): string { + const normalized = value + .trim() + .toLowerCase() + .replace(/[^a-z0-9._/-]+/gu, "-") + .replace(/-+/gu, "-") + .replace(/^[-./]+|[-./]+$/gu, ""); + + return normalized.length > 0 ? normalized : "post"; +} + +export function previewPrBranch(slug: string, prefix: string): string { + const safePrefix = prefix.endsWith("/") ? prefix : `${prefix}/`; + const segment = sanitizeBranchSegment(slug); + const branch = `${safePrefix}${segment}`; + + if (branch.length <= 255) { + return branch; + } + + const maxSegmentLength = 255 - safePrefix.length; + return `${safePrefix}${segment.slice(0, Math.max(1, maxSegmentLength))}`; +} diff --git a/apps/studio/src/lib/publish.ts b/apps/studio/src/lib/publish.ts index 68a92bb..f251837 100644 --- a/apps/studio/src/lib/publish.ts +++ b/apps/studio/src/lib/publish.ts @@ -1,4 +1,5 @@ import type { ArticleInput } from "@sourcedraft/core"; +import type { PublishMode } from "@sourcedraft/publishers"; export type DeployHookResult = { triggered: boolean; @@ -14,7 +15,13 @@ export type PublishApiSuccess = { sha: string; commitSha: string; remoteId?: string; + publishMode?: PublishMode; + prUrl?: string; + prNumber?: number; + prBranch?: string; + baseBranch?: string; deployHook?: DeployHookResult; + deployHookNote?: string; }; export type PublishApiError = { @@ -28,14 +35,24 @@ export type PublishApiResponse = PublishApiSuccess | PublishApiError; export async function publishArticle( article: ArticleInput, - sourcePath?: string | null, + options?: { + sourcePath?: string | null; + publishMode?: PublishMode; + }, ): Promise { - const payload: ArticleInput & { sourcePath?: string } = { ...article }; + const payload: ArticleInput & { sourcePath?: string; publishMode?: PublishMode } = { + ...article, + }; + const sourcePath = options?.sourcePath; if (typeof sourcePath === "string" && sourcePath.trim().length > 0) { payload.sourcePath = sourcePath.trim(); } + if (options?.publishMode !== undefined) { + payload.publishMode = options.publishMode; + } + const response = await fetch("/api/publish", { method: "POST", credentials: "include", diff --git a/apps/studio/src/lib/studioConfig.ts b/apps/studio/src/lib/studioConfig.ts index f59362d..f0b4bbb 100644 --- a/apps/studio/src/lib/studioConfig.ts +++ b/apps/studio/src/lib/studioConfig.ts @@ -1,3 +1,5 @@ +import type { PublishMode } from "@sourcedraft/publishers"; + export type StudioConfig = { adapter: string; contentDir: string; @@ -8,6 +10,10 @@ export type StudioConfig = { adapterOptions?: Record; githubOwner: string; githubRepo: string; + publisher: string; + publishMode: PublishMode; + prBranchPrefix: string; + prDraft: boolean; demoMode?: boolean; }; @@ -20,6 +26,10 @@ export const FALLBACK_STUDIO_CONFIG: StudioConfig = { categories: ["Guides", "Notes", "Reviews", "Tutorials", "Reference"], githubOwner: "", githubRepo: "", + publisher: "github", + publishMode: "direct", + prBranchPrefix: "sourcedraft/", + prDraft: false, }; export async function fetchStudioConfig(): Promise { @@ -46,6 +56,11 @@ export async function fetchStudioConfig(): Promise { : {}), githubOwner: data.githubOwner || "", githubRepo: data.githubRepo || "", + publisher: data.publisher || FALLBACK_STUDIO_CONFIG.publisher, + publishMode: data.publishMode || FALLBACK_STUDIO_CONFIG.publishMode, + prBranchPrefix: + data.prBranchPrefix || FALLBACK_STUDIO_CONFIG.prBranchPrefix, + prDraft: data.prDraft === true, demoMode: data.demoMode === true, }; } catch { diff --git a/packages/github-publisher/src/githubApi.ts b/packages/github-publisher/src/githubApi.ts new file mode 100644 index 0000000..6d68bbf --- /dev/null +++ b/packages/github-publisher/src/githubApi.ts @@ -0,0 +1,91 @@ +import { + errorContextFromConfig, + formatGitHubApiError, + formatLocalGitHubError, + type GitHubOperation, +} from "./githubErrors.js"; +import { encodeRepoPath } from "./githubPaths.js"; + +export const GITHUB_API_VERSION = "2022-11-28"; + +export type GitHubApiConfig = { + token: string; + owner: string; + repo: string; +}; + +type GitHubErrorBody = { + message?: string; +}; + +export type GitHubApiError = { + ok: false; + error: string; + status?: number; +}; + +export function repoApiBase(config: GitHubApiConfig): string { + return `https://api.github.com/repos/${config.owner}/${config.repo}`; +} + +export function contentsUrl(config: GitHubApiConfig, path: string): string { + return `${repoApiBase(config)}/contents/${encodeRepoPath(path)}`; +} + +export function githubHeaders(token: string): Record { + return { + Accept: "application/vnd.github+json", + Authorization: `Bearer ${token}`, + "X-GitHub-Api-Version": GITHUB_API_VERSION, + }; +} + +export async function readGitHubError(response: Response): Promise { + try { + const body = (await response.json()) as GitHubErrorBody; + if (typeof body.message === "string" && body.message.length > 0) { + return body.message; + } + } catch { + // Fall through to status text. + } + + return response.statusText || "GitHub API request failed."; +} + +export function apiError( + response: Response, + rawMessage: string, + operation: GitHubOperation, + config: GitHubApiConfig, + context: { path?: string; contentDir?: string; mediaDir?: string } = {}, +): GitHubApiError { + return { + ok: false, + error: formatGitHubApiError( + response.status, + rawMessage, + operation, + errorContextFromConfig(config, context), + ), + status: response.status, + }; +} + +export function localError( + message: string, + operation: GitHubOperation, + config: GitHubApiConfig, + context: { path?: string; contentDir?: string; mediaDir?: string } = {}, + status?: number, +): GitHubApiError { + return { + ok: false, + error: formatLocalGitHubError( + message, + operation, + errorContextFromConfig(config, context), + ), + ...(status !== undefined ? { status } : {}), + }; +} diff --git a/packages/github-publisher/src/githubBranchNames.test.ts b/packages/github-publisher/src/githubBranchNames.test.ts new file mode 100644 index 0000000..100ce7d --- /dev/null +++ b/packages/github-publisher/src/githubBranchNames.test.ts @@ -0,0 +1,26 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { + branchNameFromSlug, + sanitizeBranchSegment, + slugFromRepoPath, +} from "./githubBranchNames.js"; + +describe("github branch names", () => { + it("sanitizes unsafe slug segments", () => { + assert.equal(sanitizeBranchSegment("Hello World!"), "hello-world"); + assert.equal(sanitizeBranchSegment("---"), "post"); + }); + + it("builds deterministic sourcedraft branches", () => { + assert.equal(branchNameFromSlug("my-post"), "sourcedraft/my-post"); + assert.equal(branchNameFromSlug("my-post", "custom/"), "custom/my-post"); + }); + + it("derives slug segments from repo paths", () => { + assert.equal( + slugFromRepoPath("src/content/blog/my-post.mdx"), + "my-post", + ); + }); +}); diff --git a/packages/github-publisher/src/githubBranchNames.ts b/packages/github-publisher/src/githubBranchNames.ts new file mode 100644 index 0000000..d5563a9 --- /dev/null +++ b/packages/github-publisher/src/githubBranchNames.ts @@ -0,0 +1,35 @@ +const BRANCH_NAME_MAX_LENGTH = 255; + +export function sanitizeBranchSegment(value: string): string { + const normalized = value + .trim() + .toLowerCase() + .replace(/[^a-z0-9._/-]+/gu, "-") + .replace(/-+/gu, "-") + .replace(/^[-./]+|[-./]+$/gu, ""); + + return normalized.length > 0 ? normalized : "post"; +} + +export function branchNameFromSlug( + slug: string, + prefix = "sourcedraft/", +): string { + const safePrefix = prefix.endsWith("/") ? prefix : `${prefix}/`; + const segment = sanitizeBranchSegment(slug); + const branch = `${safePrefix}${segment}`; + + if (branch.length <= BRANCH_NAME_MAX_LENGTH) { + return branch; + } + + const maxSegmentLength = BRANCH_NAME_MAX_LENGTH - safePrefix.length; + return `${safePrefix}${segment.slice(0, Math.max(1, maxSegmentLength))}`; +} + +export function slugFromRepoPath(path: string): string { + const normalized = path.trim().replace(/^\/+/u, ""); + const filename = normalized.split("/").pop() ?? normalized; + const withoutExtension = filename.replace(/\.[^.]+$/u, ""); + return sanitizeBranchSegment(withoutExtension); +} diff --git a/packages/github-publisher/src/githubErrors.test.ts b/packages/github-publisher/src/githubErrors.test.ts index 2022e2f..ed9976b 100644 --- a/packages/github-publisher/src/githubErrors.test.ts +++ b/packages/github-publisher/src/githubErrors.test.ts @@ -1,9 +1,11 @@ import assert from "node:assert/strict"; import { describe, it } from "node:test"; import { + branchProtectionRecommendation, directoryListingLimitMessage, formatGitHubApiError, formatLocalGitHubError, + isBranchProtectionError, isDirectoryListingTruncated, validateGitHubFileBody, } from "./githubErrors.js"; @@ -23,6 +25,25 @@ describe("formatGitHubApiError", () => { assert.match(error, /403/); }); + it("recommends pull-request mode for protected branch publish failures", () => { + assert.equal( + isBranchProtectionError(403, "Branch main is protected"), + true, + ); + + const error = formatGitHubApiError( + 422, + "Required status check expected", + "publish", + { owner: "acme", repo: "blog" }, + ); + assert.match(error, /SOURCEDRAFT_PUBLISH_MODE/); + assert.equal( + branchProtectionRecommendation(422, "unsigned commits are not allowed"), + " Direct publish to a protected branch failed. Try pull-request or draft-pull-request publish mode (SOURCEDRAFT_PUBLISH_MODE).", + ); + }); + it("maps rate limit 403 messages without regex", () => { const error = formatGitHubApiError(403, "API rate limit exceeded", "publish"); assert.match(error, /rate limit/i); diff --git a/packages/github-publisher/src/githubErrors.ts b/packages/github-publisher/src/githubErrors.ts index 0eb2773..378aa05 100644 --- a/packages/github-publisher/src/githubErrors.ts +++ b/packages/github-publisher/src/githubErrors.ts @@ -39,6 +39,36 @@ export function repoLabel(context: GitHubErrorContext): string { return "the configured repository"; } +const BRANCH_PROTECTION_HINT = + " Direct publish to a protected branch failed. Try pull-request or draft-pull-request publish mode (SOURCEDRAFT_PUBLISH_MODE)."; + +export function isBranchProtectionError(status: number, rawMessage: string): boolean { + if (status !== 403 && status !== 422) { + return false; + } + + const lowerMessage = rawMessage.toLowerCase(); + return ( + lowerMessage.includes("protected") || + lowerMessage.includes("ruleset") || + lowerMessage.includes("required status") || + lowerMessage.includes("unsigned") || + lowerMessage.includes("commit must be signed") || + lowerMessage.includes("must be made through a pull request") + ); +} + +export function branchProtectionRecommendation( + status: number, + rawMessage: string, +): string | null { + if (!isBranchProtectionError(status, rawMessage)) { + return null; + } + + return BRANCH_PROTECTION_HINT; +} + export function formatGitHubApiError( status: number, rawMessage: string, @@ -47,6 +77,8 @@ export function formatGitHubApiError( ): string { const message = rawMessage.trim(); const target = repoLabel(context); + const protectionHint = + operation === "publish" ? branchProtectionRecommendation(status, message) : null; if (status === 401) { return "GitHub rejected the token (401). Check GITHUB_TOKEN in .env — it may be missing, expired, or revoked."; @@ -58,7 +90,8 @@ export function formatGitHubApiError( return "GitHub API rate limit reached. Wait a few minutes and try again."; } - return `GitHub denied access to ${target} (403). The token needs read and write permission for repository contents on this repo and branch.`; + const base = `GitHub denied access to ${target} (403). The token needs read and write permission for repository contents on this repo and branch.`; + return protectionHint ? `${base}${protectionHint}` : base; } if (status === 404) { @@ -94,7 +127,8 @@ export function formatGitHubApiError( } if (status === 422) { - return `GitHub rejected the request (422). ${message || "Check the file path and branch."}`; + const base = `GitHub rejected the request (422). ${message || "Check the file path and branch."}`; + return protectionHint ? `${base}${protectionHint}` : base; } if (message.length > 0) { diff --git a/packages/github-publisher/src/githubPr.test.ts b/packages/github-publisher/src/githubPr.test.ts new file mode 100644 index 0000000..a8019e9 --- /dev/null +++ b/packages/github-publisher/src/githubPr.test.ts @@ -0,0 +1,326 @@ +import assert from "node:assert/strict"; +import { afterEach, describe, it } from "node:test"; +import { + commitFileToBranch, + createPullRequest, + ensureBranchRef, + findOpenPullRequest, + publishFileViaPullRequest, + readBranchRefSha, +} from "./githubPr.js"; + +type MockResponse = { + status: number; + body: string; +}; + +const config = { + token: "gh-test", + owner: "acme", + repo: "blog", + baseBranch: "main", +}; + +const originalFetch = globalThis.fetch; + +function requestMethod(init?: RequestInit): string { + return init?.method?.toUpperCase() ?? "GET"; +} + +function mockFetch(handlers: Array<(url: string, init?: RequestInit) => MockResponse | null>) { + return (async (url: string, init?: RequestInit) => { + for (const handler of handlers) { + const result = handler(url, init); + if (result !== null) { + return new Response(result.body, { status: result.status }); + } + } + + return new Response(`unexpected request: ${requestMethod(init)} ${url}`, { status: 500 }); + }) as typeof fetch; +} + +afterEach(() => { + globalThis.fetch = originalFetch; +}); + +describe("github PR helpers", () => { + it("reads base branch ref sha", async () => { + globalThis.fetch = mockFetch([ + (url) => { + if (url.includes("/git/ref/heads%2Fmain") || url.endsWith("/git/ref/heads/main")) { + return { + status: 200, + body: JSON.stringify({ object: { sha: "base-sha" } }), + }; + } + + return null; + }, + ]); + + const result = await readBranchRefSha(config, "main"); + assert.equal(result.ok, true); + if (result.ok) { + assert.equal(result.sha, "base-sha"); + } + }); + + it("creates a branch when it does not exist", async () => { + globalThis.fetch = mockFetch([ + (url, init) => { + if ( + (url.includes("/git/ref/heads%2Fsourcedraft%2Fnew-post") || + url.endsWith("/git/ref/heads/sourcedraft/new-post")) && + requestMethod(init) === "GET" + ) { + return { status: 404, body: JSON.stringify({ message: "Not Found" }) }; + } + + if (url.endsWith("/git/refs") && requestMethod(init) === "POST") { + return { status: 201, body: JSON.stringify({ ref: "refs/heads/sourcedraft/new-post" }) }; + } + + return null; + }, + ]); + + const result = await ensureBranchRef(config, "sourcedraft/new-post", "base-sha"); + assert.equal(result.ok, true); + if (result.ok) { + assert.equal(result.created, true); + } + }); + + it("reuses an existing branch", async () => { + globalThis.fetch = mockFetch([ + (url) => { + if ( + url.includes("/git/ref/heads%2Fsourcedraft%2Fnew-post") || + url.endsWith("/git/ref/heads/sourcedraft/new-post") + ) { + return { + status: 200, + body: JSON.stringify({ object: { sha: "branch-sha" } }), + }; + } + + return null; + }, + ]); + + const result = await ensureBranchRef(config, "sourcedraft/new-post", "base-sha"); + assert.equal(result.ok, true); + if (result.ok) { + assert.equal(result.created, false); + } + }); + + it("commits a file to a PR branch", async () => { + globalThis.fetch = mockFetch([ + (url, init) => { + if (url.includes("/contents/src/content/blog/new-post.mdx") && requestMethod(init) === "PUT") { + const body = JSON.parse(String(init?.body)) as { branch?: string }; + assert.equal(body.branch, "sourcedraft/new-post"); + return { + status: 200, + body: JSON.stringify({ + content: { sha: "file-sha" }, + commit: { sha: "commit-sha" }, + }), + }; + } + + return null; + }, + ]); + + const result = await commitFileToBranch( + config, + "src/content/blog/new-post.mdx", + "sourcedraft/new-post", + Buffer.from("hello").toString("base64"), + "Publish: new-post", + ); + + assert.equal(result.ok, true); + if (result.ok) { + assert.equal(result.created, true); + assert.equal(result.sha, "file-sha"); + assert.equal(result.commitSha, "commit-sha"); + } + }); + + it("finds an existing open pull request", async () => { + globalThis.fetch = mockFetch([ + (url) => { + if (url.includes("/pulls?")) { + return { + status: 200, + body: JSON.stringify([ + { + number: 42, + html_url: "https://github.com/acme/blog/pull/42", + }, + ]), + }; + } + + return null; + }, + ]); + + const result = await findOpenPullRequest(config, "sourcedraft/new-post"); + assert.equal(result.ok, true); + if (result.ok && result.pr !== null) { + assert.equal(result.pr.number, 42); + } + }); + + it("creates a draft pull request", async () => { + globalThis.fetch = mockFetch([ + (url, init) => { + if (url.endsWith("/pulls") && requestMethod(init) === "POST") { + const body = JSON.parse(String(init?.body)) as { draft?: boolean }; + assert.equal(body.draft, true); + return { + status: 201, + body: JSON.stringify({ + number: 7, + html_url: "https://github.com/acme/blog/pull/7", + draft: true, + }), + }; + } + + return null; + }, + ]); + + const result = await createPullRequest( + config, + "sourcedraft/new-post", + "Publish: new-post", + true, + ); + + assert.equal(result.ok, true); + if (result.ok) { + assert.equal(result.pr.number, 7); + assert.equal(result.pr.draft, true); + } + }); + + it("rejects draft PR creation when GitHub does not mark it draft", async () => { + globalThis.fetch = mockFetch([ + (url, init) => { + if (url.endsWith("/pulls") && requestMethod(init) === "POST") { + return { + status: 201, + body: JSON.stringify({ + number: 8, + html_url: "https://github.com/acme/blog/pull/8", + draft: false, + }), + }; + } + + return null; + }, + ]); + + const result = await createPullRequest( + config, + "sourcedraft/new-post", + "Publish: new-post", + true, + ); + + assert.equal(result.ok, false); + if (!result.ok) { + assert.match(result.error, /draft pull requests are not available/i); + } + }); + + it("publishes via pull request end to end", async () => { + globalThis.fetch = mockFetch([ + (url, init) => { + if ( + (url.includes("/git/ref/heads%2Fmain") || url.endsWith("/git/ref/heads/main")) && + requestMethod(init) === "GET" + ) { + return { + status: 200, + body: JSON.stringify({ object: { sha: "base-sha" } }), + }; + } + + if ( + (url.includes("/git/ref/heads%2Fsourcedraft%2Fnew-post") || + url.endsWith("/git/ref/heads/sourcedraft/new-post")) && + requestMethod(init) === "GET" + ) { + return { status: 404, body: JSON.stringify({ message: "Not Found" }) }; + } + + if (url.endsWith("/git/refs") && requestMethod(init) === "POST") { + return { status: 201, body: JSON.stringify({ ref: "refs/heads/sourcedraft/new-post" }) }; + } + + if ( + url.includes("/contents/src/content/blog/new-post.mdx") && + requestMethod(init) === "GET" + ) { + return { status: 404, body: JSON.stringify({ message: "Not Found" }) }; + } + + if ( + url.includes("/contents/src/content/blog/new-post.mdx") && + requestMethod(init) === "PUT" + ) { + return { + status: 200, + body: JSON.stringify({ + content: { sha: "file-sha" }, + commit: { sha: "commit-sha" }, + }), + }; + } + + if (url.includes("/pulls?")) { + return { status: 200, body: JSON.stringify([]) }; + } + + if (url.endsWith("/pulls") && requestMethod(init) === "POST") { + return { + status: 201, + body: JSON.stringify({ + number: 99, + html_url: "https://github.com/acme/blog/pull/99", + draft: false, + }), + }; + } + + return null; + }, + ]); + + const result = await publishFileViaPullRequest(config, { + path: "src/content/blog/new-post.mdx", + content: "---\ntitle: Hi\n---\n\nBody", + message: "Publish: new-post", + slug: "new-post", + draft: false, + }); + + assert.equal(result.ok, true); + if (result.ok) { + assert.equal(result.created, true); + assert.equal(result.prNumber, 99); + assert.equal(result.prBranch, "sourcedraft/new-post"); + assert.equal(result.baseBranch, "main"); + assert.equal(result.prUrl, "https://github.com/acme/blog/pull/99"); + } + }); +}); diff --git a/packages/github-publisher/src/githubPr.ts b/packages/github-publisher/src/githubPr.ts new file mode 100644 index 0000000..2d97ac7 --- /dev/null +++ b/packages/github-publisher/src/githubPr.ts @@ -0,0 +1,463 @@ +import { branchNameFromSlug } from "./githubBranchNames.js"; +import { + apiError, + contentsUrl, + githubHeaders, + localError, + readGitHubError, + repoApiBase, + type GitHubApiConfig, + type GitHubApiError, +} from "./githubApi.js"; +import { validateGitHubFileBody } from "./githubErrors.js"; +import { normalizeRepoPath } from "./githubPaths.js"; + +export type GitHubPrConfig = GitHubApiConfig & { + baseBranch: string; + branchPrefix?: string; +}; + +export type GitHubPrPublishInput = { + path: string; + content: string; + message: string; + slug: string; + draft?: boolean; +}; + +export type GitHubPrPublishSuccess = { + ok: true; + created: boolean; + path: string; + sha: string; + commitSha: string; + prUrl: string; + prNumber: number; + prBranch: string; + baseBranch: string; + draft: boolean; +}; + +export type GitHubPrPublishResult = GitHubPrPublishSuccess | GitHubApiError; + +type GitHubRefBody = { + object?: { + sha?: string; + }; +}; + +type GitHubContentBody = { + sha?: string; + type?: string; + content?: string; + encoding?: string; +}; + +type GitHubCommitBody = { + content?: { + sha?: string; + }; + commit?: { + sha?: string; + }; +}; + +type GitHubPullRequest = { + number?: number; + html_url?: string; + draft?: boolean; +}; + +function encodeContent(content: string): string { + return Buffer.from(content, "utf8").toString("base64"); +} + +function branchRef(branch: string): string { + return `heads/${branch}`; +} + +export async function readBranchRefSha( + config: GitHubPrConfig, + branch: string, +): Promise<{ ok: true; sha: string } | GitHubApiError> { + const response = await fetch( + `${repoApiBase(config)}/git/ref/${encodeURIComponent(branchRef(branch))}`, + { + method: "GET", + headers: githubHeaders(config.token), + }, + ); + + if (!response.ok) { + const raw = await readGitHubError(response); + return apiError(response, raw, "checkFile", config); + } + + let body: GitHubRefBody; + try { + body = (await response.json()) as GitHubRefBody; + } catch { + return localError( + "GitHub returned an unreadable branch ref response.", + "checkFile", + config, + undefined, + response.status, + ); + } + + const sha = body.object?.sha; + if (typeof sha !== "string" || sha.length === 0) { + return localError( + "GitHub did not return the base branch commit sha.", + "checkFile", + config, + ); + } + + return { ok: true, sha }; +} + +export async function ensureBranchRef( + config: GitHubPrConfig, + branch: string, + baseSha: string, +): Promise<{ ok: true; created: boolean } | GitHubApiError> { + const existing = await readBranchRefSha(config, branch); + if (existing.ok) { + return { ok: true, created: false }; + } + + if (existing.status !== 404) { + return existing; + } + + const response = await fetch(`${repoApiBase(config)}/git/refs`, { + method: "POST", + headers: { + ...githubHeaders(config.token), + "Content-Type": "application/json", + }, + body: JSON.stringify({ + ref: `refs/heads/${branch}`, + sha: baseSha, + }), + }); + + if (!response.ok) { + const raw = await readGitHubError(response); + if (response.status === 422 && raw.toLowerCase().includes("already exists")) { + return { ok: true, created: false }; + } + + return apiError(response, raw, "publish", config); + } + + return { ok: true, created: true }; +} + +export async function getExistingFileShaOnBranch( + config: GitHubPrConfig, + path: string, + branch: string, +): Promise<{ found: true; sha: string } | { found: false } | GitHubApiError> { + const url = `${contentsUrl(config, path)}?ref=${encodeURIComponent(branch)}`; + const response = await fetch(url, { + method: "GET", + headers: githubHeaders(config.token), + }); + + if (response.status === 404) { + return { found: false }; + } + + if (!response.ok) { + const raw = await readGitHubError(response); + return apiError(response, raw, "checkFile", config, { path }); + } + + let body: GitHubContentBody; + try { + body = (await response.json()) as GitHubContentBody; + } catch { + return localError( + "GitHub returned an unreadable file response.", + "checkFile", + config, + { path }, + response.status, + ); + } + + const validationError = validateGitHubFileBody(body); + if (validationError !== null) { + return localError(validationError, "checkFile", config, { path }, response.status); + } + + return { found: true, sha: body.sha as string }; +} + +export async function commitFileToBranch( + config: GitHubPrConfig, + path: string, + branch: string, + contentBase64: string, + message: string, + existingSha?: string, +): Promise< + | { ok: true; created: boolean; path: string; sha: string; commitSha: string } + | GitHubApiError +> { + const payload: Record = { + message, + content: contentBase64, + branch, + }; + + if (existingSha !== undefined) { + payload.sha = existingSha; + } + + const response = await fetch(contentsUrl(config, path), { + method: "PUT", + headers: { + ...githubHeaders(config.token), + "Content-Type": "application/json", + }, + body: JSON.stringify(payload), + }); + + if (!response.ok) { + const raw = await readGitHubError(response); + return apiError(response, raw, "publish", config, { path }); + } + + let body: GitHubCommitBody; + try { + body = (await response.json()) as GitHubCommitBody; + } catch { + return localError( + "GitHub returned an unreadable publish response.", + "publish", + config, + { path }, + response.status, + ); + } + + const sha = body.content?.sha; + const commitSha = body.commit?.sha; + + if (typeof sha !== "string" || sha.length === 0) { + return localError( + "GitHub did not return the published file sha.", + "publish", + config, + { path }, + response.status, + ); + } + + if (typeof commitSha !== "string" || commitSha.length === 0) { + return localError( + "GitHub did not return the commit sha.", + "publish", + config, + { path }, + response.status, + ); + } + + return { + ok: true, + created: existingSha === undefined, + path, + sha, + commitSha, + }; +} + +export async function findOpenPullRequest( + config: GitHubPrConfig, + headBranch: string, +): Promise<{ ok: true; pr: GitHubPullRequest } | { ok: true; pr: null } | GitHubApiError> { + const head = `${config.owner}:${headBranch}`; + const url = `${repoApiBase(config)}/pulls?state=open&head=${encodeURIComponent(head)}&base=${encodeURIComponent(config.baseBranch)}`; + const response = await fetch(url, { + method: "GET", + headers: githubHeaders(config.token), + }); + + if (!response.ok) { + const raw = await readGitHubError(response); + return apiError(response, raw, "publish", config); + } + + let body: GitHubPullRequest[]; + try { + body = (await response.json()) as GitHubPullRequest[]; + } catch { + return localError( + "GitHub returned an unreadable pull request list.", + "publish", + config, + undefined, + response.status, + ); + } + + const match = body.find( + (pr) => + typeof pr.number === "number" && + typeof pr.html_url === "string" && + pr.html_url.length > 0, + ); + + return { ok: true, pr: match ?? null }; +} + +export async function createPullRequest( + config: GitHubPrConfig, + headBranch: string, + title: string, + draft: boolean, +): Promise<{ ok: true; pr: GitHubPullRequest } | GitHubApiError> { + const response = await fetch(`${repoApiBase(config)}/pulls`, { + method: "POST", + headers: { + ...githubHeaders(config.token), + "Content-Type": "application/json", + }, + body: JSON.stringify({ + title, + head: headBranch, + base: config.baseBranch, + draft, + }), + }); + + if (!response.ok) { + const raw = await readGitHubError(response); + if (draft && response.status === 422) { + return { + ok: false, + error: `GitHub rejected the draft pull request (422). ${raw || "Draft pull requests may not be supported for this repository."}`, + status: response.status, + }; + } + + return apiError(response, raw, "publish", config); + } + + let body: GitHubPullRequest; + try { + body = (await response.json()) as GitHubPullRequest; + } catch { + return localError( + "GitHub returned an unreadable pull request response.", + "publish", + config, + undefined, + response.status, + ); + } + + if (typeof body.number !== "number" || typeof body.html_url !== "string") { + return localError( + "GitHub did not return pull request metadata.", + "publish", + config, + undefined, + response.status, + ); + } + + if (draft && body.draft !== true) { + return { + ok: false, + error: + "GitHub created a pull request but did not mark it as draft. Draft pull requests are not available for this repository.", + status: response.status, + }; + } + + return { ok: true, pr: body }; +} + +export async function publishFileViaPullRequest( + config: GitHubPrConfig, + input: GitHubPrPublishInput, +): Promise { + const path = normalizeRepoPath(input.path); + if (path.length === 0) { + return { ok: false, error: "Path is required." }; + } + + if (input.message.trim().length === 0) { + return { ok: false, error: "Commit message is required." }; + } + + const prefix = config.branchPrefix ?? "sourcedraft/"; + const prBranch = branchNameFromSlug(input.slug, prefix); + const baseRef = await readBranchRefSha(config, config.baseBranch); + if (!baseRef.ok) { + return baseRef; + } + + const branchResult = await ensureBranchRef(config, prBranch, baseRef.sha); + if (!branchResult.ok) { + return branchResult; + } + + const existing = await getExistingFileShaOnBranch(config, path, prBranch); + if ("ok" in existing) { + return existing; + } + + const commitResult = await commitFileToBranch( + config, + path, + prBranch, + encodeContent(input.content), + input.message, + existing.found ? existing.sha : undefined, + ); + + if (!commitResult.ok) { + return commitResult; + } + + const existingPr = await findOpenPullRequest(config, prBranch); + if (!existingPr.ok) { + return existingPr; + } + + let pr: GitHubPullRequest; + if (existingPr.pr !== null) { + pr = existingPr.pr; + } else { + const created = await createPullRequest( + config, + prBranch, + input.message, + input.draft === true, + ); + if (!created.ok) { + return created; + } + pr = created.pr; + } + + return { + ok: true, + created: commitResult.created, + path: commitResult.path, + sha: commitResult.sha, + commitSha: commitResult.commitSha, + prUrl: pr.html_url as string, + prNumber: pr.number as number, + prBranch, + baseBranch: config.baseBranch, + draft: input.draft === true, + }; +} diff --git a/packages/github-publisher/src/index.ts b/packages/github-publisher/src/index.ts index 884e59d..4a77ab4 100644 --- a/packages/github-publisher/src/index.ts +++ b/packages/github-publisher/src/index.ts @@ -2,15 +2,40 @@ export { createGitHubPublisher, } from "./githubPublisher.js"; +export { + branchNameFromSlug, + sanitizeBranchSegment, + slugFromRepoPath, +} from "./githubBranchNames.js"; + export { directoryListingLimitMessage, formatGitHubApiError, + isBranchProtectionError, + branchProtectionRecommendation, GITHUB_DIRECTORY_LISTING_LIMIT, GITHUB_INLINE_FILE_SIZE_LIMIT, } from "./githubErrors.js"; export { encodeRepoPath, normalizeRepoPath } from "./githubPaths.js"; +export { + publishFileViaPullRequest, + readBranchRefSha, + ensureBranchRef, + getExistingFileShaOnBranch, + commitFileToBranch, + findOpenPullRequest, + createPullRequest, +} from "./githubPr.js"; + +export type { + GitHubPrConfig, + GitHubPrPublishInput, + GitHubPrPublishResult, + GitHubPrPublishSuccess, +} from "./githubPr.js"; + export type { GitHubPublisher, GitHubPublisherConfig, diff --git a/packages/publishers/src/githubPublisherAdapter.test.ts b/packages/publishers/src/githubPublisherAdapter.test.ts new file mode 100644 index 0000000..6be14ab --- /dev/null +++ b/packages/publishers/src/githubPublisherAdapter.test.ts @@ -0,0 +1,139 @@ +import assert from "node:assert/strict"; +import { afterEach, describe, it } from "node:test"; +import { githubPublisherFactory } from "./githubPublisherAdapter.js"; + +const originalFetch = globalThis.fetch; + +afterEach(() => { + globalThis.fetch = originalFetch; +}); + +describe("github publisher adapter", () => { + it("keeps direct publish behavior unchanged", async () => { + globalThis.fetch = (async (url: string, init?: RequestInit) => { + const method = init?.method?.toUpperCase() ?? "GET"; + + if (method === "GET" && String(url).includes("/contents/")) { + return new Response(JSON.stringify({ message: "Not Found" }), { status: 404 }); + } + + if (method === "PUT" && String(url).includes("/contents/")) { + return new Response( + JSON.stringify({ + content: { sha: "file-sha" }, + commit: { sha: "commit-sha" }, + }), + { status: 200 }, + ); + } + + return new Response("unexpected", { status: 500 }); + }) as typeof fetch; + + const publisher = githubPublisherFactory.createPublisher({ + token: "gh-test", + owner: "acme", + repo: "blog", + branch: "main", + contentDir: "src/content/blog", + mediaDir: "public/images", + }); + + const result = await publisher.publishArticle({ + path: "src/content/blog/new-post.mdx", + content: "---\ntitle: Hi\n---\n\nBody", + message: "Publish: new-post", + publishMode: "direct", + }); + + assert.equal(result.ok, true); + if (result.ok) { + assert.equal(result.publishMode, "direct"); + assert.equal(result.sha, "file-sha"); + assert.equal(result.commitSha, "commit-sha"); + assert.equal(result.prUrl, undefined); + } + }); + + it("returns PR metadata for pull-request mode", async () => { + globalThis.fetch = (async (url: string, init?: RequestInit) => { + const method = init?.method?.toUpperCase() ?? "GET"; + const target = String(url); + + if (target.includes("/git/ref/heads%2Fmain") || target.endsWith("/git/ref/heads/main")) { + return new Response(JSON.stringify({ object: { sha: "base-sha" } }), { + status: 200, + }); + } + + if ( + target.includes("/git/ref/heads%2Fsourcedraft%2Fnew-post") || + target.endsWith("/git/ref/heads/sourcedraft/new-post") + ) { + return new Response(JSON.stringify({ message: "Not Found" }), { status: 404 }); + } + + if (target.endsWith("/git/refs") && method === "POST") { + return new Response(JSON.stringify({ ref: "refs/heads/sourcedraft/new-post" }), { + status: 201, + }); + } + + if (target.includes("/contents/") && method === "GET") { + return new Response(JSON.stringify({ message: "Not Found" }), { status: 404 }); + } + + if (target.includes("/contents/") && method === "PUT") { + return new Response( + JSON.stringify({ + content: { sha: "file-sha" }, + commit: { sha: "commit-sha" }, + }), + { status: 200 }, + ); + } + + if (target.includes("/pulls?")) { + return new Response(JSON.stringify([]), { status: 200 }); + } + + if (target.endsWith("/pulls") && method === "POST") { + return new Response( + JSON.stringify({ + number: 12, + html_url: "https://github.com/acme/blog/pull/12", + draft: false, + }), + { status: 201 }, + ); + } + + return new Response("unexpected", { status: 500 }); + }) as typeof fetch; + + const publisher = githubPublisherFactory.createPublisher({ + token: "gh-test", + owner: "acme", + repo: "blog", + branch: "main", + contentDir: "src/content/blog", + mediaDir: "public/images", + }); + + const result = await publisher.publishArticle({ + path: "src/content/blog/new-post.mdx", + content: "---\ntitle: Hi\n---\n\nBody", + message: "Publish: new-post", + slug: "new-post", + publishMode: "pull-request", + }); + + assert.equal(result.ok, true); + if (result.ok) { + assert.equal(result.publishMode, "pull-request"); + assert.equal(result.prNumber, 12); + assert.equal(result.prBranch, "sourcedraft/new-post"); + assert.equal(result.baseBranch, "main"); + } + }); +}); diff --git a/packages/publishers/src/githubPublisherAdapter.ts b/packages/publishers/src/githubPublisherAdapter.ts index 6885d77..fe14122 100644 --- a/packages/publishers/src/githubPublisherAdapter.ts +++ b/packages/publishers/src/githubPublisherAdapter.ts @@ -1,10 +1,16 @@ -import { createGitHubPublisher } from "@sourcedraft/github-publisher"; +import { + createGitHubPublisher, + publishFileViaPullRequest, + slugFromRepoPath, +} from "@sourcedraft/github-publisher"; +import { isPrPublishMode } from "./publishMode.js"; import type { Publisher, PublisherFactory, PublisherRuntimeConfig, PublishArticleInput, PublishArticleResult, + PublishMode, ReadPostInput, ReadPostResult, ListPostsInput, @@ -39,6 +45,55 @@ function createGitHubPublisherInstance(config: PublisherRuntimeConfig): Publishe kind: "git", capabilities: GITHUB_CAPABILITIES, async publishArticle(input: PublishArticleInput): Promise { + const publishMode: PublishMode = input.publishMode ?? "direct"; + + if (isPrPublishMode(publishMode)) { + const slug = + typeof input.slug === "string" && input.slug.trim().length > 0 + ? input.slug.trim() + : slugFromRepoPath(input.path); + + const prResult = await publishFileViaPullRequest( + { + token: config.token, + owner: config.owner, + repo: config.repo, + baseBranch: config.branch, + ...(input.prBranchPrefix !== undefined + ? { branchPrefix: input.prBranchPrefix } + : {}), + }, + { + path: input.path, + content: input.content, + message: input.message, + slug, + draft: publishMode === "draft-pull-request", + }, + ); + + if (!prResult.ok) { + return { + ok: false, + error: prResult.error, + ...(prResult.status !== undefined ? { status: prResult.status } : {}), + }; + } + + return { + ok: true, + path: prResult.path, + created: prResult.created, + sha: prResult.sha, + commitSha: prResult.commitSha, + publishMode, + prUrl: prResult.prUrl, + prNumber: prResult.prNumber, + prBranch: prResult.prBranch, + baseBranch: prResult.baseBranch, + }; + } + const result = await github.publishFile({ path: input.path, content: input.content, @@ -60,6 +115,7 @@ function createGitHubPublisherInstance(config: PublisherRuntimeConfig): Publishe created: result.created, sha: result.sha, commitSha: result.commitSha, + publishMode: "direct", }; }, async uploadMedia(input: UploadMediaInput): Promise { diff --git a/packages/publishers/src/index.ts b/packages/publishers/src/index.ts index 1b942ed..f4ccd18 100644 --- a/packages/publishers/src/index.ts +++ b/packages/publishers/src/index.ts @@ -15,6 +15,15 @@ export { parseGhostAdminApiKey, } from "./ghost/ghostJwt.js"; +export { + isPrPublishMode, + isPublishMode, + parsePublishMode, + publishModeSummary, + PUBLISH_MODES, + type PublishMode, +} from "./publishMode.js"; + export { PUBLISHER_IDS, type CmsArticlePayload, diff --git a/packages/publishers/src/publishMode.test.ts b/packages/publishers/src/publishMode.test.ts new file mode 100644 index 0000000..8e189d3 --- /dev/null +++ b/packages/publishers/src/publishMode.test.ts @@ -0,0 +1,29 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { + isPrPublishMode, + isPublishMode, + parsePublishMode, + publishModeSummary, +} from "./publishMode.js"; + +describe("publish mode", () => { + it("parses supported publish modes", () => { + assert.equal(parsePublishMode("direct"), "direct"); + assert.equal(parsePublishMode(" pull-request "), "pull-request"); + assert.equal(parsePublishMode("draft-pull-request"), "draft-pull-request"); + assert.equal(parsePublishMode("invalid"), null); + }); + + it("identifies PR publish modes", () => { + assert.equal(isPublishMode("direct"), true); + assert.equal(isPrPublishMode("pull-request"), true); + assert.equal(isPrPublishMode("draft-pull-request"), true); + assert.equal(isPrPublishMode("direct"), false); + }); + + it("summarizes supported modes", () => { + assert.match(publishModeSummary(), /direct/); + assert.match(publishModeSummary(), /pull-request/); + }); +}); diff --git a/packages/publishers/src/publishMode.ts b/packages/publishers/src/publishMode.ts new file mode 100644 index 0000000..c7e960b --- /dev/null +++ b/packages/publishers/src/publishMode.ts @@ -0,0 +1,28 @@ +export const PUBLISH_MODES = [ + "direct", + "pull-request", + "draft-pull-request", +] as const; + +export type PublishMode = (typeof PUBLISH_MODES)[number]; + +export function isPublishMode(value: string): value is PublishMode { + return (PUBLISH_MODES as readonly string[]).includes(value); +} + +export function parsePublishMode(value: unknown): PublishMode | null { + if (typeof value !== "string") { + return null; + } + + const normalized = value.trim().toLowerCase(); + return isPublishMode(normalized) ? normalized : null; +} + +export function isPrPublishMode(mode: PublishMode): boolean { + return mode === "pull-request" || mode === "draft-pull-request"; +} + +export function publishModeSummary(): string { + return PUBLISH_MODES.join(", "); +} diff --git a/packages/publishers/src/types.ts b/packages/publishers/src/types.ts index 8c372d4..35a5879 100644 --- a/packages/publishers/src/types.ts +++ b/packages/publishers/src/types.ts @@ -1,3 +1,14 @@ +import type { PublishMode } from "./publishMode.js"; + +export type { PublishMode } from "./publishMode.js"; +export { + isPrPublishMode, + isPublishMode, + parsePublishMode, + publishModeSummary, + PUBLISH_MODES, +} from "./publishMode.js"; + export const PUBLISHER_IDS = [ "github", "gitlab", @@ -77,6 +88,12 @@ export type PublishArticleInput = { article?: CmsArticlePayload; /** Remote post ID for CMS updates (WordPress post id, Ghost uuid) */ remoteId?: string; + /** GitHub publish mode; ignored by non-GitHub publishers */ + publishMode?: PublishMode; + /** Article slug for deterministic PR branch naming */ + slug?: string; + /** PR branch prefix override (default sourcedraft/) */ + prBranchPrefix?: string; }; export type PublishArticleSuccess = { @@ -87,6 +104,11 @@ export type PublishArticleSuccess = { commitSha: string; /** Remote CMS post identifier when applicable */ remoteId?: string; + publishMode?: PublishMode; + prUrl?: string; + prNumber?: number; + prBranch?: string; + baseBranch?: string; }; export type PublishArticleError = { From d4c3335c40e7dc20f8c6dcc19e27d5f7a279d271 Mon Sep 17 00:00:00 2001 From: bnz183 Date: Tue, 9 Jun 2026 15:11:25 +0200 Subject: [PATCH 2/2] fix: avoid ReDoS-prone branch name sanitization regex --- apps/studio/server/demoPublish.ts | 56 ++++++++++++++++--- apps/studio/src/lib/prBranch.ts | 52 ++++++++++++++--- .../github-publisher/src/githubBranchNames.ts | 52 ++++++++++++++--- 3 files changed, 136 insertions(+), 24 deletions(-) diff --git a/apps/studio/server/demoPublish.ts b/apps/studio/server/demoPublish.ts index c25a160..1769453 100644 --- a/apps/studio/server/demoPublish.ts +++ b/apps/studio/server/demoPublish.ts @@ -19,16 +19,56 @@ import { } from "@sourcedraft/publishers"; import type { PublishRequestBody, PublishResponse } from "./publish.js"; +function isAllowedBranchChar(char: string): boolean { + const code = char.charCodeAt(0); + return ( + (code >= 97 && code <= 122) || + (code >= 48 && code <= 57) || + char === "." || + char === "_" || + char === "/" || + char === "-" + ); +} + +function isTrimChar(char: string | undefined): boolean { + return char === "-" || char === "." || char === "/"; +} + +function sanitizeBranchSegment(value: string): string { + const chars: string[] = []; + + for (const char of value.trim().toLowerCase()) { + if (isAllowedBranchChar(char)) { + if (char === "-") { + if (chars[chars.length - 1] !== "-") { + chars.push(char); + } + } else { + chars.push(char); + } + continue; + } + + if (chars.length > 0 && chars[chars.length - 1] !== "-") { + chars.push("-"); + } + } + + while (isTrimChar(chars[0])) { + chars.shift(); + } + + while (chars.length > 0 && isTrimChar(chars[chars.length - 1])) { + chars.pop(); + } + + return chars.length > 0 ? chars.join("") : "post"; +} + function demoPrBranch(slug: string, prefix: string): string { const safePrefix = prefix.endsWith("/") ? prefix : `${prefix}/`; - const segment = slug - .trim() - .toLowerCase() - .replace(/[^a-z0-9._/-]+/gu, "-") - .replace(/-+/gu, "-") - .replace(/^[-./]+|[-./]+$/gu, "") || "post"; - - return `${safePrefix}${segment}`; + return `${safePrefix}${sanitizeBranchSegment(slug)}`; } function renderArticle(article: Article, env: Omit): string { diff --git a/apps/studio/src/lib/prBranch.ts b/apps/studio/src/lib/prBranch.ts index f74144f..d5d8256 100644 --- a/apps/studio/src/lib/prBranch.ts +++ b/apps/studio/src/lib/prBranch.ts @@ -1,12 +1,48 @@ +function isAllowedBranchChar(char: string): boolean { + const code = char.charCodeAt(0); + return ( + (code >= 97 && code <= 122) || + (code >= 48 && code <= 57) || + char === "." || + char === "_" || + char === "/" || + char === "-" + ); +} + +function isTrimChar(char: string | undefined): boolean { + return char === "-" || char === "." || char === "/"; +} + function sanitizeBranchSegment(value: string): string { - const normalized = value - .trim() - .toLowerCase() - .replace(/[^a-z0-9._/-]+/gu, "-") - .replace(/-+/gu, "-") - .replace(/^[-./]+|[-./]+$/gu, ""); - - return normalized.length > 0 ? normalized : "post"; + const chars: string[] = []; + + for (const char of value.trim().toLowerCase()) { + if (isAllowedBranchChar(char)) { + if (char === "-") { + if (chars[chars.length - 1] !== "-") { + chars.push(char); + } + } else { + chars.push(char); + } + continue; + } + + if (chars.length > 0 && chars[chars.length - 1] !== "-") { + chars.push("-"); + } + } + + while (isTrimChar(chars[0])) { + chars.shift(); + } + + while (chars.length > 0 && isTrimChar(chars[chars.length - 1])) { + chars.pop(); + } + + return chars.length > 0 ? chars.join("") : "post"; } export function previewPrBranch(slug: string, prefix: string): string { diff --git a/packages/github-publisher/src/githubBranchNames.ts b/packages/github-publisher/src/githubBranchNames.ts index d5563a9..2d4030d 100644 --- a/packages/github-publisher/src/githubBranchNames.ts +++ b/packages/github-publisher/src/githubBranchNames.ts @@ -1,14 +1,50 @@ const BRANCH_NAME_MAX_LENGTH = 255; +function isAllowedBranchChar(char: string): boolean { + const code = char.charCodeAt(0); + return ( + (code >= 97 && code <= 122) || + (code >= 48 && code <= 57) || + char === "." || + char === "_" || + char === "/" || + char === "-" + ); +} + +function isTrimChar(char: string | undefined): boolean { + return char === "-" || char === "." || char === "/"; +} + export function sanitizeBranchSegment(value: string): string { - const normalized = value - .trim() - .toLowerCase() - .replace(/[^a-z0-9._/-]+/gu, "-") - .replace(/-+/gu, "-") - .replace(/^[-./]+|[-./]+$/gu, ""); - - return normalized.length > 0 ? normalized : "post"; + const chars: string[] = []; + + for (const char of value.trim().toLowerCase()) { + if (isAllowedBranchChar(char)) { + if (char === "-") { + if (chars[chars.length - 1] !== "-") { + chars.push(char); + } + } else { + chars.push(char); + } + continue; + } + + if (chars.length > 0 && chars[chars.length - 1] !== "-") { + chars.push("-"); + } + } + + while (isTrimChar(chars[0])) { + chars.shift(); + } + + while (chars.length > 0 && isTrimChar(chars[chars.length - 1])) { + chars.pop(); + } + + return chars.length > 0 ? chars.join("") : "post"; } export function branchNameFromSlug(