Skip to content
Merged
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
70 changes: 49 additions & 21 deletions src/api/github-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,63 +36,68 @@ export function classifyApiError(error: unknown): GitHubApiErrorType {
return "unknown";
}

function parseRepo(repo: string): { owner: string; repo: string } {
const [owner, repoName] = repo.split("/");
return { owner, repo: repoName };
export function parseRepo(repoFullName: string): [owner: string, repo: string] {
const [owner, repo] = repoFullName.split("/");
if (!owner || !repo) {
throw new Error(
`Invalid repository format: "${repoFullName}". Expected "owner/repo".`
);
}
return [owner, repo];
}

export async function validateRepo(
repo: string,
repoFullName: string,
userOctokit?: Octokit
): Promise<boolean> {
try {
const client = userOctokit || octokit;
const { owner, repo: repoName } = parseRepo(repo);
await client.repos.get({ owner, repo: repoName });
const [owner, repo] = parseRepo(repoFullName);
await client.repos.get({ owner, repo });
return true;
} catch {
return false;
}
}

export async function getIssue(
repo: string,
repoFullName: string,
issueNumber: string,
userOctokit?: Octokit
): Promise<GitHubIssue> {
const client = userOctokit || octokit;
const { owner, repo: repoName } = parseRepo(repo);
const [owner, repo] = parseRepo(repoFullName);
const { data } = await client.issues.get({
owner,
repo: repoName,
repo,
issue_number: parseInt(issueNumber),
});
return data;
}

export async function getPullRequest(
repo: string,
repoFullName: string,
prNumber: string,
userOctokit?: Octokit
): Promise<GitHubPullRequest> {
const client = userOctokit || octokit;
const { owner, repo: repoName } = parseRepo(repo);
const [owner, repo] = parseRepo(repoFullName);
const { data } = await client.pulls.get({
owner,
repo: repoName,
repo,
pull_number: parseInt(prNumber),
});
return data;
}

export async function listPullRequests(
repo: string,
repoFullName: string,
count: number = 10,
filters?: { state?: string; author?: string },
userOctokit?: Octokit
): Promise<GitHubPullRequestList> {
const client = userOctokit || octokit;
const { owner, repo: repoName } = parseRepo(repo);
const [owner, repo] = parseRepo(repoFullName);

// Determine API state (merged PRs are fetched as closed)
let apiState: "open" | "closed" | "all" = "all";
Expand All @@ -109,7 +114,7 @@ export async function listPullRequests(
// Use Octokit's pagination iterator
const iterator = client.paginate.iterator(client.pulls.list, {
owner,
repo: repoName,
repo,
state: apiState,
per_page: 100,
sort: "created",
Expand Down Expand Up @@ -149,19 +154,19 @@ export async function listPullRequests(
}

export async function listIssues(
repo: string,
repoFullName: string,
count: number = 10,
filters?: { state?: string; creator?: string },
userOctokit?: Octokit
): Promise<GitHubIssueList> {
const client = userOctokit || octokit;
const { owner, repo: repoName } = parseRepo(repo);
const [owner, repo] = parseRepo(repoFullName);

// Build API query parameters
const apiState = (filters?.state || "all") as "open" | "closed" | "all";
const params: Parameters<typeof client.issues.listForRepo>[0] = {
owner,
repo: repoName,
repo,
state: apiState,
per_page: 100,
sort: "created",
Expand Down Expand Up @@ -211,19 +216,19 @@ export type GitHubEventRaw =
* Returns `\{ events, etag \}` or `\{ notModified: true \}` if ETag matches
*/
export async function fetchRepoEvents(
repo: string,
repoFullName: string,
etag?: string
): Promise<
| { events: GitHubEventRaw[]; etag: string; notModified?: false }
| { notModified: true; etag?: never; events?: never }
> {
const { owner, repo: repoName } = parseRepo(repo);
const [owner, repo] = parseRepo(repoFullName);

try {
// Use Octokit's request method to support ETag headers
const response = await octokit.request("GET /repos/{owner}/{repo}/events", {
owner,
repo: repoName,
repo,
headers: etag ? { "If-None-Match": etag } : {},
});

Expand All @@ -240,3 +245,26 @@ export async function fetchRepoEvents(
throw error;
}
}

/**
* Get owner ID from username or org name
* Uses public GitHub APIs (/orgs or /users) - no special auth required
*/
export async function getOwnerIdFromUsername(
owner: string
): Promise<number | undefined> {
try {
// Try as organization first (most private repos are in orgs)
try {
const { data } = await octokit.orgs.get({ org: owner });
return data.id;
} catch {
// If not org, try as user
const { data } = await octokit.users.getByUsername({ username: owner });
return data.id;
}
} catch (error) {
console.warn(`Could not fetch owner ID for ${owner}:`, error);
return undefined;
}
}
31 changes: 0 additions & 31 deletions src/api/user-oauth-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,34 +99,3 @@ export async function getUserProfile(token: string): Promise<UserProfile> {
throw error;
}
}

/**
* Get owner ID from username or org using user's OAuth token
* Fetches public profile - works even without repo access
* @param token - User's GitHub OAuth access token
* @param owner - Repository owner username or org name
* @returns Owner ID or undefined if lookup fails
*/
export async function getOwnerIdFromUsername(
token: string,
owner: string
): Promise<number | undefined> {
try {
const octokit = new Octokit({ auth: token });

// Try as organization first (most private repos are in orgs)
try {
const { data } = await octokit.orgs.get({ org: owner });
return data.id;
} catch {
// If not org, try as user
const { data } = await octokit.users.getByUsername({
username: owner,
});
return data.id;
}
} catch (error) {
console.warn(`Could not fetch owner ID for ${owner}:`, error);
return undefined; // Fallback: omit target_id
}
}
31 changes: 31 additions & 0 deletions src/github-app/installation-service.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { and, eq } from "drizzle-orm";
import type { BotHandler } from "@towns-protocol/bot";

import { getOwnerIdFromUsername, parseRepo } from "../api/github-client";
import { db } from "../db";
import { githubInstallations, installationRepositories } from "../db/schema";
import type { SubscriptionService } from "../services/subscription-service";
Expand All @@ -9,6 +11,35 @@ import type {
} from "../types/webhooks";
import type { GitHubApp } from "./app";

/**
* Generate GitHub App installation URL
* @param targetId - Owner ID (user or org), optional
*/
export function generateInstallUrl(targetId?: number): string {
const appSlug = process.env.GITHUB_APP_SLUG || "towns-github-bot";
const baseUrl = `https://github.com/apps/${appSlug}/installations/new`;
return targetId ? `${baseUrl}/permissions?target_id=${targetId}` : baseUrl;
}

/**
* Send GitHub App installation prompt when user has OAuth but no repo access
*/
export async function sendInstallPrompt(
handler: BotHandler,
channelId: string,
repoFullName: string
): Promise<void> {
const [owner] = parseRepo(repoFullName);
const ownerId = await getOwnerIdFromUsername(owner);
const installUrl = generateInstallUrl(ownerId);
await handler.sendMessage(
channelId,
`❌ Cannot access this repository\n\n` +
`Private repositories require the GitHub App to be installed:\n` +
`[Install GitHub App](${installUrl})`
);
}

/**
* InstallationService - Manages GitHub App installation lifecycle
*
Expand Down
25 changes: 15 additions & 10 deletions src/handlers/gh-issue-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,14 @@ import {
classifyApiError,
getIssue,
listIssues,
validateRepo,
type GitHubIssueList,
} from "../api/github-client";
import {
formatIssueDetail,
formatIssueList,
} from "../formatters/command-formatters";
import { sendInstallPrompt } from "../github-app/installation-service";
import type { GitHubOAuthService } from "../services/github-oauth-service";
import type { SlashCommandEvent } from "../types/bot";
import { parseCommandArgs, validateIssueFilters } from "../utils/arg-parser";
Expand Down Expand Up @@ -82,11 +84,18 @@ async function handleShowIssue(
);
return;
} catch {
// User token also failed - they don't have access
await handler.sendMessage(
channelId,
`❌ You don't have access to this repository`
);
// Check if user has repo access
const hasAccess = await validateRepo(repo, userOctokit);
if (hasAccess) {
// User has access but issue doesn't exist
await handler.sendMessage(
channelId,
`❌ Issue #${issueNumber} not found in **${repo}**`
);
} else {
// User doesn't have access - show install prompt
await sendInstallPrompt(handler, channelId, repo);
}
return;
}
}
Expand Down Expand Up @@ -168,11 +177,7 @@ async function handleListIssues(
await sendIssueList(handler, channelId, actualIssues, repo);
return;
} catch {
// User token also failed - they don't have access
await handler.sendMessage(
channelId,
`❌ You don't have access to this repository`
);
await sendInstallPrompt(handler, channelId, repo);
return;
}
}
Expand Down
25 changes: 15 additions & 10 deletions src/handlers/gh-pr-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,11 @@ import {
classifyApiError,
getPullRequest,
listPullRequests,
validateRepo,
type GitHubPullRequestList,
} from "../api/github-client";
import { formatPrDetail, formatPrList } from "../formatters/command-formatters";
import { sendInstallPrompt } from "../github-app/installation-service";
import type { GitHubOAuthService } from "../services/github-oauth-service";
import type { SlashCommandEvent } from "../types/bot";
import { parseCommandArgs, validatePrFilters } from "../utils/arg-parser";
Expand Down Expand Up @@ -76,11 +78,18 @@ async function handleShowPr(
);
return;
} catch {
// User token also failed - they don't have access
await handler.sendMessage(
channelId,
`❌ You don't have access to this repository`
);
// Check if user has repo access
const hasAccess = await validateRepo(repo, userOctokit);
if (hasAccess) {
// User has access but PR doesn't exist
await handler.sendMessage(
channelId,
`❌ Pull request #${prNumber} not found in **${repo}**`
);
} else {
// User doesn't have access - show install prompt
await sendInstallPrompt(handler, channelId, repo);
}
return;
}
}
Expand Down Expand Up @@ -157,11 +166,7 @@ async function handleListPrs(
await sendPrList(handler, channelId, prs, repo);
return;
} catch {
// User token also failed - they don't have access
await handler.sendMessage(
channelId,
`❌ You don't have access to this repository`
);
await sendInstallPrompt(handler, channelId, repo);
return;
}
}
Expand Down
Loading