mirror of
https://github.com/github/awesome-copilot.git
synced 2026-07-17 19:31:20 +00:00
chore: publish from main
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "accessibility-kanban",
|
||||
"description": "Kanban board to manage accessibility issues, allow you to plan, track, and complete remediation work.",
|
||||
"version": "1.0.0",
|
||||
"version": "1.0.1",
|
||||
"author": {
|
||||
"name": "Aaron Powell",
|
||||
"url": "https://github.com/aaronpowell"
|
||||
@@ -15,5 +15,5 @@
|
||||
"status-tracking"
|
||||
],
|
||||
"logo": "assets/preview.png",
|
||||
"extensions": "."
|
||||
"extensions": "extensions"
|
||||
}
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 71 KiB |
@@ -0,0 +1,728 @@
|
||||
import { CanvasError, createCanvas, joinSession } from "@github/copilot-sdk/extension";
|
||||
import http from "node:http";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const EXTENSION_NAME = "accessibility-kanban";
|
||||
const STATE_FILE_PREFIX = "repository-issues-kanban-state";
|
||||
const COLUMNS = ["backlog", "plan", "ready", "implement", "done"];
|
||||
const VALID_COLUMNS = new Set(COLUMNS);
|
||||
const REFRESH_ISSUES_ERROR = "Unable to refresh issues right now. Please try again.";
|
||||
|
||||
let repoInfoCache = null;
|
||||
let githubTokenCache;
|
||||
let workspaceCwd = null;
|
||||
|
||||
// The canvas request context reports the active session's *working directory*
|
||||
// (i.e. the repo checkout). Note this is different from `session.workspacePath`,
|
||||
// which points at the session-state folder (~/.copilot/session-state/<id>) and is
|
||||
// NOT a git repo — using it for git resolution is what caused the board to fail
|
||||
// with "Unable to detect the current repository from this workspace."
|
||||
function setWorkspaceCwd(dir) {
|
||||
if (typeof dir !== "string" || !dir.trim() || dir === workspaceCwd) return;
|
||||
workspaceCwd = dir;
|
||||
repoInfoCache = null;
|
||||
githubTokenCache = undefined;
|
||||
}
|
||||
|
||||
function captureCwd(ctx) {
|
||||
setWorkspaceCwd(ctx?.session?.workingDirectory);
|
||||
}
|
||||
|
||||
function getWorkspaceCwd() {
|
||||
return workspaceCwd || process.cwd();
|
||||
}
|
||||
|
||||
// ─── Repo resolution ───
|
||||
|
||||
function runCommand(command, args, cwd = process.cwd()) {
|
||||
try {
|
||||
const result = spawnSync(command, args, { cwd, encoding: "utf8" });
|
||||
if (result.status === 0 && !result.error) {
|
||||
return (result.stdout || "").trim();
|
||||
}
|
||||
} catch {
|
||||
// Ignore and fall through to empty string.
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function normalizeRepo(repo) {
|
||||
if (typeof repo !== "string") return null;
|
||||
const cleaned = repo
|
||||
.trim()
|
||||
.replace(/^https?:\/\/github\.com\//i, "")
|
||||
.replace(/\.git$/i, "");
|
||||
if (!/^[^/\s]+\/[^/\s]+$/.test(cleaned)) return null;
|
||||
return cleaned;
|
||||
}
|
||||
|
||||
function parseRepoFromRemoteUrl(remoteUrl) {
|
||||
if (!remoteUrl) return null;
|
||||
const cleaned = remoteUrl.trim().replace(/\.git$/i, "");
|
||||
|
||||
const sshMatch = cleaned.match(/^[^@]+@[^:]+:([^/]+\/[^/]+)$/);
|
||||
if (sshMatch) return sshMatch[1];
|
||||
|
||||
const httpMatch = cleaned.match(/^https?:\/\/[^/]+\/([^/]+\/[^/]+)$/i);
|
||||
if (httpMatch) return httpMatch[1];
|
||||
|
||||
const fallbackMatch = cleaned.match(/[:/]([^/:]+\/[^/:]+)$/);
|
||||
return fallbackMatch ? fallbackMatch[1] : null;
|
||||
}
|
||||
|
||||
function candidateCwds(preferredCwd) {
|
||||
const candidates = [
|
||||
preferredCwd,
|
||||
workspaceCwd,
|
||||
process.cwd(),
|
||||
__dirname,
|
||||
path.dirname(__dirname),
|
||||
path.dirname(path.dirname(__dirname)),
|
||||
path.dirname(path.dirname(path.dirname(__dirname))),
|
||||
].filter(Boolean);
|
||||
return [...new Set(candidates)];
|
||||
}
|
||||
|
||||
function resolveRepoFromGit(cwd) {
|
||||
const gitRoot = runCommand("git", ["rev-parse", "--show-toplevel"], cwd);
|
||||
if (!gitRoot) return null;
|
||||
|
||||
const fromGh = normalizeRepo(runCommand("gh", ["repo", "view", "--json", "nameWithOwner", "-q", ".nameWithOwner"], gitRoot));
|
||||
if (fromGh) return fromGh;
|
||||
|
||||
const remoteUrl = runCommand("git", ["remote", "get-url", "origin"], gitRoot) || runCommand("git", ["config", "--get", "remote.origin.url"], gitRoot);
|
||||
return normalizeRepo(parseRepoFromRemoteUrl(remoteUrl));
|
||||
}
|
||||
|
||||
function resolveCurrentRepoInfo(cwd = getWorkspaceCwd()) {
|
||||
const fromEnv = normalizeRepo(process.env.GITHUB_REPOSITORY || "");
|
||||
if (fromEnv) return { repo: fromEnv, error: null };
|
||||
|
||||
for (const candidate of candidateCwds(cwd)) {
|
||||
const repo = resolveRepoFromGit(candidate);
|
||||
if (repo) return { repo, error: null };
|
||||
}
|
||||
|
||||
return {
|
||||
repo: "unknown/unknown",
|
||||
error: "Unable to detect the current repository from this workspace.",
|
||||
};
|
||||
}
|
||||
|
||||
function getRepoInfo() {
|
||||
const cwd = getWorkspaceCwd();
|
||||
if (!repoInfoCache || repoInfoCache.cwd !== cwd) {
|
||||
const resolved = resolveCurrentRepoInfo(cwd);
|
||||
repoInfoCache = { ...resolved, cwd };
|
||||
}
|
||||
return repoInfoCache;
|
||||
}
|
||||
|
||||
// ─── State persistence ───
|
||||
|
||||
function copilotHome() {
|
||||
return process.env.COPILOT_HOME || path.join(os.homedir(), ".copilot");
|
||||
}
|
||||
|
||||
function stateFileName(repo) {
|
||||
const key = String(repo || "unknown-unknown")
|
||||
.toLowerCase()
|
||||
.replace(/[^\w.-]+/g, "-");
|
||||
return `${STATE_FILE_PREFIX}-${key}.json`;
|
||||
}
|
||||
|
||||
function getStatePath(repo) {
|
||||
return path.join(copilotHome(), "extensions", EXTENSION_NAME, "artifacts", stateFileName(repo));
|
||||
}
|
||||
|
||||
function ensureStateDirectory(repo) {
|
||||
fs.mkdirSync(path.dirname(getStatePath(repo)), { recursive: true });
|
||||
}
|
||||
|
||||
function defaultState(repoInfo = getRepoInfo()) {
|
||||
return {
|
||||
repo: repoInfo.repo,
|
||||
error: repoInfo.error,
|
||||
updatedAt: new Date().toISOString(),
|
||||
generation: Date.now(),
|
||||
columns: COLUMNS,
|
||||
availableLabels: [],
|
||||
selectedLabels: [],
|
||||
issues: [],
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeLabelList(labels) {
|
||||
const unique = new Set();
|
||||
for (const label of Array.isArray(labels) ? labels : []) {
|
||||
if (typeof label === "string" && label.trim()) unique.add(label.trim());
|
||||
}
|
||||
return [...unique];
|
||||
}
|
||||
|
||||
function computeAvailableLabels(issues) {
|
||||
const labels = new Set();
|
||||
for (const issue of Array.isArray(issues) ? issues : []) {
|
||||
for (const label of normalizeLabelList(issue.labels)) labels.add(label);
|
||||
}
|
||||
return [...labels].sort((a, b) => a.localeCompare(b));
|
||||
}
|
||||
|
||||
function normalizeIssue(issue, repo, idx) {
|
||||
if (!issue || !Number.isInteger(issue.number) || !issue.title) return null;
|
||||
return {
|
||||
number: issue.number,
|
||||
title: issue.title,
|
||||
url: issue.url || `https://github.com/${repo}/issues/${issue.number}`,
|
||||
labels: normalizeLabelList(issue.labels),
|
||||
column: VALID_COLUMNS.has(issue.column) ? issue.column : "backlog",
|
||||
priority: issue.priority || "medium",
|
||||
order: Number.isInteger(issue.order) ? issue.order : idx,
|
||||
agentStatus: typeof issue.agentStatus === "string" ? issue.agentStatus : "",
|
||||
agentActive: Boolean(issue.agentActive),
|
||||
logs: Array.isArray(issue.logs) ? issue.logs : [],
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeState(rawState, repoInfo = getRepoInfo()) {
|
||||
const repo = repoInfo.repo;
|
||||
const issues = Array.isArray(rawState?.issues)
|
||||
? rawState.issues.map((issue, idx) => normalizeIssue(issue, repo, idx)).filter(Boolean)
|
||||
: [];
|
||||
const availableLabels = computeAvailableLabels(issues);
|
||||
|
||||
return {
|
||||
repo,
|
||||
error: repoInfo.error || (rawState?.error === REFRESH_ISSUES_ERROR ? REFRESH_ISSUES_ERROR : null),
|
||||
updatedAt: rawState?.updatedAt || new Date().toISOString(),
|
||||
generation: rawState?.generation || Date.now(),
|
||||
columns: Array.isArray(rawState?.columns) && rawState.columns.length ? rawState.columns : COLUMNS,
|
||||
availableLabels,
|
||||
selectedLabels: normalizeLabelList(rawState?.selectedLabels).filter((label) => availableLabels.includes(label)),
|
||||
issues,
|
||||
};
|
||||
}
|
||||
|
||||
function loadState(repo) {
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(getStatePath(repo), "utf8"));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function saveState(state) {
|
||||
ensureStateDirectory(state.repo);
|
||||
fs.writeFileSync(
|
||||
getStatePath(state.repo),
|
||||
JSON.stringify({ ...state, updatedAt: new Date().toISOString() }, null, 2),
|
||||
);
|
||||
}
|
||||
|
||||
function currentState() {
|
||||
const repoInfo = getRepoInfo();
|
||||
const loaded = loadState(repoInfo.repo);
|
||||
const normalized = normalizeState(loaded || defaultState(repoInfo), repoInfo);
|
||||
if (!loaded) saveState(normalized);
|
||||
return normalized;
|
||||
}
|
||||
|
||||
// ─── Issue operations ───
|
||||
|
||||
function moveIssue(issueNumber, column) {
|
||||
if (!VALID_COLUMNS.has(column)) {
|
||||
throw new CanvasError("invalid_column", `Column must be one of: ${COLUMNS.join(", ")}`);
|
||||
}
|
||||
const state = currentState();
|
||||
const issue = state.issues.find((i) => i.number === issueNumber);
|
||||
if (!issue) {
|
||||
throw new CanvasError("not_found", `Issue #${issueNumber} not found on the board`);
|
||||
}
|
||||
|
||||
const prevColumn = issue.column;
|
||||
issue.column = column;
|
||||
issue.order = state.issues.filter((i) => i.column === column).length;
|
||||
|
||||
if (column === "done" || column === "backlog") {
|
||||
issue.agentActive = false;
|
||||
issue.agentStatus = column === "done" ? "Complete" : "";
|
||||
}
|
||||
|
||||
saveState(state);
|
||||
broadcast("state", state);
|
||||
return { issue, prevColumn };
|
||||
}
|
||||
|
||||
function updateIssueStatus(issueNumber, status, logEntry) {
|
||||
const state = currentState();
|
||||
const issue = state.issues.find((i) => i.number === issueNumber);
|
||||
if (!issue) {
|
||||
throw new CanvasError("not_found", `Issue #${issueNumber} not found on the board`);
|
||||
}
|
||||
|
||||
if (issue.column === "backlog") return issue;
|
||||
|
||||
if (status !== undefined) issue.agentStatus = status;
|
||||
if (logEntry) {
|
||||
if (!issue.logs) issue.logs = [];
|
||||
issue.logs.push({ timestamp: new Date().toISOString(), message: logEntry });
|
||||
}
|
||||
issue.agentActive = true;
|
||||
saveState(state);
|
||||
broadcast("state", state);
|
||||
return issue;
|
||||
}
|
||||
|
||||
function clearAgentStatus(issueNumber) {
|
||||
const state = currentState();
|
||||
const issue = state.issues.find((i) => i.number === issueNumber);
|
||||
if (!issue) return;
|
||||
issue.agentActive = false;
|
||||
saveState(state);
|
||||
broadcast("state", state);
|
||||
}
|
||||
|
||||
function replaceIssues(issues) {
|
||||
const existing = currentState();
|
||||
const existingByNumber = new Map(existing.issues.map((i) => [i.number, i]));
|
||||
|
||||
const nextIssues = (Array.isArray(issues) ? issues : [])
|
||||
.filter((i) => i && Number.isInteger(i.number) && i.title)
|
||||
.map((issue, idx) => {
|
||||
const prev = existingByNumber.get(issue.number);
|
||||
const labels = Array.isArray(issue.labels)
|
||||
? issue.labels.map((l) => (typeof l === "string" ? l : l?.name)).filter(Boolean)
|
||||
: [];
|
||||
return {
|
||||
number: issue.number,
|
||||
title: issue.title,
|
||||
url: issue.url || `https://github.com/${existing.repo}/issues/${issue.number}`,
|
||||
labels: normalizeLabelList(labels),
|
||||
column: VALID_COLUMNS.has(issue.column) ? issue.column : prev?.column || "backlog",
|
||||
priority: issue.priority || prev?.priority || "medium",
|
||||
order: Number.isInteger(issue.order) ? issue.order : prev?.order ?? idx,
|
||||
agentStatus: prev?.agentStatus || "",
|
||||
agentActive: Boolean(prev?.agentActive),
|
||||
logs: Array.isArray(prev?.logs) ? prev.logs : [],
|
||||
};
|
||||
});
|
||||
|
||||
const availableLabels = computeAvailableLabels(nextIssues);
|
||||
const next = {
|
||||
...existing,
|
||||
issues: nextIssues,
|
||||
availableLabels,
|
||||
selectedLabels: normalizeLabelList(existing.selectedLabels).filter((label) => availableLabels.includes(label)),
|
||||
error: getRepoInfo().error,
|
||||
};
|
||||
saveState(next);
|
||||
broadcast("state", next);
|
||||
return next;
|
||||
}
|
||||
|
||||
function setSelectedLabels(labels) {
|
||||
const state = currentState();
|
||||
state.selectedLabels = normalizeLabelList(labels).filter((label) => state.availableLabels.includes(label));
|
||||
saveState(state);
|
||||
broadcast("state", state);
|
||||
return state;
|
||||
}
|
||||
|
||||
function resetBoard() {
|
||||
const state = currentState();
|
||||
const reset = {
|
||||
...state,
|
||||
selectedLabels: [],
|
||||
issues: state.issues.map((issue, idx) => ({
|
||||
...issue,
|
||||
column: "backlog",
|
||||
order: idx,
|
||||
agentStatus: "",
|
||||
agentActive: false,
|
||||
logs: [],
|
||||
})),
|
||||
};
|
||||
saveState(reset);
|
||||
broadcast("state", reset);
|
||||
return reset;
|
||||
}
|
||||
|
||||
// ─── GitHub issue sync ───
|
||||
|
||||
function resolveGitHubToken(cwd = getWorkspaceCwd()) {
|
||||
if (githubTokenCache !== undefined) return githubTokenCache;
|
||||
githubTokenCache = process.env.GITHUB_TOKEN || process.env.GH_TOKEN || runCommand("gh", ["auth", "token"], cwd) || "";
|
||||
return githubTokenCache;
|
||||
}
|
||||
|
||||
function mapGitHubIssue(issue) {
|
||||
return {
|
||||
number: issue.number,
|
||||
title: issue.title,
|
||||
url: issue.html_url,
|
||||
labels: (issue.labels || []).map((label) => (typeof label === "string" ? label : label.name)).filter(Boolean),
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchOpenIssues(repo) {
|
||||
if (!repo || repo === "unknown/unknown") {
|
||||
throw new CanvasError("repo_unavailable", "Current repository could not be detected.");
|
||||
}
|
||||
|
||||
const [owner, repoName] = repo.split("/");
|
||||
const token = resolveGitHubToken();
|
||||
const headers = {
|
||||
Accept: "application/vnd.github+json",
|
||||
"User-Agent": "repository-issues-kanban",
|
||||
};
|
||||
if (token) headers.Authorization = `token ${token}`;
|
||||
|
||||
const allIssues = [];
|
||||
let page = 1;
|
||||
while (page <= 10) {
|
||||
const params = new URLSearchParams({
|
||||
state: "open",
|
||||
per_page: "100",
|
||||
page: String(page),
|
||||
});
|
||||
|
||||
const response = await fetch(`https://api.github.com/repos/${owner}/${repoName}/issues?${params}`, { headers });
|
||||
if (!response.ok) {
|
||||
const body = await response.text();
|
||||
throw new CanvasError("github_api_error", `GitHub API request failed (${response.status}): ${body.slice(0, 200)}`);
|
||||
}
|
||||
|
||||
const pageItems = await response.json();
|
||||
const mapped = pageItems
|
||||
.filter((item) => !item.pull_request)
|
||||
.map(mapGitHubIssue);
|
||||
allIssues.push(...mapped);
|
||||
|
||||
if (pageItems.length < 100) break;
|
||||
page += 1;
|
||||
}
|
||||
|
||||
return allIssues;
|
||||
}
|
||||
|
||||
function mergeFetchedIssues(existingState, fetchedIssues) {
|
||||
const existingByNumber = new Map(existingState.issues.map((issue) => [issue.number, issue]));
|
||||
|
||||
const mergedIssues = fetchedIssues.map((issue, idx) => {
|
||||
const prev = existingByNumber.get(issue.number);
|
||||
return {
|
||||
number: issue.number,
|
||||
title: issue.title,
|
||||
url: issue.url || `https://github.com/${existingState.repo}/issues/${issue.number}`,
|
||||
labels: normalizeLabelList(issue.labels),
|
||||
column: VALID_COLUMNS.has(prev?.column) ? prev.column : "backlog",
|
||||
priority: prev?.priority || "medium",
|
||||
order: Number.isInteger(prev?.order) ? prev.order : idx,
|
||||
agentStatus: prev?.agentStatus || "",
|
||||
agentActive: Boolean(prev?.agentActive),
|
||||
logs: Array.isArray(prev?.logs) ? prev.logs : [],
|
||||
};
|
||||
});
|
||||
|
||||
const availableLabels = computeAvailableLabels(mergedIssues);
|
||||
return {
|
||||
...existingState,
|
||||
issues: mergedIssues,
|
||||
availableLabels,
|
||||
selectedLabels: normalizeLabelList(existingState.selectedLabels).filter((label) => availableLabels.includes(label)),
|
||||
error: getRepoInfo().error,
|
||||
};
|
||||
}
|
||||
|
||||
async function refreshIssuesSafe() {
|
||||
const state = currentState();
|
||||
if (state.repo === "unknown/unknown") {
|
||||
saveState(state);
|
||||
broadcast("state", state);
|
||||
return state;
|
||||
}
|
||||
|
||||
try {
|
||||
const fetchedIssues = await fetchOpenIssues(state.repo);
|
||||
const merged = mergeFetchedIssues(state, fetchedIssues);
|
||||
saveState(merged);
|
||||
broadcast("state", merged);
|
||||
return merged;
|
||||
} catch (error) {
|
||||
console.error("[accessibility-kanban] Failed to refresh issues", error);
|
||||
const failed = {
|
||||
...state,
|
||||
error: REFRESH_ISSUES_ERROR,
|
||||
};
|
||||
saveState(failed);
|
||||
broadcast("state", failed);
|
||||
return failed;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── SSE ───
|
||||
|
||||
const sseClients = new Set();
|
||||
|
||||
function broadcast(event, data) {
|
||||
const msg = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
|
||||
for (const res of sseClients) res.write(msg);
|
||||
}
|
||||
|
||||
// ─── HTTP helpers ───
|
||||
|
||||
function readJson(req) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let body = "";
|
||||
req.on("data", (c) => (body += c));
|
||||
req.on("end", () => resolve(body ? JSON.parse(body) : {}));
|
||||
req.on("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
function json(res, code, data) {
|
||||
res.writeHead(code, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify(data));
|
||||
}
|
||||
|
||||
// ─── HTTP server ───
|
||||
|
||||
const server = http.createServer(async (req, res) => {
|
||||
const url = new URL(req.url, `http://${req.headers.host}`);
|
||||
|
||||
if (url.pathname === "/events") {
|
||||
res.writeHead(200, { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", Connection: "keep-alive" });
|
||||
sseClients.add(res);
|
||||
req.on("close", () => sseClients.delete(res));
|
||||
res.write(`event: state\ndata: ${JSON.stringify(currentState())}\n\n`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method === "GET" && url.pathname === "/api/state") {
|
||||
json(res, 200, await refreshIssuesSafe());
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method === "POST" && url.pathname === "/api/move") {
|
||||
const input = await readJson(req);
|
||||
const { issue, prevColumn } = moveIssue(input.issue_number, input.column);
|
||||
|
||||
if (input.column === "plan" && prevColumn !== "plan") {
|
||||
const repo = currentState().repo;
|
||||
session.send({
|
||||
prompt: `The Repository Issues Kanban just moved issue #${issue.number} ("${issue.title}") in ${repo} into the Plan column. Start planning the implementation for this issue in a background agent. Read the GitHub issue details, analyze the repository, and produce a concrete implementation plan. Use the kanban_update_status tool to post progress and then move the issue to "ready" with kanban_move_issue when planning is complete.`,
|
||||
});
|
||||
}
|
||||
|
||||
json(res, 200, { issue, state: currentState() });
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method === "POST" && url.pathname === "/api/update-status") {
|
||||
const input = await readJson(req);
|
||||
const issue = updateIssueStatus(input.issue_number, input.status, input.log);
|
||||
if (input.done) clearAgentStatus(input.issue_number);
|
||||
json(res, 200, { issue, state: currentState() });
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method === "POST" && url.pathname === "/api/filters") {
|
||||
const input = await readJson(req);
|
||||
const state = setSelectedLabels(input.labels);
|
||||
json(res, 200, state);
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method === "GET" && url.pathname.startsWith("/api/logs/")) {
|
||||
const num = parseInt(url.pathname.split("/").pop(), 10);
|
||||
const state = currentState();
|
||||
const issue = state.issues.find((i) => i.number === num);
|
||||
if (!issue) {
|
||||
json(res, 404, { error: "not found" });
|
||||
return;
|
||||
}
|
||||
json(res, 200, { issue_number: num, title: issue.title, logs: issue.logs || [] });
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method === "POST" && url.pathname === "/api/reset") {
|
||||
resetBoard();
|
||||
json(res, 200, await refreshIssuesSafe());
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.pathname === "/") {
|
||||
res.writeHead(200, { "Content-Type": "text/html" });
|
||||
res.end(fs.readFileSync(path.join(__dirname, "public", "index.html"), "utf8"));
|
||||
return;
|
||||
}
|
||||
|
||||
res.writeHead(404);
|
||||
res.end("Not found");
|
||||
});
|
||||
|
||||
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
function getPort() {
|
||||
return server.address().port;
|
||||
}
|
||||
|
||||
// ─── Canvas declaration ───
|
||||
|
||||
const canvas = createCanvas({
|
||||
id: "accessibility-kanban",
|
||||
displayName: "Repository Issues Kanban",
|
||||
description: "Kanban board for triaging open issues from the current repository into backlog, plan, ready, implement, and done lanes.",
|
||||
actions: [
|
||||
{
|
||||
name: "get_state",
|
||||
description: "Get the current board state including open repository issues and selected label filters.",
|
||||
inputSchema: { type: "object", properties: {}, additionalProperties: false },
|
||||
async handler(ctx) {
|
||||
captureCwd(ctx);
|
||||
return refreshIssuesSafe();
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "move_issue",
|
||||
description: "Move an issue to a different column on the kanban board.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
issue_number: { type: "number", description: "GitHub issue number" },
|
||||
column: { type: "string", enum: COLUMNS, description: "Target column" },
|
||||
},
|
||||
required: ["issue_number", "column"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
handler(ctx) {
|
||||
captureCwd(ctx);
|
||||
const { issue } = moveIssue(ctx.input.issue_number, ctx.input.column);
|
||||
return { issue, state: currentState() };
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "refresh_issues",
|
||||
description: "Replace the board with issue data supplied by the agent.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
issues: {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "object",
|
||||
properties: {
|
||||
number: { type: "number" },
|
||||
title: { type: "string" },
|
||||
url: { type: "string" },
|
||||
labels: {
|
||||
type: "array",
|
||||
items: {
|
||||
oneOf: [
|
||||
{ type: "string" },
|
||||
{ type: "object", properties: { name: { type: "string" } }, required: ["name"] },
|
||||
],
|
||||
},
|
||||
},
|
||||
column: { type: "string", enum: COLUMNS },
|
||||
priority: { type: "string" },
|
||||
order: { type: "number" },
|
||||
},
|
||||
required: ["number", "title"],
|
||||
additionalProperties: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
required: ["issues"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
handler(ctx) {
|
||||
captureCwd(ctx);
|
||||
return replaceIssues(ctx.input.issues);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "set_filters",
|
||||
description: "Set selected label filters (OR semantics).",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
labels: { type: "array", items: { type: "string" } },
|
||||
},
|
||||
required: ["labels"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
handler(ctx) {
|
||||
captureCwd(ctx);
|
||||
return setSelectedLabels(ctx.input.labels);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "reset_state",
|
||||
description: "Reset all cards to backlog and clear label filters, then refresh from live repo issues.",
|
||||
inputSchema: { type: "object", properties: {}, additionalProperties: false },
|
||||
async handler(ctx) {
|
||||
captureCwd(ctx);
|
||||
resetBoard();
|
||||
return refreshIssuesSafe();
|
||||
},
|
||||
},
|
||||
],
|
||||
async open(ctx) {
|
||||
captureCwd(ctx);
|
||||
const state = await refreshIssuesSafe();
|
||||
broadcast("state", state);
|
||||
return {
|
||||
url: `http://127.0.0.1:${getPort()}`,
|
||||
title: "Repository Issues Kanban",
|
||||
status: `${state.issues.length} open issues in ${state.repo}`,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
// ─── Join session (tools + canvas) ───
|
||||
|
||||
const session = await joinSession({
|
||||
canvases: [canvas],
|
||||
tools: [
|
||||
{
|
||||
name: "kanban_move_issue",
|
||||
description: "Move an issue on the repository issues kanban board to a new column (backlog, plan, ready, implement, done).",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
issue_number: { type: "number", description: "GitHub issue number" },
|
||||
column: { type: "string", enum: COLUMNS, description: "Target column to move the issue to" },
|
||||
},
|
||||
required: ["issue_number", "column"],
|
||||
},
|
||||
handler: async (args) => {
|
||||
const { issue } = moveIssue(args.issue_number, args.column);
|
||||
return JSON.stringify({ moved: true, issue, state: currentState() });
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "kanban_update_status",
|
||||
description: "Update the agent status line and log on a kanban card while planning or implementing an issue.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
issue_number: { type: "number", description: "GitHub issue number" },
|
||||
status: { type: "string", description: "Short status text shown on the card." },
|
||||
log: { type: "string", description: "Detailed log entry appended to the issue's agent log." },
|
||||
done: { type: "boolean", description: "Set true to stop the active glow." },
|
||||
},
|
||||
required: ["issue_number", "status"],
|
||||
},
|
||||
handler: async (args) => {
|
||||
const issue = updateIssueStatus(args.issue_number, args.status, args.log);
|
||||
if (args.done) clearAgentStatus(args.issue_number);
|
||||
return JSON.stringify({ updated: true, issue });
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"name": "accessibility-kanban",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"main": "extension.mjs",
|
||||
"dependencies": {
|
||||
"@github/copilot-sdk": "latest"
|
||||
},
|
||||
"description": "Users drag accessibility issues across kanban lanes to plan, track, and complete remediation work.",
|
||||
"keywords": [
|
||||
"accessibility",
|
||||
"kanban-board",
|
||||
"issue-triage",
|
||||
"planning-workflow",
|
||||
"status-tracking",
|
||||
"github-issues"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,754 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Repository Issues Kanban</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&family=IBM+Plex+Mono:wght@400;500&display=swap" rel="stylesheet" />
|
||||
<style>
|
||||
:root {
|
||||
--bg: #ffffff;
|
||||
--outer: #f1f5f9;
|
||||
--text: #111827;
|
||||
--muted: #6b7280;
|
||||
--meta: #94a3b8;
|
||||
--border: #e5e7eb;
|
||||
--coral: #ff7f50;
|
||||
--azure: #0ea5e9;
|
||||
--sage: #84cc16;
|
||||
--violet: #a78bfa;
|
||||
--radius: 6px;
|
||||
--font: 'DM Sans', sans-serif;
|
||||
--mono: 'IBM Plex Mono', monospace;
|
||||
}
|
||||
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
font-family: var(--font);
|
||||
background: var(--outer);
|
||||
color: var(--text);
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.board-wrap {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--bg);
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
header {
|
||||
padding: 10px 16px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
header .breadcrumb {
|
||||
font-size: 10px;
|
||||
color: var(--meta);
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
header .breadcrumb .sep {
|
||||
margin: 0 5px;
|
||||
color: var(--border);
|
||||
}
|
||||
|
||||
header .breadcrumb .current {
|
||||
color: var(--text);
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
header .spacer { flex: 1; }
|
||||
|
||||
header .reset-btn {
|
||||
font-family: var(--mono);
|
||||
font-size: 9px;
|
||||
color: var(--meta);
|
||||
background: none;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
padding: 3px 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
header .reset-btn:hover {
|
||||
border-color: var(--coral);
|
||||
color: var(--coral);
|
||||
}
|
||||
|
||||
header .label-filter-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
max-width: 50%;
|
||||
overflow-x: auto;
|
||||
padding: 2px 0;
|
||||
}
|
||||
|
||||
.label-chip {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 999px;
|
||||
padding: 3px 8px;
|
||||
font-size: 9px;
|
||||
font-family: var(--mono);
|
||||
color: var(--meta);
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.label-chip:hover {
|
||||
border-color: var(--azure);
|
||||
color: var(--azure);
|
||||
}
|
||||
|
||||
.label-chip.selected {
|
||||
border-color: var(--azure);
|
||||
color: var(--azure);
|
||||
background: rgba(14, 165, 233, 0.08);
|
||||
}
|
||||
|
||||
.label-filter-empty {
|
||||
font-family: var(--mono);
|
||||
font-size: 9px;
|
||||
color: var(--meta);
|
||||
}
|
||||
|
||||
.error-banner {
|
||||
margin: 8px 12px 0;
|
||||
border: 1px solid rgba(255, 127, 80, 0.3);
|
||||
background: rgba(255, 127, 80, 0.08);
|
||||
color: #9a3412;
|
||||
border-radius: 6px;
|
||||
padding: 6px 8px;
|
||||
font-size: 10px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.board {
|
||||
flex: 1;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, 1fr);
|
||||
gap: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.column {
|
||||
background: var(--bg);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
border-right: 1px solid var(--border);
|
||||
border-top: 2px solid var(--border);
|
||||
}
|
||||
|
||||
.column:last-child { border-right: none; }
|
||||
|
||||
.col-backlog { border-top-color: var(--meta); }
|
||||
.col-plan { border-top-color: var(--azure); }
|
||||
.col-ready { border-top-color: var(--sage); }
|
||||
.col-implement { border-top-color: var(--coral); }
|
||||
.col-done { border-top-color: var(--violet); }
|
||||
|
||||
.column-header {
|
||||
padding: 10px 12px 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.column-header h2 {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.6px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.column-header .count {
|
||||
font-family: var(--mono);
|
||||
font-size: 9px;
|
||||
color: var(--meta);
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.card-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 6px 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 8px 10px;
|
||||
cursor: grab;
|
||||
transition: border-color 0.12s ease;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
border-color: var(--meta);
|
||||
}
|
||||
|
||||
.card.dragging {
|
||||
opacity: 0.35;
|
||||
}
|
||||
|
||||
.card .issue-title {
|
||||
font-size: 11px;
|
||||
font-weight: 400;
|
||||
line-height: 1.4;
|
||||
color: var(--text);
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.card .issue-title .num {
|
||||
font-family: var(--mono);
|
||||
font-size: 10px;
|
||||
color: var(--meta);
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
/* Agent status line */
|
||||
.card .agent-status {
|
||||
font-family: var(--mono);
|
||||
font-size: 9px;
|
||||
color: var(--azure);
|
||||
margin-top: 5px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.card .agent-status .pulse {
|
||||
width: 4px; height: 4px;
|
||||
border-radius: 50%;
|
||||
background: var(--azure);
|
||||
animation: pulse-dot 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* Glowing border for active agent work */
|
||||
.card.agent-active {
|
||||
border-color: rgba(14, 165, 233, 0.4);
|
||||
box-shadow:
|
||||
0 0 0 1px rgba(14, 165, 233, 0.15),
|
||||
0 0 12px rgba(14, 165, 233, 0.1);
|
||||
animation: glow-pulse 2.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes glow-pulse {
|
||||
0%, 100% {
|
||||
box-shadow:
|
||||
0 0 0 1px rgba(14, 165, 233, 0.15),
|
||||
0 0 12px rgba(14, 165, 233, 0.1);
|
||||
}
|
||||
50% {
|
||||
box-shadow:
|
||||
0 0 0 1px rgba(14, 165, 233, 0.3),
|
||||
0 0 18px rgba(14, 165, 233, 0.15);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulse-dot {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.3; }
|
||||
}
|
||||
|
||||
/* Log button */
|
||||
.card .log-btn {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
right: 6px;
|
||||
width: 16px; height: 16px;
|
||||
border-radius: 3px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--meta);
|
||||
font-size: 10px;
|
||||
cursor: pointer;
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: background 0.12s;
|
||||
}
|
||||
|
||||
.card .log-btn:hover {
|
||||
background: rgba(14,165,233,0.08);
|
||||
color: var(--azure);
|
||||
}
|
||||
|
||||
.card.agent-active .log-btn { display: flex; }
|
||||
.card.has-logs:hover .log-btn { display: flex; }
|
||||
|
||||
/* Drop target highlight */
|
||||
.column.drop-target {
|
||||
background: rgba(14,165,233,0.02);
|
||||
}
|
||||
|
||||
/* Ghost (drag preview) */
|
||||
.ghost {
|
||||
position: fixed;
|
||||
pointer-events: none;
|
||||
z-index: 9999;
|
||||
opacity: 0.85;
|
||||
transform: rotate(1.5deg) scale(1.01);
|
||||
box-shadow: 0 8px 20px rgba(0,0,0,0.12);
|
||||
}
|
||||
|
||||
/* Log Modal */
|
||||
.modal-overlay {
|
||||
display: none;
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0,0,0,0.3);
|
||||
z-index: 10000;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
backdrop-filter: blur(2px);
|
||||
}
|
||||
|
||||
.modal-overlay.open { display: flex; }
|
||||
|
||||
.modal {
|
||||
background: var(--bg);
|
||||
border-radius: 10px;
|
||||
width: 90%;
|
||||
max-width: 480px;
|
||||
max-height: 65vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-shadow: 0 20px 50px rgba(0,0,0,0.15);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.modal-header h3 {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.modal-header button {
|
||||
border: none;
|
||||
background: none;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
color: var(--muted);
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.modal-header button:hover { background: var(--border); }
|
||||
|
||||
.modal-body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 12px 16px;
|
||||
}
|
||||
|
||||
.log-entry {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
padding: 5px 0;
|
||||
border-bottom: 1px solid rgba(0,0,0,0.03);
|
||||
}
|
||||
|
||||
.log-entry:last-child { border-bottom: none; }
|
||||
|
||||
.log-entry .ts {
|
||||
font-family: var(--mono);
|
||||
font-size: 9px;
|
||||
color: var(--meta);
|
||||
white-space: nowrap;
|
||||
padding-top: 2px;
|
||||
min-width: 48px;
|
||||
}
|
||||
|
||||
.log-entry .msg {
|
||||
font-size: 11px;
|
||||
line-height: 1.5;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.empty-log {
|
||||
font-size: 11px;
|
||||
color: var(--meta);
|
||||
text-align: center;
|
||||
padding: 20px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="board-wrap">
|
||||
<header>
|
||||
<div class="breadcrumb">
|
||||
<span id="repo-name">Current Repository</span><span class="sep">/</span><span class="current">Issues Kanban</span>
|
||||
</div>
|
||||
<span class="spacer"></span>
|
||||
<div class="label-filter-container" id="label-filters"></div>
|
||||
<button class="reset-btn" id="clear-filters-btn">Clear Labels</button>
|
||||
<button class="reset-btn" id="reset-btn">Reset</button>
|
||||
</header>
|
||||
<div class="error-banner" id="error-banner" hidden></div>
|
||||
<div class="board" id="board"></div>
|
||||
</div>
|
||||
|
||||
<!-- Log Modal -->
|
||||
<div class="modal-overlay" id="modal-overlay">
|
||||
<div class="modal">
|
||||
<div class="modal-header">
|
||||
<h3 id="modal-title">Agent Log</h3>
|
||||
<button id="modal-close">×</button>
|
||||
</div>
|
||||
<div class="modal-body" id="modal-body"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const COLUMNS = ["backlog", "plan", "ready", "implement", "done"];
|
||||
const COL_LABELS = { backlog: "Backlog", plan: "Plan", ready: "Ready", implement: "Implement", done: "Done" };
|
||||
|
||||
let state = { repo: "", issues: [], availableLabels: [], selectedLabels: [], error: null };
|
||||
let dragState = null;
|
||||
|
||||
// ─── Rendering ───
|
||||
|
||||
function issueMatchesFilter(issue) {
|
||||
if (!state.selectedLabels || state.selectedLabels.length === 0) return true;
|
||||
const labels = Array.isArray(issue.labels) ? issue.labels : [];
|
||||
return labels.some((label) => state.selectedLabels.includes(label));
|
||||
}
|
||||
|
||||
function updateHeader() {
|
||||
const repoEl = document.getElementById("repo-name");
|
||||
repoEl.textContent = state.repo || "Current Repository";
|
||||
}
|
||||
|
||||
function updateErrorBanner() {
|
||||
const banner = document.getElementById("error-banner");
|
||||
const message = state.error;
|
||||
if (message) {
|
||||
banner.hidden = false;
|
||||
banner.textContent = message;
|
||||
return;
|
||||
}
|
||||
banner.hidden = true;
|
||||
banner.textContent = "";
|
||||
}
|
||||
|
||||
function renderLabelFilters() {
|
||||
const host = document.getElementById("label-filters");
|
||||
host.innerHTML = "";
|
||||
|
||||
const labels = Array.isArray(state.availableLabels) ? state.availableLabels : [];
|
||||
if (labels.length === 0) {
|
||||
host.innerHTML = '<span class="label-filter-empty">No labels</span>';
|
||||
return;
|
||||
}
|
||||
|
||||
labels.forEach((label) => {
|
||||
const chip = document.createElement("button");
|
||||
chip.type = "button";
|
||||
chip.className = "label-chip";
|
||||
chip.textContent = label;
|
||||
if (state.selectedLabels.includes(label)) chip.classList.add("selected");
|
||||
chip.addEventListener("click", () => toggleLabel(label));
|
||||
host.appendChild(chip);
|
||||
});
|
||||
}
|
||||
|
||||
function render() {
|
||||
updateHeader();
|
||||
updateErrorBanner();
|
||||
renderLabelFilters();
|
||||
|
||||
const board = document.getElementById("board");
|
||||
board.innerHTML = "";
|
||||
|
||||
COLUMNS.forEach((col) => {
|
||||
const issues = state.issues
|
||||
.filter((i) => i.column === col)
|
||||
.filter(issueMatchesFilter)
|
||||
.sort((a, b) => (a.order || 0) - (b.order || 0));
|
||||
|
||||
const colEl = document.createElement("div");
|
||||
colEl.className = `column col-${col}`;
|
||||
colEl.dataset.column = col;
|
||||
|
||||
colEl.innerHTML = `
|
||||
<div class="column-header">
|
||||
<h2>${COL_LABELS[col]}</h2>
|
||||
<span class="count">${issues.length}</span>
|
||||
</div>
|
||||
<div class="card-list" data-column="${col}"></div>
|
||||
`;
|
||||
|
||||
const list = colEl.querySelector(".card-list");
|
||||
issues.forEach((issue) => {
|
||||
const card = document.createElement("div");
|
||||
const hasLogs = issue.logs && issue.logs.length > 0;
|
||||
let cls = "card";
|
||||
if (issue.agentActive) cls += " agent-active";
|
||||
if (hasLogs) cls += " has-logs";
|
||||
card.className = cls;
|
||||
card.dataset.issueNumber = issue.number;
|
||||
|
||||
let statusHtml = "";
|
||||
if (issue.agentStatus) {
|
||||
statusHtml = `<div class="agent-status">${issue.agentActive ? '<span class="pulse"></span>' : ''}${escHtml(issue.agentStatus)}</div>`;
|
||||
}
|
||||
|
||||
card.innerHTML = `
|
||||
<button class="log-btn" title="View agent log">▣</button>
|
||||
<div class="issue-title"><span class="num">#${issue.number}</span>${escHtml(issue.title)}</div>
|
||||
${statusHtml}
|
||||
`;
|
||||
|
||||
// Log button click
|
||||
const logBtn = card.querySelector(".log-btn");
|
||||
if (logBtn) {
|
||||
logBtn.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
openLogModal(issue);
|
||||
});
|
||||
}
|
||||
|
||||
// Pointer drag
|
||||
card.addEventListener("pointerdown", (e) => startDrag(e, card, issue));
|
||||
list.appendChild(card);
|
||||
});
|
||||
|
||||
board.appendChild(colEl);
|
||||
});
|
||||
}
|
||||
|
||||
function escHtml(s) {
|
||||
return String(s || "").replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||
}
|
||||
|
||||
// ─── Log Modal ───
|
||||
|
||||
function openLogModal(issue) {
|
||||
const overlay = document.getElementById("modal-overlay");
|
||||
const title = document.getElementById("modal-title");
|
||||
const body = document.getElementById("modal-body");
|
||||
|
||||
title.textContent = `#${issue.number} — Agent Log`;
|
||||
|
||||
if (!issue.logs || issue.logs.length === 0) {
|
||||
body.innerHTML = '<div class="empty-log">No agent activity logged yet.</div>';
|
||||
} else {
|
||||
body.innerHTML = issue.logs.map((l) => `
|
||||
<div class="log-entry">
|
||||
<span class="ts">${formatTime(l.timestamp)}</span>
|
||||
<span class="msg">${escHtml(l.message)}</span>
|
||||
</div>
|
||||
`).join("");
|
||||
body.scrollTop = body.scrollHeight;
|
||||
}
|
||||
|
||||
overlay.classList.add("open");
|
||||
}
|
||||
|
||||
function formatTime(ts) {
|
||||
try {
|
||||
const d = new Date(ts);
|
||||
return d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
|
||||
} catch { return ""; }
|
||||
}
|
||||
|
||||
document.getElementById("modal-close").addEventListener("click", () => {
|
||||
document.getElementById("modal-overlay").classList.remove("open");
|
||||
});
|
||||
|
||||
document.getElementById("modal-overlay").addEventListener("click", (e) => {
|
||||
if (e.target === e.currentTarget) {
|
||||
e.currentTarget.classList.remove("open");
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Pointer Drag & Drop ───
|
||||
|
||||
function startDrag(e, cardEl, issue) {
|
||||
if (e.button !== 0) return;
|
||||
e.preventDefault();
|
||||
cardEl.setPointerCapture(e.pointerId);
|
||||
|
||||
const rect = cardEl.getBoundingClientRect();
|
||||
const ghost = cardEl.cloneNode(true);
|
||||
ghost.className = "card ghost";
|
||||
ghost.style.width = rect.width + "px";
|
||||
ghost.style.left = rect.left + "px";
|
||||
ghost.style.top = rect.top + "px";
|
||||
document.body.appendChild(ghost);
|
||||
cardEl.classList.add("dragging");
|
||||
|
||||
dragState = {
|
||||
issue,
|
||||
ghost,
|
||||
cardEl,
|
||||
offsetX: e.clientX - rect.left,
|
||||
offsetY: e.clientY - rect.top,
|
||||
pointerId: e.pointerId,
|
||||
};
|
||||
|
||||
cardEl.addEventListener("pointermove", onDragMove);
|
||||
cardEl.addEventListener("pointerup", onDragEnd);
|
||||
}
|
||||
|
||||
function onDragMove(e) {
|
||||
if (!dragState) return;
|
||||
dragState.ghost.style.left = (e.clientX - dragState.offsetX) + "px";
|
||||
dragState.ghost.style.top = (e.clientY - dragState.offsetY) + "px";
|
||||
|
||||
// Highlight drop target
|
||||
document.querySelectorAll(".column").forEach((col) => {
|
||||
const r = col.getBoundingClientRect();
|
||||
if (e.clientX >= r.left && e.clientX <= r.right) {
|
||||
col.classList.add("drop-target");
|
||||
} else {
|
||||
col.classList.remove("drop-target");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function onDragEnd(e) {
|
||||
if (!dragState) return;
|
||||
const { issue, ghost, cardEl } = dragState;
|
||||
|
||||
cardEl.classList.remove("dragging");
|
||||
cardEl.releasePointerCapture(dragState.pointerId);
|
||||
cardEl.removeEventListener("pointermove", onDragMove);
|
||||
cardEl.removeEventListener("pointerup", onDragEnd);
|
||||
ghost.remove();
|
||||
|
||||
document.querySelectorAll(".column.drop-target").forEach((col) => {
|
||||
col.classList.remove("drop-target");
|
||||
});
|
||||
|
||||
// Find target column
|
||||
const targetCol = document.elementFromPoint(e.clientX, e.clientY)?.closest(".column");
|
||||
if (targetCol && targetCol.dataset.column !== issue.column) {
|
||||
moveIssue(issue.number, targetCol.dataset.column);
|
||||
}
|
||||
|
||||
dragState = null;
|
||||
}
|
||||
|
||||
// ─── API ───
|
||||
|
||||
async function moveIssue(issueNumber, column) {
|
||||
try {
|
||||
const resp = await fetch("/api/move", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ issue_number: issueNumber, column }),
|
||||
});
|
||||
const data = await resp.json();
|
||||
state = data.state;
|
||||
render();
|
||||
} catch (err) {
|
||||
console.error("Move failed:", err);
|
||||
}
|
||||
}
|
||||
|
||||
async function setSelectedLabels(labels) {
|
||||
try {
|
||||
const resp = await fetch("/api/filters", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ labels }),
|
||||
});
|
||||
state = await resp.json();
|
||||
render();
|
||||
} catch (err) {
|
||||
console.error("Updating filters failed:", err);
|
||||
}
|
||||
}
|
||||
|
||||
function toggleLabel(label) {
|
||||
const selected = new Set(state.selectedLabels || []);
|
||||
if (selected.has(label)) selected.delete(label);
|
||||
else selected.add(label);
|
||||
setSelectedLabels([...selected]);
|
||||
}
|
||||
|
||||
// ─── SSE ───
|
||||
|
||||
function connectSSE() {
|
||||
const es = new EventSource("/events");
|
||||
es.addEventListener("state", (e) => {
|
||||
try {
|
||||
state = JSON.parse(e.data);
|
||||
render();
|
||||
} catch {}
|
||||
});
|
||||
es.addEventListener("connected", () => {});
|
||||
es.onerror = () => {
|
||||
es.close();
|
||||
setTimeout(connectSSE, 2000);
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Init ───
|
||||
|
||||
async function init() {
|
||||
try {
|
||||
const resp = await fetch("/api/state");
|
||||
state = await resp.json();
|
||||
} catch {}
|
||||
render();
|
||||
connectSSE();
|
||||
|
||||
document.getElementById("reset-btn").addEventListener("click", async () => {
|
||||
if (!confirm("Reset all issues to backlog?")) return;
|
||||
try {
|
||||
const resp = await fetch("/api/reset", { method: "POST" });
|
||||
state = await resp.json();
|
||||
render();
|
||||
} catch (err) {
|
||||
console.error("Reset failed:", err);
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById("clear-filters-btn").addEventListener("click", () => {
|
||||
setSelectedLabels([]);
|
||||
});
|
||||
}
|
||||
|
||||
init();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user