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
`;
}
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()));
}
},
}),
],
});