diff --git a/packages/shared/src/types/issue.ts b/packages/shared/src/types/issue.ts index 0aed8b4b..409a27e1 100644 --- a/packages/shared/src/types/issue.ts +++ b/packages/shared/src/types/issue.ts @@ -18,6 +18,7 @@ export interface IssueAncestorGoal { export interface IssueAncestor { id: string; + identifier: string | null; title: string; description: string | null; status: string; diff --git a/server/src/routes/activity.ts b/server/src/routes/activity.ts index 6933a446..1ff1f6d9 100644 --- a/server/src/routes/activity.ts +++ b/server/src/routes/activity.ts @@ -47,6 +47,21 @@ export function activityRoutes(db: Db) { res.status(201).json(event); }); + // Resolve issue identifiers (e.g. "PAP-39") to UUIDs + router.param("id", async (req, res, next, rawId) => { + try { + if (/^[A-Z]+-\d+$/i.test(rawId)) { + const issue = await issueSvc.getByIdentifier(rawId); + if (issue) { + req.params.id = issue.id; + } + } + next(); + } catch (err) { + next(err); + } + }); + router.get("/issues/:id/activity", async (req, res) => { const id = req.params.id as string; const issue = await issueSvc.getById(id); diff --git a/server/src/routes/agents.ts b/server/src/routes/agents.ts index 396d633a..21aef860 100644 --- a/server/src/routes/agents.ts +++ b/server/src/routes/agents.ts @@ -1017,9 +1017,10 @@ export function agentRoutes(db: Db) { }); router.get("/issues/:id/live-runs", async (req, res) => { - const id = req.params.id as string; + const rawId = req.params.id as string; const issueSvc = issueService(db); - const issue = await issueSvc.getById(id); + const isIdentifier = /^[A-Z]+-\d+$/i.test(rawId); + const issue = isIdentifier ? await issueSvc.getByIdentifier(rawId) : await issueSvc.getById(rawId); if (!issue) { res.status(404).json({ error: "Issue not found" }); return; @@ -1045,7 +1046,7 @@ export function agentRoutes(db: Db) { and( eq(heartbeatRuns.companyId, issue.companyId), inArray(heartbeatRuns.status, ["queued", "running"]), - sql`${heartbeatRuns.contextSnapshot} ->> 'issueId' = ${id}`, + sql`${heartbeatRuns.contextSnapshot} ->> 'issueId' = ${issue.id}`, ), ) .orderBy(desc(heartbeatRuns.createdAt)); @@ -1054,9 +1055,10 @@ export function agentRoutes(db: Db) { }); router.get("/issues/:id/active-run", async (req, res) => { - const id = req.params.id as string; + const rawId = req.params.id as string; const issueSvc = issueService(db); - const issue = await issueSvc.getById(id); + const isIdentifier = /^[A-Z]+-\d+$/i.test(rawId); + const issue = isIdentifier ? await issueSvc.getByIdentifier(rawId) : await issueSvc.getById(rawId); if (!issue) { res.status(404).json({ error: "Issue not found" }); return; diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index 6577e574..6e99db11 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -106,6 +106,21 @@ export function issueRoutes(db: Db, storage: StorageService) { return true; } + // Resolve issue identifiers (e.g. "PAP-39") to UUIDs for all /issues/:id routes + router.param("id", async (req, res, next, rawId) => { + try { + if (/^[A-Z]+-\d+$/i.test(rawId)) { + const issue = await svc.getByIdentifier(rawId); + if (issue) { + req.params.id = issue.id; + } + } + next(); + } catch (err) { + next(err); + } + }); + router.get("/companies/:companyId/issues", async (req, res) => { const companyId = req.params.companyId as string; assertCompanyAccess(req, companyId); @@ -119,8 +134,7 @@ export function issueRoutes(db: Db, storage: StorageService) { router.get("/issues/:id", async (req, res) => { const id = req.params.id as string; - const isIdentifier = /^[A-Z]+-\d+$/i.test(id); - const issue = isIdentifier ? await svc.getByIdentifier(id) : await svc.getById(id); + const issue = await svc.getById(id); if (!issue) { res.status(404).json({ error: "Issue not found" }); return; diff --git a/server/src/services/activity.ts b/server/src/services/activity.ts index 1fd6e3ff..ef34a07d 100644 --- a/server/src/services/activity.ts +++ b/server/src/services/activity.ts @@ -88,6 +88,7 @@ export function activityService(db: Db) { db .selectDistinctOn([issueIdAsText], { issueId: issues.id, + identifier: issues.identifier, title: issues.title, status: issues.status, priority: issues.priority, diff --git a/server/src/services/issues.ts b/server/src/services/issues.ts index ea97c210..47bdd9cb 100644 --- a/server/src/services/issues.ts +++ b/server/src/services/issues.ts @@ -574,7 +574,7 @@ export function issueService(db: Db) { getAncestors: async (issueId: string) => { const raw: Array<{ - id: string; title: string; description: string | null; + id: string; identifier: string | null; title: string; description: string | null; status: string; priority: string; assigneeAgentId: string | null; projectId: string | null; goalId: string | null; }> = []; @@ -584,14 +584,14 @@ export function issueService(db: Db) { while (currentId && !visited.has(currentId) && raw.length < 50) { visited.add(currentId); const parent = await db.select({ - id: issues.id, title: issues.title, description: issues.description, + id: issues.id, identifier: issues.identifier, title: issues.title, description: issues.description, status: issues.status, priority: issues.priority, assigneeAgentId: issues.assigneeAgentId, projectId: issues.projectId, goalId: issues.goalId, parentId: issues.parentId, }).from(issues).where(eq(issues.id, currentId)).then(r => r[0] ?? null); if (!parent) break; raw.push({ - id: parent.id, title: parent.title, description: parent.description ?? null, + id: parent.id, identifier: parent.identifier ?? null, title: parent.title, description: parent.description ?? null, status: parent.status, priority: parent.priority, assigneeAgentId: parent.assigneeAgentId ?? null, projectId: parent.projectId ?? null, goalId: parent.goalId ?? null, diff --git a/ui/src/api/activity.ts b/ui/src/api/activity.ts index 98ef9190..711133ae 100644 --- a/ui/src/api/activity.ts +++ b/ui/src/api/activity.ts @@ -15,6 +15,7 @@ export interface RunForIssue { export interface IssueForRun { issueId: string; + identifier: string | null; title: string; status: string; priority: string; diff --git a/ui/src/components/ActiveAgentsPanel.tsx b/ui/src/components/ActiveAgentsPanel.tsx index 36f74fc7..1cef5722 100644 --- a/ui/src/components/ActiveAgentsPanel.tsx +++ b/ui/src/components/ActiveAgentsPanel.tsx @@ -359,7 +359,7 @@ function AgentRunCard({
Working on: diff --git a/ui/src/components/CommandPalette.tsx b/ui/src/components/CommandPalette.tsx index 59709af3..12953143 100644 --- a/ui/src/components/CommandPalette.tsx +++ b/ui/src/components/CommandPalette.tsx @@ -149,7 +149,7 @@ export function CommandPalette() { {issues.slice(0, 10).map((issue) => ( - go(`/issues/${issue.id}`)}> + go(`/issues/${issue.identifier ?? issue.id}`)}> {issue.identifier ?? issue.id.slice(0, 8)} diff --git a/ui/src/components/IssueProperties.tsx b/ui/src/components/IssueProperties.tsx index 66ff4e9e..4f5e931a 100644 --- a/ui/src/components/IssueProperties.tsx +++ b/ui/src/components/IssueProperties.tsx @@ -211,7 +211,7 @@ export function IssueProperties({ issue, onUpdate }: IssuePropertiesProps) { {issue.parentId && ( {issue.ancestors?.[0]?.title ?? issue.parentId.slice(0, 8)} diff --git a/ui/src/components/NewIssueDialog.tsx b/ui/src/components/NewIssueDialog.tsx index 4a378928..255d97bc 100644 --- a/ui/src/components/NewIssueDialog.tsx +++ b/ui/src/components/NewIssueDialog.tsx @@ -130,7 +130,7 @@ export function NewIssueDialog() { title: `${issue.identifier ?? "Issue"} created`, body: issue.title, tone: "success", - action: { label: `View ${issue.identifier ?? "issue"}`, href: `/issues/${issue.id}` }, + action: { label: `View ${issue.identifier ?? "issue"}`, href: `/issues/${issue.identifier ?? issue.id}` }, }); }, }); diff --git a/ui/src/context/LiveUpdatesProvider.tsx b/ui/src/context/LiveUpdatesProvider.tsx index ae7c4842..9c8fa1fc 100644 --- a/ui/src/context/LiveUpdatesProvider.tsx +++ b/ui/src/context/LiveUpdatesProvider.tsx @@ -100,7 +100,7 @@ function resolveIssueToastContext( ref, title, label: title ? `${ref} - ${truncate(title, 72)}` : ref, - href: `/issues/${issueId}`, + href: `/issues/${cachedIssue?.identifier ?? issueId}`, }; } diff --git a/ui/src/lib/utils.ts b/ui/src/lib/utils.ts index d61e475b..51813350 100644 --- a/ui/src/lib/utils.ts +++ b/ui/src/lib/utils.ts @@ -36,3 +36,8 @@ export function formatTokens(n: number): string { if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k`; return String(n); } + +/** Build an issue URL using the human-readable identifier when available. */ +export function issueUrl(issue: { id: string; identifier?: string | null }): string { + return `/issues/${issue.identifier ?? issue.id}`; +} diff --git a/ui/src/pages/AgentDetail.tsx b/ui/src/pages/AgentDetail.tsx index 2e28d6f2..fd803e9e 100644 --- a/ui/src/pages/AgentDetail.tsx +++ b/ui/src/pages/AgentDetail.tsx @@ -670,7 +670,7 @@ export function AgentDetail() { key={issue.id} identifier={issue.identifier ?? issue.id.slice(0, 8)} title={issue.title} - onClick={() => navigate(`/issues/${issue.id}`)} + onClick={() => navigate(`/issues/${issue.identifier ?? issue.id}`)} trailing={} /> ))} @@ -1210,13 +1210,13 @@ function RunDetail({ run, adapterType }: { run: HeartbeatRun; adapterType: strin ))}
diff --git a/ui/src/pages/ApprovalDetail.tsx b/ui/src/pages/ApprovalDetail.tsx index fb09bd8a..c6149429 100644 --- a/ui/src/pages/ApprovalDetail.tsx +++ b/ui/src/pages/ApprovalDetail.tsx @@ -149,7 +149,7 @@ export function ApprovalDetail() { (linkedIssues?.length ?? 0) > 1 ? "Review linked issues" : "Review linked issue", - to: `/issues/${primaryLinkedIssue.id}`, + to: `/issues/${primaryLinkedIssue.identifier ?? primaryLinkedIssue.id}`, } : linkedAgentId ? { @@ -236,7 +236,7 @@ export function ApprovalDetail() { {linkedIssues.map((issue) => ( diff --git a/ui/src/pages/Dashboard.tsx b/ui/src/pages/Dashboard.tsx index 4cd6b7d6..82e6b882 100644 --- a/ui/src/pages/Dashboard.tsx +++ b/ui/src/pages/Dashboard.tsx @@ -266,7 +266,7 @@ export function Dashboard() {
navigate(`/issues/${issue.id}`)} + onClick={() => navigate(`/issues/${issue.identifier ?? issue.id}`)} >
diff --git a/ui/src/pages/Inbox.tsx b/ui/src/pages/Inbox.tsx index 35cb9259..c4a5a333 100644 --- a/ui/src/pages/Inbox.tsx +++ b/ui/src/pages/Inbox.tsx @@ -299,7 +299,7 @@ export function Inbox() { @@ -372,7 +372,7 @@ export function Inbox() {
navigate(`/issues/${issue.id}`)} + onClick={() => navigate(`/issues/${issue.identifier ?? issue.id}`)} > diff --git a/ui/src/pages/IssueDetail.tsx b/ui/src/pages/IssueDetail.tsx index 57f057ed..2fac5e89 100644 --- a/ui/src/pages/IssueDetail.tsx +++ b/ui/src/pages/IssueDetail.tsx @@ -189,11 +189,11 @@ export function IssueDetail() { }, [agents]); const childIssues = useMemo(() => { - if (!allIssues || !issueId) return []; + if (!allIssues || !issue) return []; return allIssues - .filter((i) => i.parentId === issueId) + .filter((i) => i.parentId === issue.id) .sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()); - }, [allIssues, issueId]); + }, [allIssues, issue]); const commentsWithRunMeta = useMemo(() => { const runMetaByCommentId = new Map(); @@ -281,7 +281,7 @@ export function IssueDetail() { title: `${issueRef} updated`, body: truncate(updated.title, 96), tone: "success", - action: { label: `View ${issueRef}`, href: `/issues/${updated.id}` }, + action: { label: `View ${issueRef}`, href: `/issues/${updated.identifier ?? updated.id}` }, }); }, }); @@ -298,7 +298,7 @@ export function IssueDetail() { title: `Comment posted on ${issueRef}`, body: issue?.title ? truncate(issue.title, 96) : undefined, tone: "success", - action: issueId ? { label: `View ${issueRef}`, href: `/issues/${issueId}` } : undefined, + action: issueId ? { label: `View ${issueRef}`, href: `/issues/${issue?.identifier ?? issueId}` } : undefined, }); }, }); @@ -337,6 +337,13 @@ export function IssueDetail() { ]); }, [setBreadcrumbs, issue, issueId]); + // Redirect to identifier-based URL if navigated via UUID + useEffect(() => { + if (issue?.identifier && issueId !== issue.identifier) { + navigate(`/issues/${issue.identifier}`, { replace: true }); + } + }, [issue, issueId, navigate]); + useEffect(() => { if (issue) { openPanel( @@ -373,7 +380,7 @@ export function IssueDetail() { {i > 0 && } @@ -595,7 +602,7 @@ export function IssueDetail() { {childIssues.map((child) => (
diff --git a/ui/src/pages/Issues.tsx b/ui/src/pages/Issues.tsx index c9340466..195718f2 100644 --- a/ui/src/pages/Issues.tsx +++ b/ui/src/pages/Issues.tsx @@ -157,7 +157,7 @@ export function Issues() { key={issue.id} identifier={issue.identifier ?? issue.id.slice(0, 8)} title={issue.title} - onClick={() => navigate(`/issues/${issue.id}`)} + onClick={() => navigate(`/issues/${issue.identifier ?? issue.id}`)} leading={ // eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions
e.stopPropagation()}> @@ -220,7 +220,7 @@ export function Issues() { key={issue.id} identifier={issue.identifier ?? issue.id.slice(0, 8)} title={issue.title} - onClick={() => navigate(`/issues/${issue.id}`)} + onClick={() => navigate(`/issues/${issue.identifier ?? issue.id}`)} leading={ // eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions
e.stopPropagation()}> diff --git a/ui/src/pages/MyIssues.tsx b/ui/src/pages/MyIssues.tsx index 17c9a862..0d69c410 100644 --- a/ui/src/pages/MyIssues.tsx +++ b/ui/src/pages/MyIssues.tsx @@ -52,7 +52,7 @@ export function MyIssues() { key={issue.id} identifier={issue.identifier ?? issue.id.slice(0, 8)} title={issue.title} - onClick={() => navigate(`/issues/${issue.id}`)} + onClick={() => navigate(`/issues/${issue.identifier ?? issue.id}`)} leading={ <> diff --git a/ui/src/pages/ProjectDetail.tsx b/ui/src/pages/ProjectDetail.tsx index 27330c1e..d7cbbfd0 100644 --- a/ui/src/pages/ProjectDetail.tsx +++ b/ui/src/pages/ProjectDetail.tsx @@ -132,7 +132,7 @@ export function ProjectDetail() { identifier={issue.identifier ?? issue.id.slice(0, 8)} title={issue.title} trailing={} - onClick={() => navigate(`/issues/${issue.id}`)} + onClick={() => navigate(`/issues/${issue.identifier ?? issue.id}`)} /> ))}