Fix Cloud tenant issue identifier routes (#5196)

## Summary

- Allow Cloud tenant issue identifiers with alphanumeric prefixes, such
as `PC1897-1`, to normalize as issue references.
- Resolve those identifiers through issue detail/update routes, active
run/live run polling, activity, costs, and `issueService.getById`.
- Keep UI issue-link parsing aligned so tenant links normalize back to
`/issues/<IDENTIFIER>`.

## Root Cause

Cloud tenant issue prefixes include digits from the stack-id hash. The
app-side route normalization still accepted only all-letter prefixes, so
`/api/issues/PC1897-1` skipped identifier lookup and fell through as a
non-UUID id.

## Verification

- `pnpm exec vitest run packages/shared/src/issue-references.test.ts
ui/src/lib/issue-reference.test.ts
server/src/__tests__/issue-identifier-routes.test.ts
server/src/__tests__/activity-routes.test.ts
server/src/__tests__/costs-service.test.ts
server/src/__tests__/agent-live-run-routes.test.ts
server/src/__tests__/issues-service.test.ts`
- `pnpm --filter @paperclipai/shared typecheck && pnpm --filter
@paperclipai/server typecheck`
- `git diff --check`

Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Dotta 2026-05-04 13:20:58 -05:00 committed by GitHub
parent edbb670c3b
commit d6bee62f02
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 166 additions and 41 deletions

View file

@ -1,6 +1,7 @@
import { Router } from "express";
import { z } from "zod";
import type { Db } from "@paperclipai/db";
import { normalizeIssueIdentifier } from "@paperclipai/shared";
import { validate } from "../middleware/validate.js";
import { activityService, normalizeActivityLimit } from "../services/activity.js";
import { assertAuthenticated, assertBoard, assertCompanyAccess } from "./authz.js";
@ -24,8 +25,9 @@ export function activityRoutes(db: Db) {
const issueSvc = issueService(db);
async function resolveIssueByRef(rawId: string) {
if (/^[A-Z]+-\d+$/i.test(rawId)) {
return issueSvc.getByIdentifier(rawId);
const identifier = normalizeIssueIdentifier(rawId);
if (identifier) {
return issueSvc.getByIdentifier(identifier);
}
return issueSvc.getById(rawId);
}

View file

@ -13,6 +13,7 @@ import {
createAgentSchema,
deriveAgentUrlKey,
isUuidLike,
normalizeIssueIdentifier,
resetAgentSessionSchema,
testAdapterEnvironmentSchema,
type AgentSkillSnapshot,
@ -3088,8 +3089,8 @@ export function agentRoutes(
router.get("/issues/:issueId/live-runs", async (req, res) => {
const rawId = req.params.issueId as string;
const issueSvc = issueService(db);
const isIdentifier = /^[A-Z]+-\d+$/i.test(rawId);
const issue = isIdentifier ? await issueSvc.getByIdentifier(rawId) : await issueSvc.getById(rawId);
const identifier = normalizeIssueIdentifier(rawId);
const issue = identifier ? await issueSvc.getByIdentifier(identifier) : await issueSvc.getById(rawId);
if (!issue) {
res.status(404).json({ error: "Issue not found" });
return;
@ -3142,8 +3143,8 @@ export function agentRoutes(
router.get("/issues/:issueId/active-run", async (req, res) => {
const rawId = req.params.issueId as string;
const issueSvc = issueService(db);
const isIdentifier = /^[A-Z]+-\d+$/i.test(rawId);
const issue = isIdentifier ? await issueSvc.getByIdentifier(rawId) : await issueSvc.getById(rawId);
const identifier = normalizeIssueIdentifier(rawId);
const issue = identifier ? await issueSvc.getByIdentifier(identifier) : await issueSvc.getById(rawId);
if (!issue) {
res.status(404).json({ error: "Issue not found" });
return;

View file

@ -3,6 +3,7 @@ import type { Db } from "@paperclipai/db";
import {
createCostEventSchema,
createFinanceEventSchema,
normalizeIssueIdentifier,
resolveBudgetIncidentSchema,
updateBudgetSchema,
upsertBudgetPolicySchema,
@ -62,8 +63,9 @@ export function costRoutes(
const issues = issueService(db);
async function resolveIssueByRef(rawId: string) {
if (/^[A-Z]+-\d+$/i.test(rawId)) {
return issues.getByIdentifier(rawId);
const identifier = normalizeIssueIdentifier(rawId);
if (identifier) {
return issues.getByIdentifier(identifier);
}
return issues.getById(rawId);
}

View file

@ -30,6 +30,7 @@ import {
updateIssueSchema,
getClosedIsolatedExecutionWorkspaceMessage,
isClosedIsolatedExecutionWorkspace,
normalizeIssueIdentifier as normalizeIssueReferenceIdentifier,
type ExecutionWorkspace,
} from "@paperclipai/shared";
import { trackAgentTaskCompleted } from "@paperclipai/shared/telemetry";
@ -880,9 +881,10 @@ export function issueRoutes(
});
}
async function normalizeIssueIdentifier(rawId: string): Promise<string> {
if (/^[A-Z]+-\d+$/i.test(rawId)) {
const issue = await svc.getByIdentifier(rawId);
async function resolveIssueRouteId(rawId: string): Promise<string> {
const identifier = normalizeIssueReferenceIdentifier(rawId);
if (identifier) {
const issue = await svc.getByIdentifier(identifier);
if (issue) {
return issue.id;
}
@ -920,7 +922,7 @@ export function issueRoutes(
// Resolve issue identifiers (e.g. "PAP-39") to UUIDs for all /issues/:id routes
router.param("id", async (req, res, next, rawId) => {
try {
req.params.id = await normalizeIssueIdentifier(rawId);
req.params.id = await resolveIssueRouteId(rawId);
next();
} catch (err) {
next(err);
@ -930,7 +932,7 @@ export function issueRoutes(
// Resolve issue identifiers (e.g. "PAP-39") to UUIDs for company-scoped attachment routes.
router.param("issueId", async (req, res, next, rawId) => {
try {
req.params.issueId = await normalizeIssueIdentifier(rawId);
req.params.issueId = await resolveIssueRouteId(rawId);
next();
} catch (err) {
next(err);