Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
174 changes: 174 additions & 0 deletions functions/api/create-pr.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
// Cloudflare Pages Function: opens a pull request on the repo directly, as our
// installed GitHub App. No user login and no forks — the client sends the post
// files, we authenticate as the App (private key in env) and create the branch,
// commit, and PR. The App's short-lived installation token never leaves here.

type Env = {
GH_APP_ID?: string;
GH_APP_INSTALLATION_ID?: string;
GH_APP_PRIVATE_KEY?: string;
GH_REPO?: string;
ALLOWED_ORIGIN?: string;
};

type PostFile = { path: string; content: string; encoding: 'utf-8' | 'base64' };

const DEFAULT_REPO = 'MLSysDev/mlsystems.dev';
const DEFAULT_ORIGIN = 'https://mlsystems.dev';
const UA = 'mlsystems-write';

function json(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { 'content-type': 'application/json' },
});
}

function b64urlString(s: string): string {
return btoa(s).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}
function b64urlBytes(bytes: Uint8Array): string {
let bin = '';
for (const b of bytes) bin += String.fromCharCode(b);
return btoa(bin).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}
function pemToArrayBuffer(pem: string): ArrayBuffer {
const body = pem
.replace(/-----BEGIN [^-]+-----/, '')
.replace(/-----END [^-]+-----/, '')
.replace(/\s+/g, '');
const bin = atob(body);
const buf = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) buf[i] = bin.charCodeAt(i);
return buf.buffer;
}

async function appJwt(appId: string, pem: string): Promise<string> {
const key = await crypto.subtle.importKey(
'pkcs8',
pemToArrayBuffer(pem.replace(/\\n/g, '\n')),
{ name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' },
false,
['sign'],
);
const now = Math.floor(Date.now() / 1000);
const header = b64urlString(JSON.stringify({ alg: 'RS256', typ: 'JWT' }));
const payload = b64urlString(JSON.stringify({ iat: now - 60, exp: now + 540, iss: appId }));
const data = `${header}.${payload}`;
const sig = await crypto.subtle.sign('RSASSA-PKCS1-v1_5', key, new TextEncoder().encode(data));
return `${data}.${b64urlBytes(new Uint8Array(sig))}`;
}

async function gh(
path: string,
token: string,
init: RequestInit = {},
): Promise<Record<string, unknown>> {
const res = await fetch(`https://api.github.com${path}`, {
...init,
headers: {
accept: 'application/vnd.github+json',
authorization: `Bearer ${token}`,
'user-agent': UA,
'content-type': 'application/json',
'x-github-api-version': '2022-11-28',
...(init.headers ?? {}),
},
});
if (!res.ok) {
const body = (await res.json().catch(() => ({}))) as { message?: string };
throw new Error(body.message || `GitHub API error (${res.status}).`);
}
return res.status === 204 ? {} : ((await res.json()) as Record<string, unknown>);
}

export async function onRequestPost(context: { request: Request; env: Env }): Promise<Response> {
const { request, env } = context;

const allowed = env.ALLOWED_ORIGIN || DEFAULT_ORIGIN;
const origin = request.headers.get('origin');
if (origin && origin !== allowed) return json({ error: 'Forbidden origin.' }, 403);

if (!env.GH_APP_ID || !env.GH_APP_INSTALLATION_ID || !env.GH_APP_PRIVATE_KEY) {
return json({ error: 'Publishing is not configured on the server yet.' }, 500);
}

let payload: { title?: string; slug?: string; files?: PostFile[] };
try {
payload = (await request.json()) as typeof payload;
} catch {
return json({ error: 'Invalid request body.' }, 400);
}
const slug = (payload.slug ?? '').trim();
const title = (payload.title ?? '').trim() || slug;
const files = payload.files ?? [];
if (!slug || files.length === 0) return json({ error: 'Missing post data.' }, 400);

const [owner, name] = (env.GH_REPO || DEFAULT_REPO).split('/');

try {
const jwt = await appJwt(env.GH_APP_ID, env.GH_APP_PRIVATE_KEY);
const inst = (await gh(`/app/installations/${env.GH_APP_INSTALLATION_ID}/access_tokens`, jwt, {
method: 'POST',
})) as { token: string };
const token = inst.token;

const ref = (await gh(`/repos/${owner}/${name}/git/ref/heads/main`, token)) as {
object: { sha: string };
};
const baseSha = ref.object.sha;
const baseCommit = (await gh(`/repos/${owner}/${name}/git/commits/${baseSha}`, token)) as {
tree: { sha: string };
};

const tree: { path: string; mode: '100644'; type: 'blob'; sha: string }[] = [];
for (const f of files) {
const blob = (await gh(`/repos/${owner}/${name}/git/blobs`, token, {
method: 'POST',
body: JSON.stringify({
content: f.content,
encoding: f.encoding === 'base64' ? 'base64' : 'utf-8',
}),
})) as { sha: string };
tree.push({ path: f.path, mode: '100644', type: 'blob', sha: blob.sha });
}

const newTree = (await gh(`/repos/${owner}/${name}/git/trees`, token, {
method: 'POST',
body: JSON.stringify({ base_tree: baseCommit.tree.sha, tree }),
})) as { sha: string };

const commit = (await gh(`/repos/${owner}/${name}/git/commits`, token, {
method: 'POST',
body: JSON.stringify({
message: `Add post: ${title}`,
tree: newTree.sha,
parents: [baseSha],
}),
})) as { sha: string };

const rand = Math.random().toString(36).slice(2, 8);
const branch = `post/${slug}-${rand}`;
await gh(`/repos/${owner}/${name}/git/refs`, token, {
method: 'POST',
body: JSON.stringify({ ref: `refs/heads/${branch}`, sha: commit.sha }),
});

const pr = (await gh(`/repos/${owner}/${name}/pulls`, token, {
method: 'POST',
body: JSON.stringify({
title: `New post: ${title}`,
head: branch,
base: 'main',
body: 'Submitted through the mlsystems.dev /write portal.\n\nPlease comment your author details (name, short bio, links) so a maintainer can review and publish.',
}),
})) as { html_url: string; number: number };

return json({ url: pr.html_url, number: pr.number });
} catch (err) {
return json(
{ error: err instanceof Error ? err.message : 'Could not create the pull request.' },
502,
);
}
}
3 changes: 3 additions & 0 deletions src/lib/site.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,9 @@ export const SITE = {
pitchEmail: 'admin@mlsystems.dev',
// Used in nav, footer, etc.
startYear: 2026,
// Shows the "Post to GitHub" button in /write. Flip to true once the GitHub App
// credentials are set in the Cloudflare Function env (see /api/create-pr).
githubPostEnabled: false,
};

export const APPEARANCE = {
Expand Down
118 changes: 117 additions & 1 deletion src/write/WritePortal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,11 @@ import { validate } from './serialize/validate';
import { buildZip } from './serialize/toZip';
import { buildSource } from './serialize/source';
import { fetchExisting } from './serialize/fetchExisting';
import {
assemblePostFiles,
createPullRequest,
isConfigured as isGithubConfigured,
} from './publish/github';
import { allAssets, clearAssets } from './storage/assets';
import {
clearDraft,
Expand Down Expand Up @@ -124,6 +129,11 @@ export default function WritePortal({ authors, topics, repoUrl, contactEmail }:
const [openError, setOpenError] = useState<string | null>(null);
const [openUrl, setOpenUrl] = useState('');
const [openDialog, setOpenDialog] = useState(false);
const githubEnabled = isGithubConfigured();
const [publishOpen, setPublishOpen] = useState(false);
const [publishStage, setPublishStage] = useState<'idle' | 'working' | 'done' | 'error'>('idle');
const [publishError, setPublishError] = useState<string | null>(null);
const [prUrl, setPrUrl] = useState<string | null>(null);

const variantCss = useMemo(() => tableVariantCss(tableVariants), [tableVariants]);
const images = collectImages(editor.document as unknown as SBlock[]);
Expand Down Expand Up @@ -286,6 +296,40 @@ export default function WritePortal({ authors, topics, repoUrl, contactEmail }:
}
};

const openPublish = () => {
const blocks = editor.document as unknown as SBlock[];
const found = validate(meta, blocks);
setIssues(found);
if (found.length > 0) return;
setPublishError(null);
setPrUrl(null);
setPublishStage('idle');
setPublishOpen(true);
};

const submitToGithub = async () => {
const blocks = editor.document as unknown as SBlock[];
setPublishStage('working');
setPublishError(null);
try {
const serialized = serializePost(meta, blocks, { tableVariants, today: new Date() });
const sourceJson = buildSource(meta, blocks, tableVariants);
const files = await assemblePostFiles(meta.slug, serialized, sourceJson, allAssets());
const pr = await createPullRequest({
slug: meta.slug,
title: meta.title || 'Untitled',
files,
});
setPrUrl(pr.url);
setPublishStage('done');
clearDraft();
await clearStoredAssets().catch(() => undefined);
} catch (err) {
setPublishStage('error');
setPublishError(err instanceof Error ? err.message : 'Could not create the pull request.');
}
};

const slashItems = useMemo(() => getSlashItems(editor), [editor]);

// Drop H4–H6 from the block-type dropdown — the serializer only emits h2–h4
Expand Down Expand Up @@ -523,10 +567,82 @@ export default function WritePortal({ authors, topics, repoUrl, contactEmail }:
{storageOff && (
<span className="write-note-inline">Autosave is off — your browser blocked storage.</span>
)}
<button type="button" className="write-download" disabled={busy} onClick={download}>
<button type="button" className="write-ghost-btn" disabled={busy} onClick={download}>
{busy ? 'Packaging…' : 'Download post'}
</button>
{githubEnabled && (
<button type="button" className="write-download" disabled={busy} onClick={openPublish}>
Post to GitHub →
</button>
)}
</div>

{publishOpen && (
<div
className="write-modal-backdrop"
onClick={() => publishStage !== 'working' && setPublishOpen(false)}
role="presentation"
>
<div
className="write-modal"
role="dialog"
aria-modal="true"
aria-label="Post to GitHub"
onClick={(e) => e.stopPropagation()}
>
{publishStage === 'done' ? (
<>
<h3>Pull request opened ✓</h3>
<p>
Your post is submitted as a pull request. A maintainer will review and publish it.
</p>
<div className="write-modal-actions">
{prUrl && (
<a className="write-download" href={prUrl} target="_blank" rel="noreferrer">
View pull request →
</a>
)}
<button
type="button"
className="write-ghost"
onClick={() => setPublishOpen(false)}
>
Done
</button>
</div>
</>
) : (
<>
<h3>Post to GitHub</h3>
<p>
We’ll open a pull request with your post — images and all. After it’s created,
comment your author details (name, short bio, links) so a maintainer can review
and publish.
</p>
{publishError && <p className="write-modal-error">{publishError}</p>}
<div className="write-modal-actions">
<button
type="button"
className="write-ghost"
disabled={publishStage === 'working'}
onClick={() => setPublishOpen(false)}
>
Cancel
</button>
<button
type="button"
className="write-download"
disabled={publishStage === 'working'}
onClick={() => void submitToGithub()}
>
{publishStage === 'working' ? 'Opening pull request…' : 'Create pull request'}
</button>
</div>
</>
)}
</div>
</div>
)}
</div>
);
}
21 changes: 21 additions & 0 deletions src/write/editor/editor-theme.css
Original file line number Diff line number Diff line change
Expand Up @@ -418,6 +418,27 @@
cursor: default;
}

.write-ghost-btn {
display: inline-flex;
align-items: center;
justify-content: center;
background: none;
color: var(--ink-2);
border: 1px solid var(--line-2);
border-radius: 8px;
font-size: 13px;
padding: 10px 20px;
cursor: pointer;
}
.write-ghost-btn:hover:not(:disabled) {
border-color: var(--accent);
color: var(--accent);
}
.write-ghost-btn:disabled {
opacity: 0.6;
cursor: default;
}

.write-done {
margin-top: 24px;
padding: 18px 20px;
Expand Down
Loading
Loading