Skip to content

Commit ccf9f20

Browse files
committed
feat: add scoped github oauth dashboard
1 parent c50d8c7 commit ccf9f20

21 files changed

Lines changed: 526 additions & 105 deletions

‎.env.example‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,9 @@ GITHUB_WEBHOOK_SECRET=
44
GROQ_API_KEY=
55
GROQ_MODEL=llama-3.3-70b-versatile
66
DATABASE_URL=
7+
BETTER_AUTH_URL=http://localhost:3000
8+
SESSION_SECRET=
9+
GITHUB_OAUTH_CLIENT_ID=
10+
GITHUB_OAUTH_CLIENT_SECRET=
11+
DEMO_REPO_OWNER=
12+
DEMO_REPO_NAME=

‎AGENTS.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ deadline **July 21, 2026, 5:00pm PT**).
1111
- The webhook endpoint is `POST /api/github/webhook`; it verifies signatures, scores open/synchronize events, writes Check Runs, and calibrates native merged reverts.
1212
- Code quality uses Biome (formatter and recommended linter rules) without ESLint or Prettier. Biome's recommended rules cover React exhaustive dependencies and the applicable accessibility checks for this small dashboard.
1313
- Bun is the package manager and local script runner. Vercel production remains on Node.js: the webhook route explicitly declares `runtime = "nodejs"`; Bun is not a Vercel Functions runtime here.
14+
- Per-user dashboard access uses Better Auth's stateless encrypted GitHub OAuth cookies. Enable GitHub App user authorization, grant **Email addresses: Read-only**, and set the callback to `/api/auth/callback/github`; the dashboard cross-references GitHub-accessible installation repositories server-side before querying data.
1415

1516
**Status:** planning complete, no code written yet. This file is the durable
1617
source of truth — update it (not just `IMPLEMENTATION_PLAN.md`, which is a

‎README.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
# PR Attention Router
22

3+
## GitHub OAuth dashboard setup
4+
5+
Enable **Request user authorization (OAuth) during installation** for the GitHub App, grant **Email addresses: Read-only**, and set the callback URL to `https://<your-domain>/api/auth/callback/github`. Set `BETTER_AUTH_URL`, `SESSION_SECRET` (32+ random bytes), `GITHUB_OAUTH_CLIENT_ID`, `GITHUB_OAUTH_CLIENT_SECRET`, `DEMO_REPO_OWNER`, and `DEMO_REPO_NAME` before deploying. Anonymous visitors see only the configured demo repository; signed-in users can view only repositories returned by GitHub for their installations.
6+
37
GitHub App that routes pull requests to the right level of reviewer attention, then calibrates those decisions against native GitHub reverts.
48

59
## Local setup

‎app/api/auth/[...all]/route.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
import { auth } from "@/lib/auth";
2+
import { toNextJsHandler } from "better-auth/next-js";
3+
4+
export const { GET, POST } = toNextJsHandler(auth);

‎app/app/[repoId]/page.tsx‎

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { PrTimeline } from "@/components/pr-timeline";
2+
import { SignOutButton } from "@/components/sign-out-button";
3+
import { auth } from "@/lib/auth";
4+
import { userInstallations } from "@/lib/github";
5+
import { dashboard, reposForAccessibleRepositories } from "@/lib/repositories";
6+
import { headers } from "next/headers";
7+
import { notFound, redirect } from "next/navigation";
8+
9+
export default async function RepositoryPage({
10+
params,
11+
}: {
12+
params: Promise<{ repoId: string }>;
13+
}) {
14+
const repoId = Number((await params).repoId);
15+
const requestHeaders = await headers();
16+
const session = await auth.api.getSession({ headers: requestHeaders });
17+
if (!session) redirect("/");
18+
const token = await auth.api.getAccessToken({
19+
body: { providerId: "github" },
20+
headers: requestHeaders,
21+
});
22+
if (!token) redirect("/app");
23+
const repos = await reposForAccessibleRepositories(
24+
await userInstallations(token.accessToken),
25+
);
26+
const repo = repos.find((candidate) => candidate.id === repoId);
27+
if (!repo) notFound();
28+
return (
29+
<>
30+
<header className="hero compact">
31+
<p className="eyebrow">
32+
{repo.owner}/{repo.name}
33+
</p>
34+
<h1>Review attention, calibrated by reality.</h1>
35+
<nav>
36+
<a href="/app">Change repository</a>
37+
<SignOutButton />
38+
</nav>
39+
</header>
40+
<PrTimeline records={await dashboard(repo.id)} />
41+
</>
42+
);
43+
}

‎app/app/page.tsx‎

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import { SignOutButton } from "@/components/sign-out-button";
2+
import { auth } from "@/lib/auth";
3+
import { userInstallations } from "@/lib/github";
4+
import { reposForAccessibleRepositories } from "@/lib/repositories";
5+
import { headers } from "next/headers";
6+
import { redirect } from "next/navigation";
7+
8+
export default async function AppPage() {
9+
const requestHeaders = await headers();
10+
const session = await auth.api.getSession({ headers: requestHeaders });
11+
if (!session) redirect("/");
12+
const token = await auth.api.getAccessToken({
13+
body: { providerId: "github" },
14+
headers: requestHeaders,
15+
});
16+
const repos = token
17+
? await reposForAccessibleRepositories(
18+
await userInstallations(token.accessToken),
19+
)
20+
: [];
21+
if (repos.length === 1) redirect(`/app/${repos[0].id}`);
22+
return (
23+
<section className="panel picker">
24+
<div className="picker-heading">
25+
<div>
26+
<p className="eyebrow">Signed in as {session.user.name}</p>
27+
<h1>Choose a repository</h1>
28+
</div>
29+
<SignOutButton />
30+
</div>
31+
{repos.length ? (
32+
<ul className="repo-grid">
33+
{repos.map((repo) => (
34+
<li key={repo.id}>
35+
<a className="repo-card" href={`/app/${repo.id}`}>
36+
{repo.owner}/{repo.name}
37+
</a>
38+
</li>
39+
))}
40+
</ul>
41+
) : (
42+
<p>
43+
Installed repositories appear after their first pull request webhook
44+
is received. Open a PR to see it work.
45+
</p>
46+
)}
47+
</section>
48+
);
49+
}

‎app/globals.css‎

Lines changed: 152 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -1,66 +1,181 @@
11
:root {
2-
color: #e7edf7;
3-
background: #101722;
4-
font-family: Arial, sans-serif;
2+
--canvas: #313338;
3+
--surface-1: #2b2d31;
4+
--surface-2: #1e1f22;
5+
--blurple: #5865f2;
6+
--blurple-hover: #4752c4;
7+
--ink: #dbdee1;
8+
--muted: #949ba4;
9+
--border: #3f4147;
10+
--green: #23a55a;
11+
--yellow: #f0b232;
12+
--red: #f23f43;
13+
--r-md: 8px;
14+
--r-lg: 16px;
15+
--shadow: 0 8px 16px rgb(0 0 0 / 0.24);
16+
color: var(--ink);
17+
background: var(--canvas);
18+
font-family: GGSans, Inter, -apple-system, sans-serif;
19+
}
20+
* {
21+
box-sizing: border-box;
522
}
623
body {
7-
max-width: 900px;
8-
margin: auto;
24+
margin: 0;
925
padding: 48px 24px;
1026
}
27+
main {
28+
max-width: 960px;
29+
margin: auto;
30+
display: grid;
31+
gap: 24px;
32+
}
1133
h1 {
12-
font-size: 42px;
13-
max-width: 650px;
34+
margin: 0;
35+
font-size: clamp(2.25rem, 6vw, 2.5rem);
36+
line-height: 1.1;
37+
letter-spacing: -0.02em;
38+
}
39+
h2 {
40+
margin: 0 0 16px;
41+
}
42+
p {
43+
line-height: 1.375;
44+
}
45+
a {
46+
color: var(--ink);
47+
}
48+
.hero {
49+
display: grid;
50+
gap: 12px;
51+
max-width: 680px;
1452
}
15-
header p,
16-
small {
17-
color: #9cadc3;
53+
.hero.compact {
54+
max-width: none;
1855
}
19-
.calibration {
56+
.hero nav,
57+
.picker-heading,
58+
.pr-heading {
2059
display: flex;
60+
align-items: center;
2161
justify-content: space-between;
22-
padding: 20px;
23-
background: #1b2636;
24-
border-radius: 10px;
62+
gap: 16px;
63+
}
64+
.eyebrow,
65+
small,
66+
.empty {
67+
color: var(--muted);
68+
}
69+
.eyebrow {
70+
margin: 0;
71+
font-weight: 700;
72+
}
73+
.panel {
74+
background: var(--surface-1);
75+
border: 1px solid var(--border);
76+
border-radius: var(--r-lg);
77+
box-shadow: var(--shadow);
78+
padding: 24px;
79+
}
80+
.button,
81+
.repo-card {
82+
min-height: 44px;
83+
border-radius: var(--r-md);
84+
border: 1px solid var(--border);
85+
padding: 10px 16px;
86+
font: inherit;
87+
font-weight: 700;
88+
cursor: pointer;
89+
transition: background 200ms cubic-bezier(0.4, 0, 0.2, 1);
90+
}
91+
.button {
92+
background: var(--surface-2);
93+
color: var(--ink);
2594
}
26-
ol {
95+
.button.primary {
96+
background: var(--blurple);
97+
border-color: var(--blurple);
98+
color: white;
99+
}
100+
.button.primary:hover {
101+
background: var(--blurple-hover);
102+
}
103+
.button:focus-visible,
104+
.repo-card:focus-visible,
105+
a:focus-visible {
106+
outline: 2px solid var(--blurple);
107+
outline-offset: 2px;
108+
box-shadow: 0 0 0 4px white;
109+
}
110+
ol,
111+
.repo-grid {
112+
margin: 0;
27113
padding: 0;
28114
list-style: none;
29115
}
30-
li {
31-
margin: 16px 0;
32-
padding: 20px;
33-
border: 1px solid #304159;
34-
border-radius: 10px;
35-
}
36-
li div {
37-
display: flex;
38-
justify-content: space-between;
116+
.timeline {
117+
display: grid;
39118
gap: 16px;
40119
}
41-
small {
42-
display: block;
43-
margin-top: 6px;
120+
.timeline li {
121+
margin: 0;
44122
}
45123
.tier {
124+
display: inline-block;
125+
border-radius: 9999px;
46126
padding: 4px 8px;
47-
border-radius: 20px;
127+
font-size: 0.8125rem;
128+
font-weight: 700;
48129
white-space: nowrap;
49-
font-size: 13px;
50130
}
51131
.auto-mergeable {
52-
background: #155e42;
132+
background: var(--green);
133+
color: #111214;
53134
}
54135
.quick-glance {
55-
background: #795415;
136+
background: var(--yellow);
137+
color: #111214;
56138
}
57139
.deep-review {
58-
background: #7f2430;
140+
background: var(--red);
141+
color: white;
59142
}
60-
.empty {
61-
color: #9cadc3;
62-
padding: 32px 0;
143+
.outcome {
144+
color: var(--yellow);
145+
font-weight: 700;
146+
}
147+
.picker {
148+
display: grid;
149+
gap: 24px;
150+
}
151+
.repo-grid {
152+
display: grid;
153+
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
154+
gap: 16px;
155+
}
156+
.repo-card {
157+
display: block;
158+
background: var(--surface-2);
159+
text-decoration: none;
160+
}
161+
.repo-card:hover {
162+
background: var(--border);
163+
}
164+
@media (prefers-reduced-motion: reduce) {
165+
*,
166+
*::before,
167+
*::after {
168+
transition: none;
169+
}
63170
}
64-
em {
65-
color: #f2bb61;
171+
@media (max-width: 600px) {
172+
body {
173+
padding: 24px 16px;
174+
}
175+
.hero nav,
176+
.picker-heading,
177+
.pr-heading {
178+
align-items: flex-start;
179+
flex-direction: column;
180+
}
66181
}

0 commit comments

Comments
 (0)