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 ` Repo Actions Hub

Repo Actions Hub

Browse workflows and recent runs for this repository.

Actions

Browse workflows, inspect details, and start manual runs.

Recent runs

Review the latest workflow activity and open detailed run results.
Details

Run workflow

`; } function escapeHtml(value) { return String(value) .replaceAll("&", "&") .replaceAll("<", "<") .replaceAll(">", ">") .replaceAll('"', """) .replaceAll("'", "'"); }