From bd085335d4e5b495e71815c3de5012cde9f6308b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 10 Jul 2025 06:01:20 +0000 Subject: [PATCH 1/2] Initial plan From 4058e700521fc9f6e0e05ece4606807c3a1c9206 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 10 Jul 2025 06:20:33 +0000 Subject: [PATCH 2/2] Add complete GitHub PR Link support with all variants and documentation Co-authored-by: dwjohnston <2467377+dwjohnston@users.noreply.github.com> --- README.md | 81 +++++++++++++- .../GithubPRLink/GithubPRLink.stories.tsx | 43 ++++++++ src/library/GithubPRLink/GithubPRLink.tsx | 33 ++++++ .../GithubPRLink/GithubPRLinkBase.stories.tsx | 103 ++++++++++++++++++ src/library/GithubPRLink/GithubPRLinkBase.tsx | 98 +++++++++++++++++ src/library/GithubPRLink/GithubPRLinkRsc.tsx | 14 +++ .../GithubPermalink/github-permalink.css | 78 ++++++++++++- src/library/config/BaseConfiguration.ts | 5 +- src/library/config/GithubPermalinkContext.tsx | 29 ++++- .../config/GithubPermalinkRscConfig.ts | 7 +- src/library/config/defaultFunctions.ts | 36 +++++- src/library/export.ts | 2 + src/library/rsc.ts | 3 +- src/library/utils/urlParsers.test.ts | 44 +++++++- src/library/utils/urlParsers.ts | 12 ++ 15 files changed, 574 insertions(+), 14 deletions(-) create mode 100644 src/library/GithubPRLink/GithubPRLink.stories.tsx create mode 100644 src/library/GithubPRLink/GithubPRLink.tsx create mode 100644 src/library/GithubPRLink/GithubPRLinkBase.stories.tsx create mode 100644 src/library/GithubPRLink/GithubPRLinkBase.tsx create mode 100644 src/library/GithubPRLink/GithubPRLinkRsc.tsx diff --git a/README.md b/README.md index fb1b2b5..5e5dc6a 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,8 @@ Display Github permalinks as codeblocks. Display Github issue links. +Display Github PR links. + ![screenshot of the tool in action - dark mode ](./screenshot-permalink-dark.png) ![screenshot of the tool in action - light mode ](./screenshot-permalink-light.png) ![screenshot of the tool in action - dark mode ](./screenshot-issuelink-dark.png) @@ -27,9 +29,9 @@ This package is compatible with Next 13+ and the components can be used as RSCs Three variants of each component are exported - - GithubPermalink/GithubIssueLink - Client component - It fetches the data as on the client in a useEffect. ie. Data won't be retrieved until application has loaded in user's browser. - - GithubPermalinkBase/GithubIssueLinkBase - this is the base component - it does no data fetching on its own. - - GithubPermalinkRsc/GithubIssueLinkRsc - This is an RSC. + - GithubPermalink/GithubIssueLink/GithubPRLink - Client component - It fetches the data as on the client in a useEffect. ie. Data won't be retrieved until application has loaded in user's browser. + - GithubPermalinkBase/GithubIssueLinkBase/GithubPRLinkBase - this is the base component - it does no data fetching on its own. + - GithubPermalinkRsc/GithubIssueLinkRsc/GithubPRLinkRsc - This is an RSC. @@ -112,6 +114,72 @@ export function MyApp() { } ``` +## Github PR Link + +### Usage +```jsx +import { GithubPRLink } from 'react-github-permalink'; +import "react-github-permalink/dist/github-permalink.css"; // Or provide your own styles + +export function MyApp() { + return , +} +``` + +PR Link also has an inline variant: + +```jsx +export function MyApp() { + return , +} +``` + +### PR Link with custom data + +```jsx +import { GithubPRLinkBase } from 'react-github-permalink'; +import "react-github-permalink/dist/github-permalink.css"; // Or provide your own styles + +export function MyApp() { + return +} +``` + +### PR Link RSC + +```jsx +import { GithubPRLinkRsc } from 'react-github-permalink/dist/rsc'; +import "react-github-permalink/dist/github-permalink.css"; // Or provide your own styles + +export function MyApp() { + return +} +``` + ## Rate Limits and Authentication By default the components make unauthenticated requests against Github's API. The rate limit for such requests is 60/hour and only publicly visible repositories are available. @@ -126,6 +194,7 @@ The global configuration object has this signature type BaseConfiguration = { getDataFn: (permalink: string, githubToken?: string | undefined, onError?: ((err: unknown) => void) | undefined) => Promise; getIssueFn: (issueLink: string, githubToken?: string | undefined, onError?: ((err: unknown) => void) | undefined) => Promise; + getPRFn: (prLink: string, githubToken?: string | undefined, onError?: ((err: unknown) => void) | undefined) => Promise; githubToken: string | undefined; onError: ((e: unknown) => void) | undefined; } @@ -136,7 +205,7 @@ type BaseConfiguration = { Client components are configured via context provider: ```tsx -import { GithubPermalink, GithubIssueLink GithubPermalinkProvider, } from 'react-github-permalink'; +import { GithubPermalink, GithubIssueLink, GithubPRLink, GithubPermalinkProvider } from 'react-github-permalink'; import "react-github-permalink/dist/github-permalink.css"; export function MyApp() { @@ -147,6 +216,9 @@ export function MyApp() { getIssueFn={(issueLink: string) => { // Your implementation to retrieve issue links here }} + getPRFn={(prLink: string) => { + // Your implementation to retrieve PR links here + }} // Don't put a put a github token into the context provider in production! It will visible for all the world to see! // Instead you will need to expose a data fetching function on the backend to do it for you @@ -158,6 +230,7 @@ export function MyApp() { > + } ``` diff --git a/src/library/GithubPRLink/GithubPRLink.stories.tsx b/src/library/GithubPRLink/GithubPRLink.stories.tsx new file mode 100644 index 0000000..41286f2 --- /dev/null +++ b/src/library/GithubPRLink/GithubPRLink.stories.tsx @@ -0,0 +1,43 @@ +import type { Meta, StoryObj } from "@storybook/react"; + +import { GithubPRLink } from "./GithubPRLink"; +import { + GithubPermalinkContext, + GithubPermalinkProvider, +} from "../config/GithubPermalinkContext"; +import "../GithubPermalink/github-permalink.css"; + +const meta: Meta = { + component: GithubPRLink, +}; + +export default meta; + +type Story = StoryObj; + +/* + *👇 Render functions are a framework specific feature to allow you control on how the component renders. + * See https://storybook.js.org/docs/react/api/csf + * to learn how to use render functions. + */ +export const Primary: Story = { + render: () => ( + + ), +}; + +export const WithBackground: Story = { + render: () => ( +
+ +
+ ), +}; + +export const WithToken: Story = { + render: () => ( + + + + ), +}; \ No newline at end of file diff --git a/src/library/GithubPRLink/GithubPRLink.tsx b/src/library/GithubPRLink/GithubPRLink.tsx new file mode 100644 index 0000000..4ee69ef --- /dev/null +++ b/src/library/GithubPRLink/GithubPRLink.tsx @@ -0,0 +1,33 @@ +"use client" + +import { useContext, useEffect, useState } from "react"; + +import { GithubPermalinkContext, GithubPRLinkDataResponse } from "../config/GithubPermalinkContext"; +import { GithubPRLinkBase, GithubPRLinkBaseProps } from "./GithubPRLinkBase"; + +type GithubPRLinkProps = Omit; + +export function GithubPRLink(props: GithubPRLinkProps) { + + const { prLink } = props; + const [data, setData] = useState(null as null | GithubPRLinkDataResponse) + const { getPRFn, githubToken, onError} = useContext(GithubPermalinkContext); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + getPRFn(prLink, githubToken, onError).then((v) => { + setIsLoading(false); + setData(v); + }) + }, [getPRFn, githubToken, prLink, onError]) + + if (isLoading) { + return null; + } + if (!data) { + throw new Error("Loading is complete, but no data was returned.") + } + + return + +} \ No newline at end of file diff --git a/src/library/GithubPRLink/GithubPRLinkBase.stories.tsx b/src/library/GithubPRLink/GithubPRLinkBase.stories.tsx new file mode 100644 index 0000000..19dd8ec --- /dev/null +++ b/src/library/GithubPRLink/GithubPRLinkBase.stories.tsx @@ -0,0 +1,103 @@ +import type { Meta, StoryObj } from "@storybook/react"; + +import { GithubPRLinkBase } from "./GithubPRLinkBase"; +import "../GithubPermalink/github-permalink.css"; + +const meta: Meta = { + component: GithubPRLinkBase, +}; + +export default meta; + +type Story = StoryObj; + +const sampleData = { + prTitle: "Add concurrent features to React", + prNumber: "24652", + prState: "closed" as const, + owner: "facebook", + repo: "react", + status: "ok" as const, + isDraft: false, + merged: true, + mergeable: null, + reactions: { + "+1": 42, + "-1": 0, + confused: 0, + eyes: 2, + heart: 8, + hooray: 15, + laugh: 0, + rocket: 5, + total_count: 72 + } +}; + +const draftData = { + ...sampleData, + prTitle: "WIP: New feature implementation", + prNumber: "12345", + prState: "open" as const, + isDraft: true, + merged: false, +}; + +const openData = { + ...sampleData, + prTitle: "Fix bug in component rendering", + prNumber: "54321", + prState: "open" as const, + isDraft: false, + merged: false, +}; + +export const Primary: Story = { + render: () => ( + + ), +}; + +export const Draft: Story = { + render: () => ( + + ), +}; + +export const Open: Story = { + render: () => ( + + ), +}; + +export const Inline: Story = { + render: () => ( +
+

Here's an inline PR link:

+
+ ), +}; + +export const WithBackground: Story = { + render: () => ( +
+ +
+ ), +}; \ No newline at end of file diff --git a/src/library/GithubPRLink/GithubPRLinkBase.tsx b/src/library/GithubPRLink/GithubPRLinkBase.tsx new file mode 100644 index 0000000..560d404 --- /dev/null +++ b/src/library/GithubPRLink/GithubPRLinkBase.tsx @@ -0,0 +1,98 @@ +import { PropsWithChildren } from "react"; +import { GithubSvg } from "../GithubSvg/GithubSvg"; +import { GithubPRLinkDataResponse } from "../config/GithubPermalinkContext"; +import { ErrorMessages } from "../ErrorMessages/ErrorMessages"; +import { Reactions } from "../common/Reactions/Reactions"; +import { Inline } from "../common/Inline/Inline"; + + +export type GithubPRLinkBaseProps = { + className?: string; + prLink: string; + data: GithubPRLinkDataResponse; + variant?: "inline" | "block" +} + + + +export function GithubPRLinkBase(props: GithubPRLinkBaseProps) { + const { data, variant ="block", prLink} = props; + + if (variant === "inline"){ + if(data.status === "ok"){ + return + } + else { + return + } + } + + if (data.status === "ok") { + return +
+ +

+ {data.owner}/{data.repo} +

+
+ +
+

{data.prTitle} #{data.prNumber}

+ + {data.isDraft ?
+ + Draft +
: data.merged ?
+ + Merged +
: data.prState === "open" ?
+ + Open +
:
+ + Closed +
} + + +
+
+ {data.reactions && } +
+ }> +
+ + } + + return + + + +} + + +function GithubPRLinkInner(props: PropsWithChildren<{ + header?: React.ReactNode +} & { + prLink: string; + className?: string; +}>) { + + const {prLink, className =''} = props; + + return
+ + + {props.children} + +
+} \ No newline at end of file diff --git a/src/library/GithubPRLink/GithubPRLinkRsc.tsx b/src/library/GithubPRLink/GithubPRLinkRsc.tsx new file mode 100644 index 0000000..ff62941 --- /dev/null +++ b/src/library/GithubPRLink/GithubPRLinkRsc.tsx @@ -0,0 +1,14 @@ +import { githubPermalinkRscConfig } from "../config/GithubPermalinkRscConfig"; +import { GithubPRLinkBase, GithubPRLinkBaseProps } from "./GithubPRLinkBase"; + +type GithubPRLinkRscProps = Omit; + +export async function GithubPRLinkRsc(props: GithubPRLinkRscProps) { + const { prLink } = props; + const prFunction = githubPermalinkRscConfig.getPRFn(); + const githubToken = githubPermalinkRscConfig.getGithubToken(); + const onError = githubPermalinkRscConfig.getOnError(); + const data = await prFunction(prLink, githubToken, onError); + + return ; +} \ No newline at end of file diff --git a/src/library/GithubPermalink/github-permalink.css b/src/library/GithubPermalink/github-permalink.css index 5229982..706d775 100644 --- a/src/library/GithubPermalink/github-permalink.css +++ b/src/library/GithubPermalink/github-permalink.css @@ -191,7 +191,79 @@ svg.github-logo{ padding: 0.5em; } -.react-github-permalink, .react-github-issuelink { +/* PR Link Styles */ +.react-github-prlink .react-github-prlink-body { + padding: 0.5em; + border-bottom: solid 1px var(--rgp-color-border); + padding-bottom: 0.5em; + margin-bottom: 0.5em; + padding-left: 0; +} + +.react-github-prlink-body p { + margin: 0.5em 0; +} + +.react-github-prlink a { + color: inherit; + text-decoration: none; +} + +.react-github-prlink a:hover .react-github-prlink-title, + .react-github-prlink a:hover .react-github-prlink-number { + text-decoration: underline; +} + +.react-github-prlink-number { + color: var(--rgp-color-issuenumber); +} + +.react-github-prlink-repo { + display: flex; + align-items: center; + gap: 0.5em; + background-color: var(--rgp-color-bg-frame); + padding: 0.5em; + color: var(--rgp-color-text-frame); + font-size: 12px; + font-weight: 600; +} + +.react-github-prlink-status { + display: flex; + align-items: center; + gap: 0.5em; + background-color: var(--rgp-color-bg-frame); + padding: 0.25em 0.5em; + border-radius: 4px; + font-size: 11px; + font-weight: 600; + color: var(--rgp-color-status-foreground); + display: inline-flex; + margin-top: 0.5em; +} + +.react-github-prlink-status.open { + background-color: var(--rgp-color-status-open); +} + +.react-github-prlink-status.closed { + background-color: var(--rgp-color-status-closed); +} + +.react-github-prlink-status.merged { + background-color: rgb(130, 80, 223); +} + +.react-github-prlink-status.draft { + background-color: rgb(101, 109, 118); +} + +.react-github-prlink { + padding: 0.5em; +} + +.react-github-permalink, .react-github-issuelink, .react-github-prlink { border: solid 1px var(--rgp-color-border); margin: 0.5em; font-size: 12px; @@ -323,7 +395,7 @@ svg.github-logo{ } -.react-github-permalink .header, .react-github-issuelink .header{ +.react-github-permalink .header, .react-github-issuelink .header, .react-github-prlink .header{ border-width: 0 0 1px 0; font-family: "Segoe UI", "Noto Sans", Helvetica, Arial, sans-serif; } @@ -376,7 +448,7 @@ svg.github-logo{ } -.react-github-permalink .error, .react-github-issuelink .error { +.react-github-permalink .error, .react-github-issuelink .error, .react-github-prlink .error { font-weight: bold; color: var(--rgp-color-status-error); margin-left: 1em; diff --git a/src/library/config/BaseConfiguration.ts b/src/library/config/BaseConfiguration.ts index f75651f..d923380 100644 --- a/src/library/config/BaseConfiguration.ts +++ b/src/library/config/BaseConfiguration.ts @@ -1,5 +1,5 @@ import { defaultGetPermalinkFn } from "./defaultFunctions"; -import { defaultGetIssueFn } from "./defaultFunctions"; +import { defaultGetIssueFn, defaultGetPRFn } from "./defaultFunctions"; export type BaseConfiguration = { @@ -11,6 +11,9 @@ export type BaseConfiguration = { /** Function to provide issue data payload */ getIssueFn: typeof defaultGetIssueFn; + /** Function to provide PR data payload */ + getPRFn: typeof defaultGetPRFn; + /** * A github personal access token - will be passed to the data fetching functions */ diff --git a/src/library/config/GithubPermalinkContext.tsx b/src/library/config/GithubPermalinkContext.tsx index c9b7f57..a6d50e2 100644 --- a/src/library/config/GithubPermalinkContext.tsx +++ b/src/library/config/GithubPermalinkContext.tsx @@ -1,7 +1,7 @@ "use client" import { PropsWithChildren, createContext } from "react"; import { BaseConfiguration } from "./BaseConfiguration"; -import { defaultGetIssueFn } from "./defaultFunctions"; +import { defaultGetIssueFn, defaultGetPRFn } from "./defaultFunctions"; import { defaultGetPermalinkFn } from "./defaultFunctions"; // Thanks ChatGPT @@ -56,6 +56,31 @@ export type GithubIssueLinkDataResponse = { } } | ErrorResponses; +export type GithubPRLinkDataResponse = { + prTitle: string; + prNumber: string; + prState: "open" | "closed"; + owner: string; + repo: string; + status: "ok" + isDraft?: boolean; + merged?: boolean; + mergeable?: boolean; + + // Future - will be mandatory + reactions?: { + "+1": number, + "-1": number, + confused: number, + eyes: number, + heart: number, + hooray: number, + laugh: number, + rocket: number, + total_count: number + } +} | ErrorResponses; + @@ -63,12 +88,14 @@ export type GithubIssueLinkDataResponse = { export const GithubPermalinkContext = createContext({ getDataFn: defaultGetPermalinkFn, getIssueFn: defaultGetIssueFn, + getPRFn: defaultGetPRFn, }); export function GithubPermalinkProvider(props: PropsWithChildren>) { return diff --git a/src/library/config/GithubPermalinkRscConfig.ts b/src/library/config/GithubPermalinkRscConfig.ts index 0e66ded..4e96c66 100644 --- a/src/library/config/GithubPermalinkRscConfig.ts +++ b/src/library/config/GithubPermalinkRscConfig.ts @@ -1,9 +1,10 @@ import { BaseConfiguration } from "./BaseConfiguration"; -import { defaultGetIssueFn, defaultGetPermalinkFn } from "./defaultFunctions"; +import { defaultGetIssueFn, defaultGetPermalinkFn, defaultGetPRFn } from "./defaultFunctions"; const defaultConfiguration = { getDataFn: defaultGetPermalinkFn, getIssueFn: defaultGetIssueFn, + getPRFn: defaultGetPRFn, }; class GithubPermalinkRscConfig { @@ -23,6 +24,10 @@ class GithubPermalinkRscConfig { return this.baseConfiguration.getIssueFn; } + public getPRFn() { + return this.baseConfiguration.getPRFn; + } + public getGithubToken() { return this.baseConfiguration.githubToken; } diff --git a/src/library/config/defaultFunctions.ts b/src/library/config/defaultFunctions.ts index 63f54ad..0dac324 100644 --- a/src/library/config/defaultFunctions.ts +++ b/src/library/config/defaultFunctions.ts @@ -1,5 +1,5 @@ -import { GithubIssueLinkDataResponse } from "./GithubPermalinkContext"; -import { parseGithubIssueLink, parseGithubPermalinkUrl } from "../utils/urlParsers"; +import { GithubIssueLinkDataResponse, GithubPRLinkDataResponse } from "./GithubPermalinkContext"; +import { parseGithubIssueLink, parseGithubPermalinkUrl, parseGithubPRLink } from "../utils/urlParsers"; import { GithubPermalinkDataResponse } from "./GithubPermalinkContext"; import { ErrorResponses } from "./GithubPermalinkContext"; @@ -35,6 +35,38 @@ export async function defaultGetIssueFn(issueLink: string, githubToken?: string, repo: config.repo, reactions: issueJson.reactions, }; +} + +export async function defaultGetPRFn(prLink: string, githubToken?: string, onError?: (err: unknown) => void): Promise { + const config = parseGithubPRLink(prLink); + + const options = githubToken ? { + headers: { + Authorization: `Bearer ${githubToken}` + } + } : undefined; + + const prResult = await fetch(`https://api.github.com/repos/${config.owner}/${config.repo}/pulls/${config.pr}`, options); + + if (!prResult.ok) { + onError?.(prResult); + return handleResponse(prResult); + } + + const prJson = await prResult.json(); + + return { + prTitle: prJson.title, + prNumber: config.pr, + prState: prJson.state, + status: "ok", + owner: config.owner, + repo: config.repo, + isDraft: prJson.draft, + merged: prJson.merged, + mergeable: prJson.mergeable, + reactions: prJson.reactions, + }; }export async function defaultGetPermalinkFn(permalink: string, githubToken?: string, onError?: (err: unknown) => void): Promise { const config = parseGithubPermalinkUrl(permalink); diff --git a/src/library/export.ts b/src/library/export.ts index 31db22b..bc04adc 100644 --- a/src/library/export.ts +++ b/src/library/export.ts @@ -3,4 +3,6 @@ export * from "./GithubPermalink/GithubPermalink"; export * from "./GithubPermalink/GithubPermalinkBase"; export * from "./GithubIssueLink/GithubIssueLink"; export * from "./GithubIssueLink/GithubIssueLinkBase" +export * from "./GithubPRLink/GithubPRLink"; +export * from "./GithubPRLink/GithubPRLinkBase"; export * from "./config/GithubPermalinkContext"; diff --git a/src/library/rsc.ts b/src/library/rsc.ts index ce35b3d..0e97f63 100644 --- a/src/library/rsc.ts +++ b/src/library/rsc.ts @@ -1,3 +1,4 @@ export * from "./GithubPermalink/GithubPermalinkRsc"; export * from "./config/GithubPermalinkRscConfig"; -export * from "./GithubIssueLink/GithubIssueLinkRsc"; \ No newline at end of file +export * from "./GithubIssueLink/GithubIssueLinkRsc"; +export * from "./GithubPRLink/GithubPRLinkRsc"; \ No newline at end of file diff --git a/src/library/utils/urlParsers.test.ts b/src/library/utils/urlParsers.test.ts index b8376ba..6d36d8e 100644 --- a/src/library/utils/urlParsers.test.ts +++ b/src/library/utils/urlParsers.test.ts @@ -1,5 +1,5 @@ import { expect, test, it, describe } from 'vitest' -import { parseGithubPermalinkUrl } from "./urlParsers"; +import { parseGithubPermalinkUrl, parseGithubPRLink } from "./urlParsers"; describe(parseGithubPermalinkUrl, () => { it("behaves correctly for correct urls", () => { @@ -32,3 +32,45 @@ describe(parseGithubPermalinkUrl, () => { }) }) }); + +describe(parseGithubPRLink, () => { + it("behaves correctly for correct PR URLs", () => { + expect( + parseGithubPRLink( + "https://github.com/facebook/react/pull/24652" + ) + ).toEqual( + { + "owner": "facebook", + "repo": "react", + "pr": "24652", + } + ); + }); + + it("behaves correctly for http PR URLs", () => { + expect( + parseGithubPRLink( + "http://github.com/facebook/react/pull/12345" + ) + ).toEqual( + { + "owner": "facebook", + "repo": "react", + "pr": "12345", + } + ); + }); + + it("throws error for issue URLs", () => { + expect(() => parseGithubPRLink( + "https://github.com/facebook/react/issues/24652" + )).toThrow("Invalid PR link URL"); + }); + + it("throws error for invalid URLs", () => { + expect(() => parseGithubPRLink( + "https://github.com/facebook/react/blob/main/src/index.js" + )).toThrow("Invalid PR link URL"); + }); +}); diff --git a/src/library/utils/urlParsers.ts b/src/library/utils/urlParsers.ts index 34f203b..58905aa 100644 --- a/src/library/utils/urlParsers.ts +++ b/src/library/utils/urlParsers.ts @@ -36,3 +36,15 @@ export function parseGithubIssueLink(url: string): { owner: string, repo: string throw new Error("Invalid issue link URL"); } } + +export function parseGithubPRLink(url: string): { owner: string, repo: string, pr: string } { + const regex = /^https?:\/\/github\.com\/([^/]+)\/([^/]+)\/pull\/(\d+)$/; + const match = url.match(regex); + + if (match) { + const [, owner, repo, pr] = match; + return { owner, repo, pr }; + } else { + throw new Error("Invalid PR link URL"); + } +}