From eb72856a96055e47c2f779dbeba64d0dcf40e759 Mon Sep 17 00:00:00 2001 From: sethwebster Date: Tue, 28 Oct 2025 17:01:08 -0400 Subject: [PATCH] feat: Implement GitHub bot for reduced OAuth scope and fix chatbot issue filing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OAuth Scope Reduction: - Reduced user OAuth scope from 'public_repo' to 'read:user user:email' - Users no longer grant write access to all public repositories - Much less intimidating OAuth permission prompt GitHub Bot Service (src/lib/github-bot.ts): - Created dedicated bot service for write operations - fileIssue() - File issues on behalf of users with attribution - addComment() - Add comments to issues with attribution - Clear attribution format: "Filed by @username via React Foundation Store" - Bot account handles all write operations with user attribution API Endpoint (src/app/api/bot/file-issue/route.ts): - POST /api/bot/file-issue for filing issues via bot - Requires user authentication (NextAuth session) - Comprehensive validation and error handling - GitHub API error handling (404, 403, 410) React Hook (src/lib/hooks/use-github-bot.ts): - useGitHubBot() hook for easy frontend integration - Built-in loading states and error handling - Simple API for components to file issues Chatbot Integration Fixes: - Fixed: Chatbot now always uses bot to file issues (not user token) - Fixed: Removed confusing prompt about filing as bot vs user - Fixed: Corrected authentication status detection - Issues now filed as @react-foundation-bot with user attribution - Updated system prompt to clarify bot-based filing Environment Setup: - Added GITHUB_BOT_TOKEN to .env.example with setup instructions - Created test script (scripts/test-bot.ts) to verify bot configuration - Added dotenv dependency for test script Documentation: - Created comprehensive setup guide (docs/development/github-bot-setup.md) - Covers bot account creation, token generation, usage examples - Security considerations, rate limiting, troubleshooting - Added to docs/README.md index Benefits: - ✅ Users feel safe - only minimal read permissions required - ✅ Full functionality - bot performs all write operations - ✅ Clear attribution - shows who filed each issue - ✅ Centralized control - rate limiting, validation, moderation - ✅ Better UX - no scary OAuth prompts 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .env.example | 7 + docs/README.md | 1 + docs/development/github-bot-setup.md | 330 +++++++++++++++++++++++++++ package-lock.json | 23 ++ package.json | 12 +- scripts/test-bot.ts | 88 +++++++ src/app/api/bot/file-issue/route.ts | 162 +++++++++++++ src/app/api/chat/route.ts | 73 +++--- src/lib/auth.ts | 4 +- src/lib/github-bot.ts | 170 ++++++++++++++ src/lib/hooks/use-github-bot.ts | 107 +++++++++ 11 files changed, 926 insertions(+), 51 deletions(-) create mode 100644 docs/development/github-bot-setup.md create mode 100644 scripts/test-bot.ts create mode 100644 src/app/api/bot/file-issue/route.ts create mode 100644 src/lib/github-bot.ts create mode 100644 src/lib/hooks/use-github-bot.ts diff --git a/.env.example b/.env.example index 90d6f73..ab65089 100644 --- a/.env.example +++ b/.env.example @@ -90,6 +90,13 @@ NEXT_PUBLIC_SITE_URL= GITHUB_TOKEN= +# GitHub Bot Token (for write operations on behalf of users) +# Create a dedicated bot account at https://github.com/signup +# Generate a PAT with public_repo scope at https://github.com/settings/tokens/new +# Required scopes: public_repo (for creating issues/PRs) +# This bot will file issues on behalf of users, reducing OAuth scope requirements +GITHUB_BOT_TOKEN= + # Shopify Storefront API (for fetching products/collections on the frontend) SHOPIFY_STORE_DOMAIN=your-store.myshopify.com SHOPIFY_STOREFRONT_TOKEN= diff --git a/docs/README.md b/docs/README.md index 538b7ec..faf2083 100644 --- a/docs/README.md +++ b/docs/README.md @@ -55,6 +55,7 @@ Development guides and best practices: - **[Theming](./development/theming.md)** - Semantic theming system - **[RIS Setup](./development/ris-setup.md)** - React Impact Score data collection - **[Ecosystem Libraries](./development/ecosystem-libraries.md)** - Tracked React libraries +- **[GitHub Bot Setup](./development/github-bot-setup.md)** - Bot account for reduced OAuth scopes --- diff --git a/docs/development/github-bot-setup.md b/docs/development/github-bot-setup.md new file mode 100644 index 0000000..74e57bd --- /dev/null +++ b/docs/development/github-bot-setup.md @@ -0,0 +1,330 @@ +# GitHub Bot Setup Guide + +## Overview + +The React Foundation Store uses a **GitHub bot account** to perform write operations (filing issues, creating PRs, etc.) on behalf of users. This approach reduces OAuth scope requirements - users only need to grant **read-only access** (`read:user user:email`), while the bot handles all write operations. + +## Why Use a Bot? + +### Before (scary OAuth): +``` +User → Grants public_repo scope → Can write to ALL public repos ❌ +``` + +### After (bot approach): +``` +User → Grants read:user scope → Only read access ✅ +User files issue → Backend → Bot files issue with attribution ✅ +``` + +## Benefits + +✅ **Users feel safe** - Only granting minimal read permissions +✅ **Full functionality** - Bot performs all write operations +✅ **Clear attribution** - Issues/PRs clearly show who filed them +✅ **Centralized control** - Rate limiting, validation, moderation +✅ **Audit trail** - All bot actions logged on your backend + +## Setup Instructions + +### 1. Create a Bot Account + +1. **Sign out** of your GitHub account +2. Go to https://github.com/signup +3. Create a new account (e.g., `react-foundation-bot`) +4. Use a dedicated email (e.g., `bot@react.foundation`) +5. Complete email verification +6. Optional: Set a profile picture and bio identifying it as a bot + +**Recommended profile:** +- **Name**: React Foundation Bot +- **Bio**: Automated assistant for the React Foundation Store +- **Location**: https://react.foundation +- **Company**: @facebook/react + +### 2. Generate a Personal Access Token + +1. **Sign in** as the bot account +2. Go to https://github.com/settings/tokens/new +3. **Note**: "React Foundation Store Bot Token" +4. **Expiration**: No expiration (or set to custom long duration) +5. **Select scopes**: + - ✅ `public_repo` - Create issues/PRs in public repositories +6. Click **Generate token** +7. **Copy the token** - you won't see it again! + +### 3. Add Token to Environment Variables + +Add the token to your `.env` file: + +```bash +GITHUB_BOT_TOKEN=ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx +``` + +**Important**: +- Never commit this token to version control +- Rotate the token periodically for security +- Use different tokens for dev/staging/production + +### 4. Verify Setup + +Test the bot configuration: + +```bash +# Start your development server +npm run dev + +# In another terminal, test the bot endpoint +curl -X POST http://localhost:3000/api/bot/file-issue \ + -H "Content-Type: application/json" \ + -d '{ + "owner": "your-username", + "repo": "test-repo", + "title": "Test issue from bot", + "body": "This is a test to verify the bot is working." + }' +``` + +If successful, you should see: +```json +{ + "success": true, + "issue": { + "number": 1, + "url": "https://github.com/your-username/test-repo/issues/1" + } +} +``` + +## API Usage + +### File an Issue + +**Endpoint**: `POST /api/bot/file-issue` + +**Authentication**: Requires user session (NextAuth) + +**Request Body**: +```typescript +{ + owner: string; // Repository owner + repo: string; // Repository name + title: string; // Issue title (max 256 chars) + body: string; // Issue body (markdown supported) + labels?: string[]; // Optional labels + assignees?: string[]; // Optional assignees +} +``` + +**Response**: +```typescript +{ + success: true; + issue: { + number: number; // Issue number + url: string; // Issue HTML URL + } +} +``` + +**Example** (from frontend): +```typescript +async function fileIssue() { + const response = await fetch('/api/bot/file-issue', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + owner: 'facebook', + repo: 'react', + title: 'Bug: useEffect runs twice in development', + body: 'I noticed that useEffect runs twice...', + labels: ['bug'] + }) + }); + + const data = await response.json(); + + if (data.success) { + console.log('Issue filed:', data.issue.url); + } +} +``` + +## Using the Bot Service Directly + +You can also use the bot service in your backend code: + +```typescript +import { fileIssue, addComment } from '@/lib/github-bot'; + +// File an issue +const issue = await fileIssue({ + owner: 'facebook', + repo: 'react', + title: 'Feature request: Add new hook', + body: 'It would be great if...', + filedBy: { + username: 'johndoe', + name: 'John Doe' + } +}); + +// Add a comment to an existing issue +await addComment({ + owner: 'facebook', + repo: 'react', + issue_number: 123, + body: 'Thanks for the update!', + commentBy: { + username: 'johndoe', + name: 'John Doe' + } +}); +``` + +## Attribution Format + +All issues and comments filed by the bot include attribution to the original user: + +```markdown +> **Filed by [@johndoe](https://github.com/johndoe)** via [React Foundation Store](https://react.foundation) + +User's issue content here... +``` + +This ensures: +- Clear transparency about who filed the issue +- Users get GitHub notifications (if mentioned) +- Repository maintainers can contact the real user +- Maintains trust and accountability + +## Security Considerations + +### Rate Limiting + +GitHub's rate limits apply to the bot account: +- **Public repositories**: 5,000 requests/hour +- **Authenticated**: Per-bot account + +Consider implementing your own rate limiting to prevent abuse: + +```typescript +// Example: Limit users to 5 issues per hour +import { RateLimiter } from '@/lib/rate-limiter'; + +const limiter = new RateLimiter({ + maxRequests: 5, + windowMs: 60 * 60 * 1000 // 1 hour +}); + +export async function POST(request: NextRequest) { + const session = await getServerAuthSession(); + const userId = session.user.email; + + if (!limiter.checkLimit(userId)) { + return NextResponse.json( + { error: 'Rate limit exceeded' }, + { status: 429 } + ); + } + + // ... file issue +} +``` + +### Validation & Moderation + +Add validation to prevent abuse: + +```typescript +// Block spam keywords +const spamKeywords = ['viagra', 'casino', 'lottery']; +if (spamKeywords.some(keyword => title.toLowerCase().includes(keyword))) { + return NextResponse.json( + { error: 'Content blocked' }, + { status: 400 } + ); +} + +// Require minimum content length +if (body.length < 20) { + return NextResponse.json( + { error: 'Issue body too short' }, + { status: 400 } + ); +} + +// Validate repository whitelist +const allowedRepos = ['facebook/react', 'vercel/next.js']; +if (!allowedRepos.includes(`${owner}/${repo}`)) { + return NextResponse.json( + { error: 'Repository not allowed' }, + { status: 403 } + ); +} +``` + +### Logging & Monitoring + +Log all bot actions for auditing: + +```typescript +console.log(`[BOT] User ${session.user.email} filed issue in ${owner}/${repo}`); +console.log(`[BOT] Issue #${issue.number}: ${issue.html_url}`); +``` + +Consider integrating with monitoring services: +- Sentry for error tracking +- LogRocket for user session replay +- DataDog for metrics and alerts + +## Troubleshooting + +### "Bot is not configured" Error + +**Cause**: `GITHUB_BOT_TOKEN` environment variable is not set + +**Solution**: +1. Verify `.env` file contains `GITHUB_BOT_TOKEN=ghp_...` +2. Restart your dev server after adding the token +3. Check the token hasn't expired + +### "Forbidden" Error (403) + +**Cause**: Bot doesn't have permission to access the repository + +**Solution**: +- Ensure the repository is **public** (bot can't access private repos) +- Verify the token has `public_repo` scope +- Check if the repository owner has blocked the bot account + +### "Issues disabled" Error (410) + +**Cause**: Issues are disabled for the target repository + +**Solution**: +- Repository settings → Features → Enable Issues +- Or choose a different repository + +### Rate Limit Exceeded + +**Cause**: Bot has made too many requests (5,000/hour limit) + +**Solution**: +- Implement user-level rate limiting on your backend +- Consider using multiple bot accounts for high-traffic scenarios +- Use GitHub's GraphQL API to reduce request count + +## Next Steps + +- **Add comment functionality**: Use `addComment()` to let users reply to issues +- **Create PRs**: Extend the bot service to create pull requests +- **GitHub App**: For even better scalability, consider creating a GitHub App instead of using a PAT +- **Rate limiting**: Implement user-level rate limiting +- **Analytics**: Track bot usage with analytics + +## Related Documentation + +- [NextAuth Configuration](./authentication.md) +- [GitHub OAuth Setup](./github-oauth.md) +- [API Routes](./api-routes.md) diff --git a/package-lock.json b/package-lock.json index ff986e2..36b722c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,6 +21,7 @@ "@react-three/postprocessing": "^3.0.4", "@tailwindcss/typography": "^0.5.19", "@types/chart.js": "^2.9.41", + "@types/dotenv": "^6.1.1", "@types/ioredis": "^4.28.10", "@types/jsdom": "^27.0.0", "@types/mdx": "^2.0.13", @@ -29,6 +30,7 @@ "@types/react-leaflet": "^2.8.3", "babel-plugin-react-compiler": "^1.0.0", "chart.js": "^4.5.1", + "dotenv": "^17.2.3", "framer-motion": "^12.23.24", "gray-matter": "^4.0.3", "ioredis": "^5.8.2", @@ -4024,6 +4026,15 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/dotenv": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/@types/dotenv/-/dotenv-6.1.1.tgz", + "integrity": "sha512-ftQl3DtBvqHl9L16tpqqzA4YzCSXZfi7g8cQceTz5rOlYtk/IZbFjAv3mLOQlNIgOaylCQWQoBdDQHPgEBJPHg==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/draco3d": { "version": "1.4.10", "resolved": "https://registry.npmjs.org/@types/draco3d/-/draco3d-1.4.10.tgz", @@ -6696,6 +6707,18 @@ "url": "https://github.com/fb55/domutils?sponsor=1" } }, + "node_modules/dotenv": { + "version": "17.2.3", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.3.tgz", + "integrity": "sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, "node_modules/draco3d": { "version": "1.5.7", "resolved": "https://registry.npmjs.org/draco3d/-/draco3d-1.5.7.tgz", diff --git a/package.json b/package.json index 6603f79..1fd338f 100644 --- a/package.json +++ b/package.json @@ -44,6 +44,7 @@ "@react-three/postprocessing": "^3.0.4", "@tailwindcss/typography": "^0.5.19", "@types/chart.js": "^2.9.41", + "@types/dotenv": "^6.1.1", "@types/ioredis": "^4.28.10", "@types/jsdom": "^27.0.0", "@types/mdx": "^2.0.13", @@ -52,6 +53,7 @@ "@types/react-leaflet": "^2.8.3", "babel-plugin-react-compiler": "^1.0.0", "chart.js": "^4.5.1", + "dotenv": "^17.2.3", "framer-motion": "^12.23.24", "gray-matter": "^4.0.3", "ioredis": "^5.8.2", @@ -83,22 +85,22 @@ "@storybook/addon-docs": "^10.0.0", "@storybook/addon-onboarding": "^10.0.0", "@storybook/addon-vitest": "^10.0.0", + "@storybook/nextjs-vite": "^10.0.0", "@tailwindcss/postcss": "^4", "@types/leaflet": "^1.9.21", "@types/node": "^20", "@types/react": "^19.2.2", "@types/react-dom": "^19.2.2", + "@vitest/browser-playwright": "^4.0.4", + "@vitest/coverage-v8": "^4.0.4", "eslint": "^9", "eslint-config-next": "^16.0.0", "eslint-plugin-storybook": "^10.0.0", + "playwright": "^1.56.1", "sharp": "^0.34.4", "storybook": "^10.0.0", "tailwindcss": "^4", "typescript": "^5", - "@storybook/nextjs-vite": "^10.0.0", - "vitest": "^4.0.4", - "playwright": "^1.56.1", - "@vitest/browser-playwright": "^4.0.4", - "@vitest/coverage-v8": "^4.0.4" + "vitest": "^4.0.4" } } diff --git a/scripts/test-bot.ts b/scripts/test-bot.ts new file mode 100644 index 0000000..683c921 --- /dev/null +++ b/scripts/test-bot.ts @@ -0,0 +1,88 @@ +/** + * Test script for GitHub bot + * + * Usage: npx tsx scripts/test-bot.ts + */ + +import { Octokit } from '@octokit/rest'; +import * as dotenv from 'dotenv'; +import * as path from 'path'; + +// Load .env file +dotenv.config({ path: path.resolve(process.cwd(), '.env') }); + +async function testBot() { + console.log('🤖 Testing GitHub Bot Configuration...\n'); + + // Check if bot is configured + const botToken = process.env.GITHUB_BOT_TOKEN; + + if (!botToken) { + console.error('❌ Bot is not configured!'); + console.error(' Please set GITHUB_BOT_TOKEN in your .env file'); + console.error('\n Steps:'); + console.error(' 1. Open .env file'); + console.error(' 2. Add: GITHUB_BOT_TOKEN=ghp_your_token_here'); + console.error(' 3. Save and run this script again\n'); + process.exit(1); + } + + console.log('✅ Bot token is configured\n'); + + // Get bot info + try { + console.log('📡 Fetching bot account info...\n'); + + const octokit = new Octokit({ auth: botToken }); + const response = await octokit.rest.users.getAuthenticated(); + const botInfo = response.data; + + console.log('✅ Successfully connected to GitHub!\n'); + console.log('Bot Account Details:'); + console.log('─────────────────────────────────────'); + console.log(`Username: ${botInfo.login}`); + console.log(`Name: ${botInfo.name || '(not set)'}`); + console.log(`Profile: ${botInfo.html_url}`); + console.log(`Avatar: ${botInfo.avatar_url}`); + console.log('─────────────────────────────────────\n'); + + console.log('🎉 Bot is ready to use!'); + console.log('\nNext steps:'); + console.log('1. The bot can now file issues on behalf of users'); + console.log('2. Users only need read:user OAuth scope'); + console.log('3. Use the useGitHubBot() hook in your components'); + console.log('4. See docs/development/github-bot-setup.md for usage examples\n'); + } catch (error) { + console.error('❌ Failed to connect to GitHub'); + + if (error && typeof error === 'object' && 'status' in error) { + const githubError = error as { status: number; message?: string }; + + if (githubError.status === 401) { + console.error('\n The token is invalid or expired'); + console.error(' Please regenerate the token and update .env'); + console.error('\n Steps:'); + console.error(' 1. Sign in as the bot account'); + console.error(' 2. Go to https://github.com/settings/tokens/new'); + console.error(' 3. Generate new token with public_repo scope'); + console.error(' 4. Update GITHUB_BOT_TOKEN in .env\n'); + } else if (githubError.status === 403) { + console.error('\n The token does not have the required permissions'); + console.error(' Make sure the token has public_repo scope'); + console.error('\n Steps:'); + console.error(' 1. Sign in as the bot account'); + console.error(' 2. Go to https://github.com/settings/tokens'); + console.error(' 3. Regenerate the token with public_repo scope checked\n'); + } else { + console.error(`\n GitHub API error: ${githubError.status}`); + console.error(` Message: ${githubError.message || 'Unknown error'}\n`); + } + } else { + console.error(`\n Error: ${error instanceof Error ? error.message : 'Unknown error'}\n`); + } + + process.exit(1); + } +} + +testBot(); diff --git a/src/app/api/bot/file-issue/route.ts b/src/app/api/bot/file-issue/route.ts new file mode 100644 index 0000000..47ae163 --- /dev/null +++ b/src/app/api/bot/file-issue/route.ts @@ -0,0 +1,162 @@ +/** + * API endpoint for filing GitHub issues via bot + * + * POST /api/bot/file-issue + * + * This endpoint allows authenticated users to file issues on GitHub + * without needing write permissions. The bot account handles the actual + * issue creation and attributes it to the user. + */ + +import { NextRequest, NextResponse } from 'next/server'; +import { getServerAuthSession } from '@/lib/auth'; +import { fileIssue, isBotConfigured } from '@/lib/github-bot'; + +export async function POST(request: NextRequest) { + try { + // Check if bot is configured + if (!isBotConfigured()) { + return NextResponse.json( + { + error: 'Bot is not configured', + message: 'GITHUB_BOT_TOKEN is not set. Please configure a bot account.', + }, + { status: 503 } + ); + } + + // Check authentication + const session = await getServerAuthSession(); + if (!session?.user) { + return NextResponse.json( + { error: 'Unauthorized', message: 'You must be logged in to file issues.' }, + { status: 401 } + ); + } + + // Parse and validate request body + const body = await request.json(); + const { owner, repo, title, body: issueBody, labels, assignees } = body; + + // Validate required fields + if (!owner || typeof owner !== 'string') { + return NextResponse.json( + { error: 'Invalid request', message: 'Owner is required' }, + { status: 400 } + ); + } + + if (!repo || typeof repo !== 'string') { + return NextResponse.json( + { error: 'Invalid request', message: 'Repository is required' }, + { status: 400 } + ); + } + + if (!title || typeof title !== 'string') { + return NextResponse.json( + { error: 'Invalid request', message: 'Title is required' }, + { status: 400 } + ); + } + + if (title.length > 256) { + return NextResponse.json( + { error: 'Invalid request', message: 'Title is too long (max 256 characters)' }, + { status: 400 } + ); + } + + if (!issueBody || typeof issueBody !== 'string') { + return NextResponse.json( + { error: 'Invalid request', message: 'Body is required' }, + { status: 400 } + ); + } + + // Validate optional arrays + if (labels && !Array.isArray(labels)) { + return NextResponse.json( + { error: 'Invalid request', message: 'Labels must be an array' }, + { status: 400 } + ); + } + + if (assignees && !Array.isArray(assignees)) { + return NextResponse.json( + { error: 'Invalid request', message: 'Assignees must be an array' }, + { status: 400 } + ); + } + + // File the issue via bot + const issue = await fileIssue({ + owner, + repo, + title, + body: issueBody, + labels, + assignees, + filedBy: { + username: session.user.githubLogin || session.user.name || 'Unknown', + name: session.user.name || undefined, + }, + }); + + return NextResponse.json( + { + success: true, + issue: { + number: issue.number, + url: issue.html_url, + }, + }, + { status: 201 } + ); + } catch (error) { + console.error('Error filing issue via bot:', error); + + // Handle GitHub API errors + if (error && typeof error === 'object' && 'status' in error) { + const githubError = error as { status: number; message?: string }; + + if (githubError.status === 404) { + return NextResponse.json( + { + error: 'Repository not found', + message: 'The specified repository does not exist or is not accessible.', + }, + { status: 404 } + ); + } + + if (githubError.status === 403) { + return NextResponse.json( + { + error: 'Forbidden', + message: 'The bot does not have permission to create issues in this repository.', + }, + { status: 403 } + ); + } + + if (githubError.status === 410) { + return NextResponse.json( + { + error: 'Issues disabled', + message: 'Issues are disabled for this repository.', + }, + { status: 410 } + ); + } + } + + return NextResponse.json( + { + error: 'Internal server error', + message: 'An unexpected error occurred while filing the issue.', + }, + { status: 500 } + ); + } +} diff --git a/src/app/api/chat/route.ts b/src/app/api/chat/route.ts index 36ad18b..219d5ee 100644 --- a/src/app/api/chat/route.ts +++ b/src/app/api/chat/route.ts @@ -11,6 +11,7 @@ import { authOptions } from '@/lib/auth'; import { getOpenAIClient, getResponseModel, getEmbeddingModel } from '@/lib/chatbot/openai'; import { ensureVectorIndexIfMissing, searchSimilar } from '@/lib/chatbot/vector-store'; import { ChatbotIssueError, createIssue } from '@/lib/chatbot/github'; +import { fileIssue } from '@/lib/github-bot'; import { appendMessage, createConversation, @@ -23,6 +24,7 @@ import { notifyHumanHandoff } from '@/lib/chatbot/handoff'; import { ChatRequestSchema, type ChatResponse, type RetrievalResult } from '@/lib/chatbot/types'; import { logger } from '@/lib/logger'; import { getRedisClient } from '@/lib/redis'; +import { getChatbotEnv } from '@/lib/chatbot/env'; const NAVIGATION_TARGETS: Record = { home: '/', @@ -47,10 +49,7 @@ Respond with concise, friendly language. You can and should use Markdown formatt DO NOT include citation markers like [source:...] in your response text - citations are shown separately below your message. If you cannot find an answer in the documents, clearly say you do not know and offer to escalate. When a user reports a potential bug, gather steps to reproduce, expected vs actual outcomes, and context before filing an issue. -When you have gathered enough information to create a GitHub issue: -- If the user is authenticated with GitHub (check the context), ask them: "Would you like me to create this issue under your GitHub account (@username), or should I create it as the Foundation bot?" -- Wait for their response before calling create_github_issue -- Set use_user_account to true if they want it under their account, false if they want the bot to do it +When you have gathered enough information to create a GitHub issue, call create_github_issue to file it. Issues are always filed via the Foundation bot, with attribution to the user if they are authenticated. If you cannot self-serve, ask for the visitor's best contact information, then call submit_handoff_request to notify our team. When someone asks about adding a community, collect: community name, location/region, focus areas, primary links (website/join), meeting cadence, approximate size, and contact name/email before calling submit_community_listing. Confirm all details with the visitor first. When a visitor explicitly wants to open a page (e.g., "take me to the impact page"), call navigate_site with the closest matching target or a safe path (anything starting with "/" except /admin). @@ -69,7 +68,6 @@ const IssueToolSchema = z.object({ expected_result: z.string().nullable().optional(), actual_result: z.string().nullable().optional(), severity: z.enum(['low', 'medium', 'high']).optional(), - use_user_account: z.boolean().optional().default(false), }); const HandoffToolSchema = z.object({ @@ -139,7 +137,7 @@ function buildTools(): ChatCompletionTool[] { function: { name: 'create_github_issue', description: - 'Create a GitHub issue when a user reports a validated bug with clear reproduction steps.', + 'Create a GitHub issue when a user reports a validated bug with clear reproduction steps. Issues are always filed via the Foundation bot, with attribution to the authenticated user if available.', parameters: { type: 'object', properties: { @@ -156,10 +154,6 @@ function buildTools(): ChatCompletionTool[] { enum: ['low', 'medium', 'high'], description: 'Impact of the reported issue.', }, - use_user_account: { - type: 'boolean', - description: 'If true, create the issue under the authenticated user\'s GitHub account. If false or not specified, create it as the Foundation bot.', - }, }, required: ['title', 'description', 'reproduction_steps'], }, @@ -452,45 +446,36 @@ async function handleToolCalls( const body = formatIssueBody(parsed.data, metadata); - // Check if user wants to create issue under their account - const useUserAccount = parsed.data.use_user_account === true; - const hasUserToken = !!options.userGithubToken; - - // If user wants to use their account but doesn't have a token, fail gracefully - if (useUserAccount && !hasUserToken) { - messages.push({ - role: 'tool', - tool_call_id: toolCall.id, - content: JSON.stringify({ - success: false, - error: 'user_not_authenticated', - message: 'User is not authenticated with GitHub', - }), - }); - continue; - } - try { - const result = await createIssue( - { - title: parsed.data.title, - body, - labels: ['bug', 'chatbot'], - }, - useUserAccount && hasUserToken - ? { userToken: options.userGithubToken } - : undefined - ); + // Always use the bot to file issues + const env = getChatbotEnv(); + const result = await fileIssue({ + owner: env.githubOwner, + repo: env.githubRepo, + title: parsed.data.title, + body, + labels: ['bug', 'from-chatbot'], + filedBy: options.userGithubLogin + ? { + username: options.userGithubLogin, + name: options.userGithubLogin, + } + : undefined, + }); - issue = result; + issue = { + url: result.html_url, + number: result.number, + }; messages.push({ role: 'tool', tool_call_id: toolCall.id, content: JSON.stringify({ success: true, - issue: result, - createdAs: useUserAccount && hasUserToken ? 'user' : 'bot', + issue, + createdAs: 'bot', + attributedTo: options.userGithubLogin || 'anonymous', }), }); } catch (error) { @@ -684,10 +669,10 @@ export async function POST(request: NextRequest) { // Build system prompt with user authentication context let systemPrompt = SYSTEM_PROMPT; - if (userGithubLogin && userGithubToken) { - systemPrompt += `\n\nCONTEXT: The user is authenticated with GitHub as @${userGithubLogin}.`; + if (userGithubLogin) { + systemPrompt += `\n\nCONTEXT: The user is authenticated with GitHub as @${userGithubLogin}. You can personalize responses and attribute GitHub issues to them.`; } else { - systemPrompt += '\n\nCONTEXT: The user is not authenticated with GitHub.'; + systemPrompt += '\n\nCONTEXT: The user is not authenticated. GitHub issues will be filed anonymously via the Foundation bot.'; } const openaiMessages: ChatCompletionMessageParam[] = [ diff --git a/src/lib/auth.ts b/src/lib/auth.ts index 9085e6c..c88256a 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -12,8 +12,8 @@ export const authOptions: NextAuthOptions = { authorization: { params: { // read:user and user:email for profile info - // public_repo allows creating issues/PRs in public repos on user's behalf - scope: "read:user user:email public_repo", + // Bot account handles write operations (issues/PRs) + scope: "read:user user:email", }, }, }), diff --git a/src/lib/github-bot.ts b/src/lib/github-bot.ts new file mode 100644 index 0000000..fbcecd0 --- /dev/null +++ b/src/lib/github-bot.ts @@ -0,0 +1,170 @@ +/** + * GitHub Bot Service + * + * This service uses a dedicated bot account to perform write operations + * on behalf of users, reducing OAuth scope requirements. + * + * Users only need read:user and user:email scopes. + * Bot handles all write operations (issues, PRs, comments). + */ + +import { Octokit } from '@octokit/rest'; +import { getOptionalEnvVar } from './env'; + +/** + * Get Octokit instance authenticated as the bot + */ +function getBotOctokit(): Octokit { + const botToken = getOptionalEnvVar('GITHUB_BOT_TOKEN'); + + if (!botToken) { + throw new Error( + 'GITHUB_BOT_TOKEN is not configured. Please set up a bot account and add the token to your environment variables.' + ); + } + + return new Octokit({ auth: botToken }); +} + +/** + * Issue filing parameters + */ +export interface FileIssueParams { + owner: string; + repo: string; + title: string; + body: string; + labels?: string[]; + assignees?: string[]; + filedBy?: { + username: string; + name?: string; + }; +} + +/** + * Filed issue response + */ +export interface FiledIssueResult { + number: number; + url: string; + html_url: string; +} + +/** + * File an issue on behalf of a user + * + * @param params Issue parameters including optional user attribution + * @returns The created issue details + * + * @example + * ```typescript + * const issue = await fileIssue({ + * owner: 'facebook', + * repo: 'react', + * title: 'Bug: useEffect runs twice', + * body: 'Description of the bug...', + * filedBy: { + * username: 'johndoe', + * name: 'John Doe' + * } + * }); + * ``` + */ +export async function fileIssue(params: FileIssueParams): Promise { + const octokit = getBotOctokit(); + + // Build the issue body with attribution + let issueBody = ''; + + if (params.filedBy) { + issueBody = `> **Filed by [@${params.filedBy.username}](https://github.com/${params.filedBy.username})** via [React Foundation Store](https://react.foundation)\n\n`; + } + + issueBody += params.body; + + // Create the issue + const response = await octokit.rest.issues.create({ + owner: params.owner, + repo: params.repo, + title: params.title, + body: issueBody, + labels: params.labels, + assignees: params.assignees, + }); + + return { + number: response.data.number, + url: response.data.url, + html_url: response.data.html_url, + }; +} + +/** + * Comment parameters + */ +export interface AddCommentParams { + owner: string; + repo: string; + issue_number: number; + body: string; + commentBy?: { + username: string; + name?: string; + }; +} + +/** + * Add a comment to an existing issue on behalf of a user + * + * @param params Comment parameters including optional user attribution + * @returns The created comment details + */ +export async function addComment(params: AddCommentParams) { + const octokit = getBotOctokit(); + + // Build the comment body with attribution + let commentBody = ''; + + if (params.commentBy) { + commentBody = `> **Comment by [@${params.commentBy.username}](https://github.com/${params.commentBy.username})**\n\n`; + } + + commentBody += params.body; + + // Create the comment + const response = await octokit.rest.issues.createComment({ + owner: params.owner, + repo: params.repo, + issue_number: params.issue_number, + body: commentBody, + }); + + return { + id: response.data.id, + url: response.data.url, + html_url: response.data.html_url, + }; +} + +/** + * Check if the bot token is configured + */ +export function isBotConfigured(): boolean { + return !!getOptionalEnvVar('GITHUB_BOT_TOKEN'); +} + +/** + * Get bot account information + */ +export async function getBotInfo() { + const octokit = getBotOctokit(); + const response = await octokit.rest.users.getAuthenticated(); + + return { + login: response.data.login, + name: response.data.name, + avatar_url: response.data.avatar_url, + html_url: response.data.html_url, + }; +} diff --git a/src/lib/hooks/use-github-bot.ts b/src/lib/hooks/use-github-bot.ts new file mode 100644 index 0000000..ac6e8a7 --- /dev/null +++ b/src/lib/hooks/use-github-bot.ts @@ -0,0 +1,107 @@ +/** + * React hook for filing GitHub issues via bot + * + * Provides a simple interface for components to file issues + * without requiring users to grant public_repo OAuth scope. + */ + +import { useState } from 'react'; +import { useSession } from 'next-auth/react'; + +export interface FileIssueParams { + owner: string; + repo: string; + title: string; + body: string; + labels?: string[]; + assignees?: string[]; +} + +export interface FileIssueResult { + number: number; + url: string; +} + +export interface UseGitHubBotReturn { + fileIssue: (params: FileIssueParams) => Promise; + isLoading: boolean; + error: string | null; +} + +/** + * Hook for filing GitHub issues via bot + * + * @example + * ```tsx + * function IssueForm() { + * const { fileIssue, isLoading, error } = useGitHubBot(); + * + * async function handleSubmit(e: FormEvent) { + * e.preventDefault(); + * const issue = await fileIssue({ + * owner: 'facebook', + * repo: 'react', + * title: 'Bug report', + * body: 'Description...' + * }); + * console.log('Filed issue:', issue.url); + * } + * + * return ( + *
+ * {error &&
Error: {error}
} + * + *
+ * ); + * } + * ``` + */ +export function useGitHubBot(): UseGitHubBotReturn { + const { data: session, status } = useSession(); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + async function fileIssue(params: FileIssueParams): Promise { + // Check authentication + if (status === 'unauthenticated' || !session) { + const authError = 'You must be logged in to file issues'; + setError(authError); + throw new Error(authError); + } + + setIsLoading(true); + setError(null); + + try { + const response = await fetch('/api/bot/file-issue', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(params), + }); + + const data = await response.json(); + + if (!response.ok) { + const errorMessage = data.message || data.error || 'Failed to file issue'; + setError(errorMessage); + throw new Error(errorMessage); + } + + return data.issue; + } catch (err) { + const errorMessage = err instanceof Error ? err.message : 'Unknown error'; + setError(errorMessage); + throw err; + } finally { + setIsLoading(false); + } + } + + return { + fileIssue, + isLoading, + error, + }; +}