diff --git a/.github/plugin/marketplace.json b/.github/plugin/marketplace.json index fd09a77d..6f765330 100644 --- a/.github/plugin/marketplace.json +++ b/.github/plugin/marketplace.json @@ -930,6 +930,12 @@ "description": "Compose and refine launch-ready release notes with contributor callouts and export-friendly output.", "version": "1.0.0" }, + { + "name": "repo-actions-hub", + "source": "extensions/repo-actions-hub", + "description": "Browse repository GitHub Actions workflows, inspect recent runs, and trigger manual workflow_dispatch runs from a Copilot canvas.", + "version": "1.0.0" + }, { "name": "roundup", "source": "plugins/roundup", diff --git a/extensions/repo-actions-hub/.github/plugin/plugin.json b/extensions/repo-actions-hub/.github/plugin/plugin.json new file mode 100644 index 00000000..24221451 --- /dev/null +++ b/extensions/repo-actions-hub/.github/plugin/plugin.json @@ -0,0 +1,19 @@ +{ + "name": "repo-actions-hub", + "description": "Browse repository GitHub Actions workflows, inspect recent runs, and trigger manual workflow_dispatch runs from a Copilot canvas.", + "version": "1.0.0", + "author": { + "name": "James Montemagno", + "url": "https://github.com/jamesmontemagno" + }, + "keywords": [ + "actions", + "canvas", + "copilot-extension", + "github-actions", + "recent-runs", + "workflow-dispatch" + ], + "logo": "assets/preview.png", + "extensions": "." +} diff --git a/extensions/repo-actions-hub/README.md b/extensions/repo-actions-hub/README.md new file mode 100644 index 00000000..6b7ede2e --- /dev/null +++ b/extensions/repo-actions-hub/README.md @@ -0,0 +1,33 @@ +# Repo Actions Hub + +A GitHub Copilot canvas for browsing repository GitHub Actions workflows, reviewing recent runs, opening rich workflow/run details in a modal, and triggering manual `workflow_dispatch` runs. + +## Files + +- `extension.mjs` - canvas server, GitHub Actions data loading, modal details UI, and workflow run actions. +- `assets/preview.png` - gallery preview image for the extension catalog. +- `copilot-extension.json` - Copilot extension name/version metadata. +- `package.json` - extension metadata for cataloging and packaging. +- `.github/plugin/plugin.json` - plugin metadata used by the extension marketplace and website. + +## Install + +Ask Copilot to install the committed extension URL: + +```text +Install this extension: https://github.com/github/awesome-copilot/tree/main/extensions/repo-actions-hub +``` + +You can also copy the folder into one of these locations: + +- `~/.copilot/extensions/repo-actions-hub/` - user scope +- `.github/extensions/repo-actions-hub/` - project scope + +Reload extensions in the app, then open the `repo-actions-hub` canvas. + +## Agent actions + +- `get_state` - return the current workflow and recent-run state for the active repository. +- `refresh` - reload workflows and recent runs from GitHub Actions. +- `get_workflow_details { workflowId }` - inspect a workflow, its dispatch support, inputs, YAML, and recent runs. +- `run_workflow { workflowId, ref?, inputs? }` - trigger a `workflow_dispatch` run for a workflow. diff --git a/extensions/repo-actions-hub/assets/preview.png b/extensions/repo-actions-hub/assets/preview.png new file mode 100644 index 00000000..ff95a1e5 Binary files /dev/null and b/extensions/repo-actions-hub/assets/preview.png differ diff --git a/extensions/repo-actions-hub/copilot-extension.json b/extensions/repo-actions-hub/copilot-extension.json new file mode 100644 index 00000000..dd9dc33b --- /dev/null +++ b/extensions/repo-actions-hub/copilot-extension.json @@ -0,0 +1,4 @@ +{ + "name": "repo-actions-hub", + "version": 1 +} diff --git a/extensions/repo-actions-hub/extension.mjs b/extensions/repo-actions-hub/extension.mjs new file mode 100644 index 00000000..332f1336 --- /dev/null +++ b/extensions/repo-actions-hub/extension.mjs @@ -0,0 +1,1843 @@ +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 ` + +
+ + +