Feat: Add project name to tags (Obsidian/Bear)

- Add project.ts utility for detecting git repo name
- Fallback to directory name if not in a git repo
- Include project name as second tag after "plannotator"
- Add 21 unit tests for project detection
- Increase tag limit from 6 to 7

Closes #24

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Michael Ramos
2026-01-05 09:14:31 -08:00
parent 7a7e98b55b
commit 685c67f780
4 changed files with 222 additions and 5 deletions
+12 -4
View File
@@ -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<string[]> {
const tags = new Set<string>(["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
+114
View File
@@ -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");
});
});
+94
View File
@@ -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<string | null> {
// 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;
}
+2 -1
View File
@@ -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"
}
}