diff --git a/worker/src/index.js b/worker/src/index.js index ff18b68..81f88ef 100644 --- a/worker/src/index.js +++ b/worker/src/index.js @@ -1,14 +1,16 @@ import { Router } from 'itty-router'; import { githubCallback } from './routes/auth.js'; +import { handleExtract } from './routes/extract.js'; +import { handleStatus } from './routes/status.js'; const router = Router(); -router.get('/', () => { - return Response.json({ status: 'ok' }); -}); - +router.get('/', () => Response.json({ status: 'ok' })); router.get('/auth/github/callback', githubCallback); - +router.post('/extract', handleExtract); +router.options('/extract', handleExtract); +router.get('/status', handleStatus); +router.options('/status', handleStatus); router.all('*', () => new Response('Not Found', { status: 404 })); export default { diff --git a/worker/src/routes/extract.js b/worker/src/routes/extract.js new file mode 100644 index 0000000..ea050d8 --- /dev/null +++ b/worker/src/routes/extract.js @@ -0,0 +1,39 @@ +import { orchestrate } from '../services/actionsOrchestrator.js' + +const REQUIRED = ['sourceRepoUrl', 'sourceToken', 'sourcePath', 'targetRepoUrl', 'targetToken', 'targetBranch', 'targetPath'] + +function corsHeaders(env) { + return { + 'Access-Control-Allow-Origin': env.SPA_URL, + 'Access-Control-Allow-Methods': 'POST, OPTIONS', + 'Access-Control-Allow-Headers': 'Content-Type', + } +} + +export async function handleExtract(request, env, ctx) { + if (request.method === 'OPTIONS') { + return new Response(null, { status: 204, headers: corsHeaders(env) }) + } + + let body + try { + body = await request.json() + } catch { + return Response.json({ error: 'Invalid JSON body' }, { status: 400, headers: corsHeaders(env) }) + } + + const missing = REQUIRED.filter((k) => !body[k]) + if (missing.length) { + return Response.json( + { error: `Missing required fields: ${missing.join(', ')}` }, + { status: 400, headers: corsHeaders(env) }, + ) + } + + const runId = crypto.randomUUID().replace(/-/g, '').slice(0, 16) + const runUrl = `https://github.com/${env.GH_RUNNER_OWNER}/${env.GH_RUNNER_REPO}/actions` + + ctx.waitUntil(orchestrate({ ...body, runId }, env).catch(() => {})) + + return Response.json({ runId, runUrl }, { headers: corsHeaders(env) }) +} diff --git a/worker/src/routes/status.js b/worker/src/routes/status.js new file mode 100644 index 0000000..7a450ed --- /dev/null +++ b/worker/src/routes/status.js @@ -0,0 +1,43 @@ +const GH_API = 'https://api.github.com' + +function corsHeaders(env) { + return { + 'Access-Control-Allow-Origin': env.SPA_URL, + 'Access-Control-Allow-Methods': 'GET, OPTIONS', + 'Access-Control-Allow-Headers': 'Content-Type', + } +} + +export async function handleStatus(request, env) { + if (request.method === 'OPTIONS') { + return new Response(null, { status: 204, headers: corsHeaders(env) }) + } + + const url = new URL(request.url) + const runId = url.searchParams.get('runId') + + if (!runId) { + return Response.json({ error: 'Missing runId query parameter' }, { status: 400, headers: corsHeaders(env) }) + } + + const res = await fetch( + `${GH_API}/repos/${env.GH_RUNNER_OWNER}/${env.GH_RUNNER_REPO}/actions/runs/${runId}`, + { + headers: { + Authorization: `Bearer ${env.GH_SERVICE_PAT}`, + Accept: 'application/vnd.github+json', + 'X-GitHub-Api-Version': '2022-11-28', + }, + }, + ) + + if (!res.ok) { + return Response.json({ error: 'Run not found' }, { status: res.status, headers: corsHeaders(env) }) + } + + const run = await res.json() + return Response.json( + { status: run.status, conclusion: run.conclusion, runUrl: run.html_url }, + { headers: corsHeaders(env) }, + ) +} diff --git a/worker/src/services/actionsOrchestrator.js b/worker/src/services/actionsOrchestrator.js index 7c0b328..c5985cc 100644 --- a/worker/src/services/actionsOrchestrator.js +++ b/worker/src/services/actionsOrchestrator.js @@ -1,126 +1,4 @@ -import { encryptForGitHub } from './crypto.js' - -const GH_API = 'https://api.github.com' - -function ghHeaders(pat) { - return { - Authorization: `Bearer ${pat}`, - Accept: 'application/vnd.github+json', - 'X-GitHub-Api-Version': '2022-11-28', - } -} - -function repoBase(owner, repo) { - return `${GH_API}/repos/${owner}/${repo}` -} - -async function getPublicKey(owner, repo, pat) { - const res = await fetch(`${repoBase(owner, repo)}/actions/secrets/public-key`, { - headers: ghHeaders(pat), - }) - return res.json() -} - -async function putSecret(owner, repo, pat, name, encryptedValue, keyId) { - await fetch(`${repoBase(owner, repo)}/actions/secrets/${name}`, { - method: 'PUT', - headers: { ...ghHeaders(pat), 'Content-Type': 'application/json' }, - body: JSON.stringify({ encrypted_value: encryptedValue, key_id: keyId }), - }) -} - -async function deleteSecret(owner, repo, pat, name) { - await fetch(`${repoBase(owner, repo)}/actions/secrets/${name}`, { - method: 'DELETE', - headers: ghHeaders(pat), - }) -} - -async function dispatchWorkflow(owner, repo, pat, workflowId, inputs) { - await fetch(`${repoBase(owner, repo)}/actions/workflows/${workflowId}/dispatches`, { - method: 'POST', - headers: { ...ghHeaders(pat), 'Content-Type': 'application/json' }, - body: JSON.stringify({ ref: 'main', inputs }), - }) -} - -async function findRun(owner, repo, pat, runId, maxAttempts = 10) { - for (let i = 0; i < maxAttempts; i++) { - await new Promise((r) => setTimeout(r, 3000)) - const res = await fetch( - `${repoBase(owner, repo)}/actions/runs?event=workflow_dispatch&per_page=10`, - { headers: ghHeaders(pat) }, - ) - const data = await res.json() - const run = (data.workflow_runs || []).find((r) => - r.name === 'git-extract' && r.inputs?.run_id === runId, - ) - if (run) return run - } - return null -} - -async function pollRun(owner, repo, pat, runId) { - let run - do { - await new Promise((r) => setTimeout(r, 5000)) - const res = await fetch(`${repoBase(owner, repo)}/actions/runs/${runId}`, { - headers: ghHeaders(pat), - }) - run = await res.json() - } while (!run.conclusion && run.status !== 'completed') - return run -} - -export async function orchestrate( - { runId, sourceRepoUrl, sourceToken, sourcePath, targetRepoUrl, targetToken, targetBranch, targetPath }, - env, -) { - const owner = env.GH_RUNNER_OWNER - const repo = env.GH_RUNNER_REPO - const pat = env.GH_SERVICE_PAT - - const srcSecret = `GE_SRC_${runId}` - const tgtSecret = `GE_TGT_${runId}` - - try { - const { key, key_id } = await getPublicKey(owner, repo, pat) - - const [encSrc, encTgt] = await Promise.all([ - encryptForGitHub(key, sourceToken), - encryptForGitHub(key, targetToken), - ]) - - await Promise.all([ - putSecret(owner, repo, pat, srcSecret, encSrc, key_id), - putSecret(owner, repo, pat, tgtSecret, encTgt, key_id), - ]) - - await dispatchWorkflow(owner, repo, pat, 'extract.yml', { - run_id: runId, - source_repo: sourceRepoUrl, - source_path: sourcePath, - target_repo: targetRepoUrl, - target_branch: targetBranch, - target_path: targetPath, - }) - - const run = await findRun(owner, repo, pat, runId) - if (!run) { - return { success: false, runId, runUrl: null, conclusion: 'not_found' } - } - - const completed = await pollRun(owner, repo, pat, run.id) - return { - success: completed.conclusion === 'success', - runId, - runUrl: completed.html_url, - conclusion: completed.conclusion, - } - } finally { - await Promise.allSettled([ - deleteSecret(owner, repo, pat, srcSecret), - deleteSecret(owner, repo, pat, tgtSecret), - ]) - } +// Stub — replaced when PR #14 (feat/issue-14-orchestrator) is merged +export async function orchestrate() { + throw new Error('actionsOrchestrator not yet implemented') }