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
23 changes: 23 additions & 0 deletions DEVLOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,29 @@

> Append-only session log. Read at session start. Update at session end.

## 2026-07-16 — AXI-119 shared issue search semantics

Implemented a shared issue-search parser and Prisma predicate for tRPC
`issue.list`/`issue.count`, MCP `issues.list`, and command-palette issue results.
Case-insensitive `KEY-N`, bare `N`, and `#N` are exact direct identifiers; full
keys remain workspace-bound. Ordinary text now covers title, description,
project key/name, label name, human assignee name/handle/email, and assigned
agent name/profile key while preserving tenant, API-key, lifecycle, archive,
saved-facet, and board filters. Status and priority remain explicit facets.

Fixed tRPC issue-list cursor boundaries to skip the prior cursor row and emit
the last returned row as the next cursor, matching deterministic sort
tiebreakers without duplicate or skipped results. Updated Issues-page guidance,
MCP/tRPC/product docs, focused integration coverage, and Playwright coverage for
full-key/bare-number search, URL persistence, lifecycle scope, and list/Kanban
parity. No database extension, index, migration, notification change, or agent
wake path was added.

Post-rebase verification on v0.27.1 passed lint (existing warnings only),
typecheck, all 119 local migrations, 1,425 Vitest tests with one intentional
skip, a fresh production E2E build, and both focused Issues Playwright journeys
against the dedicated `forge_e2e` database and Redis DB 15.

## 2026-07-16 — Truthful Delivery provenance and GitHub relations

AXI-117 audited production Ready to Close, Delivery, and native GitHub cards,
Expand Down
20 changes: 20 additions & 0 deletions docs/guide/issues.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,26 @@ within each category, but the category set is fixed.
| `DONE` | Shipped, closed positively |
| `CANCELED` | Closed without shipping |

## Search

Issue search is workspace-scoped and uses one contract across the Issues page,
relation and time-entry pickers, the command palette, and MCP `issues.list`.

- `AXI-117`, `117`, and `#117` are direct identifiers. They match the exact
issue number; full keys are case-insensitive and the prefix must equal the
current workspace key.
- Other text searches the issue title and description plus operator-facing
metadata: project key/name, label name, human assignee name/handle/email, and
assigned-agent name/profile key.
- Status and priority remain facets, not free-text terms. Lifecycle scope,
archive mode, saved-view facets, board columns, and API-key narrowing still
apply to every result, including direct identifiers.

Search does not inspect comments, attachments, audit text, AI reasoning, or
external-resource bodies. Forge currently uses ordinary indexed relational
filters and deterministic cursor sorting; the current data scale does not
justify a full-text or trigram extension.

## The two assignment slots

Forge keeps human and agent assignment **independent**. An issue can have:
Expand Down
11 changes: 10 additions & 1 deletion docs/reference/mcp.md
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,7 @@ long tail without advertising every direct tool:

| Tool | Summary |
| -------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- |
| `list` | Paged list with filters: `status`, `priority`, `projectId`, `assignedAgentId`, `labelIds[]`, `queued`. |
| `list` | Paged, cursor-stable list with lifecycle/scope filters and shared issue search semantics (below). |
| `get` | Fetch by `id`. Optional `include` hydrates description / comments / attachments / relations / currentRun / labels in one round-trip. |
| `create` | `{ title, description?, projectId?, priority?, statusId?, labelIds? }` → full issue. |
| `queue` | Set `queued: true`. Dispatcher only sees queued + unassigned issues. |
Expand Down Expand Up @@ -162,6 +162,15 @@ no argument at all — in which case it uses the calling key's
`linkedAgentId`. Keys without a linked agent that omit the argument get
`400`.

**`list` search** — pass `query` as `KEY-N`, bare `N`, or `#N` for an exact
direct lookup. Full keys are case-insensitive and must use the authenticated
workspace key. Other text searches title, description, project key/name,
label name, human assignee name/handle/email, and assigned-agent name/profile
key. Direct and text searches both compose with lifecycle, project, label,
initiative, cycle, priority, agent, and API-key narrowing. Status and priority
are facets rather than free-text fields. Comments, attachments, audit text, AI
reasoning, and external-resource bodies are intentionally excluded.

**`get` with `include`** — by default, `issues.get` returns the lean shape
(status + project + assignees) for backward compat. Pass an `include` object
to hydrate optional sections in one round-trip:
Expand Down
14 changes: 14 additions & 0 deletions docs/reference/trpc.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,20 @@ error shapes all flow from this router definition.

## Notable procedures

### `issue.list` / `issue.count` search

Both procedures share the same filter builder, including search, so counts and
rows cannot diverge. `query` recognizes case-insensitive `KEY-N`, bare `N`, and
`#N` as exact issue identifiers. A full key must match the active workspace.
Ordinary text searches title, description, project key/name, label name, human
assignee name/handle/email, and assigned-agent name/profile key. Existing
workspace, archive, lifecycle, saved-view, board-column, and API-key scope
clauses are composed with the search predicate rather than bypassed.

Normal-text ordering retains the requested configured sort plus a unique `id`
tiebreaker. Cursor pages skip the cursor row and return the last emitted row as
the next cursor, preventing duplicates and gaps at tied sort values.

### `agent.pipeline`

Returns the operator's per-agent dashboard data:
Expand Down
7 changes: 4 additions & 3 deletions src/app/(app)/w/[slug]/issues/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -399,11 +399,12 @@ export default function IssuesPage() {
onChange={(e) => setQuery(e.target.value)}
placeholder={
showArchived
? "Search archived issues…"
? "Search archived issues by key, number, or metadata…"
: scope === "open"
? "Search open issues…"
: "Search all issues…"
? "Search open issues by key, number, or metadata…"
: "Search all issues by key, number, or metadata…"
}
title="Use KEY-N, N, or #N for an exact issue; otherwise search titles, descriptions, projects, labels, assignees, and agents."
aria-label={
showArchived
? "Search archived issues"
Expand Down
7 changes: 6 additions & 1 deletion src/server/routers/__tests__/apiKey.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -269,7 +269,7 @@ describe("apiKey (access) router — narrowing", () => {
data: { workspaceId: fixture.workspace.id, name: "bot", color: "#b0b" },
});
const scoped = await createIssue(fixture);
await createIssue(fixture); // unscoped
const otherIssue = await createIssue(fixture); // unscoped
await prisma.issueLabel.create({
data: { issueId: scoped.id, labelId: label.id },
});
Expand All @@ -293,5 +293,10 @@ describe("apiKey (access) router — narrowing", () => {
const ids = items.map((i) => i.id);
expect(ids).toContain(scoped.id);
expect(ids.length).toBe(1);

const directAllowed = await caller.list({ query: String(scoped.number) });
expect(directAllowed.items.map((issue) => issue.id)).toEqual([scoped.id]);
const directDenied = await caller.list({ query: String(otherIssue.number) });
expect(directDenied.items).toEqual([]);
});
});
60 changes: 60 additions & 0 deletions src/server/routers/__tests__/command-palette.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { afterAll, afterEach, describe, expect, it } from "vitest";
import { commandPaletteRouter } from "@/server/routers/command-palette";
import {
buildContext,
createIssue,
createWorkspaceFixture,
disconnectPrisma,
getPrisma,
type TestFixture,
} from "./helpers";

const fixtures: TestFixture[] = [];

afterEach(async () => {
while (fixtures.length) await fixtures.pop()!.cleanup();
});

afterAll(disconnectPrisma);

describe("command palette issue search", () => {
it("shares exact identifier and metadata semantics with issue.list", async () => {
const fixture = await createWorkspaceFixture({ keyPrefix: "CP" });
fixtures.push(fixture);
const prisma = getPrisma();
const project = await prisma.project.create({
data: {
workspaceId: fixture.workspace.id,
key: "PAL",
name: "Palette Metadata",
createdById: fixture.user.id,
},
});
const issue = await createIssue(fixture, { title: "Palette target", projectId: project.id });
const caller = commandPaletteRouter.createCaller(await buildContext(fixture));

for (const query of [
`${fixture.workspace.key.toLowerCase()}-${issue.number}`,
String(issue.number),
`#${issue.number}`,
"Palette Metadata",
]) {
const result = await caller.search({
query,
workspaceId: fixture.workspace.id,
limit: 6,
});
expect(
result.issues.map((row) => row.id),
query,
).toEqual([issue.id]);
}

const wrongKey = await caller.search({
query: `WRONG-${issue.number}`,
workspaceId: fixture.workspace.id,
limit: 6,
});
expect(wrongKey.issues).toEqual([]);
});
});
168 changes: 168 additions & 0 deletions src/server/routers/__tests__/issue.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,174 @@ async function setup() {
}

describe("issueRouter — list filtering", () => {
it("searches exact identifiers without crossing workspace or lifecycle boundaries", async () => {
const { caller, fixture } = await setup();
const other = await createWorkspaceFixture({ keyPrefix: "OTH" });
fixtures.push(other);
const target = await createIssue(fixture, { title: "Direct target" });
await createIssue(other, { title: "Same number elsewhere" });

for (const query of [
`${fixture.workspace.key.toLowerCase()}-${target.number}`,
String(target.number),
`#${target.number}`,
]) {
const result = await caller.list({ query, includeDone: true, limit: 50 });
expect(result.items.map((row) => row.id)).toEqual([target.id]);
await expect(caller.count({ query, includeDone: true, limit: 50 })).resolves.toEqual({
count: 1,
});
}

const wrongKey = await caller.list({
query: `${other.workspace.key}-${target.number}`,
includeDone: true,
limit: 50,
});
expect(wrongKey.items).toEqual([]);

const done = await createIssue(fixture, { title: "Terminal direct", statusCategory: "DONE" });
expect(
(await caller.list({ query: String(done.number), includeDone: false, limit: 50 })).items,
).toEqual([]);
expect(
(
await caller.list({
query: String(done.number),
statusCategories: ["DONE"],
includeDone: false,
limit: 50,
})
).items.map((row) => row.id),
).toEqual([done.id]);
const canceled = await createIssue(fixture, {
title: "Canceled direct",
statusCategory: "CANCELED",
});
expect(
(
await caller.list({
query: `#${canceled.number}`,
statusCategories: ["CANCELED"],
includeDone: false,
limit: 50,
})
).items.map((row) => row.id),
).toEqual([canceled.id]);

await getPrisma().issue.update({
where: { id: target.id },
data: { deletedAt: new Date() },
});
expect(
(await caller.list({ query: String(target.number), archived: false, limit: 50 })).items,
).toEqual([]);
expect(
(
await caller.list({
query: String(target.number),
archived: true,
includeDone: true,
limit: 50,
})
).items.map((row) => row.id),
).toEqual([target.id]);
});

it("searches operator-facing issue metadata and composes with facets", async () => {
const { caller, fixture } = await setup();
const prisma = getPrisma();
await prisma.user.update({
where: { id: fixture.secondUser.id },
data: { name: "Search Person", handle: `search-person-${Date.now()}` },
});
const project = await prisma.project.create({
data: {
workspaceId: fixture.workspace.id,
key: "META",
name: "Metadata Project",
createdById: fixture.user.id,
},
});
const label = await prisma.label.create({
data: { workspaceId: fixture.workspace.id, name: "Search Label", color: "#123456" },
});
const agent = await prisma.agent.create({
data: {
workspaceId: fixture.workspace.id,
name: "Search Agent",
profileKey: `search-agent-${Date.now()}`,
},
});
const issue = await createIssue(fixture, { title: "Title Needle", projectId: project.id });
await prisma.issue.update({
where: { id: issue.id },
data: { description: "Description Needle", assignedAgentId: agent.id },
});
await prisma.issueAssignee.create({
data: { issueId: issue.id, userId: fixture.secondUser.id },
});
await prisma.issueLabel.create({ data: { issueId: issue.id, labelId: label.id } });

for (const query of [
"Title Needle",
"Description Needle",
"META",
"Metadata Project",
"Search Label",
"Search Person",
fixture.secondUser.email,
"Search Agent",
agent.profileKey,
]) {
const result = await caller.list({ query, includeDone: false, limit: 50 });
expect(
result.items.map((row) => row.id),
query,
).toContain(issue.id);
await expect(caller.count({ query, includeDone: false, limit: 50 })).resolves.toEqual({
count: 1,
});
}

const faceted = await caller.list({
query: "Search Label",
projectIds: [project.id],
labelIds: [label.id],
statusCategories: ["TODO"],
includeDone: false,
limit: 50,
});
expect(faceted.items.map((row) => row.id)).toEqual([issue.id]);
});

it("keeps ordinary-text sorting stable across cursor pages", async () => {
const { caller, fixture } = await setup();
const created = [];
for (const title of ["Alpha page token", "Bravo page token", "Charlie page token"]) {
created.push(await createIssue(fixture, { title }));
}
const first = await caller.list({
query: "page token",
includeDone: true,
sort: "title",
limit: 2,
});
expect(first.items.map((row) => row.title)).toEqual(["Alpha page token", "Bravo page token"]);
expect(first.nextCursor).toBeTruthy();
const second = await caller.list({
query: "page token",
includeDone: true,
sort: "title",
cursor: first.nextCursor,
limit: 2,
});
expect(second.items.map((row) => row.title)).toEqual(["Charlie page token"]);
expect(new Set([...first.items, ...second.items].map((row) => row.id))).toEqual(
new Set(created.map((row) => row.id)),
);
});

it("keeps terminal statuses reachable when explicitly filtered", async () => {
const { caller, fixture } = await setup();
const prisma = getPrisma();
Expand Down
Loading
Loading