Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions apps/cms/src/api/profile/content-types/profile/schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,10 @@
"readme": {
"type": "richtext"
},
"show_github_contributions": {
"type": "boolean",
"default": true
},
"countries": {
"type": "relation",
"relation": "oneToMany",
Expand Down
23 changes: 23 additions & 0 deletions apps/cms/src/extensions/better-auth/controllers/user.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
6 changes: 6 additions & 0 deletions apps/cms/src/extensions/better-auth/routes/user.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
32 changes: 32 additions & 0 deletions apps/cms/src/extensions/better-auth/services/user.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import { factories, type Modules, type UID } from "@strapi/strapi";
import {
deleteOwnedFile,
fetchGithubContributions,
findOwnedContentIds,
uploadOwnedFile,
} from "../utils";
Expand Down Expand Up @@ -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<UID extends UID.ContentType>({
organizationId,
uid,
Expand Down
254 changes: 254 additions & 0 deletions apps/cms/src/extensions/better-auth/utils/github-contributions.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> {
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<string | null> {
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<GithubContributions | null> {
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<GithubContributions | null> {
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);
}
10 changes: 9 additions & 1 deletion apps/cms/src/extensions/better-auth/utils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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",
Expand All @@ -120,6 +127,7 @@ export const userProfileFields = [
"location",
"email",
"readme",
"show_github_contributions",
] as const;

/**
Expand Down
Loading
Loading