From a64b2596744222c192e7d05be7d48fca081ddca4 Mon Sep 17 00:00:00 2001 From: John Fawcett Date: Sat, 9 May 2026 18:33:59 +0000 Subject: [PATCH 1/2] feat(wasteland): add listClaims tRPC procedure for claims overview page --- apps/web/src/lib/wasteland/types/router.d.ts | 80 ++++++++++++ services/wasteland/src/trpc/claims.util.ts | 18 +++ services/wasteland/src/trpc/router.test.ts | 41 ++++++ services/wasteland/src/trpc/router.ts | 120 ++++++++++++++++++ services/wasteland/src/trpc/schemas.ts | 22 ++++ services/wasteland/src/util/analytics.util.ts | 1 + .../wasteland/src/util/rate-limit.util.ts | 1 + 7 files changed, 283 insertions(+) create mode 100644 services/wasteland/src/trpc/claims.util.ts create mode 100644 services/wasteland/src/trpc/router.test.ts diff --git a/apps/web/src/lib/wasteland/types/router.d.ts b/apps/web/src/lib/wasteland/types/router.d.ts index ff5e0736ea..cd0be86bf1 100644 --- a/apps/web/src/lib/wasteland/types/router.d.ts +++ b/apps/web/src/lib/wasteland/types/router.d.ts @@ -338,6 +338,46 @@ export declare const wastelandRouter: import('@trpc/server').TRPCBuiltRouter< }; meta: object; }>; + listClaims: import('@trpc/server').TRPCQueryProcedure<{ + input: { + wastelandId: string; + rigHandle?: string | undefined; + limit?: number | undefined; + }; + output: { + claims: Array<{ + item: { + id: string; + title: string; + description: string | null; + project: string | null; + type: string | null; + priority: string | number | null; + tags: string | null; + posted_by: string | null; + claimed_by: string | null; + status: string; + effort_level: string | null; + evidence_url: string | null; + sandbox_required: string | number | null; + sandbox_scope: string | null; + sandbox_min_tier: string | null; + created_at: string | null; + updated_at: string | null; + }; + pending_pr: { + pull_id: string; + pr_url: string; + kind: 'claim' | 'done' | 'unclaim' | 'edit' | 'unknown'; + from_branch: string; + state: 'Open'; + created_at: string | null; + updated_at: string | null; + } | null; + }>; + }; + meta: object; + }>; claimWantedItem: import('@trpc/server').TRPCMutationProcedure<{ input: { wastelandId: string; @@ -1093,6 +1133,46 @@ export declare const wrappedWastelandRouter: import('@trpc/server').TRPCBuiltRou }; meta: object; }>; + listClaims: import('@trpc/server').TRPCQueryProcedure<{ + input: { + wastelandId: string; + rigHandle?: string | undefined; + limit?: number | undefined; + }; + output: { + claims: Array<{ + item: { + id: string; + title: string; + description: string | null; + project: string | null; + type: string | null; + priority: string | number | null; + tags: string | null; + posted_by: string | null; + claimed_by: string | null; + status: string; + effort_level: string | null; + evidence_url: string | null; + sandbox_required: string | number | null; + sandbox_scope: string | null; + sandbox_min_tier: string | null; + created_at: string | null; + updated_at: string | null; + }; + pending_pr: { + pull_id: string; + pr_url: string; + kind: 'claim' | 'done' | 'unclaim' | 'edit' | 'unknown'; + from_branch: string; + state: 'Open'; + created_at: string | null; + updated_at: string | null; + } | null; + }>; + }; + meta: object; + }>; claimWantedItem: import('@trpc/server').TRPCMutationProcedure<{ input: { wastelandId: string; diff --git a/services/wasteland/src/trpc/claims.util.ts b/services/wasteland/src/trpc/claims.util.ts new file mode 100644 index 0000000000..7381cd9507 --- /dev/null +++ b/services/wasteland/src/trpc/claims.util.ts @@ -0,0 +1,18 @@ +const PR_KIND_KEYWORDS: ReadonlyArray<{ + keyword: string; + kind: 'claim' | 'done' | 'unclaim' | 'edit'; +}> = [ + { keyword: 'unclaim', kind: 'unclaim' }, + { keyword: 'done', kind: 'done' }, + { keyword: 'claim', kind: 'claim' }, + { keyword: 'update', kind: 'edit' }, + { keyword: 'edit', kind: 'edit' }, +]; + +export function inferPrKind(title: string): 'claim' | 'done' | 'unclaim' | 'edit' | 'unknown' { + const lower = title.toLowerCase(); + for (const { keyword, kind } of PR_KIND_KEYWORDS) { + if (lower.includes(keyword)) return kind; + } + return 'unknown'; +} diff --git a/services/wasteland/src/trpc/router.test.ts b/services/wasteland/src/trpc/router.test.ts new file mode 100644 index 0000000000..3edab0e0e5 --- /dev/null +++ b/services/wasteland/src/trpc/router.test.ts @@ -0,0 +1,41 @@ +import { describe, it, expect } from 'vitest'; +import { inferPrKind } from './claims.util'; + +describe('inferPrKind', () => { + it('detects claim from title', () => { + expect(inferPrKind('wl claim: w-abc123')).toBe('claim'); + }); + + it('detects done from title', () => { + expect(inferPrKind('wl done: w-abc123')).toBe('done'); + }); + + it('detects unclaim from title', () => { + expect(inferPrKind('wl unclaim: w-abc123')).toBe('unclaim'); + }); + + it('detects edit from "update" keyword', () => { + expect(inferPrKind('wl update: w-abc123')).toBe('edit'); + }); + + it('detects edit from "edit" keyword', () => { + expect(inferPrKind('Edit wanted item w-abc123')).toBe('edit'); + }); + + it('returns unknown for unrecognized titles', () => { + expect(inferPrKind('Some random PR title')).toBe('unknown'); + }); + + it('is case-insensitive', () => { + expect(inferPrKind('WL CLAIM: w-abc123')).toBe('claim'); + expect(inferPrKind('WL Done: w-abc123')).toBe('done'); + }); + + it('prefers unclaim over claim (unclaim checked first)', () => { + expect(inferPrKind('wl unclaim: w-abc123')).toBe('unclaim'); + }); + + it('prefers done over claim when both present', () => { + expect(inferPrKind('wl done: w-abc123 after claim')).toBe('done'); + }); +}); diff --git a/services/wasteland/src/trpc/router.ts b/services/wasteland/src/trpc/router.ts index cd2bfc20d5..2b152df8de 100644 --- a/services/wasteland/src/trpc/router.ts +++ b/services/wasteland/src/trpc/router.ts @@ -18,6 +18,8 @@ import * as wantedBoard from '../wanted-board/wanted-board-ops'; import { WantedBoardOpError } from '../wanted-board/wanted-board-ops'; import * as doltApi from '../util/dolthub-api.util'; import * as inbox from '../inbox/inbox-classifier'; +import { writeEvent } from '../util/analytics.util'; +import { inferPrKind } from './claims.util'; import { RpcWastelandOutput, RpcWastelandMemberOutput, @@ -32,6 +34,7 @@ import { RpcUpstreamRigOutput, RpcRigDetailOutput, RpcRigActivityOutput, + RpcClaimListOutput, WantedBoardRowOutput, } from './schemas'; import type { TRPCContext } from './init'; @@ -1025,6 +1028,123 @@ export const wastelandRouter = router({ } }), + // ── Claims overview (all claimed items + in-flight PRs) ──────────── + // Returns every currently claimed wanted item in the wasteland, enriched + // with metadata about any open DoltHub PR so the Claims page can show + // "PR pending merge" badges per row. Owner-access gated (admin view). + + listClaims: procedure + .input( + z.object({ + wastelandId: z.string().uuid(), + rigHandle: z + .string() + .min(1) + .max(64) + .regex(/^[a-zA-Z0-9_-]+$/) + .optional(), + limit: z.number().int().min(1).max(200).default(100), + }) + ) + .output(RpcClaimListOutput) + .query(async ({ ctx, input }) => { + await requireOwnerAccess(ctx.env, ctx, input.wastelandId); + let loaded: Awaited>; + try { + loaded = await loadAdminContext(ctx.env, input.wastelandId, ctx.userId); + } catch { + return { claims: [] }; + } + const { token, upstream } = loaded; + const wantedCols = + 'id, title, description, project, type, priority, tags, posted_by, claimed_by, status, effort_level, evidence_url, sandbox_required, sandbox_scope, sandbox_min_tier, created_at, updated_at'; + + let sql = `SELECT ${wantedCols} FROM wanted WHERE status = 'claimed'`; + if (input.rigHandle) { + sql += ` AND claimed_by = '${input.rigHandle}'`; + } + sql += ` ORDER BY updated_at DESC LIMIT ${input.limit}`; + + let claimedRows: z.infer[] = []; + try { + const result = await doltApi.runUnsafeSql(upstream, token, 'main', sql); + claimedRows = parseWantedBoardRows(result.rows ?? []); + } catch { + return { claims: [] }; + } + + let openPulls: Awaited> = []; + try { + openPulls = await doltApi.listPulls(upstream, token, { state: 'Open' }); + } catch { + return { claims: claimedRows.map(item => ({ item, pending_pr: null })) }; + } + + const rigFilter = input.rigHandle ?? null; + const candidates = openPulls.filter( + p => !rigFilter || !p.creator_name || p.creator_name === rigFilter + ); + + let pullDetails: (Awaited> | null)[] = []; + try { + pullDetails = await doltApi.mapWithLimit(candidates, 6, p => + doltApi.getPull(upstream, token, p.pull_id).catch(() => null) + ); + } catch { + return { claims: claimedRows.map(item => ({ item, pending_pr: null })) }; + } + + type PrEntry = { + pull_id: string; + pr_url: string; + kind: 'claim' | 'done' | 'unclaim' | 'edit' | 'unknown'; + from_branch: string; + state: 'Open'; + created_at: string | null; + updated_at: string | null; + }; + + const prByItemId = new Map(); + + for (const detail of pullDetails) { + if (!detail) continue; + const branchInfo = doltApi.parseWlBranch(detail.from_branch_name); + if (!branchInfo) continue; + if (rigFilter && branchInfo.rigHandle !== rigFilter) continue; + + const itemId = branchInfo.itemId; + const kind = inferPrKind(detail.title); + const entry: PrEntry = { + pull_id: detail.pull_id, + pr_url: doltApi.buildPullWebUrl(upstream, detail.pull_id), + kind, + from_branch: detail.from_branch_name ?? '', + state: 'Open' as const, + created_at: detail.created_at, + updated_at: detail.updated_at, + }; + + const existing = prByItemId.get(itemId); + if (!existing) { + prByItemId.set(itemId, entry); + } + } + + writeEvent(ctx.env, { + event: 'claims.list', + delivery: 'trpc', + userId: ctx.userId, + wastelandId: input.wastelandId, + }); + + return { + claims: claimedRows.map(item => ({ + item, + pending_pr: prByItemId.get(item.id) ?? null, + })), + }; + }), + // ── Wanted Board Mutations ──────────────────────────────────────── claimWantedItem: procedure diff --git a/services/wasteland/src/trpc/schemas.ts b/services/wasteland/src/trpc/schemas.ts index fb11d1cfa5..9714da4f93 100644 --- a/services/wasteland/src/trpc/schemas.ts +++ b/services/wasteland/src/trpc/schemas.ts @@ -107,6 +107,27 @@ export const WantedBoardRowOutput = z.object({ updated_at: z.string().nullable().default(null), }); +// ── Claim with optional in-flight PR ──────────────────────────────────── +// Enriches a claimed wanted item with metadata about any open DoltHub PR +// (claim, done, unclaim, or edit) so the Claims page can show "PR pending" +// badges per row. + +export const RpcClaimOutput = z.object({ + item: WantedBoardRowOutput, + pending_pr: z + .object({ + pull_id: z.string(), + pr_url: z.string(), + kind: z.enum(['claim', 'done', 'unclaim', 'edit', 'unknown']), + from_branch: z.string(), + state: z.literal('Open'), + created_at: z.string().nullable(), + updated_at: z.string().nullable(), + }) + .nullable(), +}); +export type RpcClaim = z.infer; + // ── Admin: mergeUpstreamPR result ─────────────────────────────────────── export const MergePullOutput = z.object({ @@ -308,3 +329,4 @@ export const RpcRigDetailOutput = rpcSafe(RigDetailOutput); export const RpcCompletionOutput = rpcSafe(CompletionOutput); export const RpcStampOutput = rpcSafe(StampOutput); export const RpcRigActivityOutput = rpcSafe(RigActivityOutput); +export const RpcClaimListOutput = rpcSafe(z.object({ claims: z.array(RpcClaimOutput) })); diff --git a/services/wasteland/src/util/analytics.util.ts b/services/wasteland/src/util/analytics.util.ts index 4ebae0cde3..2f7aa30bfe 100644 --- a/services/wasteland/src/util/analytics.util.ts +++ b/services/wasteland/src/util/analytics.util.ts @@ -14,6 +14,7 @@ export type WastelandEventName = | 'wanted.done' | 'wanted.post' | 'wanted.sync' + | 'claims.list' // Controller-level events (HTTP) use string to avoid maintaining // a massive union — event names are derived from route patterns. | (string & {}); diff --git a/services/wasteland/src/util/rate-limit.util.ts b/services/wasteland/src/util/rate-limit.util.ts index a136822060..fc3e08c34d 100644 --- a/services/wasteland/src/util/rate-limit.util.ts +++ b/services/wasteland/src/util/rate-limit.util.ts @@ -26,6 +26,7 @@ export const RATE_LIMITS: Record = { 'wasteland.markWantedItemDone': { maxRequests: 10, windowMs: 60_000 }, 'wasteland.postWantedItem': { maxRequests: 5, windowMs: 60_000 }, 'wasteland.browseWantedBoard': { maxRequests: 60, windowMs: 60_000 }, + 'wasteland.listClaims': { maxRequests: 30, windowMs: 60_000 }, }; // Global store — lives for the lifetime of the worker isolate. From b391ea967288a8263765659d3a27eb3a1c541f7c Mon Sep 17 00:00:00 2001 From: John Fawcett Date: Sat, 9 May 2026 18:53:22 +0000 Subject: [PATCH 2/2] test(wasteland): add unit tests for listClaims tRPC procedure --- services/wasteland/src/trpc/router.test.ts | 280 ++++++++++++++++++++- 1 file changed, 279 insertions(+), 1 deletion(-) diff --git a/services/wasteland/src/trpc/router.test.ts b/services/wasteland/src/trpc/router.test.ts index 3edab0e0e5..248f7a5ab2 100644 --- a/services/wasteland/src/trpc/router.test.ts +++ b/services/wasteland/src/trpc/router.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; import { inferPrKind } from './claims.util'; describe('inferPrKind', () => { @@ -39,3 +39,281 @@ describe('inferPrKind', () => { expect(inferPrKind('wl done: w-abc123 after claim')).toBe('done'); }); }); + +// ── listClaims procedure tests ────────────────────────────────────────── + +vi.mock('../util/dolthub-api.util', () => ({ + runUnsafeSql: vi.fn(), + listPulls: vi.fn(), + getPull: vi.fn(), + parseWlBranch: vi.fn(), + buildPullWebUrl: vi.fn(), + mapWithLimit: vi.fn(), + DoltHubApiError: class extends Error { + status: number; + constructor(msg: string, status: number) { + super(msg); + this.status = status; + } + }, +})); + +vi.mock('./ownership', () => ({ + resolveWastelandOwnership: vi.fn(), +})); + +vi.mock('../dos/Wasteland.do', () => ({ + getWastelandDOStub: vi.fn(), +})); + +vi.mock('../util/secret.util', () => ({ + resolveSecret: vi.fn(), +})); + +vi.mock('../util/crypto.util', () => ({ + deriveEncryptionKey: vi.fn(), + decryptToken: vi.fn(), + encryptToken: vi.fn(), +})); + +vi.mock('../util/analytics.util', () => ({ + writeEvent: vi.fn(), +})); + +vi.mock('../util/billing.util', () => ({ + meterEvent: vi.fn(), +})); + +vi.mock('../wanted-board/wanted-board-ops', () => ({ + WantedBoardOpError: class extends Error {}, +})); + +vi.mock('../dos/WastelandContainer.do', () => ({ + getWastelandContainerStub: vi.fn(), +})); + +vi.mock('../dos/WastelandRegistry.do', () => ({ + getWastelandRegistryStub: vi.fn(), +})); + +vi.mock('../inbox/inbox-classifier', () => ({ + classifyInbox: vi.fn(), +})); + +import { TRPCError } from '@trpc/server'; +import { wastelandRouter } from './router'; +import * as doltApi from '../util/dolthub-api.util'; +import { resolveWastelandOwnership } from './ownership'; +import { getWastelandDOStub } from '../dos/Wasteland.do'; +import { resolveSecret } from '../util/secret.util'; +import { deriveEncryptionKey, decryptToken } from '../util/crypto.util'; + +const WASTELAND_ID = '550e8400-e29b-41d4-a716-446655440000'; +const USER_ID = 'user-test-001'; + +const mockCtx = { + env: {} as Env, + userId: USER_ID, + isAdmin: false, + apiTokenPepper: null, + orgMemberships: [] as Array<{ orgId: string; role: 'owner' | 'member' | 'billing_manager' }>, +}; + +function makeClaimedRow(overrides: Record = {}) { + return { + id: 'w-item1', + title: 'Fix login bug', + description: null, + project: null, + type: 'bug', + priority: 'high', + tags: null, + posted_by: 'admin', + claimed_by: 'rig-alpha', + status: 'claimed', + effort_level: null, + evidence_url: null, + sandbox_required: null, + sandbox_scope: null, + sandbox_min_tier: null, + created_at: '2025-01-01 00:00:00', + updated_at: '2025-01-02 00:00:00', + ...overrides, + }; +} + +describe('listClaims', () => { + beforeEach(() => { + vi.clearAllMocks(); + (resolveWastelandOwnership as ReturnType).mockResolvedValue({ + type: 'user', + userId: USER_ID, + }); + }); + + async function callListClaims(input: { + wastelandId?: string; + rigHandle?: string; + limit?: number; + }) { + const caller = wastelandRouter.createCaller(mockCtx); + return caller.listClaims({ + wastelandId: input.wastelandId ?? WASTELAND_ID, + rigHandle: input.rigHandle, + limit: input.limit, + }); + } + + function setupAdminContext() { + const mockStub = { + getConfig: vi.fn().mockResolvedValue({ + dolthub_upstream: 'hop/wl-commons', + status: 'active', + }), + getCredential: vi.fn().mockResolvedValue({ + encrypted_token: 'enc-token', + is_upstream_admin: true, + rig_handle: 'rig-alpha', + dolthub_org: 'rig-alpha', + }), + }; + (getWastelandDOStub as ReturnType).mockReturnValue(mockStub); + (resolveSecret as ReturnType).mockResolvedValue('test-key'); + (deriveEncryptionKey as ReturnType).mockResolvedValue({}); + (decryptToken as ReturnType).mockResolvedValue('dolt-token'); + } + + it('returns all claimed items when no rigHandle filter', async () => { + setupAdminContext(); + (doltApi.runUnsafeSql as ReturnType).mockResolvedValue({ + rows: [ + makeClaimedRow({ id: 'w-1', claimed_by: 'rig-alpha' }), + makeClaimedRow({ id: 'w-2', claimed_by: 'rig-beta', title: 'Add feature' }), + ], + }); + (doltApi.listPulls as ReturnType).mockResolvedValue([]); + (doltApi.mapWithLimit as ReturnType).mockResolvedValue([]); + + const result = await callListClaims({}); + + expect(result.claims).toHaveLength(2); + expect(result.claims[0].item.id).toBe('w-1'); + expect(result.claims[1].item.id).toBe('w-2'); + expect(result.claims[0].pending_pr).toBeNull(); + expect(result.claims[1].pending_pr).toBeNull(); + + const sql = (doltApi.runUnsafeSql as ReturnType).mock.calls[0][3]; + expect(sql).toContain("WHERE status = 'claimed'"); + expect(sql).not.toContain("AND claimed_by ="); + }); + + it('scopes SQL to rigHandle when provided', async () => { + setupAdminContext(); + (doltApi.runUnsafeSql as ReturnType).mockResolvedValue({ + rows: [makeClaimedRow({ id: 'w-1', claimed_by: 'rig-alpha' })], + }); + (doltApi.listPulls as ReturnType).mockResolvedValue([]); + (doltApi.mapWithLimit as ReturnType).mockResolvedValue([]); + + const result = await callListClaims({ rigHandle: 'rig-alpha' }); + + expect(result.claims).toHaveLength(1); + const sql = (doltApi.runUnsafeSql as ReturnType).mock.calls[0][3]; + expect(sql).toContain("claimed_by = 'rig-alpha'"); + }); + + it('returns empty claims when credentials are missing', async () => { + const mockStub = { + getConfig: vi.fn().mockResolvedValue({ + dolthub_upstream: 'hop/wl-commons', + status: 'active', + }), + getCredential: vi.fn().mockResolvedValue(null), + }; + (getWastelandDOStub as ReturnType).mockReturnValue(mockStub); + + const result = await callListClaims({}); + + expect(result.claims).toEqual([]); + }); + + it('returns claims with pending_pr null when DoltHub PR list fails', async () => { + setupAdminContext(); + (doltApi.runUnsafeSql as ReturnType).mockResolvedValue({ + rows: [makeClaimedRow({ id: 'w-1' })], + }); + (doltApi.listPulls as ReturnType).mockRejectedValue( + new Error('DoltHub unavailable') + ); + + const result = await callListClaims({}); + + expect(result.claims).toHaveLength(1); + expect(result.claims[0].pending_pr).toBeNull(); + }); + + it('attaches pending_pr when an open PR matches a claimed item', async () => { + setupAdminContext(); + (doltApi.runUnsafeSql as ReturnType).mockResolvedValue({ + rows: [makeClaimedRow({ id: 'w-item1', claimed_by: 'rig-alpha' })], + }); + + const openPulls = [ + { pull_id: '42', creator_name: 'rig-alpha', state: 'Open' }, + ]; + (doltApi.listPulls as ReturnType).mockResolvedValue(openPulls); + + const pullDetail = { + pull_id: '42', + title: 'wl claim: w-item1', + state: 'Open', + from_branch_name: 'wl/rig-alpha/w-item1', + from_branch: null, + creator_name: 'rig-alpha', + created_at: '2025-01-03T00:00:00Z', + updated_at: '2025-01-03T01:00:00Z', + }; + (doltApi.mapWithLimit as ReturnType).mockImplementation( + async (items: unknown[], _limit: number, fn: (p: unknown) => Promise) => + Promise.all(items.map(fn)) + ); + (doltApi.getPull as ReturnType).mockResolvedValue(pullDetail); + (doltApi.parseWlBranch as ReturnType).mockReturnValue({ + rigHandle: 'rig-alpha', + itemId: 'w-item1', + }); + (doltApi.buildPullWebUrl as ReturnType).mockReturnValue( + 'https://www.dolthub.com/repositories/hop/wl-commons/pulls/42' + ); + + const result = await callListClaims({}); + + expect(result.claims).toHaveLength(1); + expect(result.claims[0].pending_pr).not.toBeNull(); + expect(result.claims[0].pending_pr!.pull_id).toBe('42'); + expect(result.claims[0].pending_pr!.kind).toBe('claim'); + expect(result.claims[0].pending_pr!.from_branch).toBe('wl/rig-alpha/w-item1'); + expect(result.claims[0].pending_pr!.state).toBe('Open'); + }); + + it('returns empty claims when SQL query fails', async () => { + setupAdminContext(); + (doltApi.runUnsafeSql as ReturnType).mockRejectedValue( + new Error('SQL failed') + ); + + const result = await callListClaims({}); + + expect(result.claims).toEqual([]); + }); + + it('throws FORBIDDEN when caller is not an owner', async () => { + (resolveWastelandOwnership as ReturnType).mockResolvedValue({ + type: 'org', + orgId: 'org-1', + }); + mockCtx.orgMemberships = [{ orgId: 'org-1', role: 'member' }]; + + await expect(callListClaims({})).rejects.toThrow(/Only wasteland owners/); + }); +});