Release: CI/CD Pipeline & Architecture Updates #177

Merged
jason.woltje merged 173 commits from develop into main 2026-02-01 19:18:48 +00:00
3 changed files with 285 additions and 1 deletions
Showing only changes of commit 24768bd664 - Show all commits

View File

@@ -1,2 +1,6 @@
export { LinkResolutionService } from "./link-resolution.service";
export type { ResolvedEntry } from "./link-resolution.service";
export type {
ResolvedEntry,
ResolvedLink,
Backlink,
} from "./link-resolution.service";

View File

@@ -63,6 +63,9 @@ describe("LinkResolutionService", () => {
findFirst: vi.fn(),
findMany: vi.fn(),
},
knowledgeLink: {
findMany: vi.fn(),
},
};
beforeEach(async () => {
@@ -403,4 +406,186 @@ describe("LinkResolutionService", () => {
expect(result).toHaveLength(1);
});
});
describe("resolveLinksFromContent", () => {
it("should parse and resolve wiki links from content", async () => {
const content =
"Check out [[TypeScript Guide]] and [[React Hooks]] for more info.";
// Mock resolveLink for each target
mockPrismaService.knowledgeEntry.findFirst
.mockResolvedValueOnce({ id: "entry-1" }) // TypeScript Guide
.mockResolvedValueOnce({ id: "entry-2" }); // React Hooks
const result = await service.resolveLinksFromContent(content, workspaceId);
expect(result).toHaveLength(2);
expect(result[0].link.target).toBe("TypeScript Guide");
expect(result[0].entryId).toBe("entry-1");
expect(result[1].link.target).toBe("React Hooks");
expect(result[1].entryId).toBe("entry-2");
});
it("should return null entryId for unresolved links", async () => {
const content = "See [[Non-existent Page]] for details.";
mockPrismaService.knowledgeEntry.findFirst.mockResolvedValueOnce(null);
mockPrismaService.knowledgeEntry.findUnique.mockResolvedValueOnce(null);
mockPrismaService.knowledgeEntry.findMany.mockResolvedValueOnce([]);
const result = await service.resolveLinksFromContent(content, workspaceId);
expect(result).toHaveLength(1);
expect(result[0].link.target).toBe("Non-existent Page");
expect(result[0].entryId).toBeNull();
});
it("should return empty array for content with no wiki links", async () => {
const content = "This content has no wiki links.";
const result = await service.resolveLinksFromContent(content, workspaceId);
expect(result).toEqual([]);
expect(mockPrismaService.knowledgeEntry.findFirst).not.toHaveBeenCalled();
});
it("should handle content with display text syntax", async () => {
const content = "Read the [[typescript-guide|TS Guide]] first.";
mockPrismaService.knowledgeEntry.findFirst.mockResolvedValueOnce(null);
mockPrismaService.knowledgeEntry.findUnique.mockResolvedValueOnce({
id: "entry-1",
});
const result = await service.resolveLinksFromContent(content, workspaceId);
expect(result).toHaveLength(1);
expect(result[0].link.target).toBe("typescript-guide");
expect(result[0].link.displayText).toBe("TS Guide");
expect(result[0].entryId).toBe("entry-1");
});
it("should preserve link position information", async () => {
const content = "Start [[Page One]] middle [[Page Two]] end.";
mockPrismaService.knowledgeEntry.findFirst
.mockResolvedValueOnce({ id: "entry-1" })
.mockResolvedValueOnce({ id: "entry-2" });
const result = await service.resolveLinksFromContent(content, workspaceId);
expect(result).toHaveLength(2);
expect(result[0].link.start).toBe(6);
expect(result[0].link.end).toBe(18);
expect(result[1].link.start).toBe(26);
expect(result[1].link.end).toBe(38);
});
});
describe("getBacklinks", () => {
it("should return all entries that link to the target entry", async () => {
const targetEntryId = "entry-target";
const mockBacklinks = [
{
id: "link-1",
sourceId: "entry-1",
targetId: targetEntryId,
linkText: "Target Page",
displayText: "Target Page",
positionStart: 10,
positionEnd: 25,
resolved: true,
context: null,
createdAt: new Date(),
source: {
id: "entry-1",
title: "TypeScript Guide",
slug: "typescript-guide",
},
},
{
id: "link-2",
sourceId: "entry-2",
targetId: targetEntryId,
linkText: "Target Page",
displayText: "See Target",
positionStart: 50,
positionEnd: 70,
resolved: true,
context: null,
createdAt: new Date(),
source: {
id: "entry-2",
title: "React Hooks",
slug: "react-hooks",
},
},
];
mockPrismaService.knowledgeLink.findMany.mockResolvedValueOnce(
mockBacklinks
);
const result = await service.getBacklinks(targetEntryId);
expect(result).toHaveLength(2);
expect(result[0]).toEqual({
sourceId: "entry-1",
sourceTitle: "TypeScript Guide",
sourceSlug: "typescript-guide",
linkText: "Target Page",
displayText: "Target Page",
});
expect(result[1]).toEqual({
sourceId: "entry-2",
sourceTitle: "React Hooks",
sourceSlug: "react-hooks",
linkText: "Target Page",
displayText: "See Target",
});
expect(mockPrismaService.knowledgeLink.findMany).toHaveBeenCalledWith({
where: {
targetId: targetEntryId,
resolved: true,
},
include: {
source: {
select: {
id: true,
title: true,
slug: true,
},
},
},
orderBy: {
createdAt: "desc",
},
});
});
it("should return empty array when no backlinks exist", async () => {
mockPrismaService.knowledgeLink.findMany.mockResolvedValueOnce([]);
const result = await service.getBacklinks("entry-with-no-backlinks");
expect(result).toEqual([]);
});
it("should only return resolved backlinks", async () => {
const targetEntryId = "entry-target";
mockPrismaService.knowledgeLink.findMany.mockResolvedValueOnce([]);
await service.getBacklinks(targetEntryId);
expect(mockPrismaService.knowledgeLink.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({
resolved: true,
}),
})
);
});
});
});

View File

@@ -1,5 +1,6 @@
import { Injectable } from "@nestjs/common";
import { PrismaService } from "../../prisma/prisma.service";
import { parseWikiLinks, WikiLink } from "../utils/wiki-link-parser";
/**
* Represents a knowledge entry that matches a link target
@@ -9,6 +10,32 @@ export interface ResolvedEntry {
title: string;
}
/**
* Represents a resolved wiki link with entry information
*/
export interface ResolvedLink {
/** The parsed wiki link */
link: WikiLink;
/** The resolved entry ID, or null if not found */
entryId: string | null;
}
/**
* Represents a backlink - an entry that links to a target entry
*/
export interface Backlink {
/** The source entry ID */
sourceId: string;
/** The source entry title */
sourceTitle: string;
/** The source entry slug */
sourceSlug: string;
/** The link text used to reference the target */
linkText: string;
/** The display text shown for the link */
displayText: string;
}
/**
* Service for resolving wiki-style links to knowledge entries
*
@@ -165,4 +192,72 @@ export class LinkResolutionService {
return matches;
}
/**
* Parse wiki links from content and resolve them to knowledge entries
*
* @param content - The markdown content containing wiki links
* @param workspaceId - The workspace scope for resolution
* @returns Array of resolved links with entry IDs (or null if not found)
*/
async resolveLinksFromContent(
content: string,
workspaceId: string
): Promise<ResolvedLink[]> {
// Parse wiki links from content
const parsedLinks = parseWikiLinks(content);
if (parsedLinks.length === 0) {
return [];
}
// Resolve each link
const resolvedLinks: ResolvedLink[] = [];
for (const link of parsedLinks) {
const entryId = await this.resolveLink(workspaceId, link.target);
resolvedLinks.push({
link,
entryId,
});
}
return resolvedLinks;
}
/**
* Get all entries that link TO a specific entry (backlinks)
*
* @param entryId - The target entry ID
* @returns Array of backlinks with source entry information
*/
async getBacklinks(entryId: string): Promise<Backlink[]> {
// Find all links where this entry is the target
const links = await this.prisma.knowledgeLink.findMany({
where: {
targetId: entryId,
resolved: true,
},
include: {
source: {
select: {
id: true,
title: true,
slug: true,
},
},
},
orderBy: {
createdAt: "desc",
},
});
return links.map((link) => ({
sourceId: link.source.id,
sourceTitle: link.source.title,
sourceSlug: link.source.slug,
linkText: link.linkText,
displayText: link.displayText,
}));
}
}