feat: server-side issue search, dashboard charts, and inbox badges
Add ILIKE-based issue search across title, identifier, description, and comments with relevance ranking. Add assigneeUserId filter and allow agents to return issues to creator. Show assigned issue count in sidebar badges. Add minCount param to live-runs endpoint. Add activity charts (run activity, priority, status, success rate) to dashboard. Improve active agents panel with recent run cards. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1495,7 +1495,11 @@ export function heartbeatService(db: Db) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (issueId) {
|
||||
const bypassIssueExecutionLock =
|
||||
reason === "issue_comment_mentioned" ||
|
||||
readNonEmptyString(enrichedContextSnapshot.wakeReason) === "issue_comment_mentioned";
|
||||
|
||||
if (issueId && !bypassIssueExecutionLock) {
|
||||
const agentNameKey = normalizeAgentNameKey(agent.name);
|
||||
const sessionBefore = await resolveSessionBeforeForWakeup(agent, taskKey);
|
||||
|
||||
|
||||
@@ -47,8 +47,10 @@ function applyStatusSideEffects(
|
||||
export interface IssueFilters {
|
||||
status?: string;
|
||||
assigneeAgentId?: string;
|
||||
assigneeUserId?: string;
|
||||
projectId?: string;
|
||||
labelId?: string;
|
||||
q?: string;
|
||||
}
|
||||
|
||||
type IssueRow = typeof issues.$inferSelect;
|
||||
@@ -62,6 +64,10 @@ function sameRunLock(checkoutRunId: string | null, actorRunId: string | null) {
|
||||
|
||||
const TERMINAL_HEARTBEAT_RUN_STATUSES = new Set(["succeeded", "failed", "cancelled", "timed_out"]);
|
||||
|
||||
function escapeLikePattern(value: string): string {
|
||||
return value.replace(/[\\%_]/g, "\\$&");
|
||||
}
|
||||
|
||||
async function labelMapForIssues(dbOrTx: any, issueIds: string[]): Promise<Map<string, IssueLabelRow[]>> {
|
||||
const map = new Map<string, IssueLabelRow[]>();
|
||||
if (issueIds.length === 0) return map;
|
||||
@@ -219,6 +225,25 @@ export function issueService(db: Db) {
|
||||
return {
|
||||
list: async (companyId: string, filters?: IssueFilters) => {
|
||||
const conditions = [eq(issues.companyId, companyId)];
|
||||
const rawSearch = filters?.q?.trim() ?? "";
|
||||
const hasSearch = rawSearch.length > 0;
|
||||
const escapedSearch = hasSearch ? escapeLikePattern(rawSearch) : "";
|
||||
const startsWithPattern = `${escapedSearch}%`;
|
||||
const containsPattern = `%${escapedSearch}%`;
|
||||
const titleStartsWithMatch = sql<boolean>`${issues.title} ILIKE ${startsWithPattern} ESCAPE '\\'`;
|
||||
const titleContainsMatch = sql<boolean>`${issues.title} ILIKE ${containsPattern} ESCAPE '\\'`;
|
||||
const identifierStartsWithMatch = sql<boolean>`${issues.identifier} ILIKE ${startsWithPattern} ESCAPE '\\'`;
|
||||
const identifierContainsMatch = sql<boolean>`${issues.identifier} ILIKE ${containsPattern} ESCAPE '\\'`;
|
||||
const descriptionContainsMatch = sql<boolean>`${issues.description} ILIKE ${containsPattern} ESCAPE '\\'`;
|
||||
const commentContainsMatch = sql<boolean>`
|
||||
EXISTS (
|
||||
SELECT 1
|
||||
FROM ${issueComments}
|
||||
WHERE ${issueComments.issueId} = ${issues.id}
|
||||
AND ${issueComments.companyId} = ${companyId}
|
||||
AND ${issueComments.body} ILIKE ${containsPattern} ESCAPE '\\'
|
||||
)
|
||||
`;
|
||||
if (filters?.status) {
|
||||
const statuses = filters.status.split(",").map((s) => s.trim());
|
||||
conditions.push(statuses.length === 1 ? eq(issues.status, statuses[0]) : inArray(issues.status, statuses));
|
||||
@@ -226,6 +251,9 @@ export function issueService(db: Db) {
|
||||
if (filters?.assigneeAgentId) {
|
||||
conditions.push(eq(issues.assigneeAgentId, filters.assigneeAgentId));
|
||||
}
|
||||
if (filters?.assigneeUserId) {
|
||||
conditions.push(eq(issues.assigneeUserId, filters.assigneeUserId));
|
||||
}
|
||||
if (filters?.projectId) conditions.push(eq(issues.projectId, filters.projectId));
|
||||
if (filters?.labelId) {
|
||||
const labeledIssueIds = await db
|
||||
@@ -235,14 +263,35 @@ export function issueService(db: Db) {
|
||||
if (labeledIssueIds.length === 0) return [];
|
||||
conditions.push(inArray(issues.id, labeledIssueIds.map((row) => row.issueId)));
|
||||
}
|
||||
if (hasSearch) {
|
||||
conditions.push(
|
||||
or(
|
||||
titleContainsMatch,
|
||||
identifierContainsMatch,
|
||||
descriptionContainsMatch,
|
||||
commentContainsMatch,
|
||||
)!,
|
||||
);
|
||||
}
|
||||
conditions.push(isNull(issues.hiddenAt));
|
||||
|
||||
const priorityOrder = sql`CASE ${issues.priority} WHEN 'critical' THEN 0 WHEN 'high' THEN 1 WHEN 'medium' THEN 2 WHEN 'low' THEN 3 ELSE 4 END`;
|
||||
const searchOrder = sql<number>`
|
||||
CASE
|
||||
WHEN ${titleStartsWithMatch} THEN 0
|
||||
WHEN ${titleContainsMatch} THEN 1
|
||||
WHEN ${identifierStartsWithMatch} THEN 2
|
||||
WHEN ${identifierContainsMatch} THEN 3
|
||||
WHEN ${descriptionContainsMatch} THEN 4
|
||||
WHEN ${commentContainsMatch} THEN 5
|
||||
ELSE 6
|
||||
END
|
||||
`;
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(issues)
|
||||
.where(and(...conditions))
|
||||
.orderBy(asc(priorityOrder), desc(issues.updatedAt));
|
||||
.orderBy(hasSearch ? asc(searchOrder) : asc(priorityOrder), asc(priorityOrder), desc(issues.updatedAt));
|
||||
return withIssueLabels(db, rows);
|
||||
},
|
||||
|
||||
|
||||
@@ -8,7 +8,10 @@ const FAILED_HEARTBEAT_STATUSES = ["failed", "timed_out"];
|
||||
|
||||
export function sidebarBadgeService(db: Db) {
|
||||
return {
|
||||
get: async (companyId: string, extra?: { joinRequests?: number }): Promise<SidebarBadges> => {
|
||||
get: async (
|
||||
companyId: string,
|
||||
extra?: { joinRequests?: number; assignedIssues?: number },
|
||||
): Promise<SidebarBadges> => {
|
||||
const actionableApprovals = await db
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(approvals)
|
||||
@@ -40,8 +43,9 @@ export function sidebarBadgeService(db: Db) {
|
||||
).length;
|
||||
|
||||
const joinRequests = extra?.joinRequests ?? 0;
|
||||
const assignedIssues = extra?.assignedIssues ?? 0;
|
||||
return {
|
||||
inbox: actionableApprovals + failedRuns + joinRequests,
|
||||
inbox: actionableApprovals + failedRuns + joinRequests + assignedIssues,
|
||||
approvals: actionableApprovals,
|
||||
failedRuns,
|
||||
joinRequests,
|
||||
|
||||
Reference in New Issue
Block a user