feat(KB-022): add GitHub issue status badges to task cards

- Extend core types with GitHub issue tracking fields (issueNumber, issueStatus, lastIssueSync)
- Add TaskStore.getTaskGitHubIssueInfo() to fetch issue details from GitHub API
- Create server-side API endpoint for issue status lookup with caching
- Build GitHubBadge component with color-coded status indicators (open/closed)
- Integrate badges into TaskCard with hover state linking to GitHub issues
- Add CSS styles for badge positioning and visual polish
- Include comprehensive tests for GitHubBadge and TaskCard badge rendering
This commit is contained in:
gsxdsm
2026-03-29 20:12:13 -07:00
parent 37afb24d60
commit b935f310ce
12 changed files with 930 additions and 30 deletions

View File

@@ -271,6 +271,23 @@ export function refreshPrStatus(id: string): Promise<PrInfo> {
});
}
// --- Issue Management API ---
/** Re-export IssueInfo type for convenience */
export type { IssueInfo } from "@kb/core";
/** Fetch cached issue status for a task */
export function fetchIssueStatus(id: string): Promise<{ issueInfo: import("@kb/core").IssueInfo; stale: boolean }> {
return api<{ issueInfo: import("@kb/core").IssueInfo; stale: boolean }>(`/tasks/${id}/issue/status`);
}
/** Force refresh issue status from GitHub */
export function refreshIssueStatus(id: string): Promise<import("@kb/core").IssueInfo> {
return api<import("@kb/core").IssueInfo>(`/tasks/${id}/issue/refresh`, {
method: "POST",
});
}
// --- Git Management API ---
/** Current git status */

View File

@@ -0,0 +1,90 @@
import { GitPullRequest, CircleDot } from "lucide-react";
import type { IssueInfo, PrInfo } from "@kb/core";
import type { ToastType } from "../hooks/useToast";
interface GitHubBadgeProps {
prInfo?: PrInfo;
issueInfo?: IssueInfo;
onIssueRefresh?: () => void;
addToast?: (message: string, type?: ToastType) => void;
}
// Color scheme for PR and Issue badges
const COLORS = {
pr: {
open: { bg: "rgba(63,185,80,0.2)", text: "#3fb950" },
closed: { bg: "rgba(218,54,51,0.2)", text: "#da3633" },
merged: { bg: "rgba(188,140,255,0.2)", text: "#bc8cff" },
},
issue: {
open: { bg: "rgba(63,185,80,0.2)", text: "#3fb950" },
completed: { bg: "rgba(188,140,255,0.2)", text: "#bc8cff" },
not_planned: { bg: "rgba(248,81,73,0.2)", text: "#f85149" },
default: { bg: "rgba(139,148,158,0.2)", text: "#8b949e" },
},
};
function getPrColors(status: string) {
return COLORS.pr[status as keyof typeof COLORS.pr] ?? COLORS.pr.open;
}
function getIssueColors(state: string, stateReason?: string) {
if (state === "open") return COLORS.issue.open;
if (stateReason === "completed") return COLORS.issue.completed;
if (stateReason === "not_planned") return COLORS.issue.not_planned;
return COLORS.issue.default;
}
function getIssueModifierClass(state: string, stateReason?: string): string {
if (state === "open") return "card-github-badge--open";
if (stateReason === "completed") return "card-github-badge--completed";
if (stateReason === "not_planned") return "card-github-badge--closed";
return "";
}
export function GitHubBadge({ prInfo, issueInfo, onIssueRefresh }: GitHubBadgeProps) {
const handlePrClick = () => {
if (prInfo?.url) {
window.open(prInfo.url, "_blank", "noopener,noreferrer");
}
};
const handleIssueClick = () => {
if (issueInfo?.url) {
window.open(issueInfo.url, "_blank", "noopener,noreferrer");
}
};
return (
<>
{prInfo && (
<span
className={`card-github-badge card-github-badge--${prInfo.status}`}
title={`PR #${prInfo.number}: ${prInfo.title}`}
onClick={handlePrClick}
style={{
background: getPrColors(prInfo.status).bg,
color: getPrColors(prInfo.status).text,
}}
>
<GitPullRequest size={12} />
<span>#{prInfo.number}</span>
</span>
)}
{issueInfo && (
<span
className={`card-github-badge ${getIssueModifierClass(issueInfo.state, issueInfo.stateReason)}`}
title={`Issue #${issueInfo.number}: ${issueInfo.title}`}
onClick={handleIssueClick}
style={{
background: getIssueColors(issueInfo.state, issueInfo.stateReason).bg,
color: getIssueColors(issueInfo.state, issueInfo.stateReason).text,
}}
>
<CircleDot size={12} />
<span>#{issueInfo.number}</span>
</span>
)}
</>
);
}

View File

@@ -1,7 +1,8 @@
import { useCallback, useState, useRef, useEffect } from "react";
import { Link, Clock, Layers, GitPullRequest, Pencil, ChevronDown } from "lucide-react";
import { Link, Clock, Layers, Pencil, ChevronDown } from "lucide-react";
import type { Task, TaskDetail, Column } from "@kb/core";
import { fetchTaskDetail, uploadAttachment } from "../api";
import { GitHubBadge } from "./GitHubBadge";
import type { ToastType } from "../hooks/useToast";
const COLUMN_COLOR_MAP: Record<Column, string> = {
@@ -331,40 +332,14 @@ export function TaskCard({
{task.status}
</span>
)}
{/* PR Status Indicator for in-review tasks */}
{task.column === "in-review" && task.prInfo && (
<span
className="card-pr-badge"
title={`PR #${task.prInfo.number}: ${task.prInfo.status}`}
style={{
background: task.prInfo.status === "merged"
? "rgba(188,140,255,0.2)"
: task.prInfo.status === "closed"
? "rgba(139,148,158,0.2)"
: "rgba(63,185,80,0.2)",
color: task.prInfo.status === "merged"
? "#bc8cff"
: task.prInfo.status === "closed"
? "#8b949e"
: "#3fb950",
fontSize: "11px",
padding: "2px 6px",
borderRadius: "10px",
display: "flex",
alignItems: "center",
gap: "4px",
}}
>
<GitPullRequest size={12} />
#{task.prInfo.number}
</span>
)}
{/* Size Indicator */}
{task.size && (
<span className={`card-size-badge size-${task.size.toLowerCase()}`}>
{task.size}
</span>
)}
{/* GitHub Badges - PR and Issue */}
<GitHubBadge prInfo={task.prInfo} issueInfo={task.issueInfo} />
{/* Edit button - visible on hover for editable cards */}
{canEdit && (
<button

View File

@@ -0,0 +1,239 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import type { IssueInfo, PrInfo } from "@kb/core";
import { GitHubBadge } from "../GitHubBadge";
describe("GitHubBadge", () => {
const mockPrInfo: PrInfo = {
url: "https://github.com/owner/repo/pull/42",
number: 42,
status: "open",
title: "Fix critical bug",
headBranch: "feature/bugfix",
baseBranch: "main",
commentCount: 5,
lastCheckedAt: "2026-01-01T00:00:00Z",
};
const mockIssueInfo: IssueInfo = {
url: "https://github.com/owner/repo/issues/123",
number: 123,
state: "open",
title: "Feature request: dark mode",
lastCheckedAt: "2026-01-01T00:00:00Z",
};
beforeEach(() => {
vi.clearAllMocks();
});
describe("PR badge rendering", () => {
it("renders PR badge with correct number and icon when prInfo is provided", () => {
render(<GitHubBadge prInfo={mockPrInfo} />);
expect(screen.getByText("#42")).toBeDefined();
// Check for the PR icon (GitPullRequest)
const badge = screen.getByTitle("PR #42: Fix critical bug");
expect(badge).toBeDefined();
});
it("does not render PR badge when prInfo is undefined", () => {
render(<GitHubBadge />);
expect(screen.queryByText(/#/)).toBeNull();
});
it("applies correct color classes for open PR", () => {
const { container } = render(<GitHubBadge prInfo={mockPrInfo} />);
const badge = container.querySelector(".card-github-badge--open");
expect(badge).toBeDefined();
});
it("applies correct color classes for closed PR", () => {
const closedPr: PrInfo = { ...mockPrInfo, status: "closed" };
const { container } = render(<GitHubBadge prInfo={closedPr} />);
const badge = container.querySelector(".card-github-badge--closed");
expect(badge).toBeDefined();
});
it("applies correct color classes for merged PR", () => {
const mergedPr: PrInfo = { ...mockPrInfo, status: "merged" };
const { container } = render(<GitHubBadge prInfo={mergedPr} />);
const badge = container.querySelector(".card-github-badge--merged");
expect(badge).toBeDefined();
});
});
describe("Issue badge rendering", () => {
it("renders Issue badge with correct number and icon when issueInfo is provided", () => {
render(<GitHubBadge issueInfo={mockIssueInfo} />);
expect(screen.getByText("#123")).toBeDefined();
const badge = screen.getByTitle("Issue #123: Feature request: dark mode");
expect(badge).toBeDefined();
});
it("does not render Issue badge when issueInfo is undefined", () => {
render(<GitHubBadge />);
expect(screen.queryByText(/#/)).toBeNull();
});
it("applies correct color classes for open Issue", () => {
const { container } = render(<GitHubBadge issueInfo={mockIssueInfo} />);
const badge = container.querySelector(".card-github-badge--open");
expect(badge).toBeDefined();
});
it("applies correct color classes for completed Issue", () => {
const completedIssue: IssueInfo = { ...mockIssueInfo, state: "closed", stateReason: "completed" };
const { container } = render(<GitHubBadge issueInfo={completedIssue} />);
const badge = container.querySelector(".card-github-badge--completed");
expect(badge).toBeDefined();
});
it("applies correct color classes for not_planned Issue", () => {
const notPlannedIssue: IssueInfo = { ...mockIssueInfo, state: "closed", stateReason: "not_planned" };
const { container } = render(<GitHubBadge issueInfo={notPlannedIssue} />);
const badge = container.querySelector(".card-github-badge--closed");
expect(badge).toBeDefined();
});
it("handles Issue with no state reason gracefully", () => {
const noReasonIssue: IssueInfo = { ...mockIssueInfo, state: "closed", stateReason: undefined };
const { container } = render(<GitHubBadge issueInfo={noReasonIssue} />);
// Should not have any modifier class
const badge = container.querySelector(".card-github-badge");
expect(badge).toBeDefined();
expect(badge?.classList.contains("card-github-badge--open")).toBe(false);
expect(badge?.classList.contains("card-github-badge--completed")).toBe(false);
expect(badge?.classList.contains("card-github-badge--closed")).toBe(false);
});
});
describe("Both badges can appear simultaneously", () => {
it("renders both PR and Issue badges when both props are provided", () => {
render(<GitHubBadge prInfo={mockPrInfo} issueInfo={mockIssueInfo} />);
expect(screen.getByText("#42")).toBeDefined();
expect(screen.getByText("#123")).toBeDefined();
});
it("renders PR badge with open status and Issue badge with completed status", () => {
const completedIssue: IssueInfo = { ...mockIssueInfo, state: "closed", stateReason: "completed" };
const { container } = render(<GitHubBadge prInfo={mockPrInfo} issueInfo={completedIssue} />);
const badges = container.querySelectorAll(".card-github-badge");
expect(badges.length).toBe(2);
// First badge should be PR (open)
expect(badges[0].classList.contains("card-github-badge--open")).toBe(true);
// Second badge should be Issue (completed)
expect(badges[1].classList.contains("card-github-badge--completed")).toBe(true);
});
});
describe("Click behavior", () => {
it("opens PR URL in new tab when PR badge is clicked", () => {
const mockOpen = vi.fn();
vi.stubGlobal("open", mockOpen);
render(<GitHubBadge prInfo={mockPrInfo} />);
const badge = screen.getByTitle("PR #42: Fix critical bug");
fireEvent.click(badge);
expect(mockOpen).toHaveBeenCalledWith(
"https://github.com/owner/repo/pull/42",
"_blank",
"noopener,noreferrer"
);
vi.unstubAllGlobals();
});
it("opens Issue URL in new tab when Issue badge is clicked", () => {
const mockOpen = vi.fn();
vi.stubGlobal("open", mockOpen);
render(<GitHubBadge issueInfo={mockIssueInfo} />);
const badge = screen.getByTitle("Issue #123: Feature request: dark mode");
fireEvent.click(badge);
expect(mockOpen).toHaveBeenCalledWith(
"https://github.com/owner/repo/issues/123",
"_blank",
"noopener,noreferrer"
);
vi.unstubAllGlobals();
});
it("does not open window when PR badge is clicked but URL is missing", () => {
const mockOpen = vi.fn();
vi.stubGlobal("open", mockOpen);
const prWithoutUrl: PrInfo = { ...mockPrInfo, url: "" };
render(<GitHubBadge prInfo={prWithoutUrl} />);
const badge = screen.getByTitle("PR #42: Fix critical bug");
fireEvent.click(badge);
expect(mockOpen).not.toHaveBeenCalled();
vi.unstubAllGlobals();
});
});
describe("Tooltip text", () => {
it("shows correct tooltip for PR badge", () => {
render(<GitHubBadge prInfo={mockPrInfo} />);
const badge = screen.getByTitle("PR #42: Fix critical bug");
expect(badge).toBeDefined();
});
it("shows correct tooltip for Issue badge", () => {
render(<GitHubBadge issueInfo={mockIssueInfo} />);
const badge = screen.getByTitle("Issue #123: Feature request: dark mode");
expect(badge).toBeDefined();
});
it("handles long titles in tooltips", () => {
const longTitlePr: PrInfo = {
...mockPrInfo,
title: "This is a very long PR title that exceeds normal length limits",
};
render(<GitHubBadge prInfo={longTitlePr} />);
const badge = screen.getByTitle(`PR #42: ${longTitlePr.title}`);
expect(badge).toBeDefined();
});
});
describe("No badges when no data", () => {
it("renders nothing when both prInfo and issueInfo are undefined", () => {
const { container } = render(<GitHubBadge />);
const badges = container.querySelectorAll(".card-github-badge");
expect(badges.length).toBe(0);
});
it("renders nothing when both prInfo and issueInfo are null", () => {
const { container } = render(<GitHubBadge prInfo={undefined} issueInfo={undefined} />);
const badges = container.querySelectorAll(".card-github-badge");
expect(badges.length).toBe(0);
});
});
});

View File

@@ -1238,3 +1238,214 @@ describe("TaskCard steps toggle", () => {
expect(chevron?.classList.contains("expanded")).toBe(true);
});
});
/**
* Tests for GitHub badges rendering in TaskCard.
*/
describe("TaskCard GitHub badges", () => {
const noopToast = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
});
it("renders GitHubBadge when task has prInfo", () => {
const task = makeTask({
prInfo: {
url: "https://github.com/owner/repo/pull/42",
number: 42,
status: "open",
title: "Fix bug",
headBranch: "feature/bugfix",
baseBranch: "main",
commentCount: 3,
},
});
render(
<TaskCard
task={task}
onOpenDetail={vi.fn()}
addToast={noopToast}
/>
);
// Should show the PR badge with the PR number
expect(screen.getByText("#42")).toBeDefined();
expect(screen.getByTitle("PR #42: Fix bug")).toBeDefined();
});
it("renders GitHubBadge when task has issueInfo", () => {
const task = makeTask({
issueInfo: {
url: "https://github.com/owner/repo/issues/123",
number: 123,
state: "open",
title: "Feature request",
},
});
render(
<TaskCard
task={task}
onOpenDetail={vi.fn()}
addToast={noopToast}
/>
);
// Should show the Issue badge with the issue number
expect(screen.getByText("#123")).toBeDefined();
expect(screen.getByTitle("Issue #123: Feature request")).toBeDefined();
});
it("renders both PR and Issue badges when task has both", () => {
const task = makeTask({
prInfo: {
url: "https://github.com/owner/repo/pull/42",
number: 42,
status: "open",
title: "Fix bug",
headBranch: "feature/bugfix",
baseBranch: "main",
commentCount: 3,
},
issueInfo: {
url: "https://github.com/owner/repo/issues/123",
number: 123,
state: "closed",
stateReason: "completed",
title: "Related issue",
},
});
render(
<TaskCard
task={task}
onOpenDetail={vi.fn()}
addToast={noopToast}
/>
);
// Both badges should appear
expect(screen.getByText("#42")).toBeDefined();
expect(screen.getByText("#123")).toBeDefined();
expect(screen.getByTitle("PR #42: Fix bug")).toBeDefined();
expect(screen.getByTitle("Issue #123: Related issue")).toBeDefined();
});
it("does not render GitHubBadge when task has neither prInfo nor issueInfo", () => {
const task = makeTask();
render(
<TaskCard
task={task}
onOpenDetail={vi.fn()}
addToast={noopToast}
/>
);
// No badge numbers should appear
const badgeNumbers = screen.queryAllByText(/^#\d+$/);
expect(badgeNumbers.length).toBe(0);
});
it("renders PR badge in all columns (not just in-review)", () => {
const columns: Column[] = ["triage", "todo", "in-progress", "in-review", "done"];
for (const column of columns) {
const task = makeTask({
column,
prInfo: {
url: "https://github.com/owner/repo/pull/42",
number: 42,
status: "open",
title: "Fix bug",
headBranch: "feature/bugfix",
baseBranch: "main",
commentCount: 3,
},
});
const { unmount } = render(
<TaskCard
task={task}
onOpenDetail={vi.fn()}
addToast={noopToast}
/>
);
expect(screen.getByText("#42")).toBeDefined();
unmount();
}
});
it("renders Issue badge with correct color class for open state", () => {
const task = makeTask({
issueInfo: {
url: "https://github.com/owner/repo/issues/123",
number: 123,
state: "open",
title: "Open issue",
},
});
const { container } = render(
<TaskCard
task={task}
onOpenDetail={vi.fn()}
addToast={noopToast}
/>
);
const badge = container.querySelector(".card-github-badge--open");
expect(badge).toBeDefined();
});
it("renders Issue badge with correct color class for completed state", () => {
const task = makeTask({
issueInfo: {
url: "https://github.com/owner/repo/issues/123",
number: 123,
state: "closed",
stateReason: "completed",
title: "Completed issue",
},
});
const { container } = render(
<TaskCard
task={task}
onOpenDetail={vi.fn()}
addToast={noopToast}
/>
);
const badge = container.querySelector(".card-github-badge--completed");
expect(badge).toBeDefined();
});
it("renders PR badge with correct color class for merged status", () => {
const task = makeTask({
prInfo: {
url: "https://github.com/owner/repo/pull/42",
number: 42,
status: "merged",
title: "Merged PR",
headBranch: "feature/merged",
baseBranch: "main",
commentCount: 5,
},
});
const { container } = render(
<TaskCard
task={task}
onOpenDetail={vi.fn()}
addToast={noopToast}
/>
);
const badge = container.querySelector(".card-github-badge--merged");
expect(badge).toBeDefined();
});
});

View File

@@ -1705,6 +1705,44 @@ body {
color: var(--text-muted);
}
/* === GitHub Badges === */
.card-github-badge {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 11px;
padding: 2px 6px;
border-radius: 10px;
cursor: pointer;
transition: filter 0.15s ease, transform 0.1s ease;
user-select: none;
}
.card-github-badge:hover {
filter: brightness(1.15);
}
.card-github-badge:active {
transform: scale(0.95);
}
/* Modifier classes for state-based styling */
.card-github-badge--open {
/* Green styling applied via inline styles */
}
.card-github-badge--closed {
/* Red styling applied via inline styles */
}
.card-github-badge--merged {
/* Purple styling applied via inline styles */
}
.card-github-badge--completed {
/* Purple styling applied via inline styles */
}
/* Card saving state */
.card.card-saving {
opacity: 0.7;

View File

@@ -220,6 +220,56 @@ export class GitHubClient {
private mapPrState(state: string): "open" | "closed" {
return state === "open" ? "open" : "closed";
}
/**
* Fetch current issue status from GitHub API.
* Returns null if the issue is not found or is a pull request.
*/
async getIssueStatus(
owner: string,
repo: string,
number: number,
): Promise<Omit<import("@kb/core").IssueInfo, "lastCheckedAt"> | null> {
const url = `${this.baseUrl}/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/issues/${number}`;
const headers = this.buildHeaders();
const response = await fetch(url, { headers });
if (!response.ok) {
if (response.status === 404) {
return null;
}
const error = await response.json().catch(() => ({ message: response.statusText }));
throw new Error(`GitHub API error: ${response.status} ${error.message || response.statusText}`);
}
const data = (await response.json()) as {
number: number;
html_url: string;
title: string;
state: string;
state_reason?: "completed" | "not_planned" | "reopened";
pull_request?: unknown;
};
// Filter out pull requests - this endpoint returns both issues and PRs
if (data.pull_request) {
return null;
}
return {
url: data.html_url,
number: data.number,
state: this.mapIssueState(data.state),
title: data.title,
stateReason: data.state_reason,
};
}
private mapIssueState(state: string): "open" | "closed" {
return state === "open" ? "open" : "closed";
}
}
/**

View File

@@ -1650,9 +1650,220 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
}
});
// ── Issue Status Routes ─────────────────────────────────────────────
/**
* Helper to extract GitHub issue owner/repo/number from a URL.
* Returns null if the URL is not a valid GitHub issue URL.
*/
function parseGitHubIssueUrl(url: string): { owner: string; repo: string; number: number } | null {
const match = url.match(/https:\/\/github\.com\/([^\/]+)\/([^\/]+)\/issues\/(\d+)/);
if (!match) return null;
const [, owner, repo, numberStr] = match;
const number = parseInt(numberStr, 10);
if (isNaN(number) || number < 1) return null;
return { owner, repo, number };
}
/**
* GET /api/tasks/:id/issue/status
* Get cached issue status for a task. Triggers background refresh if stale (>5 min).
*/
router.get("/tasks/:id/issue/status", async (req, res) => {
try {
const task = await store.getTask(req.params.id);
// Use cached issueInfo if available
if (task.issueInfo) {
// Check if data is stale (>5 minutes since last check)
const fiveMinutesMs = 5 * 60 * 1000;
const lastChecked = task.issueInfo.lastCheckedAt || task.updatedAt;
const lastCheckedTime = new Date(lastChecked).getTime();
const isStale = Date.now() - lastCheckedTime > fiveMinutesMs;
res.json({
issueInfo: task.issueInfo,
stale: isStale,
});
// Trigger background refresh if stale (don't await)
if (isStale) {
refreshIssueInBackground(store, task.id, task.issueInfo, githubToken);
}
return;
}
// Try to extract issue URL from description
const issueUrlMatch = task.description.match(/https:\/\/github\.com\/[^\/]+\/[^\/]+\/issues\/\d+/);
if (!issueUrlMatch) {
res.status(404).json({ error: "Task has no associated issue" });
return;
}
const parsed = parseGitHubIssueUrl(issueUrlMatch[0]);
if (!parsed) {
res.status(404).json({ error: "Task has no associated issue" });
return;
}
// Check rate limit before fetching
const repoKey = `${parsed.owner}/${parsed.repo}`;
if (!ghRateLimiter.canMakeRequest(repoKey)) {
const resetTime = ghRateLimiter.getResetTime(repoKey);
res.status(429).json({
error: "GitHub API rate limit exceeded for this repository",
resetAt: resetTime?.toISOString(),
});
return;
}
// Fetch fresh issue status
const client = new GitHubClient(githubToken);
const issueData = await client.getIssueStatus(parsed.owner, parsed.repo, parsed.number);
if (!issueData) {
res.status(404).json({ error: "Issue not found or is a pull request" });
return;
}
// Build IssueInfo with timestamp
const issueInfo: import("@kb/core").IssueInfo = {
...issueData,
lastCheckedAt: new Date().toISOString(),
};
// Store issue info
await store.updateIssueInfo(task.id, issueInfo);
res.json({
issueInfo,
stale: false,
});
} catch (err: any) {
if (err.code === "ENOENT") {
res.status(404).json({ error: `Task ${req.params.id} not found` });
} else {
res.status(500).json({ error: err.message });
}
}
});
/**
* POST /api/tasks/:id/issue/refresh
* Force refresh issue status from GitHub API.
*/
router.post("/tasks/:id/issue/refresh", async (req, res) => {
try {
const task = await store.getTask(req.params.id);
// Get owner/repo/number from cached issueInfo or description
let owner: string;
let repo: string;
let issueNumber: number;
if (task.issueInfo) {
const parsed = parseGitHubIssueUrl(task.issueInfo.url);
if (!parsed) {
res.status(400).json({ error: "Invalid cached issue URL" });
return;
}
owner = parsed.owner;
repo = parsed.repo;
issueNumber = parsed.number;
} else {
const issueUrlMatch = task.description.match(/https:\/\/github\.com\/[^\/]+\/[^\/]+\/issues\/\d+/);
if (!issueUrlMatch) {
res.status(404).json({ error: "Task has no associated issue" });
return;
}
const parsed = parseGitHubIssueUrl(issueUrlMatch[0]);
if (!parsed) {
res.status(404).json({ error: "Task has no associated issue" });
return;
}
owner = parsed.owner;
repo = parsed.repo;
issueNumber = parsed.number;
}
// Check rate limit
const repoKey = `${owner}/${repo}`;
if (!ghRateLimiter.canMakeRequest(repoKey)) {
const resetTime = ghRateLimiter.getResetTime(repoKey);
res.status(429).json({
error: "GitHub API rate limit exceeded for this repository",
resetAt: resetTime?.toISOString(),
});
return;
}
// Fetch fresh issue status
const client = new GitHubClient(githubToken);
const issueData = await client.getIssueStatus(owner, repo, issueNumber);
if (!issueData) {
res.status(404).json({ error: "Issue not found or is a pull request" });
return;
}
// Build IssueInfo with timestamp
const issueInfo: import("@kb/core").IssueInfo = {
...issueData,
lastCheckedAt: new Date().toISOString(),
};
// Store issue info
await store.updateIssueInfo(task.id, issueInfo);
res.json(issueInfo);
} catch (err: any) {
if (err.code === "ENOENT") {
res.status(404).json({ error: `Task ${req.params.id} not found` });
} else if (err.message?.includes("not found")) {
res.status(404).json({ error: err.message });
} else {
res.status(500).json({ error: err.message });
}
}
});
return router;
}
/**
* Background Issue refresh - updates issue status without blocking the response.
* Silently logs errors without affecting the user experience.
*/
async function refreshIssueInBackground(
store: TaskStore,
taskId: string,
currentIssueInfo: import("@kb/core").IssueInfo,
token?: string,
): Promise<void> {
try {
// Parse owner/repo/number from the cached issue URL
const match = currentIssueInfo.url.match(/https:\/\/github\.com\/([^\/]+)\/([^\/]+)\/issues\/(\d+)/);
if (!match) return; // Silent fail - invalid URL format
const [, owner, repo, numberStr] = match;
const number = parseInt(numberStr, 10);
if (isNaN(number) || number < 1) return;
const client = new GitHubClient(token);
const issueData = await client.getIssueStatus(owner, repo, number);
if (!issueData) return; // Silent fail - issue not found or is a PR
const issueInfo: import("@kb/core").IssueInfo = {
...issueData,
lastCheckedAt: new Date().toISOString(),
};
await store.updateIssueInfo(taskId, issueInfo);
} catch {
// Silent fail - background refresh is best-effort
}
}
/**
* Background PR refresh - updates PR status without blocking the response.
* Silently logs errors without affecting the user experience.