diff --git a/apps/cms/src/api/profile/content-types/profile/schema.json b/apps/cms/src/api/profile/content-types/profile/schema.json index 717d1f4..3003c46 100644 --- a/apps/cms/src/api/profile/content-types/profile/schema.json +++ b/apps/cms/src/api/profile/content-types/profile/schema.json @@ -41,6 +41,10 @@ "readme": { "type": "richtext" }, + "show_github_contributions": { + "type": "boolean", + "default": true + }, "countries": { "type": "relation", "relation": "oneToMany", diff --git a/apps/cms/src/extensions/better-auth/controllers/user.ts b/apps/cms/src/extensions/better-auth/controllers/user.ts index 37b40b0..c4b24ba 100644 --- a/apps/cms/src/extensions/better-auth/controllers/user.ts +++ b/apps/cms/src/extensions/better-auth/controllers/user.ts @@ -48,6 +48,29 @@ export default factories.createCoreController( }; }, + /** + * GET /users/:id/github-contributions — used by the public user page's + * "GitHub Contributions" tab. `data` is `null` when there's nothing to + * show (see the service's `getGithubContributions`). A GitHub failure + * responds 502 rather than `null`: the web app caches only 200 + * responses, so a temporary failure isn't cached as "nothing to show". + */ + async githubContributions(ctx) { + const { id } = ctx.params; + + try { + const contributions = await getPluginService( + "user", + ).getGithubContributions({ userId: id }); + + ctx.body = { data: contributions }; + } catch (error) { + strapi.log.warn(`GitHub contributions fetch failed: ${error}`); + ctx.status = 502; + ctx.body = { error: "Failed to load GitHub contributions." }; + } + }, + /** * GET /users/me/profile — used by the web app's personal "Profile" * tab. Gated by the `is-authenticated` policy. diff --git a/apps/cms/src/extensions/better-auth/routes/user.ts b/apps/cms/src/extensions/better-auth/routes/user.ts index 08a883d..a64004c 100644 --- a/apps/cms/src/extensions/better-auth/routes/user.ts +++ b/apps/cms/src/extensions/better-auth/routes/user.ts @@ -26,6 +26,12 @@ export default { handler: "user.relatedContent", config: { policies: [], middlewares: [], prefix: "" }, }, + { + method: "GET", + path: "/users/:id/github-contributions", + handler: "user.githubContributions", + config: { policies: [], middlewares: [], prefix: "" }, + }, { method: "GET", path: "/users/me/profile", diff --git a/apps/cms/src/extensions/better-auth/services/user.ts b/apps/cms/src/extensions/better-auth/services/user.ts index f86a871..1c8c11d 100644 --- a/apps/cms/src/extensions/better-auth/services/user.ts +++ b/apps/cms/src/extensions/better-auth/services/user.ts @@ -5,6 +5,7 @@ import { factories, type Modules, type UID } from "@strapi/strapi"; import { deleteOwnedFile, + fetchGithubContributions, findOwnedContentIds, uploadOwnedFile, } from "../utils"; @@ -95,6 +96,37 @@ export default factories.createCoreService("plugin::better-auth.user", () => ({ return profile; }, + /** + * The user's issues/PRs/discussions in the `strapi/strapi` GitHub repo, + * for the public profile's "GitHub Contributions" tab. `null` when the + * user has no linked GitHub account, has turned the tab off + * (`show_github_contributions` — on unless explicitly `false`, so users + * without a profile yet are included). Throws if GitHub can't be + * reached. + */ + async getGithubContributions({ userId }: { userId: string | number }) { + const [user] = await strapi.documents("plugin::better-auth.user").findMany({ + filters: { id: userId }, + fields: ["documentId"], + populate: { profile: { fields: ["show_github_contributions"] } }, + }); + + if (!user || user.profile?.show_github_contributions === false) { + return null; + } + + const account = await strapi.db + .query("plugin::better-auth.account") + .findOne({ + where: { userId, providerId: "github" }, + select: ["accountId"], + }); + + if (!account?.accountId) return null; + + return fetchGithubContributions(account.accountId); + }, + async getRelatedContent({ organizationId, uid, diff --git a/apps/cms/src/extensions/better-auth/utils/github-contributions.ts b/apps/cms/src/extensions/better-auth/utils/github-contributions.ts new file mode 100644 index 0000000..e783164 --- /dev/null +++ b/apps/cms/src/extensions/better-auth/utils/github-contributions.ts @@ -0,0 +1,254 @@ +/** + * Fetches a GitHub user's issues, pull requests and discussions in the + * `GITHUB_REPOS` repositories — rendered as the "GitHub Contributions" tab + * on a user's public profile page. + * + * Uses the server-side `GITHUB_ACCESS_TOKEN` (the same one the + * package-info plugin uses), not the user's own OAuth token: GitHub's + * GraphQL API (the only API that can search discussions) requires *a* + * token. + * + * One GraphQL request fetches the latest `MAX_ITEMS_PER_TYPE` of each type, + * merged newest first. Anything older is still counted in `totals`, and + * reachable through the profile's "View all on GitHub" links. Not cached + * here — the web app caches the profile's request (see + * apps/web/src/features/cms/pages/user/page.tsx). + */ + +const GITHUB_REPOS = ["strapi/strapi"]; + +/** GitHub's maximum page size for a single `search`. */ +const MAX_ITEMS_PER_TYPE = 100; + +export type GithubContributionType = "issue" | "pull_request" | "discussion"; + +export type GithubContributionState = "open" | "closed" | "merged" | "answered"; + +export type GithubContribution = { + type: GithubContributionType; + title: string; + url: string; + number: number; + repository: string; + state: GithubContributionState; + createdAt: string; +}; + +export type GithubContributions = { + login: string; + totals: { + issues: number; + pullRequests: number; + discussions: number; + }; + /** The latest items across all types, newest first. */ + items: GithubContribution[]; +}; + +type RepositoryNode = { nameWithOwner: string }; + +type IssueNode = { + title: string; + url: string; + number: number; + state: "OPEN" | "CLOSED" | "MERGED"; + createdAt: string; + repository: RepositoryNode; +}; + +type DiscussionNode = { + title: string; + url: string; + number: number; + closed: boolean; + isAnswered: boolean | null; + createdAt: string; + repository: RepositoryNode; +}; + +type SearchResponse = { + data?: { + issues: { issueCount: number; nodes: IssueNode[] }; + pullRequests: { issueCount: number; nodes: IssueNode[] }; + discussions: { discussionCount: number; nodes: DiscussionNode[] }; + }; + errors?: { message: string }[]; +}; + +const SEARCH_QUERY = ` + query ($issues: String!, $pullRequests: String!, $discussions: String!, $first: Int!) { + issues: search(query: $issues, type: ISSUE, first: $first) { + issueCount + nodes { + ... on Issue { + title url number state createdAt + repository { nameWithOwner } + } + } + } + pullRequests: search(query: $pullRequests, type: ISSUE, first: $first) { + issueCount + nodes { + ... on PullRequest { + title url number state createdAt + repository { nameWithOwner } + } + } + } + discussions: search(query: $discussions, type: DISCUSSION, first: $first) { + discussionCount + nodes { + ... on Discussion { + title url number closed isAnswered createdAt + repository { nameWithOwner } + } + } + } + } +`; + +function githubHeaders(token: string): Record { + return { + Accept: "application/vnd.github+json", + Authorization: `Bearer ${token}`, + }; +} + +/** + * Resolves a GitHub login from the numeric user id better-auth stores as + * the GitHub account's `accountId` — the login itself isn't stored, and + * can change, while the id can't. + */ +async function fetchGithubLogin( + githubUserId: string, + token: string, +): Promise { + const res = await fetch( + `https://api.github.com/user/${encodeURIComponent(githubUserId)}`, + { headers: githubHeaders(token) }, + ); + // A deleted GitHub account — nothing to show, rather than an error. + if (res.status === 404) return null; + if (!res.ok) throw new Error(`GitHub user lookup failed: ${res.status}`); + + const body = (await res.json()) as { login?: string }; + return body.login ?? null; +} + +function toIssueContribution( + type: "issue" | "pull_request", + node: IssueNode, +): GithubContribution { + return { + type, + title: node.title, + url: node.url, + number: node.number, + repository: node.repository.nameWithOwner, + state: node.state.toLowerCase() as GithubContributionState, + createdAt: node.createdAt, + }; +} + +function toDiscussionContribution(node: DiscussionNode): GithubContribution { + return { + type: "discussion", + title: node.title, + url: node.url, + number: node.number, + repository: node.repository.nameWithOwner, + state: node.isAnswered ? "answered" : node.closed ? "closed" : "open", + createdAt: node.createdAt, + }; +} + +// `search` nodes are typed as a union, so a fragment that doesn't apply +// comes back as `{}` — drop those. +const hasRepository = (node: { repository?: RepositoryNode }) => + Boolean(node.repository); + +async function searchContributions( + login: string, + token: string, +): Promise { + const repos = GITHUB_REPOS.map((repo) => `repo:${repo}`).join(" "); + const base = `author:${login} ${repos}`; + + const res = await fetch("https://api.github.com/graphql", { + method: "POST", + headers: { + ...githubHeaders(token), + "Content-Type": "application/json", + }, + body: JSON.stringify({ + query: SEARCH_QUERY, + variables: { + issues: `${base} is:issue sort:created-desc`, + pullRequests: `${base} is:pr sort:created-desc`, + // Discussion search ignores `sort:created-*`, but does honor + // `sort:updated-*` — the most recently active ones, which are + // re-sorted by `createdAt` below anyway. + discussions: `${base} sort:updated-desc`, + first: MAX_ITEMS_PER_TYPE, + }, + }), + }); + + if (!res.ok) { + throw new Error( + `GitHub contributions search failed for "${login}": ${res.status}`, + ); + } + + const { data, errors } = (await res.json()) as SearchResponse; + if (!data) { + throw new Error( + `GitHub contributions search failed for "${login}": ${errors?.map((e) => e.message).join("; ")}`, + ); + } + + const items = [ + ...data.issues.nodes + .filter(hasRepository) + .map((node) => toIssueContribution("issue", node)), + ...data.pullRequests.nodes + .filter(hasRepository) + .map((node) => toIssueContribution("pull_request", node)), + ...data.discussions.nodes + .filter(hasRepository) + .map(toDiscussionContribution), + ].sort((a, b) => b.createdAt.localeCompare(a.createdAt)); + + return { + login, + totals: { + issues: data.issues.issueCount, + pullRequests: data.pullRequests.issueCount, + discussions: data.discussions.discussionCount, + }, + items, + }; +} + +/** + * Returns the contributions for the GitHub account with the given numeric + * id, or `null` if there's nothing to show (no token configured, deleted + * GitHub account). Throws if GitHub can't be reached or errors, so callers + * can tell a temporary failure apart from "nothing to show". + */ +export async function fetchGithubContributions( + githubUserId: string, +): Promise { + const token = process.env.GITHUB_ACCESS_TOKEN; + if (!token) { + strapi.log.warn( + "GITHUB_ACCESS_TOKEN is not set — GitHub contributions are disabled.", + ); + return null; + } + + const login = await fetchGithubLogin(githubUserId, token); + if (!login) return null; + + return searchContributions(login, token); +} diff --git a/apps/cms/src/extensions/better-auth/utils/index.ts b/apps/cms/src/extensions/better-auth/utils/index.ts index b9b1299..c635b1c 100644 --- a/apps/cms/src/extensions/better-auth/utils/index.ts +++ b/apps/cms/src/extensions/better-auth/utils/index.ts @@ -16,6 +16,11 @@ export { type OwnerType, } from "./cascade-delete"; export { extractContentTypeName } from "./content-type-name"; +export { + fetchGithubContributions, + type GithubContribution, + type GithubContributions, +} from "./github-contributions"; export { validateProfileData } from "./profile-validation"; export { sanitizeIfSvg } from "./sanitize-svg"; @@ -110,7 +115,9 @@ export const organizationProfileFields = [ * "Profile" tab — scoped to exactly what the public user page renders * (bio/subtitle/website/github/location/email/readme). Includes `email` * (a public contact address), unlike `organizationProfileFields` — an - * organization has no equivalent distinct-from-account address. + * organization has no equivalent distinct-from-account address. Also + * includes `show_github_contributions`, the user-only toggle for the + * "GitHub Contributions" tab (see `getGithubContributions`). */ export const userProfileFields = [ "bio", @@ -120,6 +127,7 @@ export const userProfileFields = [ "location", "email", "readme", + "show_github_contributions", ] as const; /** diff --git a/apps/cms/src/extensions/better-auth/utils/profile-validation.ts b/apps/cms/src/extensions/better-auth/utils/profile-validation.ts index ed5905a..d0b1119 100644 --- a/apps/cms/src/extensions/better-auth/utils/profile-validation.ts +++ b/apps/cms/src/extensions/better-auth/utils/profile-validation.ts @@ -52,6 +52,14 @@ const VALIDATORS: Record string | undefined> = { export function validateProfileData(data: Record): string[] { const errors: string[] = []; + if ( + "show_github_contributions" in data && + data.show_github_contributions != null && + typeof data.show_github_contributions !== "boolean" + ) { + errors.push("Show GitHub contributions must be true or false."); + } + for (const [field, validate] of Object.entries(VALIDATORS)) { if (!(field in data)) continue; diff --git a/apps/cms/types/generated/contentTypes.d.ts b/apps/cms/types/generated/contentTypes.d.ts index cd19599..2e2aa21 100644 --- a/apps/cms/types/generated/contentTypes.d.ts +++ b/apps/cms/types/generated/contentTypes.d.ts @@ -1051,6 +1051,8 @@ export interface ApiProfileProfile extends Struct.CollectionTypeSchema { publishedAt: Schema.Attribute.DateTime; readme: Schema.Attribute.RichText; services: Schema.Attribute.Relation<'oneToMany', 'api::service.service'>; + show_github_contributions: Schema.Attribute.Boolean & + Schema.Attribute.DefaultTo; subtitle: Schema.Attribute.String & Schema.Attribute.SetMinMaxLength<{ maxLength: 50; diff --git a/apps/web/src/features/auth/components/account-view/account-view.tsx b/apps/web/src/features/auth/components/account-view/account-view.tsx index 83a152c..614b63c 100644 --- a/apps/web/src/features/auth/components/account-view/account-view.tsx +++ b/apps/web/src/features/auth/components/account-view/account-view.tsx @@ -8,7 +8,6 @@ import { DeleteAccountCard, getViewByPath, OrganizationsCard, - SecuritySettingsCards, UserAvatar, UserInvitationsCard, useAuthenticate, @@ -23,6 +22,7 @@ import type { import { cn } from "@/lib/utils"; import { ProfileView } from "../profile-view"; import { SettingsContextSwitcher } from "../settings-context-switcher"; +import { SecurityView } from "./security-view"; type View = | "SETTINGS" @@ -288,7 +288,7 @@ export function AccountView({ /> )} - {view === "SECURITY" && } + {view === "SECURITY" && } {view === "API_KEYS" && apiKey && } diff --git a/apps/web/src/features/auth/components/account-view/security-view.tsx b/apps/web/src/features/auth/components/account-view/security-view.tsx new file mode 100644 index 0000000..02e594a --- /dev/null +++ b/apps/web/src/features/auth/components/account-view/security-view.tsx @@ -0,0 +1,99 @@ +"use client"; + +import { + AuthUIContext, + ChangePasswordCard, + PasskeysCard, + ProvidersCard, + SessionsCard, + TwoFactorCard, +} from "@daveyplate/better-auth-ui"; +import { Github } from "lucide-react"; +import Link from "next/link"; +import { useContext } from "react"; +import { AuthNotice } from "../auth-notice"; + +/** + * A hand-rolled replacement for `@daveyplate/better-auth-ui`'s + * `SecuritySettingsCards` — same cards in the same order, but rendered + * individually so a notice about GitHub contributions (see the "Show + * GitHub contributions" setting in the Profile tab) can sit directly + * above the "Providers" card holding the GitHub connect button. The + * library's `DeleteAccountCard` is left out: it's only rendered there + * with `deleteUser` set, which this app doesn't — the Settings tab + * renders its own instead. + * + * NOTE: upgrading `@daveyplate/better-auth-ui` won't automatically carry + * changes to the library's own `SecuritySettingsCards` into this copy. + */ +export function SecurityView() { + const { + credentials, + hooks: { useListAccounts }, + passkey, + social, + genericOAuth, + twoFactor, + } = useContext(AuthUIContext); + + const { + data: accounts, + isPending: accountsPending, + refetch: refetchAccounts, + } = useListAccounts(); + + const credentialsLinked = accounts?.some( + (account) => account.providerId === "credential", + ); + const githubLinked = accounts?.some( + (account) => account.providerId === "github", + ); + const hasProviders = Boolean( + social?.providers?.length || genericOAuth?.providers?.length, + ); + + return ( +
+ {credentials && ( + + )} + + {hasProviders && ( +
+ {!accountsPending && ( + + {githubLinked + ? "Your issues, pull requests and discussions in the strapi/strapi GitHub repository are shown on your public profile. " + : "Connect your GitHub account to showcase your issues, pull requests and discussions in the strapi/strapi GitHub repository on your public profile. "} + You can turn this off in your{" "} + + Profile settings + + . + + )} + + +
+ )} + + {twoFactor && credentialsLinked && } + + {passkey && } + + +
+ ); +} diff --git a/apps/web/src/features/auth/components/auth-notice/auth-notice.tsx b/apps/web/src/features/auth/components/auth-notice/auth-notice.tsx index 38a8ce8..3eadc97 100644 --- a/apps/web/src/features/auth/components/auth-notice/auth-notice.tsx +++ b/apps/web/src/features/auth/components/auth-notice/auth-notice.tsx @@ -7,11 +7,12 @@ type AuthNoticeProps = { }; /** - * A small informational banner rendered above an auth view's card (e.g. - * sign-in/sign-up) — for surfacing context the underlying - * `@daveyplate/better-auth-ui` view doesn't provide on its own, such as why - * the visitor landed there. Sized by its parent rather than a width of its - * own, so it lines up with whatever auth card it sits above. + * A small informational banner rendered alongside an auth view's card + * (e.g. above sign-in/sign-up, or above the account "Providers" card) — + * for surfacing context the underlying `@daveyplate/better-auth-ui` view + * doesn't provide on its own, such as why the visitor landed there. Sized + * by its parent rather than a width of its own, so it lines up with + * whatever auth card it sits next to. */ export function AuthNotice({ icon: Icon, children }: AuthNoticeProps) { return ( diff --git a/apps/web/src/features/auth/components/profile-view/profile-form.tsx b/apps/web/src/features/auth/components/profile-view/profile-form.tsx index 2e9041a..162ea74 100644 --- a/apps/web/src/features/auth/components/profile-view/profile-form.tsx +++ b/apps/web/src/features/auth/components/profile-view/profile-form.tsx @@ -5,7 +5,7 @@ import { MarkdownEditor } from "@/components/content/markdown-editor"; import { Input } from "@/components/ui/input"; import { cn } from "@/lib/utils"; import { COPY } from "./fields"; -import type { Field, Profile, Props } from "./types"; +import type { Field, Profile, Props, TextField } from "./types"; import { BIO_MAX_LENGTH, SUBTITLE_MAX_LENGTH, @@ -13,7 +13,7 @@ import { validateProfileField, } from "./validation"; -const FIELD_MAX_LENGTHS: Partial> = { +const FIELD_MAX_LENGTHS: Partial> = { bio: BIO_MAX_LENGTH, subtitle: SUBTITLE_MAX_LENGTH, }; @@ -30,6 +30,7 @@ export function ProfileForm({ setValues, disabled, onSubmit, + children, }: { variant: Props["variant"]; fields: Field[]; @@ -37,17 +38,19 @@ export function ProfileForm({ setValues: (updater: (prev: Profile) => Profile) => void; disabled: boolean; onSubmit: (event: React.FormEvent) => void; + /** Extra, variant-specific settings rendered after the text fields. */ + children?: React.ReactNode; }) { const { heading, description } = COPY[variant]; - const [touched, setTouched] = useState< - Partial> - >({}); + const [touched, setTouched] = useState>>( + {}, + ); const [submitAttempted, setSubmitAttempted] = useState(false); const errors = validateProfile(values); - const markTouched = (name: keyof Profile) => + const markTouched = (name: TextField) => setTouched((prev) => ({ ...prev, [name]: true })); const handleSubmit = (event: React.FormEvent) => { @@ -157,6 +160,8 @@ export function ProfileForm({ })} + {children} +
+
+ )} + + ); +} diff --git a/apps/web/src/features/github-contributions/components/github-contributions-feed/index.ts b/apps/web/src/features/github-contributions/components/github-contributions-feed/index.ts new file mode 100644 index 0000000..b061153 --- /dev/null +++ b/apps/web/src/features/github-contributions/components/github-contributions-feed/index.ts @@ -0,0 +1 @@ +export * from "./github-contributions-feed"; diff --git a/apps/web/src/features/github-contributions/components/github-contributions-section/github-contributions-section.tsx b/apps/web/src/features/github-contributions/components/github-contributions-section/github-contributions-section.tsx new file mode 100644 index 0000000..e1e2a31 --- /dev/null +++ b/apps/web/src/features/github-contributions/components/github-contributions-section/github-contributions-section.tsx @@ -0,0 +1,96 @@ +import { ExternalLink } from "lucide-react"; +import Link from "next/link"; +import { CONTRIBUTION_TYPES, GITHUB_REPO } from "../../lib/config"; +import type { + GithubContributions, + GithubContributionType, +} from "../../lib/types"; +import { GithubContributionsFeed } from "../github-contributions-feed"; + +/** A GitHub search for all of the user's `strapi/strapi` items of a type. */ +function searchUrl(login: string, type: GithubContributionType) { + const { qualifier, searchType } = CONTRIBUTION_TYPES[type]; + const query = [`author:${login}`, `repo:${GITHUB_REPO}`, qualifier] + .filter(Boolean) + .join(" "); + + return `https://github.com/search?${new URLSearchParams({ + q: query, + type: searchType, + })}`; +} + +const GithubContributionsSection = ({ + contributions, +}: { + contributions: GithubContributions; +}) => { + const { login, totals, items } = contributions; + + return ( +
+
+

+ GitHub Contributions +

+

+ Issues, pull requests and discussions by{" "} + + @{login} + {" "} + in the{" "} + + {GITHUB_REPO} + {" "} + repository. +

+
+ +
+ {(Object.keys(CONTRIBUTION_TYPES) as GithubContributionType[]).map( + (type) => { + const { label, icon: Icon, total } = CONTRIBUTION_TYPES[type]; + + return ( + + +
+ + {total(totals).toLocaleString("en-US")} + + + {label} + +
+ + + ); + }, + )} +
+ +

+ Recent activity +

+ +
+ ); +}; + +export { GithubContributionsSection }; diff --git a/apps/web/src/features/github-contributions/components/github-contributions-section/index.ts b/apps/web/src/features/github-contributions/components/github-contributions-section/index.ts new file mode 100644 index 0000000..f6db009 --- /dev/null +++ b/apps/web/src/features/github-contributions/components/github-contributions-section/index.ts @@ -0,0 +1 @@ +export * from "./github-contributions-section"; diff --git a/apps/web/src/features/github-contributions/components/github-contributions-toggle/github-contributions-toggle.tsx b/apps/web/src/features/github-contributions/components/github-contributions-toggle/github-contributions-toggle.tsx new file mode 100644 index 0000000..163868f --- /dev/null +++ b/apps/web/src/features/github-contributions/components/github-contributions-toggle/github-contributions-toggle.tsx @@ -0,0 +1,67 @@ +"use client"; + +import { AuthUIContext } from "@daveyplate/better-auth-ui"; +import Link from "next/link"; +import { useContext } from "react"; +import { Checkbox } from "@/components/ui/checkbox"; + +/** + * The user-only "GitHub contributions" setting in the personal "Profile" + * tab — toggles the "GitHub Contributions" tab on the public profile + * (fetched by the CMS's `getGithubContributions`, which needs a linked + * GitHub account). On by default: `null`/`undefined` count as checked. + */ +export function GithubContributionsToggle({ + checked, + disabled, + onCheckedChange, +}: { + checked: boolean | null | undefined; + disabled: boolean; + onCheckedChange: (checked: boolean) => void; +}) { + const { + hooks: { useListAccounts }, + } = useContext(AuthUIContext); + const { data: accounts, isPending } = useListAccounts(); + + const githubLinked = accounts?.some( + (account) => account.providerId === "github", + ); + + return ( +
+ onCheckedChange(value === true)} + className="mt-0.5" + /> +
+ +

+ Show your issues, pull requests and discussions in the strapi/strapi + GitHub repository on your profile. +

+ {!isPending && !githubLinked && ( +

+ Connect your GitHub account under{" "} + + Security + {" "} + to show them. +

+ )} +
+
+ ); +} diff --git a/apps/web/src/features/github-contributions/components/github-contributions-toggle/index.ts b/apps/web/src/features/github-contributions/components/github-contributions-toggle/index.ts new file mode 100644 index 0000000..321d28b --- /dev/null +++ b/apps/web/src/features/github-contributions/components/github-contributions-toggle/index.ts @@ -0,0 +1 @@ +export * from "./github-contributions-toggle"; diff --git a/apps/web/src/features/github-contributions/lib/config.ts b/apps/web/src/features/github-contributions/lib/config.ts new file mode 100644 index 0000000..9294558 --- /dev/null +++ b/apps/web/src/features/github-contributions/lib/config.ts @@ -0,0 +1,28 @@ +import { CircleDot, GitPullRequest, MessagesSquare } from "lucide-react"; +import type { GithubContributions, GithubContributionType } from "./types"; + +export const GITHUB_REPO = "strapi/strapi"; + +export const CONTRIBUTION_TYPES = { + issue: { + label: "Issues", + icon: CircleDot, + qualifier: "is:issue", + searchType: "issues", + total: (totals: GithubContributions["totals"]) => totals.issues, + }, + pull_request: { + label: "Pull requests", + icon: GitPullRequest, + qualifier: "is:pr", + searchType: "pullrequests", + total: (totals: GithubContributions["totals"]) => totals.pullRequests, + }, + discussion: { + label: "Discussions", + icon: MessagesSquare, + qualifier: "", + searchType: "discussions", + total: (totals: GithubContributions["totals"]) => totals.discussions, + }, +} as const satisfies Record; diff --git a/apps/web/src/features/github-contributions/lib/fetch-github-contributions.ts b/apps/web/src/features/github-contributions/lib/fetch-github-contributions.ts new file mode 100644 index 0000000..c0c1afe --- /dev/null +++ b/apps/web/src/features/github-contributions/lib/fetch-github-contributions.ts @@ -0,0 +1,36 @@ +import "server-only"; + +import type { GithubContributions } from "./types"; + +/** How long a profile's GitHub contributions are cached. */ +const GITHUB_CONTRIBUTIONS_REVALIDATE = 30 * 60; + +/** + * Fetches a user's GitHub contributions through Next's data cache, so + * GitHub is only hit once per user per `GITHUB_CONTRIBUTIONS_REVALIDATE` + * — the catch-all route is `force-dynamic`, which only leaves fetches + * uncached when they set no cache options of their own. A plain `fetch` + * rather than `cmsClient.fetch`, which rebuilds the request internally and + * drops the `next` option. Only 200 responses are cached; the CMS answers + * a GitHub failure with a 502, so that's retried on the next render. + */ +export async function fetchGithubContributions( + userId: number | string, +): Promise { + try { + const res = await fetch( + `${process.env.NEXT_PUBLIC_CMS_URL}/api/users/${userId}/github-contributions`, + { + headers: { Authorization: `Bearer ${process.env.CMS_BEARER_TOKEN}` }, + next: { revalidate: GITHUB_CONTRIBUTIONS_REVALIDATE }, + }, + ); + if (!res.ok) return null; + + const body = (await res.json()) as { data: GithubContributions | null }; + return body.data; + } catch { + // Optional extra — a GitHub/CMS hiccup shouldn't take the page down. + return null; + } +} diff --git a/apps/web/src/features/github-contributions/lib/types.ts b/apps/web/src/features/github-contributions/lib/types.ts new file mode 100644 index 0000000..f051d6f --- /dev/null +++ b/apps/web/src/features/github-contributions/lib/types.ts @@ -0,0 +1,33 @@ +/** + * Mirrors the CMS's `GithubContributions` + * (apps/cms/src/extensions/better-auth/utils/github-contributions.ts), + * returned by `GET /users/:id/github-contributions`. + */ +export type GithubContributionType = "issue" | "pull_request" | "discussion"; + +export type GithubContribution = { + type: GithubContributionType; + title: string; + url: string; + number: number; + repository: string; + state: "open" | "closed" | "merged" | "answered"; + createdAt: string; +}; + +export type GithubContributions = { + login: string; + totals: { + issues: number; + pullRequests: number; + discussions: number; + }; + /** The latest items across all types (up to 100 per type), newest first. */ + items: GithubContribution[]; +}; + +export function hasGithubContributions( + contributions: GithubContributions | null | undefined, +): contributions is GithubContributions { + return Boolean(contributions && contributions.items.length > 0); +}