Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
121 changes: 118 additions & 3 deletions packages/ohno-core/src/db.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,34 @@ describe("TaskDatabase", () => {
expect(tasks.length).toBe(2);
});

it("should respect offset", () => {
const page1 = db.getTasks({ limit: 2, offset: 0 });
const page2 = db.getTasks({ limit: 2, offset: 2 });

expect(page1.length).toBe(2);
expect(page2.length).toBe(1);
expect(page1.map((task) => task.id)).not.toContain(page2[0].id);
});

it("should search by title case-insensitively", () => {
db.createTask({ title: "SQLite migration follow-up" });
db.createTask({ title: "Unrelated cleanup" });

const tasks = db.getTasks({ search: "migration" });

expect(tasks.map((task) => task.title)).toEqual(["SQLite migration follow-up"]);
});

it("should default to a compact limit", () => {
for (let i = 0; i < 20; i++) {
db.createTask({ title: `Bulk task ${i}` });
}

const tasks = db.getTasks();

expect(tasks.length).toBe(10);
});

describe("field selection", () => {
it("should return minimal fields by default", () => {
db.createTask({ title: "Test task", description: "A long description" });
Expand Down Expand Up @@ -942,6 +970,51 @@ describe("TaskDatabase", () => {
});
});

describe("Epic list queries", () => {
it("should search epics by title case-insensitively", () => {
db.createEpic({ title: "Migration epic", description: "Large epic description" });
db.createEpic({ title: "Cleanup epic", description: "Other large description" });

const epics = db.getEpics({ search: "migration" });

expect(epics.map((epic) => epic.title)).toEqual(["Migration epic"]);
});

it("should default to a compact limit", () => {
for (let i = 0; i < 15; i++) {
db.createEpic({ title: `Bulk epic ${i}` });
}

const epics = db.getEpics();

expect(epics.length).toBe(10);
});

describe("field selection", () => {
it("should return minimal fields by default", () => {
db.createEpic({ title: "Short epic", description: "Large epic description", priority: "P0" });

const epics = db.getEpics();

expect(epics[0].id).toBeDefined();
expect(epics[0].title).toBe("Short epic");
expect(epics[0].priority).toBe("P0");
expect(epics[0].status).toBe("todo");
expect(epics[0].description).toBeUndefined();
expect(epics[0].created_at).toBeUndefined();
});

it("should return description when standard fields are requested", () => {
db.createEpic({ title: "Detailed epic", description: "Useful detail" });

const epics = db.getEpics({ fields: "standard" });

expect(epics[0].description).toBe("Useful detail");
expect(epics[0].updated_at).toBeDefined();
});
});
});

describe("Story CRUD Operations", () => {
describe("createStory", () => {
it("should create a story with minimal options", () => {
Expand Down Expand Up @@ -1177,6 +1250,48 @@ describe("TaskDatabase", () => {
expect(stories.length).toBe(2);
});

it("should search stories by title case-insensitively", () => {
db.createStory({ title: "Migration story", description: "Large story description" });
db.createStory({ title: "Cleanup story", description: "Other large description" });

const stories = db.getStories({ search: "migration" });

expect(stories.map((story) => story.title)).toEqual(["Migration story"]);
});

it("should default to a compact limit", () => {
for (let i = 0; i < 15; i++) {
db.createStory({ title: `Bulk story ${i}` });
}

const stories = db.getStories();

expect(stories.length).toBe(10);
});

describe("field selection", () => {
it("should return minimal fields by default", () => {
db.createStory({ title: "Short story", description: "Large story description" });

const stories = db.getStories();

expect(stories[0].id).toBeDefined();
expect(stories[0].title).toBe("Short story");
expect(stories[0].status).toBe("todo");
expect(stories[0].description).toBeUndefined();
expect(stories[0].created_at).toBeUndefined();
});

it("should return description when standard fields are requested", () => {
db.createStory({ title: "Detailed story", description: "Useful detail" });

const stories = db.getStories({ fields: "standard" });

expect(stories[0].description).toBe("Useful detail");
expect(stories[0].updated_at).toBeDefined();
});
});

it("should respect offset option", () => {
const story1 = db.createStory({ title: "Story 1" });
const story2 = db.createStory({ title: "Story 2" });
Expand Down Expand Up @@ -1216,7 +1331,7 @@ describe("TaskDatabase", () => {
description: "A description",
});

const stories = db.getStories();
const stories = db.getStories({ fields: "full" });
expect(stories.length).toBe(1);

const story = stories[0];
Expand All @@ -1232,15 +1347,15 @@ describe("TaskDatabase", () => {
it("should handle null epic_id in returned stories", () => {
const storyId = db.createStory({ title: "Orphan Story" });

const stories = db.getStories();
const stories = db.getStories({ fields: "full" });
expect(stories.length).toBe(1);
expect(stories[0].epic_id).toBeNull();
});

it("should handle null description in returned stories", () => {
const storyId = db.createStory({ title: "No Description" });

const stories = db.getStories();
const stories = db.getStories({ fields: "full" });
expect(stories.length).toBe(1);
expect(stories[0].description).toBeNull();
});
Expand Down
49 changes: 41 additions & 8 deletions packages/ohno-core/src/db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,18 @@ const SQLITE_BUSY = 5;
const SQLITE_BUSY_SNAPSHOT = 517; // SQLITE_BUSY | (2 << 8)
const SQLITE_LOCKED = 6;

const STORY_FIELD_SETS: Record<FieldSet, string[]> = {
minimal: ["id", "title", "status", "epic_id"],
standard: ["id", "title", "status", "epic_id", "description", "created_at", "updated_at"],
full: ["id", "epic_id", "title", "description", "status", "created_at", "updated_at"],
};

const EPIC_FIELD_SETS: Record<FieldSet, string[]> = {
minimal: ["id", "title", "status", "priority"],
standard: ["id", "project_id", "title", "description", "priority", "status", "created_at", "updated_at"],
full: ["id", "project_id", "title", "description", "priority", "status", "created_at", "updated_at"],
Comment on lines +73 to +82

Copilot AI Apr 27, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

STORY_FIELD_SETS and EPIC_FIELD_SETS currently make "standard" and "full" effectively identical (story differs only by column order; epic is identical). This makes the fields parameter misleading and harder to evolve. Consider making standard omit some heavier/less-needed fields (e.g., timestamps/project_id) so full is meaningfully larger, or documenting that standard == full for these tables.

Suggested change
const STORY_FIELD_SETS: Record<FieldSet, string[]> = {
minimal: ["id", "title", "status", "epic_id"],
standard: ["id", "title", "status", "epic_id", "description", "created_at", "updated_at"],
full: ["id", "epic_id", "title", "description", "status", "created_at", "updated_at"],
};
const EPIC_FIELD_SETS: Record<FieldSet, string[]> = {
minimal: ["id", "title", "status", "priority"],
standard: ["id", "project_id", "title", "description", "priority", "status", "created_at", "updated_at"],
full: ["id", "project_id", "title", "description", "priority", "status", "created_at", "updated_at"],
const STORY_FULL_FIELDS = ["id", "epic_id", "title", "description", "status", "created_at", "updated_at"];
const STORY_FIELD_SETS: Record<FieldSet, string[]> = {
minimal: ["id", "title", "status", "epic_id"],
// `standard` intentionally aliases `full` for stories today to preserve existing API behavior.
// `minimal` is the only reduced projection until a smaller story-standard shape is introduced.
standard: STORY_FULL_FIELDS,
full: STORY_FULL_FIELDS,
};
const EPIC_FULL_FIELDS = ["id", "project_id", "title", "description", "priority", "status", "created_at", "updated_at"];
const EPIC_FIELD_SETS: Record<FieldSet, string[]> = {
minimal: ["id", "title", "status", "priority"],
// `standard` intentionally aliases `full` for epics today to preserve existing API behavior.
// `minimal` is the only reduced projection until a smaller epic-standard shape is introduced.
standard: EPIC_FULL_FIELDS,
full: EPIC_FULL_FIELDS,

Copilot uses AI. Check for mistakes.
};

/**
* Typed error for database lock contention that is recoverable by the user.
* Thrown when node:sqlite reports SQLITE_BUSY or SQLITE_BUSY_SNAPSHOT.
Expand Down Expand Up @@ -301,7 +313,7 @@ export class TaskDatabase {
* Get tasks with optional filtering
*/
getTasks(opts: GetTasksOptions = {}): Task[] {
const { status, epic_id, priority, story_status, epic_status, limit = 50, fields = "minimal" } = opts;
const { status, epic_id, priority, story_status, epic_status, limit = 10, offset = 0, fields = "minimal", search } = opts;

Comment on lines 315 to 317

Copilot AI Apr 27, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changing getTasks() default limit to 10 will also implicitly cap any internal callers that don’t pass an explicit limit (e.g., getBlockedTasks()), which can silently truncate results while server.ts describes get_blocked_tasks as returning “all blocked tasks”. Consider either (a) making getBlockedTasks pass an explicit higher limit / expose pagination, or (b) only applying the 10-default at the MCP layer while leaving the core DB default unchanged.

Copilot uses AI. Check for mistakes.
// Build SELECT clause based on fields parameter
const fieldSet = FIELD_SETS[fields] || FIELD_SETS.minimal;
Expand Down Expand Up @@ -339,10 +351,15 @@ export class TaskDatabase {
params.push(epic_status);
}

if (search) {
conditions.push("LOWER(t.title) LIKE LOWER(?)");
params.push(`%${search}%`);
Comment on lines +355 to +356

Copilot AI Apr 27, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new title search uses LIKE with user input wrapped in %…%. If the search string contains SQL LIKE wildcard characters (e.g., '%' or '_'), results will not be a literal substring match despite the API describing it as such. Consider escaping LIKE wildcards (and adding an ESCAPE clause) or switching to a function-based substring check (e.g., INSTR on lowercased strings) to guarantee literal substring semantics.

Suggested change
conditions.push("LOWER(t.title) LIKE LOWER(?)");
params.push(`%${search}%`);
conditions.push("INSTR(LOWER(t.title), LOWER(?)) > 0");
params.push(search);

Copilot uses AI. Check for mistakes.
}

sql += ` WHERE ${conditions.join(" AND ")}`;
sql += " ORDER BY CASE t.status WHEN 'in_progress' THEN 0 WHEN 'review' THEN 1 WHEN 'blocked' THEN 2 WHEN 'todo' THEN 3 WHEN 'done' THEN 4 ELSE 5 END, t.updated_at DESC, t.created_at DESC";
sql += " LIMIT ?";
params.push(limit);
sql += " LIMIT ? OFFSET ?";
params.push(limit, offset);

return this.db.prepare(sql).all(...(params as never[])) as unknown as Task[];
}
Expand All @@ -351,7 +368,7 @@ export class TaskDatabase {
* Count tasks matching the given filters (ignores limit)
*/
countTasks(opts: GetTasksOptions = {}): number {
const { status, epic_id, priority, story_status, epic_status } = opts;
const { status, epic_id, priority, story_status, epic_status, search } = opts;

let sql = `SELECT COUNT(*) as count FROM tasks t
LEFT JOIN stories s ON t.story_id = s.id
Expand Down Expand Up @@ -380,6 +397,10 @@ export class TaskDatabase {
conditions.push("e.status = ?");
params.push(epic_status);
}
if (search) {
conditions.push("LOWER(t.title) LIKE LOWER(?)");
params.push(`%${search}%`);
}

sql += ` WHERE ${conditions.join(" AND ")}`;

Expand Down Expand Up @@ -754,9 +775,10 @@ export class TaskDatabase {
* Get stories with optional filtering
*/
getStories(opts: GetStoriesOptions = {}): Story[] {
const { epic_id, status, limit = 50, offset = 0 } = opts;
const { epic_id, status, limit = 10, offset = 0, fields = "minimal", search } = opts;

let sql = "SELECT * FROM stories";
const fieldSet = STORY_FIELD_SETS[fields] || STORY_FIELD_SETS.minimal;
let sql = `SELECT ${fieldSet.join(", ")} FROM stories`;
const conditions: string[] = [];
const params: unknown[] = [];

Expand All @@ -775,6 +797,11 @@ export class TaskDatabase {
params.push(status);
}

if (search) {
conditions.push("LOWER(title) LIKE LOWER(?)");
params.push(`%${search}%`);
}
Comment on lines +800 to +803

Copilot AI Apr 27, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same LIKE-wildcard issue as getTasks(): getStories search is documented as a substring match, but '%' and '_' in user input will act as wildcards. Escaping LIKE wildcards (or using a literal substring function) would make behavior match the tool description.

Copilot uses AI. Check for mistakes.

if (conditions.length > 0) {
sql += ` WHERE ${conditions.join(" AND ")}`;
}
Expand All @@ -798,9 +825,10 @@ export class TaskDatabase {
* Get epics with optional filtering
*/
getEpics(opts: GetEpicsOptions = {}): Epic[] {
const { status, priority, limit = 50 } = opts;
const { status, priority, limit = 10, fields = "minimal", search } = opts;

let sql = "SELECT * FROM epics";
const fieldSet = EPIC_FIELD_SETS[fields] || EPIC_FIELD_SETS.minimal;
let sql = `SELECT ${fieldSet.join(", ")} FROM epics`;
const conditions: string[] = [];
const params: unknown[] = [];

Expand All @@ -814,6 +842,11 @@ export class TaskDatabase {
params.push(priority);
}

if (search) {
conditions.push("LOWER(title) LIKE LOWER(?)");
params.push(`%${search}%`);
Comment on lines +846 to +847

Copilot AI Apr 27, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same LIKE-wildcard issue as getTasks()/getStories(): getEpics search currently treats '%' and '_' in user input as wildcards, which can surprise callers expecting a literal substring search. Escaping LIKE wildcards (or using a literal substring function) would align behavior with the API description.

Suggested change
conditions.push("LOWER(title) LIKE LOWER(?)");
params.push(`%${search}%`);
const escapedSearch = search
.replace(/\\/g, "\\\\")
.replace(/%/g, "\\%")
.replace(/_/g, "\\_");
conditions.push("LOWER(title) LIKE LOWER(?) ESCAPE '\\'");
params.push(`%${escapedSearch}%`);

Copilot uses AI. Check for mistakes.
}

if (conditions.length > 0) {
sql += ` WHERE ${conditions.join(" AND ")}`;
}
Expand Down
15 changes: 15 additions & 0 deletions packages/ohno-core/src/types.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,17 @@ describe("Story Types", () => {
expect(story.status).toBe("todo");
});

it("should allow minimal list response fields", () => {
const story: Story = {
id: "story-123",
epic_id: "epic-456",
title: "User Authentication",
status: "todo",
};

expect(story.description).toBeUndefined();
});

it("should allow null epic_id", () => {
const story: Story = {
id: "story-123",
Expand Down Expand Up @@ -146,12 +157,16 @@ describe("Story Types", () => {
status: "done",
limit: 5,
offset: 0,
fields: "minimal",
search: "migration",
};

expect(options.epic_id).toBe("epic-456");
expect(options.status).toBe("done");
expect(options.limit).toBe(5);
expect(options.offset).toBe(0);
expect(options.fields).toBe("minimal");
expect(options.search).toBe("migration");
});
});
});
Expand Down
12 changes: 9 additions & 3 deletions packages/ohno-core/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -211,10 +211,10 @@ export interface Story {
id: string;
epic_id: string | null;
title: string;
description: string | null;
description?: string | null;
status: StoryStatus;
created_at: string;
updated_at: string;
created_at?: string;
updated_at?: string;
}

/**
Expand Down Expand Up @@ -249,6 +249,8 @@ export interface GetEpicsOptions {
status?: TaskStatus;
priority?: Priority;
limit?: number;
fields?: FieldSet;
search?: string;
}

/**
Expand All @@ -259,6 +261,8 @@ export interface GetStoriesOptions {
status?: StoryStatus;
limit?: number;
offset?: number;
fields?: FieldSet;
search?: string;
}

/**
Expand All @@ -271,7 +275,9 @@ export interface GetTasksOptions {
story_status?: TaskStatus;
epic_status?: TaskStatus;
limit?: number;
offset?: number;
fields?: FieldSet;
search?: string;
}

/**
Expand Down
Loading
Loading