diff --git a/apps/dashboard/src/components/repo/repo-overview-page.tsx b/apps/dashboard/src/components/repo/repo-overview-page.tsx
index fbc6018..f97c244 100644
--- a/apps/dashboard/src/components/repo/repo-overview-page.tsx
+++ b/apps/dashboard/src/components/repo/repo-overview-page.tsx
@@ -75,7 +75,14 @@ export function RepoOverviewPage() {
/>
-
+
{treeQuery.data ? (
{
+ const repoCodeKey = githubRevalidationSignalKeys.repoCode({
+ owner: data.owner,
+ repo: data.repo,
+ });
+
+ return getCachedGitHubRequest({
+ context,
+ resource: "repos.commit",
+ params: data,
+ freshForMs: githubCachePolicy.detail.staleTimeMs,
+ signalKeys: [repoCodeKey],
+ namespaceKeys: [repoCodeKey],
+ cacheMode: "split",
+ request: (headers, signal) =>
+ context.octokit.rest.repos.getCommit({
+ owner: data.owner,
+ repo: data.repo,
+ ref: data.sha,
+ headers,
+ request: { signal },
+ }),
+ mapData: (commit) => ({
+ sha: commit.sha,
+ message: commit.commit.message ?? "",
+ date:
+ commit.commit.author?.date ??
+ commit.commit.committer?.date ??
+ new Date().toISOString(),
+ author: commit.author
+ ? {
+ login: commit.author.login,
+ avatarUrl: commit.author.avatar_url,
+ url: commit.author.html_url,
+ type: commit.author.type ?? "User",
+ }
+ : null,
+ files: (commit.files ?? []).map((file) => ({
+ sha: file.sha,
+ filename: file.filename,
+ status: file.status as PullFile["status"],
+ additions: file.additions,
+ deletions: file.deletions,
+ changes: file.changes,
+ patch: file.patch ?? null,
+ previousFilename: file.previous_filename ?? null,
+ })),
+ }),
+ });
+}
+
+export const getRepoCommit = createServerFn({ method: "GET" })
+ .inputValidator(identityValidator)
+ .handler(async ({ data }): Promise => {
+ const context = await getGitHubContextForRepository(data);
+ if (!context) {
+ return null;
+ }
+
+ try {
+ return await getRepoCommitResult(context, data);
+ } catch (error) {
+ if (
+ error instanceof RequestError &&
+ (error.status === 404 || error.status === 403)
+ ) {
+ return null;
+ }
+ throw error;
+ }
+ });
+
async function getPullReviewCommentsResult(
context: GitHubContext,
data: PullFromRepoInput,
@@ -8294,6 +8371,59 @@ export const getFileLastCommit = createServerFn({ method: "GET" })
}).catch(() => null);
});
+type RefHeadCommitInput = {
+ owner: string;
+ repo: string;
+ ref: string;
+};
+
+/** Tip commit for a branch/tag/SHA (first page of `listCommits` for `ref`, no path filter). */
+export const getRefHeadCommit = createServerFn({ method: "GET" })
+ .inputValidator(identityValidator)
+ .handler(async ({ data }): Promise => {
+ const context = await getGitHubContextForRepository(data);
+ if (!context) return null;
+
+ return getCachedGitHubRequest<
+ Awaited>["data"],
+ FileLastCommit | null
+ >({
+ context,
+ resource: "repo.refHeadCommit.v1",
+ params: data,
+ freshForMs: githubCachePolicy.detail.staleTimeMs,
+ signalKeys: [githubRevalidationSignalKeys.repoCode(data)],
+ namespaceKeys: [githubRevalidationSignalKeys.repoCode(data)],
+ cacheMode: "split",
+ request: (headers) =>
+ context.octokit.rest.repos.listCommits({
+ owner: data.owner,
+ repo: data.repo,
+ sha: data.ref,
+ per_page: 1,
+ headers,
+ }),
+ mapData: (commits) => {
+ const commit = commits[0];
+ if (!commit) return null;
+ return {
+ sha: commit.sha,
+ message: commit.commit.message,
+ date:
+ commit.commit.committer?.date ?? commit.commit.author?.date ?? "",
+ author: commit.author
+ ? {
+ login: commit.author.login,
+ avatarUrl: commit.author.avatar_url,
+ url: commit.author.html_url,
+ type: commit.author.type,
+ }
+ : null,
+ };
+ },
+ }).catch(() => null);
+ });
+
// ---------------------------------------------------------------------------
// Batch tree entry commits (single GraphQL query for all entries in a dir)
// ---------------------------------------------------------------------------
diff --git a/apps/dashboard/src/lib/github.query.ts b/apps/dashboard/src/lib/github.query.ts
index 4439ad8..f5df9b3 100644
--- a/apps/dashboard/src/lib/github.query.ts
+++ b/apps/dashboard/src/lib/github.query.ts
@@ -23,8 +23,10 @@ import {
getPullStatus,
getPullsFromRepo,
getPullsFromUser,
+ getRefHeadCommit,
getRepoBranches,
getRepoCollaborators,
+ getRepoCommit,
getRepoContributors,
getRepoDiscussions,
getRepoFileContent,
@@ -214,6 +216,14 @@ export const githubQueryKeys = {
scope: GitHubQueryScope,
input: { owner: string; repo: string; ref: string; path: string },
) => ["github", scope.userId, "repo", "fileLastCommit", input] as const,
+ commit: (
+ scope: GitHubQueryScope,
+ input: { owner: string; repo: string; sha: string },
+ ) => ["github", scope.userId, "repo", "commit", input] as const,
+ refHeadCommit: (
+ scope: GitHubQueryScope,
+ input: { owner: string; repo: string; ref: string },
+ ) => ["github", scope.userId, "repo", "refHeadCommit", input] as const,
treeEntryCommits: (
scope: GitHubQueryScope,
input: { owner: string; repo: string; ref: string; dirPath: string },
@@ -742,6 +752,32 @@ export function githubFileLastCommitQueryOptions(
});
}
+export function githubRepoCommitQueryOptions(
+ scope: GitHubQueryScope,
+ input: { owner: string; repo: string; sha: string },
+) {
+ return queryOptions({
+ queryKey: githubQueryKeys.repo.commit(scope, input),
+ queryFn: () => getRepoCommit({ data: input }),
+ staleTime: githubCachePolicy.detail.staleTimeMs,
+ gcTime: githubCachePolicy.detail.gcTimeMs,
+ meta: tabPersistedMeta,
+ });
+}
+
+export function githubRefHeadCommitQueryOptions(
+ scope: GitHubQueryScope,
+ input: { owner: string; repo: string; ref: string },
+) {
+ return queryOptions({
+ queryKey: githubQueryKeys.repo.refHeadCommit(scope, input),
+ queryFn: () => getRefHeadCommit({ data: input }),
+ staleTime: githubCachePolicy.detail.staleTimeMs,
+ gcTime: githubCachePolicy.detail.gcTimeMs,
+ meta: tabPersistedMeta,
+ });
+}
+
export function githubTreeEntryCommitsQueryOptions(
scope: GitHubQueryScope,
input: {
diff --git a/apps/dashboard/src/lib/github.types.ts b/apps/dashboard/src/lib/github.types.ts
index ac83f17..5e50b3a 100644
--- a/apps/dashboard/src/lib/github.types.ts
+++ b/apps/dashboard/src/lib/github.types.ts
@@ -340,6 +340,20 @@ export type PullFilesPage = {
nextPage: number | null;
};
+export type RepoCommitInput = {
+ owner: string;
+ repo: string;
+ sha: string;
+};
+
+export type RepoCommitDetail = {
+ sha: string;
+ message: string;
+ date: string;
+ author: GitHubActor | null;
+ files: PullFile[];
+};
+
export type PullReviewComment = {
id: number;
nodeId: string;
diff --git a/apps/dashboard/src/lib/tab-store.ts b/apps/dashboard/src/lib/tab-store.ts
index ba5fe8e..c2f6463 100644
--- a/apps/dashboard/src/lib/tab-store.ts
+++ b/apps/dashboard/src/lib/tab-store.ts
@@ -1,6 +1,6 @@
import { useSyncExternalStore } from "react";
-export type TabType = "pull" | "issue" | "review" | "repo";
+export type TabType = "pull" | "issue" | "review" | "repo" | "commit";
export interface Tab {
id: string;
@@ -23,6 +23,7 @@ const VALID_TAB_TYPES = {
issue: true,
review: true,
repo: true,
+ commit: true,
} satisfies Record;
function isValidTabType(type: unknown): type is TabType {
diff --git a/apps/dashboard/src/lib/use-register-tab.ts b/apps/dashboard/src/lib/use-register-tab.ts
index fd03974..f5e7c16 100644
--- a/apps/dashboard/src/lib/use-register-tab.ts
+++ b/apps/dashboard/src/lib/use-register-tab.ts
@@ -13,14 +13,16 @@ export function useRegisterTab(
additions?: number;
deletions?: number;
merged?: boolean;
+ tabId?: string;
} | null,
) {
useEffect(() => {
if (!tab?.title) return;
const id =
- tab.number != null
+ tab.tabId ??
+ (tab.number != null
? `${tab.type}:${tab.repo}#${tab.number}`
- : `${tab.type}:${tab.repo}`;
+ : `${tab.type}:${tab.repo}`);
addTab({
id,
type: tab.type,
@@ -45,5 +47,6 @@ export function useRegisterTab(
tab?.additions,
tab?.deletions,
tab?.merged,
+ tab?.tabId,
]);
}
diff --git a/apps/dashboard/src/routeTree.gen.ts b/apps/dashboard/src/routeTree.gen.ts
index cd0b61b..6c453d5 100644
--- a/apps/dashboard/src/routeTree.gen.ts
+++ b/apps/dashboard/src/routeTree.gen.ts
@@ -37,6 +37,7 @@ import { Route as ProtectedOwnerRepoReviewPullIdRouteImport } from './routes/_pr
import { Route as ProtectedOwnerRepoPullPullIdRouteImport } from './routes/_protected/$owner/$repo/pull.$pullId'
import { Route as ProtectedOwnerRepoIssuesNewRouteImport } from './routes/_protected/$owner/$repo/issues.new'
import { Route as ProtectedOwnerRepoIssuesIssueIdRouteImport } from './routes/_protected/$owner/$repo/issues.$issueId'
+import { Route as ProtectedOwnerRepoCommitShaRouteImport } from './routes/_protected/$owner/$repo/commit.$sha'
import { Route as ProtectedOwnerRepoBlobSplatRouteImport } from './routes/_protected/$owner/$repo/blob.$'
const TermsRoute = TermsRouteImport.update({
@@ -185,6 +186,12 @@ const ProtectedOwnerRepoIssuesIssueIdRoute =
path: '/$owner/$repo/issues/$issueId',
getParentRoute: () => ProtectedRoute,
} as any)
+const ProtectedOwnerRepoCommitShaRoute =
+ ProtectedOwnerRepoCommitShaRouteImport.update({
+ id: '/$owner/$repo/commit/$sha',
+ path: '/$owner/$repo/commit/$sha',
+ getParentRoute: () => ProtectedRoute,
+ } as any)
const ProtectedOwnerRepoBlobSplatRoute =
ProtectedOwnerRepoBlobSplatRouteImport.update({
id: '/$owner/$repo/blob/$',
@@ -215,6 +222,7 @@ export interface FileRoutesByFullPath {
'/api/github/app/callback': typeof ApiGithubAppCallbackRoute
'/$owner/$repo/': typeof ProtectedOwnerRepoIndexRoute
'/$owner/$repo/blob/$': typeof ProtectedOwnerRepoBlobSplatRoute
+ '/$owner/$repo/commit/$sha': typeof ProtectedOwnerRepoCommitShaRoute
'/$owner/$repo/issues/$issueId': typeof ProtectedOwnerRepoIssuesIssueIdRoute
'/$owner/$repo/issues/new': typeof ProtectedOwnerRepoIssuesNewRoute
'/$owner/$repo/pull/$pullId': typeof ProtectedOwnerRepoPullPullIdRoute
@@ -244,6 +252,7 @@ export interface FileRoutesByTo {
'/api/github/app/callback': typeof ApiGithubAppCallbackRoute
'/$owner/$repo': typeof ProtectedOwnerRepoIndexRoute
'/$owner/$repo/blob/$': typeof ProtectedOwnerRepoBlobSplatRoute
+ '/$owner/$repo/commit/$sha': typeof ProtectedOwnerRepoCommitShaRoute
'/$owner/$repo/issues/$issueId': typeof ProtectedOwnerRepoIssuesIssueIdRoute
'/$owner/$repo/issues/new': typeof ProtectedOwnerRepoIssuesNewRoute
'/$owner/$repo/pull/$pullId': typeof ProtectedOwnerRepoPullPullIdRoute
@@ -276,6 +285,7 @@ export interface FileRoutesById {
'/api/github/app/callback': typeof ApiGithubAppCallbackRoute
'/_protected/$owner/$repo/': typeof ProtectedOwnerRepoIndexRoute
'/_protected/$owner/$repo/blob/$': typeof ProtectedOwnerRepoBlobSplatRoute
+ '/_protected/$owner/$repo/commit/$sha': typeof ProtectedOwnerRepoCommitShaRoute
'/_protected/$owner/$repo/issues/$issueId': typeof ProtectedOwnerRepoIssuesIssueIdRoute
'/_protected/$owner/$repo/issues/new': typeof ProtectedOwnerRepoIssuesNewRoute
'/_protected/$owner/$repo/pull/$pullId': typeof ProtectedOwnerRepoPullPullIdRoute
@@ -308,6 +318,7 @@ export interface FileRouteTypes {
| '/api/github/app/callback'
| '/$owner/$repo/'
| '/$owner/$repo/blob/$'
+ | '/$owner/$repo/commit/$sha'
| '/$owner/$repo/issues/$issueId'
| '/$owner/$repo/issues/new'
| '/$owner/$repo/pull/$pullId'
@@ -337,6 +348,7 @@ export interface FileRouteTypes {
| '/api/github/app/callback'
| '/$owner/$repo'
| '/$owner/$repo/blob/$'
+ | '/$owner/$repo/commit/$sha'
| '/$owner/$repo/issues/$issueId'
| '/$owner/$repo/issues/new'
| '/$owner/$repo/pull/$pullId'
@@ -368,6 +380,7 @@ export interface FileRouteTypes {
| '/api/github/app/callback'
| '/_protected/$owner/$repo/'
| '/_protected/$owner/$repo/blob/$'
+ | '/_protected/$owner/$repo/commit/$sha'
| '/_protected/$owner/$repo/issues/$issueId'
| '/_protected/$owner/$repo/issues/new'
| '/_protected/$owner/$repo/pull/$pullId'
@@ -587,6 +600,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof ProtectedOwnerRepoIssuesIssueIdRouteImport
parentRoute: typeof ProtectedRoute
}
+ '/_protected/$owner/$repo/commit/$sha': {
+ id: '/_protected/$owner/$repo/commit/$sha'
+ path: '/$owner/$repo/commit/$sha'
+ fullPath: '/$owner/$repo/commit/$sha'
+ preLoaderRoute: typeof ProtectedOwnerRepoCommitShaRouteImport
+ parentRoute: typeof ProtectedRoute
+ }
'/_protected/$owner/$repo/blob/$': {
id: '/_protected/$owner/$repo/blob/$'
path: '/$owner/$repo/blob/$'
@@ -622,6 +642,7 @@ interface ProtectedRouteChildren {
ProtectedOwnerRepoPullsRoute: typeof ProtectedOwnerRepoPullsRoute
ProtectedOwnerRepoIndexRoute: typeof ProtectedOwnerRepoIndexRoute
ProtectedOwnerRepoBlobSplatRoute: typeof ProtectedOwnerRepoBlobSplatRoute
+ ProtectedOwnerRepoCommitShaRoute: typeof ProtectedOwnerRepoCommitShaRoute
ProtectedOwnerRepoIssuesIssueIdRoute: typeof ProtectedOwnerRepoIssuesIssueIdRoute
ProtectedOwnerRepoIssuesNewRoute: typeof ProtectedOwnerRepoIssuesNewRoute
ProtectedOwnerRepoPullPullIdRoute: typeof ProtectedOwnerRepoPullPullIdRoute
@@ -642,6 +663,7 @@ const ProtectedRouteChildren: ProtectedRouteChildren = {
ProtectedOwnerRepoPullsRoute: ProtectedOwnerRepoPullsRoute,
ProtectedOwnerRepoIndexRoute: ProtectedOwnerRepoIndexRoute,
ProtectedOwnerRepoBlobSplatRoute: ProtectedOwnerRepoBlobSplatRoute,
+ ProtectedOwnerRepoCommitShaRoute: ProtectedOwnerRepoCommitShaRoute,
ProtectedOwnerRepoIssuesIssueIdRoute: ProtectedOwnerRepoIssuesIssueIdRoute,
ProtectedOwnerRepoIssuesNewRoute: ProtectedOwnerRepoIssuesNewRoute,
ProtectedOwnerRepoPullPullIdRoute: ProtectedOwnerRepoPullPullIdRoute,
diff --git a/apps/dashboard/src/routes/_protected/$owner/$repo/commit.$sha.tsx b/apps/dashboard/src/routes/_protected/$owner/$repo/commit.$sha.tsx
new file mode 100644
index 0000000..86aa0d2
--- /dev/null
+++ b/apps/dashboard/src/routes/_protected/$owner/$repo/commit.$sha.tsx
@@ -0,0 +1,26 @@
+import { createFileRoute } from "@tanstack/react-router";
+import { CommitPage } from "#/components/repo/commit-page";
+import { githubRepoCommitQueryOptions } from "#/lib/github.query";
+import { buildSeo, formatPageTitle } from "#/lib/seo";
+
+export const Route = createFileRoute("/_protected/$owner/$repo/commit/$sha")({
+ ssr: false,
+ loader: ({ context, params }) => {
+ const scope = { userId: context.user.id };
+ const input = { owner: params.owner, repo: params.repo, sha: params.sha };
+ void context.queryClient.prefetchQuery(
+ githubRepoCommitQueryOptions(scope, input),
+ );
+ return {};
+ },
+ head: ({ match, params }) =>
+ buildSeo({
+ path: match.pathname,
+ title: formatPageTitle(
+ `Commit ${params.sha.slice(0, 7)} · ${params.owner}/${params.repo}`,
+ ),
+ description: `View commit ${params.sha} in ${params.owner}/${params.repo}.`,
+ robots: "noindex",
+ }),
+ component: CommitPage,
+});