From 8f29eb651d49b88e7be728ea127ec3e8bde13534 Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Fri, 18 Sep 2026 11:42:39 +0200 Subject: [PATCH] feat(activities): rank work package activities by relevance Adds optional query/matchMode to listWorkPackageActivities, ranking the already-fetched activity list client-side via rankRecords (no extra API calls). Exposes the same fields on the openproject_list_work_package_activities tool. When no query is supplied, every activity is returned exactly as before, and onlyComments continues to filter independently. Also fixes a defect in the brief's exact-mode test fixture: the specified typo query "certificat" is a literal prefix substring of "certificate", so byte-for-byte exact containment matching (scoreExact in src/search/rank.ts) finds it regardless of typo tolerance, making the test unable to pass against a correct implementation. Swapped in "certificatee" (not a substring of "certificate") to preserve the test's intent without touching the assertion. Updated one pre-existing test-tools.test.ts call site to supply matchMode, now required by the extended args type. Co-Authored-By: Claude Opus 5 (1M context) --- src/services/work-packages.ts | 23 +++++++++- src/tools/work-packages.ts | 17 +++++++ tests/tools.test.ts | 1 + tests/work-package-activities.test.ts | 66 +++++++++++++++++++++++++++ 4 files changed, 105 insertions(+), 2 deletions(-) diff --git a/src/services/work-packages.ts b/src/services/work-packages.ts index d97a6c4..da6d7bf 100644 --- a/src/services/work-packages.ts +++ b/src/services/work-packages.ts @@ -19,7 +19,7 @@ import type { } from "../client/types.ts"; import { resolveClient, resolveProjectId } from "./helper.ts"; import { isFatalSearchError, searchPipeline } from "../search/pipeline.ts"; -import type { FieldSpec, MatchMode } from "../search/rank.ts"; +import { rankRecords, type FieldSpec, type MatchMode } from "../search/rank.ts"; export interface ListWorkPackagesParams extends WorkPackageFilterParams { pageSize?: number; @@ -249,6 +249,9 @@ export interface WorkPackageActivity { export interface ListWorkPackageActivitiesOptions { workPackageId: number; onlyComments?: boolean; + /** Ranks the fetched activities client-side. No extra API calls. */ + query?: string; + matchMode?: MatchMode; } /** @@ -307,6 +310,22 @@ export async function listWorkPackageActivities( }); } - return activities; + const query = options.query?.trim() ?? ""; + if (query.length === 0) { + return activities; + } + + const fields: FieldSpec[] = [ + { name: "comment", weight: 3, extract: (activity) => activity.comment }, + { + name: "details", + weight: 1, + extract: (activity) => activity.details.map((detail) => detail.raw), + }, + ]; + + return rankRecords(activities, query, fields, { + matchMode: options.matchMode ?? "fuzzy", + }).map((entry) => entry.record); } diff --git a/src/tools/work-packages.ts b/src/tools/work-packages.ts index 3ddf900..9f021e5 100644 --- a/src/tools/work-packages.ts +++ b/src/tools/work-packages.ts @@ -181,6 +181,21 @@ export const listWorkPackageActivitiesShape = { .describe( "When true, filters out property change audits and returns only comments" ), + query: z + .string() + .optional() + .describe( + "Optional search text to rank activities by relevance. Matched against " + + "comment text and change details; tolerates typos." + ), + matchMode: z + .enum(["fuzzy", "exact"]) + .optional() + .default("fuzzy") + .describe( + "Matching strategy. 'fuzzy' (default) tolerates typos, word reordering, and " + + "partial words. Use 'exact' only for literal strings you know verbatim." + ), }; export type ListWorkPackageActivitiesArgs = z.infer< @@ -197,6 +212,8 @@ export async function handleListWorkPackageActivities( const result = await listWorkPackageActivities({ workPackageId: args.workPackageId, onlyComments: args.onlyComments, + query: args.query, + matchMode: args.matchMode, }); return formatToolSuccess(result); } catch (error) { diff --git a/tests/tools.test.ts b/tests/tools.test.ts index 86ef553..207f858 100644 --- a/tests/tools.test.ts +++ b/tests/tools.test.ts @@ -598,6 +598,7 @@ describe("Work Package Tools", () => { handleListWorkPackageActivities({ workPackageId: 999, onlyComments: false, + matchMode: "fuzzy", }) ); diff --git a/tests/work-package-activities.test.ts b/tests/work-package-activities.test.ts index 50cdf23..a5bdab4 100644 --- a/tests/work-package-activities.test.ts +++ b/tests/work-package-activities.test.ts @@ -138,3 +138,69 @@ describe("Work Package Activities Service", () => { }); }); }); + +describe("Activity Ranking", () => { + function activitiesClient() { + return { + get: async () => ({ + _type: "Collection", + _embedded: { + elements: [ + { + id: 1, + version: 1, + createdAt: "2026-09-01T00:00:00Z", + comment: { raw: "Deployment blocked by the expired TLS certificate" }, + _links: { user: { href: "/api/v3/users/4", title: "Admin" } }, + }, + { + id: 2, + version: 2, + createdAt: "2026-09-02T00:00:00Z", + comment: { raw: "Merged the dependency bump" }, + _links: { user: { href: "/api/v3/users/5", title: "Dev" } }, + }, + ], + }, + }), + } as unknown as OpenProjectClient; + } + + test("ranks activities by relevance when a query is supplied", async () => { + const result = await listWorkPackageActivities( + { workPackageId: 1, query: "TLS certificate" }, + activitiesClient() + ); + expect(result).toHaveLength(1); + expect(result[0]!.id).toBe(1); + }); + + test("tolerates a typo in the query", async () => { + const result = await listWorkPackageActivities( + { workPackageId: 1, query: "certificat" }, + activitiesClient() + ); + expect(result[0]!.id).toBe(1); + }); + + test("returns every activity when no query is supplied", async () => { + const result = await listWorkPackageActivities({ workPackageId: 1 }, activitiesClient()); + expect(result).toHaveLength(2); + }); + + test("exact mode finds nothing for a typo", async () => { + // Deliberately not "certificat" (the brief's original value): that string + // is a literal prefix substring of "certificate", so byte-for-byte exact + // containment matching (scoreExact in src/search/rank.ts, "reproducing + // the pre-fuzzy behaviour byte for byte") finds it regardless of typo + // tolerance, making the original fixture unable to exercise "exact mode + // rejects typos" against a correct implementation. "certificatee" (an + // extra trailing letter) is not a substring of "certificate", so it + // exercises the same intent without the collision. + const result = await listWorkPackageActivities( + { workPackageId: 1, query: "certificatee", matchMode: "exact" }, + activitiesClient() + ); + expect(result).toHaveLength(0); + }); +});