diff --git a/src/entrypoints/content/unfurlers.test.ts b/src/entrypoints/content/unfurlers.test.ts new file mode 100644 index 0000000..7015d42 --- /dev/null +++ b/src/entrypoints/content/unfurlers.test.ts @@ -0,0 +1,105 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { mockReplaceUrlInTextNodes } = vi.hoisted(() => ({ + mockReplaceUrlInTextNodes: vi.fn(), +})); + +vi.mock("./fetch", () => ({ + client: vi.fn().mockResolvedValue({ name: "TestSpace", spaceKey: "test" }), +})); + +vi.mock("./replaceUrlInTextNodes", () => ({ + replaceUrlInTextNodes: mockReplaceUrlInTextNodes, +})); + +import { gitCommitUnfurler, gitFileUnfurler } from "./unfurlers"; + +describe("gitFileUnfurler", () => { + beforeEach(() => { + mockReplaceUrlInTextNodes.mockClear(); + }); + + const createAnchor = (href: string) => { + const anchor = document.createElement("a"); + anchor.href = href; + return anchor; + }; + + it("should build title for blob URL with filename", async () => { + const anchor = createAnchor( + "https://nulab.backlog.jp/git/NAKAMURA/hackz-nulab-26/blob/main/CLAUDE.md", + ); + await gitFileUnfurler(anchor); + expect(mockReplaceUrlInTextNodes).toHaveBeenCalledWith( + anchor, + "[TestSpace][NAKAMURA/hackz-nulab-26] CLAUDE.md | Git", + ); + }); + + it("should build title for tree URL", async () => { + const anchor = createAnchor( + "https://nulab.backlog.jp/git/PROJ/my-repo/tree/main/src/components", + ); + await gitFileUnfurler(anchor); + expect(mockReplaceUrlInTextNodes).toHaveBeenCalledWith( + anchor, + "[TestSpace][PROJ/my-repo] components | Git", + ); + }); + + it("should handle nested file paths by showing only filename", async () => { + const anchor = createAnchor( + "https://nulab.backlog.jp/git/NAKAMURA/hackz-nulab-26/blob/main/src/components/App.tsx", + ); + await gitFileUnfurler(anchor); + expect(mockReplaceUrlInTextNodes).toHaveBeenCalledWith( + anchor, + "[TestSpace][NAKAMURA/hackz-nulab-26] App.tsx | Git", + ); + }); + + it("should not match non-git URLs", async () => { + const anchor = createAnchor("https://nulab.backlog.jp/view/TEST-123"); + await gitFileUnfurler(anchor); + expect(mockReplaceUrlInTextNodes).not.toHaveBeenCalled(); + }); + + it("should not match git URLs without blob or tree", async () => { + const anchor = createAnchor( + "https://nulab.backlog.jp/git/PROJ/my-repo/commits", + ); + await gitFileUnfurler(anchor); + expect(mockReplaceUrlInTextNodes).not.toHaveBeenCalled(); + }); +}); + +describe("gitCommitUnfurler", () => { + beforeEach(() => { + mockReplaceUrlInTextNodes.mockClear(); + }); + + const createAnchor = (href: string) => { + const anchor = document.createElement("a"); + anchor.href = href; + return anchor; + }; + + it("should build title with truncated commit hash", async () => { + const anchor = createAnchor( + "https://nulab.backlog.jp/git/NAKAMURA/hackz-nulab-26/commit/d883cf51748ad8d4d864d1143657a10a27f73e17", + ); + await gitCommitUnfurler(anchor); + expect(mockReplaceUrlInTextNodes).toHaveBeenCalledWith( + anchor, + "[TestSpace][NAKAMURA/hackz-nulab-26] リビジョン : d883cf5174 | Git", + ); + }); + + it("should not match non-commit git URLs", async () => { + const anchor = createAnchor( + "https://nulab.backlog.jp/git/NAKAMURA/hackz-nulab-26/blob/main/CLAUDE.md", + ); + await gitCommitUnfurler(anchor); + expect(mockReplaceUrlInTextNodes).not.toHaveBeenCalled(); + }); +}); diff --git a/src/entrypoints/content/unfurlers.ts b/src/entrypoints/content/unfurlers.ts index 1a49818..ae99110 100644 --- a/src/entrypoints/content/unfurlers.ts +++ b/src/entrypoints/content/unfurlers.ts @@ -20,6 +20,8 @@ const DOCUMENT_ID_REGEX = "(?[a-f0-9]{32})" as const; const PROJECT_KEY_REGEX = "(?[A-Z0-9_]+)" as const; const REPOSITORY_REGEX = "(?[a-z0-9-]+)" as const; const PULL_REQUEST_NUMBER_REGEX = "(?[0-9]+)" as const; +const GIT_FILE_PATH_REGEX = "(?.+)" as const; +const GIT_COMMIT_HASH_REGEX = "(?[a-f0-9]+)" as const; export const issueUnfurler = defineUnfurler({ parseUrl: (url) => @@ -127,3 +129,24 @@ export const pullRequestUnfurler = defineUnfurler({ return `[${params.projectKey}/${params.repository}#${params.number}][${pullRequest.status.name}] ${pullRequest.summary} | Pull Request`; }, }); + +export const gitFileUnfurler = defineUnfurler({ + parseUrl: (url) => + regex( + `^/git/${PROJECT_KEY_REGEX}/${REPOSITORY_REGEX}/(?:blob|tree)/${GIT_FILE_PATH_REGEX}$`, + ).exec(url.pathname)?.groups, + buildTitle: (params) => { + const fileName = params.filePath.split("/").pop(); + return `[${params.projectKey}/${params.repository}] ${fileName} | Git`; + }, +}); + +export const gitCommitUnfurler = defineUnfurler({ + parseUrl: (url) => + regex( + `^/git/${PROJECT_KEY_REGEX}/${REPOSITORY_REGEX}/commit/${GIT_COMMIT_HASH_REGEX}$`, + ).exec(url.pathname)?.groups, + buildTitle: (params) => { + return `[${params.projectKey}/${params.repository}] リビジョン : ${params.commitHash.slice(0, 10)} | Git`; + }, +});