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:
5
.changeset/github-badges-on-cards.md
Normal file
5
.changeset/github-badges-on-cards.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@dustinbyrne/kb": minor
|
||||||
|
---
|
||||||
|
|
||||||
|
Add GitHub issue and PR badges to task cards in the dashboard. Badges display in the card header with colors indicating state (open=green, closed=red, merged/completed=purple). Clicking a badge opens the GitHub link in a new tab.
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
export { COLUMNS, COLUMN_LABELS, COLUMN_DESCRIPTIONS, VALID_TRANSITIONS, DEFAULT_SETTINGS, THINKING_LEVELS } from "./types.js";
|
export { COLUMNS, COLUMN_LABELS, COLUMN_DESCRIPTIONS, VALID_TRANSITIONS, DEFAULT_SETTINGS, THINKING_LEVELS } from "./types.js";
|
||||||
export type { Column, PrInfo, PrStatus, Task, TaskAttachment, TaskCreateInput, TaskDetail, AgentLogEntry, AgentLogType, AgentRole, BoardConfig, MergeResult, Settings, TaskStep, StepStatus, TaskLogEntry, ThinkingLevel, SteeringComment } from "./types.js";
|
export type { Column, IssueInfo, IssueState, PrInfo, PrStatus, Task, TaskAttachment, TaskCreateInput, TaskDetail, AgentLogEntry, AgentLogType, AgentRole, BoardConfig, MergeResult, Settings, TaskStep, StepStatus, TaskLogEntry, ThinkingLevel, SteeringComment } from "./types.js";
|
||||||
export { TaskStore } from "./store.js";
|
export { TaskStore } from "./store.js";
|
||||||
export { canTransition, getValidTransitions, resolveDependencyOrder } from "./board.js";
|
export { canTransition, getValidTransitions, resolveDependencyOrder } from "./board.js";
|
||||||
|
|||||||
@@ -1206,6 +1206,57 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update or clear Issue information for a task.
|
||||||
|
* Updates task.json atomically and emits `task:updated` event.
|
||||||
|
*
|
||||||
|
* @param id - The task ID
|
||||||
|
* @param issueInfo - The Issue info to set, or null to clear
|
||||||
|
* @returns The updated task
|
||||||
|
*/
|
||||||
|
async updateIssueInfo(
|
||||||
|
id: string,
|
||||||
|
issueInfo: import("./types.js").IssueInfo | null,
|
||||||
|
): Promise<Task> {
|
||||||
|
return this.withTaskLock(id, async () => {
|
||||||
|
const dir = this.taskDir(id);
|
||||||
|
const task = await this.readTaskJson(dir);
|
||||||
|
|
||||||
|
const prevIssueNumber = task.issueInfo?.number;
|
||||||
|
const prevIssueState = task.issueInfo?.state;
|
||||||
|
|
||||||
|
if (issueInfo) {
|
||||||
|
task.issueInfo = issueInfo;
|
||||||
|
task.log.push({
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
action: "Issue linked",
|
||||||
|
outcome: `Issue #${issueInfo.number}: ${issueInfo.url}`,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
task.issueInfo = undefined;
|
||||||
|
if (prevIssueNumber) {
|
||||||
|
task.log.push({
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
action: "Issue unlinked",
|
||||||
|
outcome: `Issue #${prevIssueNumber} removed`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
task.updatedAt = new Date().toISOString();
|
||||||
|
|
||||||
|
await this.atomicWriteTaskJson(dir, task);
|
||||||
|
if (this.watcher) this.taskCache.set(id, { ...task });
|
||||||
|
|
||||||
|
// Only emit if Issue info actually changed
|
||||||
|
if (prevIssueNumber !== issueInfo?.number || prevIssueState !== issueInfo?.state) {
|
||||||
|
this.emit("task:updated", task);
|
||||||
|
}
|
||||||
|
|
||||||
|
return task;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Read all historical agent log entries for a task from its agent log file.
|
* Read all historical agent log entries for a task from its agent log file.
|
||||||
* Returns entries in chronological order (oldest first).
|
* Returns entries in chronological order (oldest first).
|
||||||
|
|||||||
@@ -19,6 +19,17 @@ export interface PrInfo {
|
|||||||
lastCheckedAt?: string;
|
lastCheckedAt?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type IssueState = "open" | "closed";
|
||||||
|
|
||||||
|
export interface IssueInfo {
|
||||||
|
url: string;
|
||||||
|
number: number;
|
||||||
|
state: IssueState;
|
||||||
|
title: string;
|
||||||
|
stateReason?: "completed" | "not_planned" | "reopened";
|
||||||
|
lastCheckedAt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export type StepStatus = "pending" | "in-progress" | "done" | "skipped";
|
export type StepStatus = "pending" | "in-progress" | "done" | "skipped";
|
||||||
|
|
||||||
export interface TaskStep {
|
export interface TaskStep {
|
||||||
@@ -97,6 +108,8 @@ export interface Task {
|
|||||||
steeringComments?: SteeringComment[];
|
steeringComments?: SteeringComment[];
|
||||||
/** PR information for tasks linked to GitHub pull requests */
|
/** PR information for tasks linked to GitHub pull requests */
|
||||||
prInfo?: PrInfo;
|
prInfo?: PrInfo;
|
||||||
|
/** Issue information for tasks imported from GitHub issues */
|
||||||
|
issueInfo?: IssueInfo;
|
||||||
log: TaskLogEntry[];
|
log: TaskLogEntry[];
|
||||||
size?: "S" | "M" | "L";
|
size?: "S" | "M" | "L";
|
||||||
reviewLevel?: number;
|
reviewLevel?: number;
|
||||||
|
|||||||
@@ -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 ---
|
// --- Git Management API ---
|
||||||
|
|
||||||
/** Current git status */
|
/** Current git status */
|
||||||
|
|||||||
90
packages/dashboard/app/components/GitHubBadge.tsx
Normal file
90
packages/dashboard/app/components/GitHubBadge.tsx
Normal 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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
import { useCallback, useState, useRef, useEffect } from "react";
|
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 type { Task, TaskDetail, Column } from "@kb/core";
|
||||||
import { fetchTaskDetail, uploadAttachment } from "../api";
|
import { fetchTaskDetail, uploadAttachment } from "../api";
|
||||||
|
import { GitHubBadge } from "./GitHubBadge";
|
||||||
import type { ToastType } from "../hooks/useToast";
|
import type { ToastType } from "../hooks/useToast";
|
||||||
|
|
||||||
const COLUMN_COLOR_MAP: Record<Column, string> = {
|
const COLUMN_COLOR_MAP: Record<Column, string> = {
|
||||||
@@ -331,40 +332,14 @@ export function TaskCard({
|
|||||||
{task.status}
|
{task.status}
|
||||||
</span>
|
</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 */}
|
{/* Size Indicator */}
|
||||||
{task.size && (
|
{task.size && (
|
||||||
<span className={`card-size-badge size-${task.size.toLowerCase()}`}>
|
<span className={`card-size-badge size-${task.size.toLowerCase()}`}>
|
||||||
{task.size}
|
{task.size}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
{/* GitHub Badges - PR and Issue */}
|
||||||
|
<GitHubBadge prInfo={task.prInfo} issueInfo={task.issueInfo} />
|
||||||
{/* Edit button - visible on hover for editable cards */}
|
{/* Edit button - visible on hover for editable cards */}
|
||||||
{canEdit && (
|
{canEdit && (
|
||||||
<button
|
<button
|
||||||
|
|||||||
239
packages/dashboard/app/components/__tests__/GitHubBadge.test.tsx
Normal file
239
packages/dashboard/app/components/__tests__/GitHubBadge.test.tsx
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1238,3 +1238,214 @@ describe("TaskCard steps toggle", () => {
|
|||||||
expect(chevron?.classList.contains("expanded")).toBe(true);
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1705,6 +1705,44 @@ body {
|
|||||||
color: var(--text-muted);
|
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 saving state */
|
||||||
.card.card-saving {
|
.card.card-saving {
|
||||||
opacity: 0.7;
|
opacity: 0.7;
|
||||||
|
|||||||
@@ -220,6 +220,56 @@ export class GitHubClient {
|
|||||||
private mapPrState(state: string): "open" | "closed" {
|
private mapPrState(state: string): "open" | "closed" {
|
||||||
return state === "open" ? "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";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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;
|
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.
|
* Background PR refresh - updates PR status without blocking the response.
|
||||||
* Silently logs errors without affecting the user experience.
|
* Silently logs errors without affecting the user experience.
|
||||||
|
|||||||
Reference in New Issue
Block a user