diff --git a/__tests__/next-best-action-panel.test.tsx b/__tests__/next-best-action-panel.test.tsx new file mode 100644 index 00000000..8a5d148e --- /dev/null +++ b/__tests__/next-best-action-panel.test.tsx @@ -0,0 +1,54 @@ +/** @jest-environment jsdom */ + +import React from "react"; +import { render, screen } from "@testing-library/react"; + +import { NextBestActionPanel } from "@/components/next-best-action-panel"; + +describe("NextBestActionPanel", () => { + test("shows copy install + report issue when install command + github repo exist", () => { + render( + + ); + + expect(screen.getByRole("button", { name: /copy install command/i })).toBeInTheDocument(); + + const report = screen.getByRole("link", { name: /report issue/i }); + expect(report).toBeInTheDocument(); + expect(report.getAttribute("href")).toMatch(/github\.com\/acme\/foo\/issues\/new/); + }); + + test("shows copy install when install command begins with comment lines", () => { + render( + + ); + + expect(screen.getByRole("button", { name: /copy install command/i })).toBeInTheDocument(); + }); + + test("falls back to view repository when no install command", () => { + render( + + ); + + expect(screen.queryByRole("button", { name: /copy install command/i })).toBeNull(); + + const view = screen.getByRole("link", { name: /view on github|view repository/i }); + expect(view.getAttribute("href")).toBe("https://github.com/acme/foo"); + }); +}); diff --git a/__tests__/reportIssue.test.ts b/__tests__/reportIssue.test.ts new file mode 100644 index 00000000..99cbac82 --- /dev/null +++ b/__tests__/reportIssue.test.ts @@ -0,0 +1,49 @@ +import { parseGitHubRepo } from "@/lib/reportIssue"; + +describe("parseGitHubRepo", () => { + test("parses https://github.com/owner/repo", () => { + expect(parseGitHubRepo("https://github.com/acme/foo")).toEqual({ + owner: "acme", + repo: "foo", + }); + }); + + test("parses https://www.github.com/owner/repo", () => { + expect(parseGitHubRepo("https://www.github.com/acme/foo")).toEqual({ + owner: "acme", + repo: "foo", + }); + }); + + test("parses git+https://github.com/owner/repo.git", () => { + expect(parseGitHubRepo("git+https://github.com/acme/foo.git")).toEqual({ + owner: "acme", + repo: "foo", + }); + }); + + test("parses ssh://git@github.com/owner/repo.git", () => { + expect(parseGitHubRepo("ssh://git@github.com/acme/foo.git")).toEqual({ + owner: "acme", + repo: "foo", + }); + }); + + test("parses git+ssh://git@github.com/owner/repo.git", () => { + expect(parseGitHubRepo("git+ssh://git@github.com/acme/foo.git")).toEqual({ + owner: "acme", + repo: "foo", + }); + }); + + test("parses git@github.com:owner/repo.git", () => { + expect(parseGitHubRepo("git@github.com:acme/foo.git")).toEqual({ + owner: "acme", + repo: "foo", + }); + }); + + test("returns null for non-github urls", () => { + expect(parseGitHubRepo("https://gitlab.com/acme/foo")).toBeNull(); + }); +}); diff --git a/src/app/skills/[slug]/page.tsx b/src/app/skills/[slug]/page.tsx index 9285abb4..925f6cfd 100644 --- a/src/app/skills/[slug]/page.tsx +++ b/src/app/skills/[slug]/page.tsx @@ -2,7 +2,7 @@ import { notFound } from "next/navigation"; import { getSkills, getSkillBySlug } from "@/lib/data"; import { Badge } from "@/components/ui/badge"; import { Separator } from "@/components/ui/separator"; -import { ReportIssueButton } from "@/components/report-issue-button"; +import { NextBestActionPanel } from "@/components/next-best-action-panel"; import { buildSkillIssueBodyTemplate } from "@/lib/reportIssue"; import Link from "next/link"; @@ -95,6 +95,16 @@ export default async function SkillPage({

+ {/* Next Best Action */} +
+ +
+ {/* Install */} @@ -129,13 +139,6 @@ export default async function SkillPage({ > 📄 GET /api/skill/{skill.slug} - - {/* Report issue actions */} - diff --git a/src/components/next-best-action-panel.tsx b/src/components/next-best-action-panel.tsx new file mode 100644 index 00000000..7a66ad85 --- /dev/null +++ b/src/components/next-best-action-panel.tsx @@ -0,0 +1,150 @@ +"use client"; + +import * as React from "react"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { buildGitHubNewIssueUrl, parseGitHubRepo } from "@/lib/reportIssue"; + +type Props = { + installCmd?: string | null; + repoUrl?: string | null; + issueTitle: string; + issueBody: string; +}; + +async function copyToClipboard(text: string): Promise { + try { + await navigator.clipboard.writeText(text); + return true; + } catch { + try { + const el = document.createElement("textarea"); + el.value = text; + el.setAttribute("readonly", ""); + el.style.position = "fixed"; + el.style.top = "-1000px"; + el.style.left = "-1000px"; + document.body.appendChild(el); + el.focus(); + el.select(); + const ok = document.execCommand("copy"); + document.body.removeChild(el); + return ok; + } catch { + return false; + } + } +} + +function extractPrimaryInstallCmd(cmd: string | null | undefined): string | null { + const raw0 = (cmd ?? "").trim(); + if (!raw0) return null; + + // Accept both real newlines and literal "\\n" sequences. + const raw = raw0.replace(/\\r\\n/g, "\n").replace(/\\n/g, "\n"); + + // Support install_cmd strings that start with one or more comment lines. + // We copy the first non-empty, non-comment line. + const lines = raw.split(/\r?\n/); + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed) continue; + if (trimmed.startsWith("#")) continue; + return trimmed; + } + + return null; +} + +export function NextBestActionPanel({ + installCmd, + repoUrl, + issueTitle, + issueBody, +}: Props) { + const repo = React.useMemo(() => parseGitHubRepo(repoUrl), [repoUrl]); + + const issueUrl = React.useMemo(() => { + if (!repo) return null; + return buildGitHubNewIssueUrl({ repo, title: issueTitle, body: issueBody }); + }, [repo, issueTitle, issueBody]); + + const primaryInstallCmd = extractPrimaryInstallCmd(installCmd); + + const hasInstall = Boolean(primaryInstallCmd); + const primaryHref = !hasInstall ? repoUrl?.trim() : null; + const primaryLabel = repo ? "View on GitHub ↗" : "View repository ↗"; + + const [copyLabel, setCopyLabel] = React.useState("Copy install command"); + + const onCopyInstall = async () => { + if (!primaryInstallCmd) return; + const ok = await copyToClipboard(primaryInstallCmd); + setCopyLabel(ok ? "Copied" : "Copy failed"); + window.setTimeout(() => setCopyLabel("Copy install command"), 1500); + if (!ok) window.prompt("Copy the install command:", primaryInstallCmd); + }; + + return ( + + + Next best action + + Do the most useful thing in one click. + + + + +
+ {hasInstall ? ( + + ) : primaryHref ? ( + + ) : null} + + {issueUrl ? ( + + ) : repoUrl?.trim() && !hasInstall ? null : repoUrl?.trim() ? ( + + ) : null} +
+
+
+ ); +} diff --git a/src/components/report-issue-button.tsx b/src/components/report-issue-button.tsx deleted file mode 100644 index 65796927..00000000 --- a/src/components/report-issue-button.tsx +++ /dev/null @@ -1,99 +0,0 @@ -"use client"; - -import * as React from "react"; -import { Button } from "@/components/ui/button"; -import { buildGitHubNewIssueUrl, parseGitHubRepo } from "@/lib/reportIssue"; - -type Props = { - repoUrl?: string; - issueTitle: string; - issueBody: string; -}; - -async function copyToClipboard(text: string): Promise { - try { - await navigator.clipboard.writeText(text); - return true; - } catch { - // Fallback: textarea + execCommand - try { - const el = document.createElement("textarea"); - el.value = text; - el.setAttribute("readonly", ""); - el.style.position = "fixed"; - el.style.top = "-1000px"; - el.style.left = "-1000px"; - document.body.appendChild(el); - el.focus(); - el.select(); - const ok = document.execCommand("copy"); - document.body.removeChild(el); - return ok; - } catch { - return false; - } - } -} - -export function ReportIssueButton({ repoUrl, issueTitle, issueBody }: Props) { - const repo = React.useMemo(() => parseGitHubRepo(repoUrl), [repoUrl]); - - const issueUrl = React.useMemo(() => { - if (!repo) return null; - return buildGitHubNewIssueUrl({ repo, title: issueTitle, body: issueBody }); - }, [repo, issueTitle, issueBody]); - - const [templateLabel, setTemplateLabel] = React.useState("Copy issue template"); - const [linkLabel, setLinkLabel] = React.useState("Copy issue link"); - - const onCopyTemplate = async () => { - const ok = await copyToClipboard(issueBody); - setTemplateLabel(ok ? "Copied" : "Copy failed"); - window.setTimeout(() => setTemplateLabel("Copy issue template"), 1500); - if (!ok) window.prompt("Copy the issue template:", issueBody); - }; - - const onCopyLink = async () => { - if (!issueUrl) return; - const ok = await copyToClipboard(issueUrl); - setLinkLabel(ok ? "Copied" : "Copy failed"); - window.setTimeout(() => setLinkLabel("Copy issue link"), 1500); - if (!ok) window.prompt("Copy the issue link:", issueUrl); - }; - - return ( -
- {issueUrl ? ( - - ) : null} - - - - {issueUrl ? ( - - ) : null} -
- ); -} diff --git a/src/lib/reportIssue.ts b/src/lib/reportIssue.ts index 1fd15de1..c583f7b2 100644 --- a/src/lib/reportIssue.ts +++ b/src/lib/reportIssue.ts @@ -19,8 +19,11 @@ export function parseGitHubRepo( ): GitHubRepoInfo | null { if (!url) return null; - const raw = url.trim(); - if (!raw) return null; + const raw0 = url.trim(); + if (!raw0) return null; + + // Support git+https://... and git+ssh://... forms + const raw = raw0.startsWith("git+") ? raw0.slice(4) : raw0; // SSH form const sshMatch = raw.match( @@ -35,7 +38,7 @@ export function parseGitHubRepo( // HTTPS/HTTP form try { const u = new URL(raw); - if (u.hostname !== "github.com") return null; + if (u.hostname !== "github.com" && u.hostname !== "www.github.com") return null; const parts = u.pathname.split("/").filter(Boolean); if (parts.length < 2) return null;