mirror of
https://github.com/github/awesome-copilot.git
synced 2026-06-24 08:27:40 +00:00
chore: publish from staged
This commit is contained in:
@@ -3,88 +3,108 @@ import http from "node:http";
|
|||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
import { spawnSync } from "node:child_process";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
const EXTENSION_NAME = "accessibility-kanban";
|
const EXTENSION_NAME = "accessibility-kanban";
|
||||||
const STATE_FILE = "signalbox-accessibility-kanban-state.json";
|
const STATE_FILE_PREFIX = "repository-issues-kanban-state";
|
||||||
const COLUMNS = ["backlog", "plan", "ready", "implement", "done"];
|
const COLUMNS = ["backlog", "plan", "ready", "implement", "done"];
|
||||||
const VALID_COLUMNS = new Set(COLUMNS);
|
const VALID_COLUMNS = new Set(COLUMNS);
|
||||||
|
|
||||||
const defaultIssues = [
|
let repoInfoCache = null;
|
||||||
{
|
let githubTokenCache;
|
||||||
number: 39,
|
let sessionRef = null;
|
||||||
title: "Add keyboard trap prevention for modal-like interactions",
|
|
||||||
url: "https://github.com/sethjuarez/SignalBox/issues/39",
|
function getWorkspaceCwd() {
|
||||||
labels: ["signalbox-mvp", "frontend", "accessibility"],
|
return sessionRef?.workspacePath || process.cwd();
|
||||||
column: "backlog",
|
}
|
||||||
priority: "high",
|
|
||||||
},
|
// ─── Repo resolution ───
|
||||||
{
|
|
||||||
number: 38,
|
function runCommand(command, args, cwd = process.cwd()) {
|
||||||
title: "Ensure color contrast meets WCAG AA for all text",
|
try {
|
||||||
url: "https://github.com/sethjuarez/SignalBox/issues/38",
|
const result = spawnSync(command, args, { cwd, encoding: "utf8" });
|
||||||
labels: ["signalbox-mvp", "product-polish", "accessibility"],
|
if (result.status === 0 && !result.error) {
|
||||||
column: "backlog",
|
return (result.stdout || "").trim();
|
||||||
priority: "high",
|
}
|
||||||
},
|
} catch {
|
||||||
{
|
// Ignore and fall through to empty string.
|
||||||
number: 37,
|
}
|
||||||
title: "Add aria-live region for form submission feedback",
|
return "";
|
||||||
url: "https://github.com/sethjuarez/SignalBox/issues/37",
|
}
|
||||||
labels: ["signalbox-mvp", "frontend", "accessibility"],
|
|
||||||
column: "backlog",
|
function normalizeRepo(repo) {
|
||||||
priority: "high",
|
if (typeof repo !== "string") return null;
|
||||||
},
|
const cleaned = repo
|
||||||
{
|
.trim()
|
||||||
number: 36,
|
.replace(/^https?:\/\/github\.com\//i, "")
|
||||||
title: "Add focus-visible outline to all interactive elements",
|
.replace(/\.git$/i, "");
|
||||||
url: "https://github.com/sethjuarez/SignalBox/issues/36",
|
if (!/^[^/\s]+\/[^/\s]+$/.test(cleaned)) return null;
|
||||||
labels: ["signalbox-mvp", "frontend", "accessibility"],
|
return cleaned;
|
||||||
column: "backlog",
|
}
|
||||||
priority: "high",
|
|
||||||
},
|
function parseRepoFromRemoteUrl(remoteUrl) {
|
||||||
{
|
if (!remoteUrl) return null;
|
||||||
number: 35,
|
const cleaned = remoteUrl.trim().replace(/\.git$/i, "");
|
||||||
title: "Add aria-hidden to decorative SVG icons in AuthPage",
|
|
||||||
url: "https://github.com/sethjuarez/SignalBox/issues/35",
|
const sshMatch = cleaned.match(/^[^@]+@[^:]+:([^/]+\/[^/]+)$/);
|
||||||
labels: ["signalbox-mvp", "frontend", "accessibility"],
|
if (sshMatch) return sshMatch[1];
|
||||||
column: "backlog",
|
|
||||||
priority: "medium",
|
const httpMatch = cleaned.match(/^https?:\/\/[^/]+\/([^/]+\/[^/]+)$/i);
|
||||||
},
|
if (httpMatch) return httpMatch[1];
|
||||||
{
|
|
||||||
number: 20,
|
const fallbackMatch = cleaned.match(/[:/]([^/:]+\/[^/:]+)$/);
|
||||||
title: "Audit and fix form field label association and aria-describedby",
|
return fallbackMatch ? fallbackMatch[1] : null;
|
||||||
url: "https://github.com/sethjuarez/SignalBox/issues/20",
|
}
|
||||||
labels: ["signalbox-mvp", "frontend", "product-polish", "accessibility"],
|
|
||||||
column: "backlog",
|
function candidateCwds(preferredCwd) {
|
||||||
priority: "medium",
|
const candidates = [
|
||||||
},
|
preferredCwd,
|
||||||
{
|
sessionRef?.workspacePath,
|
||||||
number: 19,
|
__dirname,
|
||||||
title: "Ensure consistent keyboard focus styles across the intake form",
|
path.dirname(__dirname),
|
||||||
url: "https://github.com/sethjuarez/SignalBox/issues/19",
|
path.dirname(path.dirname(__dirname)),
|
||||||
labels: ["enhancement", "good first issue", "ready-for-implementation", "frontend", "accessibility"],
|
path.dirname(path.dirname(path.dirname(__dirname))),
|
||||||
column: "backlog",
|
process.cwd(),
|
||||||
priority: "medium",
|
].filter(Boolean);
|
||||||
},
|
return [...new Set(candidates)];
|
||||||
{
|
}
|
||||||
number: 17,
|
|
||||||
title: "Add accessible client-side validation errors to the intake form",
|
function resolveRepoFromGit(cwd) {
|
||||||
url: "https://github.com/sethjuarez/SignalBox/issues/17",
|
const gitRoot = runCommand("git", ["rev-parse", "--show-toplevel"], cwd);
|
||||||
labels: ["enhancement", "good first issue", "ready-for-implementation", "frontend", "accessibility"],
|
if (!gitRoot) return null;
|
||||||
column: "backlog",
|
|
||||||
priority: "medium",
|
const fromGh = normalizeRepo(runCommand("gh", ["repo", "view", "--json", "nameWithOwner", "-q", ".nameWithOwner"], gitRoot));
|
||||||
},
|
if (fromGh) return fromGh;
|
||||||
{
|
|
||||||
number: 16,
|
const remoteUrl = runCommand("git", ["remote", "get-url", "origin"], gitRoot) || runCommand("git", ["config", "--get", "remote.origin.url"], gitRoot);
|
||||||
title: "Improve page landmark and heading structure for screen reader navigation",
|
return normalizeRepo(parseRepoFromRemoteUrl(remoteUrl));
|
||||||
url: "https://github.com/sethjuarez/SignalBox/issues/16",
|
}
|
||||||
labels: ["good first issue", "signalbox-mvp", "frontend", "product-polish", "accessibility"],
|
|
||||||
column: "backlog",
|
function resolveCurrentRepoInfo(cwd = getWorkspaceCwd()) {
|
||||||
priority: "medium",
|
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 ───
|
// ─── State persistence ───
|
||||||
|
|
||||||
@@ -92,43 +112,107 @@ function copilotHome() {
|
|||||||
return process.env.COPILOT_HOME || path.join(os.homedir(), ".copilot");
|
return process.env.COPILOT_HOME || path.join(os.homedir(), ".copilot");
|
||||||
}
|
}
|
||||||
|
|
||||||
function getStatePath() {
|
function stateFileName(repo) {
|
||||||
return path.join(copilotHome(), "extensions", EXTENSION_NAME, "artifacts", STATE_FILE);
|
const key = String(repo || "unknown-unknown")
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^\w.-]+/g, "-");
|
||||||
|
return `${STATE_FILE_PREFIX}-${key}.json`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function defaultState() {
|
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 {
|
return {
|
||||||
repo: "sethjuarez/SignalBox",
|
repo: repoInfo.repo,
|
||||||
|
error: repoInfo.error,
|
||||||
updatedAt: new Date().toISOString(),
|
updatedAt: new Date().toISOString(),
|
||||||
generation: Date.now(),
|
generation: Date.now(),
|
||||||
columns: COLUMNS,
|
columns: COLUMNS,
|
||||||
issues: defaultIssues.map((issue, index) => ({ ...issue, order: index })),
|
availableLabels: [],
|
||||||
|
selectedLabels: [],
|
||||||
|
issues: [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function ensureStateDirectory() {
|
function normalizeLabelList(labels) {
|
||||||
fs.mkdirSync(path.dirname(getStatePath()), { recursive: true });
|
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 loadState() {
|
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 || 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 {
|
try {
|
||||||
return JSON.parse(fs.readFileSync(getStatePath(), "utf8"));
|
return JSON.parse(fs.readFileSync(getStatePath(repo), "utf8"));
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function saveState(state) {
|
function saveState(state) {
|
||||||
ensureStateDirectory();
|
ensureStateDirectory(state.repo);
|
||||||
fs.writeFileSync(getStatePath(), JSON.stringify({ ...state, updatedAt: new Date().toISOString() }, null, 2));
|
fs.writeFileSync(
|
||||||
|
getStatePath(state.repo),
|
||||||
|
JSON.stringify({ ...state, updatedAt: new Date().toISOString() }, null, 2),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function currentState() {
|
function currentState() {
|
||||||
const state = loadState();
|
const repoInfo = getRepoInfo();
|
||||||
if (state) return state;
|
const loaded = loadState(repoInfo.repo);
|
||||||
const initial = defaultState();
|
const normalized = normalizeState(loaded || defaultState(repoInfo), repoInfo);
|
||||||
saveState(initial);
|
if (!loaded) saveState(normalized);
|
||||||
return initial;
|
return normalized;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Issue operations ───
|
// ─── Issue operations ───
|
||||||
@@ -142,16 +226,18 @@ function moveIssue(issueNumber, column) {
|
|||||||
if (!issue) {
|
if (!issue) {
|
||||||
throw new CanvasError("not_found", `Issue #${issueNumber} not found on the board`);
|
throw new CanvasError("not_found", `Issue #${issueNumber} not found on the board`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const prevColumn = issue.column;
|
const prevColumn = issue.column;
|
||||||
issue.column = column;
|
issue.column = column;
|
||||||
issue.order = state.issues.filter((i) => i.column === column).length;
|
issue.order = state.issues.filter((i) => i.column === column).length;
|
||||||
// Clear agent status when moved to done or backlog
|
|
||||||
if (column === "done" || column === "backlog") {
|
if (column === "done" || column === "backlog") {
|
||||||
issue.agentActive = false;
|
issue.agentActive = false;
|
||||||
issue.agentStatus = column === "done" ? "Complete" : "";
|
issue.agentStatus = column === "done" ? "Complete" : "";
|
||||||
}
|
}
|
||||||
|
|
||||||
saveState(state);
|
saveState(state);
|
||||||
broadcast("state", currentState());
|
broadcast("state", state);
|
||||||
return { issue, prevColumn };
|
return { issue, prevColumn };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -161,10 +247,9 @@ function updateIssueStatus(issueNumber, status, logEntry) {
|
|||||||
if (!issue) {
|
if (!issue) {
|
||||||
throw new CanvasError("not_found", `Issue #${issueNumber} not found on the board`);
|
throw new CanvasError("not_found", `Issue #${issueNumber} not found on the board`);
|
||||||
}
|
}
|
||||||
// Don't update agent status on issues that have been reset to backlog
|
|
||||||
if (issue.column === "backlog") {
|
if (issue.column === "backlog") return issue;
|
||||||
return issue;
|
|
||||||
}
|
|
||||||
if (status !== undefined) issue.agentStatus = status;
|
if (status !== undefined) issue.agentStatus = status;
|
||||||
if (logEntry) {
|
if (logEntry) {
|
||||||
if (!issue.logs) issue.logs = [];
|
if (!issue.logs) issue.logs = [];
|
||||||
@@ -172,7 +257,7 @@ function updateIssueStatus(issueNumber, status, logEntry) {
|
|||||||
}
|
}
|
||||||
issue.agentActive = true;
|
issue.agentActive = true;
|
||||||
saveState(state);
|
saveState(state);
|
||||||
broadcast("state", currentState());
|
broadcast("state", state);
|
||||||
return issue;
|
return issue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -182,35 +267,184 @@ function clearAgentStatus(issueNumber) {
|
|||||||
if (!issue) return;
|
if (!issue) return;
|
||||||
issue.agentActive = false;
|
issue.agentActive = false;
|
||||||
saveState(state);
|
saveState(state);
|
||||||
broadcast("state", currentState());
|
broadcast("state", state);
|
||||||
}
|
}
|
||||||
|
|
||||||
function replaceIssues(issues) {
|
function replaceIssues(issues) {
|
||||||
const existing = currentState();
|
const existing = currentState();
|
||||||
const existingByNumber = new Map(existing.issues.map((i) => [i.number, i]));
|
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 = {
|
const next = {
|
||||||
...existing,
|
...existing,
|
||||||
issues: issues
|
issues: nextIssues,
|
||||||
.filter((i) => i && Number.isInteger(i.number) && i.title)
|
availableLabels,
|
||||||
.map((issue, idx) => {
|
selectedLabels: normalizeLabelList(existing.selectedLabels).filter((label) => availableLabels.includes(label)),
|
||||||
const prev = existingByNumber.get(issue.number);
|
error: getRepoInfo().error,
|
||||||
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/sethjuarez/SignalBox/issues/${issue.number}`,
|
|
||||||
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,
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
};
|
};
|
||||||
saveState(next);
|
saveState(next);
|
||||||
broadcast("state", currentState());
|
broadcast("state", next);
|
||||||
return currentState();
|
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) {
|
||||||
|
const failed = {
|
||||||
|
...state,
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
};
|
||||||
|
saveState(failed);
|
||||||
|
broadcast("state", failed);
|
||||||
|
return failed;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── SSE ───
|
// ─── SSE ───
|
||||||
@@ -252,7 +486,7 @@ const server = http.createServer(async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (req.method === "GET" && url.pathname === "/api/state") {
|
if (req.method === "GET" && url.pathname === "/api/state") {
|
||||||
json(res, 200, currentState());
|
json(res, 200, await refreshIssuesSafe());
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -260,18 +494,11 @@ const server = http.createServer(async (req, res) => {
|
|||||||
const input = await readJson(req);
|
const input = await readJson(req);
|
||||||
const { issue, prevColumn } = moveIssue(input.issue_number, input.column);
|
const { issue, prevColumn } = moveIssue(input.issue_number, input.column);
|
||||||
|
|
||||||
// When an issue moves INTO "plan", send a prompt to the agent
|
|
||||||
if (input.column === "plan" && prevColumn !== "plan") {
|
if (input.column === "plan" && prevColumn !== "plan") {
|
||||||
if (issue.number === 35) {
|
const repo = currentState().repo;
|
||||||
// Fast path for demo — issue 35 is trivial, skip full analysis
|
session.send({
|
||||||
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.`,
|
||||||
prompt: `The accessibility kanban board just moved issue #35 ("Add aria-hidden to decorative SVG icons in AuthPage") into the Plan column. This is a simple fix — just add aria-hidden="true" to the two decorative blur divs and the Microsoft logo SVG in src/components/AuthPage.tsx. Use the kanban_update_status tool to post a brief status update ("Analyzing..."), then after a moment post the plan summary, then move the issue to "ready" using kanban_move_issue. Keep it quick — no need to read the GitHub issue or deeply analyze the codebase. The plan is: add aria-hidden="true" to lines ~47-48 (decorative background circles) and the SVG element at lines ~6-17.`,
|
});
|
||||||
});
|
|
||||||
} else {
|
|
||||||
session.send({
|
|
||||||
prompt: `The accessibility kanban board just moved issue #${issue.number} ("${issue.title}") into the Plan column. Please start planning the implementation for this issue in a background agent. Read the issue details from GitHub, analyze the codebase to understand what needs to change, and produce a concrete implementation plan. When planning is complete, move the issue to "ready" on the canvas using the move_issue canvas action.`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
json(res, 200, { issue, state: currentState() });
|
json(res, 200, { issue, state: currentState() });
|
||||||
@@ -286,20 +513,28 @@ const server = http.createServer(async (req, res) => {
|
|||||||
return;
|
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/")) {
|
if (req.method === "GET" && url.pathname.startsWith("/api/logs/")) {
|
||||||
const num = parseInt(url.pathname.split("/").pop(), 10);
|
const num = parseInt(url.pathname.split("/").pop(), 10);
|
||||||
const state = currentState();
|
const state = currentState();
|
||||||
const issue = state.issues.find((i) => i.number === num);
|
const issue = state.issues.find((i) => i.number === num);
|
||||||
if (!issue) { json(res, 404, { error: "not found" }); return; }
|
if (!issue) {
|
||||||
|
json(res, 404, { error: "not found" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
json(res, 200, { issue_number: num, title: issue.title, logs: issue.logs || [] });
|
json(res, 200, { issue_number: num, title: issue.title, logs: issue.logs || [] });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (req.method === "POST" && url.pathname === "/api/reset") {
|
if (req.method === "POST" && url.pathname === "/api/reset") {
|
||||||
const s = defaultState();
|
resetBoard();
|
||||||
saveState(s);
|
json(res, 200, await refreshIssuesSafe());
|
||||||
broadcast("state", currentState());
|
|
||||||
json(res, 200, currentState());
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -314,26 +549,28 @@ const server = http.createServer(async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||||
function getPort() { return server.address().port; }
|
function getPort() {
|
||||||
|
return server.address().port;
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Canvas declaration ───
|
// ─── Canvas declaration ───
|
||||||
|
|
||||||
const canvas = createCanvas({
|
const canvas = createCanvas({
|
||||||
id: "accessibility-kanban",
|
id: "accessibility-kanban",
|
||||||
displayName: "Accessibility Kanban",
|
displayName: "Repository Issues Kanban",
|
||||||
description: "Kanban board for triaging open SignalBox accessibility issues into backlog, plan, ready, implement, and done lanes. Moving an issue to plan triggers a background planning agent.",
|
description: "Kanban board for triaging open issues from the current repository into backlog, plan, ready, implement, and done lanes.",
|
||||||
actions: [
|
actions: [
|
||||||
{
|
{
|
||||||
name: "get_state",
|
name: "get_state",
|
||||||
description: "Get the current Kanban board state including all issues and their columns.",
|
description: "Get the current board state including open repository issues and selected label filters.",
|
||||||
inputSchema: { type: "object", properties: {}, additionalProperties: false },
|
inputSchema: { type: "object", properties: {}, additionalProperties: false },
|
||||||
handler() {
|
async handler() {
|
||||||
return currentState();
|
return refreshIssuesSafe();
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "move_issue",
|
name: "move_issue",
|
||||||
description: "Move an issue to a different column on the Kanban board.",
|
description: "Move an issue to a different column on the kanban board.",
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
type: "object",
|
type: "object",
|
||||||
properties: {
|
properties: {
|
||||||
@@ -350,7 +587,7 @@ const canvas = createCanvas({
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "refresh_issues",
|
name: "refresh_issues",
|
||||||
description: "Replace the board with fresh issue data supplied by the agent.",
|
description: "Replace the board with issue data supplied by the agent.",
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
type: "object",
|
type: "object",
|
||||||
properties: {
|
properties: {
|
||||||
@@ -362,7 +599,15 @@ const canvas = createCanvas({
|
|||||||
number: { type: "number" },
|
number: { type: "number" },
|
||||||
title: { type: "string" },
|
title: { type: "string" },
|
||||||
url: { type: "string" },
|
url: { type: "string" },
|
||||||
labels: { type: "array", items: { oneOf: [{ type: "string" }, { type: "object", properties: { name: { type: "string" } }, required: ["name"] }] } },
|
labels: {
|
||||||
|
type: "array",
|
||||||
|
items: {
|
||||||
|
oneOf: [
|
||||||
|
{ type: "string" },
|
||||||
|
{ type: "object", properties: { name: { type: "string" } }, required: ["name"] },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
column: { type: "string", enum: COLUMNS },
|
column: { type: "string", enum: COLUMNS },
|
||||||
priority: { type: "string" },
|
priority: { type: "string" },
|
||||||
order: { type: "number" },
|
order: { type: "number" },
|
||||||
@@ -379,25 +624,38 @@ const canvas = createCanvas({
|
|||||||
return replaceIssues(input.issues);
|
return replaceIssues(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({ input }) {
|
||||||
|
return setSelectedLabels(input.labels);
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "reset_state",
|
name: "reset_state",
|
||||||
description: "Reset the board to the default issue list with everything in backlog.",
|
description: "Reset all cards to backlog and clear label filters, then refresh from live repo issues.",
|
||||||
inputSchema: { type: "object", properties: {}, additionalProperties: false },
|
inputSchema: { type: "object", properties: {}, additionalProperties: false },
|
||||||
handler() {
|
async handler() {
|
||||||
const s = defaultState();
|
resetBoard();
|
||||||
saveState(s);
|
return refreshIssuesSafe();
|
||||||
broadcast("state", currentState());
|
|
||||||
return currentState();
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
open() {
|
async open() {
|
||||||
const state = currentState();
|
const state = await refreshIssuesSafe();
|
||||||
broadcast("state", state);
|
broadcast("state", state);
|
||||||
return {
|
return {
|
||||||
url: `http://127.0.0.1:${getPort()}`,
|
url: `http://127.0.0.1:${getPort()}`,
|
||||||
title: "Accessibility Kanban",
|
title: "Repository Issues Kanban",
|
||||||
status: `${state.issues.length} issues across ${COLUMNS.length} columns`,
|
status: `${state.issues.length} open issues in ${state.repo}`,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -409,7 +667,7 @@ const session = await joinSession({
|
|||||||
tools: [
|
tools: [
|
||||||
{
|
{
|
||||||
name: "kanban_move_issue",
|
name: "kanban_move_issue",
|
||||||
description: "Move an issue on the accessibility Kanban board to a new column (backlog, plan, ready, implement, done). Use after completing a planning or implementation step to advance the issue.",
|
description: "Move an issue on the repository issues kanban board to a new column (backlog, plan, ready, implement, done).",
|
||||||
parameters: {
|
parameters: {
|
||||||
type: "object",
|
type: "object",
|
||||||
properties: {
|
properties: {
|
||||||
@@ -425,14 +683,14 @@ const session = await joinSession({
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "kanban_update_status",
|
name: "kanban_update_status",
|
||||||
description: "Update the agent status line and log on a Kanban card. Use this to report progress while planning or implementing an issue. The status appears under the card title and a glow indicates active work.",
|
description: "Update the agent status line and log on a kanban card while planning or implementing an issue.",
|
||||||
parameters: {
|
parameters: {
|
||||||
type: "object",
|
type: "object",
|
||||||
properties: {
|
properties: {
|
||||||
issue_number: { type: "number", description: "GitHub issue number" },
|
issue_number: { type: "number", description: "GitHub issue number" },
|
||||||
status: { type: "string", description: "Short status text shown on the card (e.g. 'Reading issue...', 'Analyzing codebase...', 'Plan complete')" },
|
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 (viewable in modal)" },
|
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 (agent finished working)" },
|
done: { type: "boolean", description: "Set true to stop the active glow." },
|
||||||
},
|
},
|
||||||
required: ["issue_number", "status"],
|
required: ["issue_number", "status"],
|
||||||
},
|
},
|
||||||
@@ -444,3 +702,5 @@ const session = await joinSession({
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
sessionRef = session;
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<title>Accessibility Kanban</title>
|
<title>Repository Issues Kanban</title>
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
<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" />
|
<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>
|
<style>
|
||||||
@@ -56,7 +56,7 @@ header {
|
|||||||
}
|
}
|
||||||
|
|
||||||
header .breadcrumb {
|
header .breadcrumb {
|
||||||
font-size: 12px;
|
font-size: 10px;
|
||||||
color: var(--meta);
|
color: var(--meta);
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
}
|
}
|
||||||
@@ -69,6 +69,7 @@ header .breadcrumb .sep {
|
|||||||
header .breadcrumb .current {
|
header .breadcrumb .current {
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
|
|
||||||
header .spacer { flex: 1; }
|
header .spacer { flex: 1; }
|
||||||
@@ -92,6 +93,55 @@ header .reset-btn:hover {
|
|||||||
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 {
|
.board {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -364,11 +414,14 @@ header .reset-btn:hover {
|
|||||||
<div class="board-wrap">
|
<div class="board-wrap">
|
||||||
<header>
|
<header>
|
||||||
<div class="breadcrumb">
|
<div class="breadcrumb">
|
||||||
SignalBox<span class="sep">/</span><span class="current">Accessibility Sprint</span>
|
<span id="repo-name">Current Repository</span><span class="sep">/</span><span class="current">Issues Kanban</span>
|
||||||
</div>
|
</div>
|
||||||
<span class="spacer"></span>
|
<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>
|
<button class="reset-btn" id="reset-btn">Reset</button>
|
||||||
</header>
|
</header>
|
||||||
|
<div class="error-banner" id="error-banner" hidden></div>
|
||||||
<div class="board" id="board"></div>
|
<div class="board" id="board"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -387,18 +440,67 @@ header .reset-btn:hover {
|
|||||||
const COLUMNS = ["backlog", "plan", "ready", "implement", "done"];
|
const COLUMNS = ["backlog", "plan", "ready", "implement", "done"];
|
||||||
const COL_LABELS = { backlog: "Backlog", plan: "Plan", ready: "Ready", implement: "Implement", done: "Done" };
|
const COL_LABELS = { backlog: "Backlog", plan: "Plan", ready: "Ready", implement: "Implement", done: "Done" };
|
||||||
|
|
||||||
let state = { issues: [] };
|
let state = { repo: "", issues: [], availableLabels: [], selectedLabels: [], error: null };
|
||||||
let dragState = null;
|
let dragState = null;
|
||||||
|
|
||||||
// ─── Rendering ───
|
// ─── 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() {
|
function render() {
|
||||||
|
updateHeader();
|
||||||
|
updateErrorBanner();
|
||||||
|
renderLabelFilters();
|
||||||
|
|
||||||
const board = document.getElementById("board");
|
const board = document.getElementById("board");
|
||||||
board.innerHTML = "";
|
board.innerHTML = "";
|
||||||
|
|
||||||
COLUMNS.forEach((col) => {
|
COLUMNS.forEach((col) => {
|
||||||
const issues = state.issues
|
const issues = state.issues
|
||||||
.filter((i) => i.column === col)
|
.filter((i) => i.column === col)
|
||||||
|
.filter(issueMatchesFilter)
|
||||||
.sort((a, b) => (a.order || 0) - (b.order || 0));
|
.sort((a, b) => (a.order || 0) - (b.order || 0));
|
||||||
|
|
||||||
const colEl = document.createElement("div");
|
const colEl = document.createElement("div");
|
||||||
@@ -453,7 +555,7 @@ function render() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function escHtml(s) {
|
function escHtml(s) {
|
||||||
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
return String(s || "").replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Log Modal ───
|
// ─── Log Modal ───
|
||||||
@@ -582,6 +684,27 @@ async function moveIssue(issueNumber, column) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 ───
|
// ─── SSE ───
|
||||||
|
|
||||||
function connectSSE() {
|
function connectSSE() {
|
||||||
@@ -619,6 +742,10 @@ async function init() {
|
|||||||
console.error("Reset failed:", err);
|
console.error("Reset failed:", err);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
document.getElementById("clear-filters-btn").addEventListener("click", () => {
|
||||||
|
setSelectedLabels([]);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
init();
|
init();
|
||||||
|
|||||||
Reference in New Issue
Block a user