This document covers Inspectra's GitHub integration, including OAuth authentication, API usage, and webhook handling.
Inspectra integrates with GitHub to:
- Authenticate users via GitHub OAuth
- Access repositories via the REST API (Octokit)
- Receive events via webhooks for PR automation
GitHub OAuth is handled by Better Auth with the GitHub provider.
// /lib/auth.ts
import { betterAuth } from "better-auth";
import { github } from "better-auth/providers/github";
export const auth = betterAuth({
// ...
socialProviders: {
github: {
clientId: process.env.GITHUB_CLIENT_ID!,
clientSecret: process.env.GITHUB_CLIENT_SECRET!,
scope: ["read:user", "user:email", "repo"],
},
},
});| Scope | Purpose |
|---|---|
read:user |
Access user profile information |
user:email |
Access user email addresses |
repo |
Full access to private and public repositories |
- Go to GitHub Settings → Developer settings → OAuth Apps
- Click New OAuth App
- Fill in the details:
- Application name: Inspectra
- Homepage URL:
http://localhost:3000(or production URL) - Authorization callback URL:
http://localhost:3000/api/auth/callback/github
- Copy the Client ID and Client Secret to your
.envfile
// /module/github/lib/github.ts
import { Octokit } from "octokit";
const octokit = new Octokit({
auth: token, // User's access token from Account table
});Retrieves the user's GitHub access token from the database:
export const getGithubToken = async (): Promise<string | null> => {
const session = await auth.api.getSession({
headers: await headers(),
});
if (!session?.user) return null;
const account = await prisma.account.findFirst({
where: {
userId: session.user.id,
providerId: "github",
},
});
return account?.accessToken ?? null;
};Fetches contribution data using GitHub's GraphQL API:
export const fetchUserContribution = async (
token: string,
username: string,
) => {
const octokit = new Octokit({ auth: token });
const { user } = await octokit.graphql<ContributionData>(
`
query($username: String!) {
user(login: $username) {
contributionsCollection {
contributionCalendar {
totalContributions
weeks {
contributionDays {
contributionCount
date
color
}
}
}
}
}
}
`,
{ username },
);
return user.contributionsCollection.contributionCalendar;
};Fetches paginated list of user repositories:
export const getRepositories = async (
page: number = 1,
perPage: number = 10,
) => {
const token = await getGithubToken();
if (!token) throw new Error("No token found");
const octokit = new Octokit({ auth: token });
const { data } = await octokit.rest.repos.listForAuthenticatedUser({
visibility: "all",
sort: "updated",
direction: "desc",
per_page: perPage,
page: page,
});
return data;
};Recursively fetches all files from a repository:
export const getRepoFileContents = async (
token: string,
owner: string,
repo: string,
path: string = "",
): Promise<{ path: string; content: string }[]> => {
const octokit = new Octokit({ auth: token });
const { data } = await octokit.rest.repos.getContent({
owner,
repo,
path,
});
// Handle single file
if (!Array.isArray(data)) {
if (data.type === "file" && data.content) {
return [
{
path: data.path,
content: Buffer.from(data.content, "base64").toString("utf-8"),
},
];
}
return [];
}
// Handle directory
let files: { path: string; content: string }[] = [];
for (const item of data) {
if (item.type === "file") {
// Skip binary files
if (/\.(png|jpg|jpeg|gif|svg|ico|pdf|zip|tar|gz)$/i.test(item.path)) {
continue;
}
// Fetch file content
const { data: fileData } = await octokit.rest.repos.getContent({
owner,
repo,
path: item.path,
});
if (!Array.isArray(fileData) && fileData.type === "file") {
files.push({
path: item.path,
content: Buffer.from(fileData.content!, "base64").toString("utf-8"),
});
}
} else if (item.type === "dir") {
// Recurse into subdirectory
const subFiles = await getRepoFileContents(token, owner, repo, item.path);
files = files.concat(subFiles);
}
}
return files;
};When a user connects a repository, we create a webhook:
export const createWebhook = async (owner: string, repo: string) => {
const token = await getGithubToken();
if (!token) throw new Error("No token found");
const octokit = new Octokit({ auth: token });
const { data } = await octokit.rest.repos.createWebhook({
owner,
repo,
name: "web",
active: true,
events: ["pull_request", "push"],
config: {
url: `${process.env.NEXT_PUBLIC_APP_BASE_URL}/api/webhooks/github`,
content_type: "json",
insecure_ssl: "0",
},
});
return data;
};When a user disconnects a repository:
export const deleteWebhook = async (owner: string, repo: string) => {
const token = await getGithubToken();
if (!token) return false;
const octokit = new Octokit({ auth: token });
// List all webhooks
const { data: hooks } = await octokit.rest.repos.listWebhooks({
owner,
repo,
});
// Find our webhook
const webhook = hooks.find((hook) =>
hook.config.url?.includes(process.env.NEXT_PUBLIC_APP_BASE_URL!),
);
if (webhook) {
await octokit.rest.repos.deleteWebhook({
owner,
repo,
hook_id: webhook.id,
});
}
return true;
};The webhook handler receives events from GitHub and triggers the AI review process:
// /app/api/webhooks/github/route.ts
import { reviewPullRequest } from "@/module/ai/actions";
import { NextRequest, NextResponse } from "next/server";
export async function POST(req: NextRequest) {
try {
const body = await req.json();
const event = req.headers.get("x-github-event");
// Handle ping event (webhook verification)
if (event === "ping") {
return NextResponse.json({ message: "pong" }, { status: 200 });
}
// Handle pull_request events
if (event === "pull_request") {
const action = body.action;
const repo = body.repository.full_name;
const prNumber = body.number;
const [owner, repoName] = repo.split("/");
if (action === "opened" || action === "synchronize") {
reviewPullRequest(owner, repoName, prNumber)
.then(() => console.log(`Review completed for ${repo} #${prNumber}`))
.catch((error) =>
console.log(`Review failed for ${repo} #${prNumber}`, error),
);
}
}
return NextResponse.json({ message: "Event Processed", status: 200 });
} catch (error) {
console.log("Error processing webhook", error);
return NextResponse.json({
message: "Error processing webhook",
status: 500,
});
}
}Fetches the PR title, description, and diff for AI review:
export async function getPullRequestDiff(
token: string,
owner: string,
repo: string,
prNumber: number,
) {
const octokit = new Octokit({ auth: token });
// Get PR metadata
const { data: pr } = await octokit.rest.pulls.get({
owner,
repo,
pull_number: prNumber,
});
// Get PR diff
const { data: diff } = await octokit.rest.pulls.get({
owner,
repo,
pull_number: prNumber,
mediaType: { format: "diff" },
});
return {
title: pr.title,
diff: diff as unknown as string,
description: pr.body || "",
};
}Returns:
type PRDiffResponse = {
title: string; // PR title
diff: string; // Full diff content
description: string; // PR body/description
};Posts the AI-generated review as a comment on the PR:
export async function postReviewComment(
token: string,
owner: string,
repo: string,
prNumber: number,
review: string,
) {
const octokit = new Octokit({ auth: token });
await octokit.rest.issues.createComment({
owner,
repo,
issue_number: prNumber,
body: `## AI CODE REVIEW \n\n ${review} \n\n -- \n *Powered by Inspectra* \n\n `,
});
}| Type | Limit | Reset |
|---|---|---|
| Authenticated requests | 5,000/hour | 1 hour |
| GraphQL | 5,000 points/hour | 1 hour |
| Search API | 30/minute | 1 minute |
- Cache responses where possible
- Use conditional requests with ETags
- Implement exponential backoff for rate limit errors
- Monitor rate limit headers:
X-RateLimit-LimitX-RateLimit-RemainingX-RateLimit-Reset
| Error | Cause | Solution |
|---|---|---|
401 Unauthorized |
Invalid/expired token | Refresh token or re-authenticate |
403 Forbidden |
Rate limited or missing scope | Wait for reset or request more scopes |
404 Not Found |
Repo/resource doesn't exist | Check ownership/permissions |
422 Unprocessable |
Invalid payload | Validate request data |
try {
await octokit.rest.repos.getContent({ owner, repo, path });
} catch (error) {
if (error.status === 404) {
console.log("Repository or file not found");
} else if (error.status === 403) {
console.log("Rate limited or insufficient permissions");
} else {
throw error;
}
}- Never expose tokens in client-side code
- Store tokens securely in the database (consider encryption)
- Validate webhook signatures (implementation needed)
- Use minimum required scopes
- Implement token refresh when expired