import { createServer } from "node:http"; import { spawn } from "node:child_process"; import { joinSession, createCanvas, CanvasError } from "@github/copilot-sdk/extension"; const instanceEntries = new Map(); const OPEN_INPUT_SCHEMA = { type: "object", properties: { repo: { type: "string", description: "Optional owner/repo override. Defaults to the repository for the current workspace.", }, workflowLimit: { type: "integer", minimum: 1, maximum: 200, description: "Maximum workflows to load.", default: 100, }, runLimit: { type: "integer", minimum: 1, maximum: 100, description: "Maximum recent runs to load.", default: 25, }, }, additionalProperties: false, }; const RUN_WORKFLOW_INPUT_SCHEMA = { type: "object", properties: { workflowId: { oneOf: [{ type: "string" }, { type: "integer" }], description: "Workflow file name, workflow name, or workflow database ID.", }, ref: { type: "string", description: "Optional branch or tag ref to dispatch.", }, inputs: { type: "object", description: "Optional workflow_dispatch inputs.", additionalProperties: { oneOf: [{ type: "string" }, { type: "number" }, { type: "boolean" }], }, }, }, required: ["workflowId"], additionalProperties: false, }; let currentWorkingDirectory; const session = await joinSession({ hooks: { onSessionStart: async (input) => { updateWorkingDirectory(input?.workingDirectory); }, onUserPromptSubmitted: async (input) => { updateWorkingDirectory(input?.workingDirectory); }, onPreToolUse: async (input) => { updateWorkingDirectory(input?.workingDirectory); }, }, canvases: [ createCanvas({ id: "repo-actions-hub", displayName: "Repo Actions Hub", description: "Browse GitHub Actions workflows for the current repository, inspect recent runs, and trigger workflow_dispatch runs.", inputSchema: OPEN_INPUT_SCHEMA, actions: [ { name: "get_state", description: "Return the current workflow and recent run state for the canvas repository.", handler: async (ctx) => { const entry = requireInstance(ctx.instanceId); return await ensureState(entry, false); }, }, { name: "refresh", description: "Refresh workflows and recent runs for the canvas repository.", handler: async (ctx) => { const entry = requireInstance(ctx.instanceId); return await refreshInstance(entry); }, }, { name: "get_workflow_details", description: "Inspect a workflow and report whether it supports workflow_dispatch along with any declared inputs.", inputSchema: { type: "object", properties: { workflowId: { oneOf: [{ type: "string" }, { type: "integer" }], }, }, required: ["workflowId"], additionalProperties: false, }, handler: async (ctx) => { const entry = requireInstance(ctx.instanceId); return await getWorkflowDetails(entry, ctx.input.workflowId); }, }, { name: "run_workflow", description: "Trigger a workflow_dispatch run for a workflow in the current repository.", inputSchema: RUN_WORKFLOW_INPUT_SCHEMA, handler: async (ctx) => { const entry = requireInstance(ctx.instanceId); return await triggerWorkflow(entry, ctx.input); }, }, ], open: async (ctx) => { const config = normalizeOpenInput(ctx.input); let entry = instanceEntries.get(ctx.instanceId); if (!entry) { entry = await startServer(ctx.instanceId, config); instanceEntries.set(ctx.instanceId, entry); } else { entry.config = config; } try { await refreshInstance(entry); } catch (error) { throw toCanvasError(error); } return { title: "Repo Actions Hub", status: entry.state?.repo?.nameWithOwner ?? "Loading repository", url: entry.url, }; }, onClose: async (ctx) => { const entry = instanceEntries.get(ctx.instanceId); if (!entry) { return; } instanceEntries.delete(ctx.instanceId); for (const client of entry.clients) { client.end(); } await new Promise((resolve) => entry.server.close(resolve)); }, }), ], }); function requireInstance(instanceId) { const entry = instanceEntries.get(instanceId); if (!entry) { throw new CanvasError("canvas_instance_missing", `No canvas instance is open for '${instanceId}'.`); } return entry; } function updateWorkingDirectory(workingDirectory) { if (typeof workingDirectory === "string" && workingDirectory.trim()) { currentWorkingDirectory = workingDirectory; } } function normalizeOpenInput(input) { return { repo: typeof input?.repo === "string" && input.repo.trim() ? input.repo.trim() : undefined, workflowLimit: Number.isInteger(input?.workflowLimit) ? input.workflowLimit : 100, runLimit: Number.isInteger(input?.runLimit) ? input.runLimit : 25, }; } async function startServer(instanceId, config) { const entry = { instanceId, config, clients: new Set(), server: null, url: "", state: null, }; entry.server = createServer((req, res) => { handleRequest(entry, req, res).catch((error) => { writeJson(res, 500, { error: error.message || "Request failed." }); }); }); await new Promise((resolve) => entry.server.listen(0, "127.0.0.1", resolve)); const address = entry.server.address(); const port = typeof address === "object" && address ? address.port : 0; entry.url = `http://127.0.0.1:${port}/`; return entry; } async function handleRequest(entry, req, res) { const url = new URL(req.url || "/", entry.url); if (req.method === "GET" && url.pathname === "/") { res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }); res.end(renderHtml(entry.instanceId)); return; } if (req.method === "GET" && url.pathname === "/api/state") { const state = await ensureState(entry, url.searchParams.get("refresh") === "1"); writeJson(res, 200, state); return; } if (req.method === "POST" && url.pathname === "/api/refresh") { const state = await refreshInstance(entry); writeJson(res, 200, state); return; } if (req.method === "GET" && url.pathname === "/api/workflow") { const workflowId = url.searchParams.get("id"); if (!workflowId) { throw new CanvasError("workflow_id_required", "A workflow id is required."); } const details = await getWorkflowDetails(entry, workflowId); writeJson(res, 200, details); return; } if (req.method === "GET" && url.pathname === "/api/run-details") { const runId = url.searchParams.get("id"); if (!runId) { throw new CanvasError("run_id_required", "A run id is required."); } const details = await getRunDetails(entry, runId); writeJson(res, 200, details); return; } if (req.method === "POST" && url.pathname === "/api/run") { const input = await readJsonBody(req); const result = await triggerWorkflow(entry, input); writeJson(res, 200, result); return; } if (req.method === "GET" && url.pathname === "/events") { res.writeHead(200, { "Content-Type": "text/event-stream", "Cache-Control": "no-cache, no-transform", Connection: "keep-alive", }); res.write("\n"); entry.clients.add(res); if (entry.state) { sendEvent(res, "state", entry.state); } req.on("close", () => entry.clients.delete(res)); return; } writeJson(res, 404, { error: "Not found." }); } async function ensureState(entry, forceRefresh) { if (!entry.state || forceRefresh) { return await refreshInstance(entry); } return entry.state; } async function refreshInstance(entry) { const repo = await resolveRepoContext(entry.config.repo); const [workflows, runs] = await Promise.all([ listWorkflows(repo, entry.config.workflowLimit), listRuns(repo, entry.config.runLimit), ]); const latestRunsByWorkflowId = new Map(); for (const run of runs) { const workflowKey = String(run.workflowDatabaseId ?? ""); if (workflowKey && !latestRunsByWorkflowId.has(workflowKey)) { latestRunsByWorkflowId.set(workflowKey, run); } } entry.state = { repo, workflows: workflows .slice() .sort((left, right) => left.name.localeCompare(right.name)) .map((workflow) => ({ ...workflow, latestRun: latestRunsByWorkflowId.get(String(workflow.id)) ?? null, })), runs, updatedAt: new Date().toISOString(), }; broadcast(entry, "state", entry.state); return entry.state; } async function resolveRepoContext(explicitRepo) { const workingDirectory = await getActiveWorkingDirectory(); if (!workingDirectory && !explicitRepo) { throw new CanvasError("workspace_unavailable", "No repository working directory is attached to this session yet."); } const args = ["repo", "view"]; if (explicitRepo) { args.push(explicitRepo); } args.push("--json", "nameWithOwner,defaultBranchRef,url"); const { stdout } = await runGh(args, { cwd: workingDirectory }); const repo = JSON.parse(stdout); const defaultBranch = repo.defaultBranchRef?.name || "main"; return { nameWithOwner: repo.nameWithOwner, defaultBranch, url: repo.url, cwd: workingDirectory, }; } async function getActiveWorkingDirectory() { if (currentWorkingDirectory) { return currentWorkingDirectory; } const snapshot = await session.rpc.metadata.snapshot(); updateWorkingDirectory(snapshot?.workingDirectory); return currentWorkingDirectory; } async function listWorkflows(repo, limit) { const { stdout } = await runGh( [ "workflow", "list", "--all", "--json", "id,name,path,state", "-L", String(limit), "-R", repo.nameWithOwner, ], { cwd: repo.cwd } ); return JSON.parse(stdout); } async function listRuns(repo, limit) { const { stdout } = await runGh( [ "run", "list", "--all", "--json", "attempt,conclusion,createdAt,databaseId,displayTitle,event,headBranch,headSha,name,number,startedAt,status,updatedAt,url,workflowDatabaseId,workflowName", "-L", String(limit), "-R", repo.nameWithOwner, ], { cwd: repo.cwd } ); return JSON.parse(stdout); } async function getWorkflowDetails(entry, workflowId) { const state = await ensureState(entry, false); const repo = state.repo; const key = String(workflowId); const workflow = state.workflows.find((candidate) => String(candidate.id) === key || candidate.name === key || candidate.path === key); if (!workflow) { throw new CanvasError("workflow_not_found", `Workflow '${workflowId}' was not found in ${repo.nameWithOwner}.`); } const { stdout } = await runGh( ["workflow", "view", String(workflow.id), "--yaml", "--ref", repo.defaultBranch, "-R", repo.nameWithOwner], { cwd: repo.cwd } ); const dispatch = parseWorkflowDispatch(stdout); const recentRuns = state.runs.filter((run) => String(run.workflowDatabaseId) === String(workflow.id)).slice(0, 10); return { repo, workflow, dispatch, recentRuns, workflowFileUrl: toWorkflowFileUrl(repo, workflow.path), yaml: stdout, }; } async function getRunDetails(entry, runId) { const state = await ensureState(entry, false); const repo = state.repo; const key = String(runId); const knownRun = state.runs.find((candidate) => String(candidate.databaseId) === key); const { stdout } = await runGh( [ "run", "view", key, "--json", "attempt,conclusion,createdAt,databaseId,displayTitle,event,headBranch,headSha,jobs,name,number,startedAt,status,updatedAt,url,workflowDatabaseId,workflowName", "-R", repo.nameWithOwner, ], { cwd: repo.cwd } ); const run = JSON.parse(stdout); return { repo, run, workflow: state.workflows.find((candidate) => String(candidate.id) === String(run.workflowDatabaseId)) ?? null, summary: knownRun ?? null, }; } async function triggerWorkflow(entry, input) { const state = await ensureState(entry, false); const workflowId = input?.workflowId; if (!workflowId && workflowId !== 0) { throw new CanvasError("workflow_id_required", "A workflow id is required."); } const workflowDetails = await getWorkflowDetails(entry, workflowId); if (!workflowDetails.dispatch.supported) { throw new CanvasError( "workflow_dispatch_unsupported", `Workflow '${workflowDetails.workflow.name}' does not declare workflow_dispatch.` ); } const ref = typeof input?.ref === "string" && input.ref.trim() ? input.ref.trim() : state.repo.defaultBranch; const inputs = sanitizeInputs(input?.inputs); const args = ["workflow", "run", String(workflowDetails.workflow.id), "--ref", ref, "-R", state.repo.nameWithOwner]; for (const [key, value] of Object.entries(inputs)) { args.push("-f", `${key}=${String(value)}`); } const { stdout } = await runGh(args, { cwd: state.repo.cwd, }); await session.log(`Triggered workflow '${workflowDetails.workflow.name}' on ${state.repo.nameWithOwner}.`, { ephemeral: true, }); await refreshInstance(entry); return { message: stdout.trim() || `Triggered workflow '${workflowDetails.workflow.name}'.`, workflow: workflowDetails.workflow, ref, inputs, }; } function sanitizeInputs(inputs) { if (!inputs || typeof inputs !== "object" || Array.isArray(inputs)) { return {}; } const sanitized = {}; for (const [key, value] of Object.entries(inputs)) { if (typeof key !== "string" || !key.trim()) { continue; } if (["string", "number", "boolean"].includes(typeof value)) { sanitized[key] = value; } } return sanitized; } function parseWorkflowDispatch(yaml) { const lines = yaml.replace(/\r/g, "").split("\n"); const onBlock = findYamlBlock(lines, "on"); if (!onBlock) { return { supported: false, inputs: [] }; } const inlineValue = onBlock.inlineValue.trim(); if (inlineValue && inlineContainsWorkflowDispatch(inlineValue)) { return { supported: true, inputs: [] }; } const workflowDispatchBlock = findChildBlock(lines, onBlock.startIndex, onBlock.indent, "workflow_dispatch"); if (!workflowDispatchBlock) { return { supported: false, inputs: [] }; } const inputsBlock = findChildBlock(lines, workflowDispatchBlock.startIndex, workflowDispatchBlock.indent, "inputs"); if (!inputsBlock) { return { supported: true, inputs: [] }; } return { supported: true, inputs: parseInputs(lines, inputsBlock.startIndex, inputsBlock.indent), }; } function inlineContainsWorkflowDispatch(value) { if (!value) { return false; } if (value.includes("workflow_dispatch")) { return true; } return false; } function findYamlBlock(lines, key) { const matcher = new RegExp(`^\\s*["']?${escapeRegExp(key)}["']?\\s*:\\s*(.*)$`); for (let index = 0; index < lines.length; index += 1) { const match = lines[index].match(matcher); if (!match) { continue; } return { startIndex: index, indent: countIndent(lines[index]), inlineValue: match[1] ?? "", }; } return null; } function findChildBlock(lines, parentIndex, parentIndent, key) { const matcher = new RegExp(`^\\s*["']?${escapeRegExp(key)}["']?\\s*:\\s*(.*)$`); for (let index = parentIndex + 1; index < lines.length; index += 1) { const line = lines[index]; if (!line.trim() || line.trim().startsWith("#")) { continue; } const indent = countIndent(line); if (indent <= parentIndent) { break; } const match = line.match(matcher); if (!match || indent <= parentIndent) { continue; } return { startIndex: index, indent, inlineValue: match[1] ?? "", }; } return null; } function parseInputs(lines, inputsIndex, inputsIndent) { const inputs = []; let currentInput = null; let optionCollector = null; for (let index = inputsIndex + 1; index < lines.length; index += 1) { const rawLine = lines[index]; const trimmed = rawLine.trim(); if (!trimmed || trimmed.startsWith("#")) { continue; } const indent = countIndent(rawLine); if (indent <= inputsIndent) { break; } if (indent === inputsIndent + 2 && /^[A-Za-z0-9_.-]+\s*:/.test(trimmed)) { if (currentInput) { inputs.push(currentInput); } currentInput = { name: trimmed.slice(0, trimmed.indexOf(":")).trim(), description: "", required: false, default: "", type: "string", options: [], }; optionCollector = null; continue; } if (!currentInput) { continue; } if (trimmed === "options:") { optionCollector = currentInput.options; continue; } if (optionCollector && trimmed.startsWith("- ")) { optionCollector.push(unquote(trimmed.slice(2).trim())); continue; } optionCollector = null; const separatorIndex = trimmed.indexOf(":"); if (separatorIndex === -1) { continue; } const property = trimmed.slice(0, separatorIndex).trim(); const value = trimmed.slice(separatorIndex + 1).trim(); if (property === "description") { currentInput.description = unquote(value); } else if (property === "required") { currentInput.required = value === "true"; } else if (property === "default") { currentInput.default = unquote(value); } else if (property === "type") { currentInput.type = unquote(value) || "string"; } } if (currentInput) { inputs.push(currentInput); } return inputs; } function countIndent(line) { let indent = 0; while (indent < line.length && line[indent] === " ") { indent += 1; } return indent; } function unquote(value) { if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) { return value.slice(1, -1); } return value; } function escapeRegExp(value) { return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } function toWorkflowFileUrl(repo, workflowPath) { const normalizedPath = String(workflowPath || "") .replace(/\\/g, "/") .replace(/^\/+/, ""); const encodedPath = normalizedPath .split("/") .filter(Boolean) .map((segment) => encodeURIComponent(segment)) .join("/"); return `${repo.url}/blob/${encodeURIComponent(repo.defaultBranch)}/${encodedPath}`; } function runGh(args, options = {}) { return new Promise((resolve, reject) => { const child = spawn("gh", args, { cwd: options.cwd, windowsHide: true, env: { ...process.env, GH_PAGER: "cat", }, }); let stdout = ""; let stderr = ""; child.stdout.on("data", (chunk) => { stdout += chunk.toString(); }); child.stderr.on("data", (chunk) => { stderr += chunk.toString(); }); child.on("error", (error) => { reject(error); }); child.on("close", (code) => { if (code === 0) { resolve({ stdout, stderr }); return; } reject(new Error(stderr.trim() || stdout.trim() || `gh exited with code ${code}`)); }); if (options.stdin) { child.stdin.write(options.stdin); } child.stdin.end(); }); } function broadcast(entry, eventName, payload) { for (const client of entry.clients) { sendEvent(client, eventName, payload); } } function sendEvent(res, eventName, payload) { res.write(`event: ${eventName}\n`); res.write(`data: ${JSON.stringify(payload)}\n\n`); } function writeJson(res, statusCode, payload) { res.writeHead(statusCode, { "Content-Type": "application/json; charset=utf-8" }); res.end(JSON.stringify(payload)); } function readJsonBody(req) { return new Promise((resolve, reject) => { let body = ""; req.on("data", (chunk) => { body += chunk.toString(); }); req.on("end", () => { try { resolve(body ? JSON.parse(body) : {}); } catch (error) { reject(new CanvasError("invalid_json", "Request body must be valid JSON.")); } }); req.on("error", reject); }); } function toCanvasError(error) { if (error instanceof CanvasError) { return error; } return new CanvasError("repo_actions_error", error?.message || "Canvas operation failed."); } function renderHtml(instanceId) { return `