diff --git a/apps/hook/server/index.ts b/apps/hook/server/index.ts index 9a4bc628..0f2e6c9a 100644 --- a/apps/hook/server/index.ts +++ b/apps/hook/server/index.ts @@ -13,6 +13,7 @@ import { $ } from "bun"; import { join } from "path"; import { mkdirSync, existsSync, statSync } from "fs"; +import { detectProjectName } from "./project"; // --- Obsidian Integration --- @@ -30,10 +31,17 @@ interface BearConfig { /** * Extract tags from markdown content using simple heuristics + * Includes project name detection (git repo or directory name) */ -function extractTags(markdown: string): string[] { +async function extractTags(markdown: string): Promise { const tags = new Set(["plannotator"]); + // Add project name tag (git repo name or directory fallback) + const projectName = await detectProjectName(); + if (projectName) { + tags.add(projectName); + } + const stopWords = new Set([ "the", "and", "for", "with", "this", "that", "from", "into", "plan", "implementation", "overview", "phase", "step", "steps", @@ -64,7 +72,7 @@ function extractTags(markdown: string): string[] { } } - return Array.from(tags).slice(0, 6); + return Array.from(tags).slice(0, 7); } /** @@ -205,7 +213,7 @@ async function saveToObsidian( const filePath = join(targetFolder, filename); // Generate content with frontmatter and backlink - const tags = extractTags(plan); + const tags = await extractTags(plan); const frontmatter = generateFrontmatter(tags); const content = `${frontmatter}\n\n[[Plannotator Plans]]\n\n${plan}`; @@ -231,7 +239,7 @@ async function saveToBear( // Extract title and tags const title = extractTitle(plan); - const tags = extractTags(plan); + const tags = await extractTags(plan); const hashtags = tags.map(t => `#${t}`).join(' '); // Append hashtags to content diff --git a/apps/hook/server/project.test.ts b/apps/hook/server/project.test.ts new file mode 100644 index 00000000..78e9d4d9 --- /dev/null +++ b/apps/hook/server/project.test.ts @@ -0,0 +1,114 @@ +/** + * Project Detection Tests + * + * Run: bun test apps/hook/server/project.test.ts + */ + +import { describe, expect, test } from "bun:test"; +import { sanitizeTag, extractRepoName, extractDirName, detectProjectName } from "./project"; + +describe("sanitizeTag", () => { + test("lowercases input", () => { + expect(sanitizeTag("MyProject")).toBe("myproject"); + }); + + test("replaces spaces with hyphens", () => { + expect(sanitizeTag("my project")).toBe("my-project"); + }); + + test("replaces underscores with hyphens", () => { + expect(sanitizeTag("my_project")).toBe("my-project"); + }); + + test("removes special characters", () => { + expect(sanitizeTag("my@project!name")).toBe("myprojectname"); + }); + + test("collapses multiple hyphens", () => { + expect(sanitizeTag("my--project")).toBe("my-project"); + }); + + test("trims to 30 chars", () => { + const long = "a".repeat(50); + expect(sanitizeTag(long)?.length).toBe(30); + }); + + test("returns null for empty string", () => { + expect(sanitizeTag("")).toBeNull(); + }); + + test("returns null for single char", () => { + expect(sanitizeTag("a")).toBeNull(); + }); + + test("returns null for null/undefined", () => { + expect(sanitizeTag(null as any)).toBeNull(); + expect(sanitizeTag(undefined as any)).toBeNull(); + }); +}); + +describe("extractRepoName", () => { + test("extracts name from full path", () => { + expect(extractRepoName("/Users/dev/projects/my-app")).toBe("my-app"); + }); + + test("handles trailing slash", () => { + expect(extractRepoName("/Users/dev/my-app/")).toBe("my-app"); + }); + + test("handles multiple trailing slashes", () => { + expect(extractRepoName("/home/user/repo///")).toBe("repo"); + }); + + test("returns null for empty string", () => { + expect(extractRepoName("")).toBeNull(); + }); + + test("returns null for just slash", () => { + expect(extractRepoName("/")).toBeNull(); + }); +}); + +describe("extractDirName", () => { + test("extracts directory name", () => { + expect(extractDirName("/home/user/workspace")).toBe("workspace"); + }); + + test("skips generic names", () => { + expect(extractDirName("/home")).toBeNull(); + expect(extractDirName("/Users")).toBeNull(); + expect(extractDirName("/root")).toBeNull(); + expect(extractDirName("/tmp")).toBeNull(); + }); + + test("returns null for root path", () => { + expect(extractDirName("/")).toBeNull(); + }); + + test("sanitizes the result", () => { + expect(extractDirName("/home/user/My Project")).toBe("my-project"); + }); +}); + +describe("detectProjectName", () => { + test("returns a string or null", async () => { + const result = await detectProjectName(); + expect(result === null || typeof result === "string").toBe(true); + }); + + test("result is sanitized if not null", async () => { + const result = await detectProjectName(); + if (result) { + expect(result).toMatch(/^[a-z0-9-]+$/); + expect(result.length).toBeGreaterThanOrEqual(2); + expect(result.length).toBeLessThanOrEqual(30); + } + }); + + // This test verifies we get "planning-hook" when run from this repo + test("detects current repo name", async () => { + const result = await detectProjectName(); + // We're in the planning-hook repo, so should get that name + expect(result).toBe("planning-hook"); + }); +}); diff --git a/apps/hook/server/project.ts b/apps/hook/server/project.ts new file mode 100644 index 00000000..53ed58a1 --- /dev/null +++ b/apps/hook/server/project.ts @@ -0,0 +1,94 @@ +/** + * Project Detection Utility + * + * Detects the current project name for tagging plans. + * Priority: git repo name > directory name > null + */ + +import { $ } from "bun"; + +/** + * Sanitize a string for use as a tag + * - lowercase + * - replace spaces/underscores with hyphens + * - remove special characters + * - trim to reasonable length + */ +export function sanitizeTag(name: string): string | null { + if (!name || typeof name !== "string") return null; + + const sanitized = name + .toLowerCase() + .trim() + .replace(/[\s_]+/g, "-") // spaces/underscores -> hyphens + .replace(/[^a-z0-9-]/g, "") // remove special chars + .replace(/-+/g, "-") // collapse multiple hyphens + .replace(/^-|-$/g, "") // trim leading/trailing hyphens + .slice(0, 30); // max 30 chars + + return sanitized.length >= 2 ? sanitized : null; +} + +/** + * Extract repo name from a git root path + */ +export function extractRepoName(gitRootPath: string): string | null { + if (!gitRootPath || typeof gitRootPath !== "string") return null; + + const trimmed = gitRootPath.trim().replace(/\/+$/, ""); // remove trailing slashes + const parts = trimmed.split("/"); + const name = parts[parts.length - 1]; + + return sanitizeTag(name); +} + +/** + * Extract directory name from a path + */ +export function extractDirName(path: string): string | null { + if (!path || typeof path !== "string") return null; + + const trimmed = path.trim().replace(/\/+$/, ""); + if (trimmed === "" || trimmed === "/") return null; + + const parts = trimmed.split("/"); + const name = parts[parts.length - 1]; + + // Skip generic names + const skipNames = new Set(["home", "users", "user", "root", "tmp", "var"]); + if (skipNames.has(name.toLowerCase())) return null; + + return sanitizeTag(name); +} + +/** + * Detect project name from current context + * + * Priority: + * 1. Git repository name (most reliable) + * 2. Current directory name (fallback) + * 3. null (if nothing useful found) + */ +export async function detectProjectName(): Promise { + // Try git repo name first + try { + const result = await $`git rev-parse --show-toplevel`.quiet().nothrow(); + if (result.exitCode === 0) { + const repoName = extractRepoName(result.stdout.toString()); + if (repoName) return repoName; + } + } catch { + // Git not available or not in a repo - continue to fallback + } + + // Fallback to current directory name + try { + const cwd = process.cwd(); + const dirName = extractDirName(cwd); + if (dirName) return dirName; + } catch { + // process.cwd() failed (rare) + } + + return null; +} diff --git a/package.json b/package.json index 4640a81a..8070fcbb 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "build:portal": "bun run --cwd apps/portal build", "build:marketing": "bun run --cwd apps/marketing build", "build:opencode": "bun run --cwd apps/opencode-plugin build", - "build": "bun run build:hook && bun run build:opencode" + "build": "bun run build:hook && bun run build:opencode", + "test": "bun test" } }