Support issue identifiers (PAP-39) in URLs and prefer them throughout
Backend: - Add router.param middleware in issues, activity, and agents routes to resolve identifiers (e.g. PAP-39) to UUIDs before handlers run - Simplify GET /issues/:id now that param middleware handles resolution - Include identifier in getAncestors response and issuesForRun query - Add identifier field to IssueAncestor shared type Frontend: - Update all issue navigation links across 15+ files to use issue.identifier ?? issue.id instead of bare UUIDs - Add URL redirect in IssueDetail: navigating via UUID automatically replaces the URL with the human-readable identifier - Fix childIssues filter to use issue.id (UUID) instead of URL param so it works correctly with identifier-based URLs - Add issueUrl() utility in lib/utils.ts Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user