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
12 changes: 7 additions & 5 deletions worker/src/index.js
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down
39 changes: 39 additions & 0 deletions worker/src/routes/extract.js
Original file line number Diff line number Diff line change
@@ -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) })
}
43 changes: 43 additions & 0 deletions worker/src/routes/status.js
Original file line number Diff line number Diff line change
@@ -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) },
)
}
128 changes: 3 additions & 125 deletions worker/src/services/actionsOrchestrator.js
Original file line number Diff line number Diff line change
@@ -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')
}
Loading