Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 17 additions & 1 deletion frontend/src/api.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { Project, ProjectWebhook, UpdateProjectInput, Task, ChecklistItem, AgentMessage, DeepLink, Priority, Status, OwnerType, RecurrenceStatus, RegistryUser, RegistryAgent, AgentStatus, TaskActivity, TaskReminder, TaskRun, WorkloadRow, Label, TaskLabel, EstimatesSummary, EstimatesGroupBy, Comment, RelationType, TaskRelation, TaskRelationsResponse, GithubBackfillResult, GithubBackfillState } from './types';
import type { Project, ProjectWebhook, UpdateProjectInput, Task, ChecklistItem, AgentMessage, DeepLink, Priority, Status, OwnerType, RecurrenceStatus, RegistryUser, RegistryAgent, AgentStatus, TaskActivity, TaskReminder, TaskRun, WorkloadRow, Label, TaskLabel, EstimatesSummary, EstimatesGroupBy, Comment, RelationType, TaskRelation, TaskRelationsResponse, GithubBackfillResult, GithubBackfillState, GithubRetryFailedResult, GithubRetryResult, GithubSyncEventsResponse, GithubSyncStatus } from './types';
import { getAuthHeaders } from './auth';

const BASE = import.meta.env.VITE_API_BASE_URL || 'http://localhost:3001';
Expand Down Expand Up @@ -35,6 +35,22 @@ export const runGithubBackfill = (projectId: string, data: {
includeComments?: boolean;
}) =>
req<GithubBackfillResult>(`/api/projects/${projectId}/github-sync/backfill`, { method: 'POST', body: JSON.stringify(data) });
export const getGithubSyncEvents = (projectId: string, params: { status?: GithubSyncStatus; limit?: number } = {}) => {
const qs = new URLSearchParams();
if (params.status) qs.set('status', params.status);
if (params.limit !== undefined) qs.set('limit', String(params.limit));
const suffix = qs.toString() ? `?${qs}` : '';
return req<GithubSyncEventsResponse>(`/api/projects/${projectId}/github-sync${suffix}`);
};
export const retryFailedGithubSyncEvents = (projectId: string, limit?: number) =>
req<GithubRetryFailedResult>(`/api/projects/${projectId}/github-sync/retry-failed`, {
method: 'POST',
body: JSON.stringify({ limit }),
});
export const retryGithubWebhookDelivery = (projectId: string, deliveryId: string) =>
req<GithubRetryResult>(`/api/projects/${projectId}/github-sync/webhook-deliveries/${encodeURIComponent(deliveryId)}/retry`, { method: 'POST' });
export const retryGithubOutboundEvent = (projectId: string, eventId: string) =>
req<GithubRetryResult>(`/api/projects/${projectId}/github-sync/outbound-events/${encodeURIComponent(eventId)}/retry`, { method: 'POST' });
export const getProjectEstimatesSummary = (projectId: string, groupBy: EstimatesGroupBy = 'status') =>
req<EstimatesSummary>(`/api/projects/${projectId}/estimates/summary?groupBy=${groupBy}`);
export const exportYaml = (projectId: string) =>
Expand Down
261 changes: 260 additions & 1 deletion frontend/src/components/ProjectSettings.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import React, { useEffect, useState } from 'react';
import type { EstimateUnit, GithubBackfillResult, GithubBackfillState, Label, Project, ProjectWebhook, UpdateProjectInput } from '../types';
import type { EstimateUnit, GithubBackfillResult, GithubBackfillState, GithubOutboundEvent, GithubRetryFailedResult, GithubSyncEventsResponse, GithubSyncStatus, GithubWebhookDelivery, Label, Project, ProjectWebhook, UpdateProjectInput } from '../types';
import { labelTextColor } from '../utils';
import * as api from '../api';

Expand Down Expand Up @@ -400,6 +400,7 @@ export function ProjectSettings({ project, taskCount, onClose, onUpdate, onDelet
</div>
)}
</div>
<GithubSyncEvents projectId={project.id} />
</div>

<div className="border-t border-[#1a1a1a] -mx-5 px-5 pt-5">
Expand Down Expand Up @@ -518,6 +519,264 @@ function Toggle({ checked, onChange, label }: { checked: boolean; onChange: (v:
);
}

function GithubSyncEvents({ projectId }: { projectId: string }) {
const [status, setStatus] = useState<GithubSyncStatus>('open');
const [limit, setLimit] = useState(50);
const [data, setData] = useState<GithubSyncEventsResponse | null>(null);
const [loading, setLoading] = useState(true);
const [busyId, setBusyId] = useState<string | null>(null);
const [message, setMessage] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);

const load = async () => {
setLoading(true);
setError(null);
try {
const safeLimit = Number.isFinite(limit) ? Math.max(1, Math.min(200, Math.round(limit))) : 50;
setLimit(safeLimit);
setData(await api.getGithubSyncEvents(projectId, { status, limit: safeLimit }));
} catch (e) {
setError(e instanceof Error ? e.message : 'Failed to load GitHub sync events');
} finally {
setLoading(false);
}
};

useEffect(() => { void load(); }, [projectId, status]);

const retryFailed = async () => {
setBusyId('retry-failed');
setMessage(null);
setError(null);
try {
const result = await api.retryFailedGithubSyncEvents(projectId, limit);
setMessage(formatRetryFailed(result));
await load();
} catch (e) {
setError(e instanceof Error ? e.message : 'Failed to retry GitHub sync failures');
} finally {
setBusyId(null);
}
};

const retryDelivery = async (deliveryId: string) => {
setBusyId(`delivery:${deliveryId}`);
setMessage(null);
setError(null);
try {
await api.retryGithubWebhookDelivery(projectId, deliveryId);
setMessage('Webhook delivery retry completed.');
await load();
} catch (e) {
setError(e instanceof Error ? e.message : 'Failed to retry webhook delivery');
} finally {
setBusyId(null);
}
};

const retryOutbound = async (eventId: string) => {
setBusyId(`outbound:${eventId}`);
setMessage(null);
setError(null);
try {
await api.retryGithubOutboundEvent(projectId, eventId);
setMessage('Outbound event retry completed.');
await load();
} catch (e) {
setError(e instanceof Error ? e.message : 'Failed to retry outbound event');
} finally {
setBusyId(null);
}
};

return (
<div className="border border-[#1f1f1f] rounded-lg p-3 space-y-3 bg-[#111]">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3">
<div>
<div className="text-xs font-medium text-zinc-300">Sync events</div>
<div className="text-[10px] text-zinc-700">Recent inbound webhook deliveries and outbound GitHub writes.</div>
</div>
<div className="flex flex-wrap items-center gap-2">
<select
value={status}
onChange={e => setStatus(e.target.value as GithubSyncStatus)}
className={`${inputClass} w-28 text-xs py-1.5`}
>
<option value="open">Open</option>
<option value="failed">Failed</option>
<option value="processed">Processed</option>
<option value="all">All</option>
</select>
<input
type="number"
min={1}
max={200}
value={limit}
onChange={e => setLimit(Number(e.target.value))}
onBlur={() => void load()}
className={`${inputClass} w-20 text-xs py-1.5`}
aria-label="GitHub sync event limit"
/>
<button
onClick={() => void load()}
disabled={loading || busyId !== null}
className="text-xs border border-[#2a2a2a] disabled:opacity-40 text-zinc-300 px-3 py-1.5 rounded"
>Refresh</button>
<button
onClick={retryFailed}
disabled={loading || busyId !== null}
className="text-xs bg-amber-500 disabled:opacity-40 text-black font-medium px-3 py-1.5 rounded"
>{busyId === 'retry-failed' ? 'Retrying…' : 'Retry failed'}</button>
</div>
</div>

{data && (
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2">
<SyncSummaryTile label="Inbound open" value={data.summary.webhookDeliveries.open} failed={data.summary.webhookDeliveries.failed} />
<SyncSummaryTile label="Inbound done" value={data.summary.webhookDeliveries.processed} total={data.summary.webhookDeliveries.total} />
<SyncSummaryTile label="Outbound open" value={data.summary.outboundEvents.open} failed={data.summary.outboundEvents.failed} />
<SyncSummaryTile label="Outbound done" value={data.summary.outboundEvents.processed} total={data.summary.outboundEvents.total} />
</div>
)}

{error && (
<div className="text-xs text-red-400 bg-red-500/10 border border-red-500/20 rounded px-3 py-2">{error}</div>
)}
{message && (
<div className="text-xs text-emerald-400 bg-emerald-500/10 border border-emerald-500/20 rounded px-3 py-2">{message}</div>
)}

{loading ? (
<div className="text-xs text-zinc-600">Loading…</div>
) : data && data.webhookDeliveries.length === 0 && data.outboundEvents.length === 0 ? (
<div className="text-xs text-zinc-700">No GitHub sync events match this filter.</div>
) : data && (
<div className="space-y-4">
<SyncEventSection title="Webhook deliveries" empty="No webhook deliveries." rows={data.webhookDeliveries}>
{data.webhookDeliveries.map(row => (
<WebhookDeliveryRow
key={row.delivery_id}
row={row}
busy={busyId === `delivery:${row.delivery_id}`}
onRetry={() => retryDelivery(row.delivery_id)}
/>
))}
</SyncEventSection>
<SyncEventSection title="Outbound events" empty="No outbound events." rows={data.outboundEvents}>
{data.outboundEvents.map(row => (
<OutboundEventRow
key={row.id}
row={row}
busy={busyId === `outbound:${row.id}`}
onRetry={() => retryOutbound(row.id)}
/>
))}
</SyncEventSection>
</div>
)}
</div>
);
}

function SyncSummaryTile({ label, value, failed, total }: { label: string; value: number; failed?: number; total?: number }) {
return (
<div className="bg-[#0d0d0d] border border-[#1f1f1f] rounded px-3 py-2">
<div className="text-[10px] text-zinc-700 uppercase tracking-wider">{label}</div>
<div className="text-sm text-zinc-300">
{value}
{total !== undefined && <span className="text-[10px] text-zinc-700"> / {total}</span>}
{failed !== undefined && failed > 0 && <span className="text-[10px] text-red-400"> {failed} failed</span>}
</div>
</div>
);
}

function SyncEventSection<T>({ title, empty, rows, children }: { title: string; empty: string; rows: T[]; children: React.ReactNode }) {
return (
<div>
<div className="text-[10px] text-zinc-600 uppercase tracking-wider mb-2">{title}</div>
{rows.length === 0 ? (
<div className="text-xs text-zinc-700">{empty}</div>
) : (
<div className="space-y-1.5">{children}</div>
)}
</div>
);
}

function WebhookDeliveryRow({ row, busy, onRetry }: { row: GithubWebhookDelivery; busy: boolean; onRetry: () => void }) {
return (
<div className="bg-[#0d0d0d] border border-[#1f1f1f] rounded px-3 py-2">
<div className="flex items-start gap-2">
{syncBadge(row)}
<div className="min-w-0 flex-1">
<div className="text-xs text-zinc-300 truncate">
{row.event}{row.action ? `.${row.action}` : ''} <span className="text-zinc-600">from</span> {row.repo}
</div>
<div className="text-[10px] text-zinc-700">
{formatDate(row.received_at)} · attempts {row.attempts} · delivery {row.delivery_id}
</div>
{row.error && <div className="text-[10px] text-red-400 mt-1 line-clamp-2">{row.error}</div>}
</div>
{!row.processed && (
<button
onClick={onRetry}
disabled={busy}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Disable all row retries while one is in flight

When an admin starts one row retry, only that row is disabled. Because busyId is global, clicking a different row changes it and re-enables the first button while its request may still be in flight; a second click can send two concurrent retries for the same webhook delivery. The backend processGithubDelivery selects processed = false and marks processed only after applying the payload, so overlapping manual retries can duplicate sync side effects/activity. Disable every row retry whenever busyId !== null or track per-row in-flight state.

Useful? React with 👍 / 👎.

className="text-[10px] border border-[#2a2a2a] disabled:opacity-40 text-zinc-400 hover:text-amber-400 px-2 py-1 rounded"
>{busy ? 'Retrying…' : 'Retry'}</button>
)}
</div>
</div>
);
}

function OutboundEventRow({ row, busy, onRetry }: { row: GithubOutboundEvent; busy: boolean; onRetry: () => void }) {
return (
<div className="bg-[#0d0d0d] border border-[#1f1f1f] rounded px-3 py-2">
<div className="flex items-start gap-2">
{syncBadge(row)}
<div className="min-w-0 flex-1">
<div className="text-xs text-zinc-300 truncate">
{row.action} <span className="text-zinc-600">to</span> {row.gh_url}
</div>
<div className="text-[10px] text-zinc-700">
{formatDate(row.created_at)} · attempts {row.attempts} · task {row.task_id || 'unknown'}
</div>
{row.error && <div className="text-[10px] text-red-400 mt-1 line-clamp-2">{row.error}</div>}
</div>
{!row.processed && (
<button
onClick={onRetry}
disabled={busy}
className="text-[10px] border border-[#2a2a2a] disabled:opacity-40 text-zinc-400 hover:text-amber-400 px-2 py-1 rounded"
>{busy ? 'Retrying…' : 'Retry'}</button>
)}
</div>
</div>
);
}

function syncBadge(row: { processed: boolean; error: string | null }) {
if (row.processed) {
return <span className="text-[10px] bg-emerald-500/10 text-emerald-400 border border-emerald-500/20 rounded px-1.5 py-0.5">done</span>;
}
if (row.error) {
return <span className="text-[10px] bg-red-500/10 text-red-400 border border-red-500/20 rounded px-1.5 py-0.5">failed</span>;
}
return <span className="text-[10px] bg-amber-500/10 text-amber-400 border border-amber-500/20 rounded px-1.5 py-0.5">open</span>;
}

function formatDate(value: string | null) {
if (!value) return 'Never';
return new Date(value).toLocaleString();
}

function formatRetryFailed(result: GithubRetryFailedResult) {
const inbound = result.webhookDeliveries;
const outbound = result.outboundEvents;
return `Retried failed events. Inbound: ${inbound.processed} processed, ${inbound.failed} failed, ${inbound.skipped} skipped. Outbound: ${outbound.processed} processed, ${outbound.failed} failed, ${outbound.skipped} skipped.`;
}

function Webhooks({ projectId }: { projectId: string }) {
const [hooks, setHooks] = useState<ProjectWebhook[]>([]);
const [loading, setLoading] = useState(true);
Expand Down
66 changes: 66 additions & 0 deletions frontend/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,72 @@ export interface GithubBackfillResult {
};
}

export type GithubSyncStatus = 'open' | 'failed' | 'processed' | 'all';

export interface GithubSyncSummaryBucket {
total: number;
processed: number;
open: number;
failed: number;
}

export interface GithubWebhookDelivery {
delivery_id: string;
event: string;
action: string | null;
repo: string;
attempts: number;
processed: boolean;
processed_at: string | null;
error: string | null;
received_at: string;
}

export interface GithubOutboundEvent {
id: string;
task_id: string | null;
gh_url: string;
action: string;
attempts: number;
processed: boolean;
processed_at: string | null;
error: string | null;
created_at: string;
}

export interface GithubSyncEventsResponse {
projectId: string;
status: GithubSyncStatus;
limit: number;
summary: {
webhookDeliveries: GithubSyncSummaryBucket;
outboundEvents: GithubSyncSummaryBucket;
};
webhookDeliveries: GithubWebhookDelivery[];
outboundEvents: GithubOutboundEvent[];
}

export interface GithubRetryFailedResult {
projectId: string;
limit: number;
webhookDeliveries: {
processed: number;
failed: number;
skipped: number;
};
outboundEvents: {
processed: number;
failed: number;
skipped: number;
};
}

export interface GithubRetryResult {
processed?: boolean;
skipped?: boolean;
reason?: string;
}

export interface Label {
id: string;
project_id: string;
Expand Down
Loading