mirror of
https://github.com/backnotprop/plannotator.git
synced 2026-09-14 14:17:26 +08:00
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:
@@ -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
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user