import { createServer } from "node:http"; import { execFileSync, spawnSync, execSync } from "node:child_process"; import { dirname } from "node:path"; import { createRequire } from "node:module"; import { joinSession, createCanvas } from "@github/copilot-sdk/extension"; const require = createRequire(import.meta.url); const { marked } = require("marked"); const servers = new Map(); let workspacePath = null; let lastError = null; // Fetch live issues from GitHub REST API instead of gh CLI subprocess async function fetchLiveIssues(cwd) { try { // Use GitHub REST API to fetch issues // This avoids the subprocess execution restriction const owner = "github"; const repo = "awesome-copilot"; const label = "external-plugin"; // Get authentication token from environment or use public access const token = process.env.GITHUB_TOKEN || process.env.GH_TOKEN; const headers = { "Accept": "application/vnd.github.v3+json" }; if (token) { headers["Authorization"] = `token ${token}`; } // Fetch issues with external-plugin label const response = await fetch( `https://api.github.com/repos/${owner}/${repo}/issues?labels=${label}&state=open&per_page=100`, { headers } ); if (!response.ok) { const error = await response.text(); throw new Error(`GitHub API error ${response.status}: ${error.substring(0, 200)}`); } const issues = await response.json(); // Filter to only external-plugin labeled issues and map to our format return issues .filter(issue => issue.labels && issue.labels.some(l => l.name === label)) .map(issue => ({ number: issue.number, title: issue.title, body: issue.body || "", bodyHtml: marked.parse(issue.body || ""), labels: (issue.labels || []).map(l => ({ name: l.name })), pr_url: issue.body?.match(/\[Generated PR\]\(([^)]+)\)/)?.[1], created_at: issue.created_at, updated_at: issue.updated_at })); } catch (err) { lastError = err.message; throw err; } } function renderHtml() { return ` External Plugins Board

External Plugins Board

Loading issues...
`; } async function startServer(instanceId, cwd) { const server = createServer(async (req, res) => { res.setHeader("Access-Control-Allow-Origin", "*"); if (req.url === "/" && req.method === "GET") { res.setHeader("Content-Type", "text/html; charset=utf-8"); res.end(renderHtml()); } else if (req.url === "/api/issues" && req.method === "GET") { try { const issues = await fetchLiveIssues(cwd); res.setHeader("Content-Type", "application/json"); res.end(JSON.stringify(issues || [])); } catch (err) { res.writeHead(500, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: err.message })); } } else if (req.url === "/api/issues/update" && req.method === "POST") { let body = ""; req.on("data", chunk => { body += chunk; }); req.on("end", async () => { try { const { issueNumber, newState } = JSON.parse(body); const labels = ['requires-submitter-fixes', 'ready-for-review', 'approved', 'rejected']; for (const label of labels.filter(l => l !== newState)) { try { spawnSync("gh", [ "issue", "edit", issueNumber.toString(), "--remove-label", label ], { cwd, shell: true }); } catch (e) {} } spawnSync("gh", [ "issue", "edit", issueNumber.toString(), "--add-label", newState ], { cwd, shell: true }); res.setHeader("Content-Type", "application/json"); res.end(JSON.stringify({ ok: true })); } catch (err) { res.writeHead(500, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: err.message })); } }); } else { res.writeHead(404); res.end("Not found"); } }); await new Promise(resolve => server.listen(0, "127.0.0.1", resolve)); const port = server.address().port; return { server, url: `http://127.0.0.1:${port}/` }; } const session = await joinSession({ canvases: [ createCanvas({ id: "external-plugins-board", displayName: "External Plugins Board", description: "Kanban board for managing external plugin submission issues", open: async (ctx) => { let entry = servers.get(ctx.instanceId); if (!entry) { if (!workspacePath) { const filePath = import.meta.url.replace(/^file:\/\//, '').replace(/\//g, '\\'); workspacePath = dirname(dirname(dirname(filePath))); } entry = await startServer(ctx.instanceId, workspacePath); servers.set(ctx.instanceId, entry); } return { title: "External Plugins Board", url: entry.url }; }, onClose: async (ctx) => { const entry = servers.get(ctx.instanceId); if (entry) { servers.delete(ctx.instanceId); await new Promise(resolve => entry.server.close(() => resolve())); } }, }), ], });