From 7b5831ce6c68287749b551e12e9a410e1149bd27 Mon Sep 17 00:00:00 2001 From: Taz Singh Date: Thu, 11 Dec 2025 06:22:09 +0000 Subject: [PATCH 1/5] Git Ranch yeehaw --- README.md | 158 ++++++++--------- index.html | 2 +- package.json | 3 +- ...andscape-on-transparent-background-png.png | Bin 0 -> 32738 bytes src/App.tsx | 53 +++--- src/components/ErrorBoundary.tsx | 72 ++++++-- src/components/PullRequestList.tsx | 162 ++++++++++-------- src/index.css | 37 +++- src/lib/auth.ts | 68 ++++---- src/pages/CallbackPage.tsx | 65 +++---- src/pages/HomePage.tsx | 37 ++-- 11 files changed, 387 insertions(+), 270 deletions(-) create mode 100644 public/desert-tumbleweed-landscape-on-transparent-background-png.png diff --git a/README.md b/README.md index 71e7b39..e4dd969 100644 --- a/README.md +++ b/README.md @@ -1,47 +1,47 @@ -# GitHub GraphQL Demo with Relay +# 🤠 Git Ranch - Wrangle Yer Pull Requests -![GitHub Pull Request Viewer](./example.png) +![Git Ranch - Cattle Roundup](./example.png) -A Vite + React application that authenticates with GitHub using OAuth and displays your open pull requests using the GitHub GraphQL API with Relay. +Yeehaw, partner! Git Ranch is a rootin' tootin' Vite + React application that lets you saddle up with GitHub and wrangle all yer open pull requests using the GitHub GraphQL API with Relay. ## Features -- 🔐 GitHub OAuth authentication -- 📊 GraphQL queries using Relay -- 🔀 View all your open pull requests across repositories -- 📈 See PR status, review decisions, and code changes -- 🎨 Modern UI with Tailwind CSS -- 📱 Responsive design -- 🌙 Dark mode support -- ⚡ Type-safe with TypeScript -- 🚀 Fast development with Vite +- 🐴 **Saddle Up** - GitHub OAuth authentication to join the ranch +- 🐄 **Cattle Roundup** - GraphQL queries using Relay to round up yer PRs +- 🤠 **Wrangle PRs** - View all yer open pull requests across the range +- 🏷️ **Brand Checkin'** - See review status (Branded & Ready, Needs Re-shoein', Needs Inspectin') +- 🎨 **Ranch Style** - Modern UI with Tailwind CSS +- 📱 **Trail Ready** - Responsive design for cowboys on the go +- 🌙 **Night Ridin'** - Dark mode for late night wranglin' +- ⚡ **Fast as Lightnin'** - Type-safe with TypeScript +- 🚀 **Quick Draw** - Fast development with Vite -## Tech Stack +## The Outfit (Tech Stack) -- **Vite** - Build tool and dev server -- **React 18** - UI library -- **React Router** - Client-side routing -- **Relay** - GraphQL client -- **Express** - OAuth token exchange server -- **GitHub GraphQL API** - Data source -- **Tailwind CSS v4** - Styling -- **TypeScript** - Type safety +- **Vite** - Build tool faster than a rattlesnake strike +- **React 18** - UI library for buildin' the ranch house +- **React Router** - Trail markers for client-side routing +- **Relay** - GraphQL wrangler for fetchin' data +- **Express** - OAuth token exchange at the tradin' post +- **GitHub GraphQL API** - The cattle source +- **Tailwind CSS v4** - Ranch stylin' +- **TypeScript** - Type safety like a good fence -## Setup Instructions +## Hitchin' Up Instructions -### 1. Create a GitHub OAuth App +### 1. Register Yer Brand at GitHub -1. Go to [GitHub Developer Settings](https://github.com/settings/developers) +1. Mosey on over to [GitHub Developer Settings](https://github.com/settings/developers) 2. Click "New OAuth App" -3. Fill in the details: - - **Application name**: GraphQL Demo (or any name) +3. Fill in the ranch details: + - **Application name**: Git Ranch (or whatever brand ya fancy) - **Homepage URL**: `http://localhost:3000` - **Authorization callback URL**: `http://localhost:3000/callback` 4. Click "Register application" -5. Copy the **Client ID** -6. Generate a new **Client Secret** and copy it +5. Copy the **Client ID** (yer ranch brand) +6. Generate a new **Client Secret** and copy it (keep it secret, keep it safe) -### 2. Configure Environment Variables +### 2. Set Up the Bunkhouse (.env) 1. Copy the example environment file: @@ -49,98 +49,102 @@ A Vite + React application that authenticates with GitHub using OAuth and displa cp .env.example .env ``` -2. Edit `.env` and add your credentials: +2. Edit `.env` and add yer credentials: ```env - VITE_GITHUB_CLIENT_ID=your_github_client_id - GITHUB_CLIENT_SECRET=your_github_client_secret + VITE_GITHUB_CLIENT_ID=yer_github_client_id + GITHUB_CLIENT_SECRET=yer_github_client_secret VITE_REDIRECT_URI=http://localhost:3000/callback PORT=3001 ``` -### 3. Install Dependencies +### 3. Stock the Barn (Install Dependencies) ```bash npm install ``` -### 4. Compile Relay Queries +### 4. Prep the Lassos (Compile Relay Queries) ```bash npm run relay ``` -### 5. Run the Application +### 5. Open the Ranch Gates -This will start both the OAuth server (port 3001) and Vite dev server (port 3000): +This'll start both the OAuth server (port 3001) and Vite dev server (port 3000): ```bash npm start ``` -Or run them separately: +Or run 'em separately like two cowboys on patrol: ```bash -# Terminal 1 - OAuth server +# Terminal 1 - The Tradin' Post (OAuth server) npm run server -# Terminal 2 - Vite dev server +# Terminal 2 - The Ranch House (Vite dev server) npm run dev ``` -Open [http://localhost:3000](http://localhost:3000) in your browser. +Ride on over to [http://localhost:3000](http://localhost:3000) in yer browser. -## Usage +## How to Wrangle -1. Click "Sign in with GitHub" on the homepage -2. Authorize the application -3. View your open pull requests with details like: - - PR title and number - - Repository name - - Branch information (head → base) - - Review decision status (Approved, Changes Requested, Review Required) - - Draft status - - Code changes (+additions / -deletions) - - Created and updated dates +1. Click "🐴 Saddle Up with GitHub" at the saloon entrance +2. Authorize the ranch to access yer GitHub +3. View yer cattle (pull requests) with all the details: + - 🐮 PR title and number (cattle tag) + - 🏠 Repository name (which pasture) + - 🔀 Branch information (head → base trail) + - 🏷️ Brandin' status (Branded & Ready, Needs Re-shoein', Needs Inspectin') + - 📝 Draft status (Still Ropin') + - ➕➖ Code changes (+additions / -deletions) + - 🌅 When the roundup started and last wrangled -## Project Structure +## Ranch Layout (Project Structure) ``` ├── src/ │ ├── components/ -│ │ ├── PullRequestList.tsx # Pull request list with Relay query -│ │ └── ErrorBoundary.tsx # Error boundary component +│ │ ├── RoundupList.tsx # Cattle list (PRs) with Relay query +│ │ └── Tumbleweed.tsx # Error boundary (when things go sideways) │ ├── pages/ -│ │ ├── HomePage.tsx # Main page with auth logic -│ │ └── CallbackPage.tsx # OAuth callback handler +│ │ ├── Saloon.tsx # Main gathering hall +│ │ └── TrailPost.tsx # OAuth callback checkpoint │ ├── lib/ -│ │ ├── auth.ts # OAuth authentication logic +│ │ ├── auth.ts # Cowboy authentication │ │ └── relay/ │ │ └── environment.ts # Relay environment -│ ├── App.tsx # Root app component -│ ├── main.tsx # App entry point -│ └── index.css # Global styles +│ ├── App.tsx # The whole dang ranch +│ ├── main.tsx # Ranch entrance +│ └── index.css # Ranch dress code ├── __generated__/ # Relay generated files -├── server.js # OAuth token exchange server +├── server.js # OAuth token tradin' post ├── vite.config.ts # Vite configuration ├── relay.config.js # Relay compiler configuration -└── schema.graphql # GitHub GraphQL schema +└── schema.graphql # GitHub GraphQL schema (the brand book) ``` -## Available Scripts +## Ranch Commands -- `npm start` - Start both OAuth server and Vite dev server -- `npm run dev` - Start Vite development server only -- `npm run server` - Start OAuth server only -- `npm run build` - Build for production (includes Relay compilation) -- `npm run preview` - Preview production build -- `npm run relay` - Compile Relay queries -- `npm run lint` - Run ESLint +- `npm start` - Open the ranch (both servers) +- `npm run dev` - Just the ranch house (Vite) +- `npm run server` - Just the tradin' post (OAuth) +- `npm run build` - Prep for the cattle drive (production build) +- `npm run preview` - Preview the finished ranch +- `npm run relay` - Compile them Relay queries +- `npm run lint` - Check for varmints in the code -## Learn More +## Trail Guides & Wisdom -- [Vite Documentation](https://vite.dev/) -- [React Documentation](https://react.dev/) -- [Relay Documentation](https://relay.dev/docs/) -- [React Router Documentation](https://reactrouter.com/) -- [GitHub GraphQL API](https://docs.github.com/en/graphql) -- [Tailwind CSS](https://tailwindcss.com/docs) +- [Vite Documentation](https://vite.dev/) - Fast build tool knowledge +- [React Documentation](https://react.dev/) - React wisdom +- [Relay Documentation](https://relay.dev/docs/) - GraphQL wranglin' guide +- [React Router Documentation](https://reactrouter.com/) - Trail navigation +- [GitHub GraphQL API](https://docs.github.com/en/graphql) - The cattle source docs +- [Tailwind CSS](https://tailwindcss.com/docs) - Ranch stylin' guide + +--- + +_Happy trails, partner!_ 🤠🐴 diff --git a/index.html b/index.html index 3abf974..3f2f3b6 100644 --- a/index.html +++ b/index.html @@ -4,7 +4,7 @@ - GitHub GraphQL Demo + 🤠 Git Ranch - Wrangle Yer Pull Requests
diff --git a/package.json b/package.json index b794794..54ba190 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,6 @@ { - "name": "graphql-demo", + "name": "git-ranch", + "description": "🤠 Yeehaw! Wrangle yer GitHub Pull Requests like a true cowpoke", "version": "0.1.0", "private": true, "type": "module", diff --git a/public/desert-tumbleweed-landscape-on-transparent-background-png.png b/public/desert-tumbleweed-landscape-on-transparent-background-png.png new file mode 100644 index 0000000000000000000000000000000000000000..9eef9f834e1a4aae0b063670bfa6880a39217b4b GIT binary patch literal 32738 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 z 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 +// 🤠 GitRanch - The main app component, yeehaw! +const GitRanch = () => { + const [ranchHand, setRanchHand] = useState(loadAuthState()); const [relayEnvironment, setRelayEnvironment] = useState(() => - authState.accessToken ? getRelayEnvironment(authState.accessToken) : null + ranchHand.accessToken ? getRelayEnvironment(ranchHand.accessToken) : null ); useEffect(() => { - if (authState.accessToken) { - const env = getRelayEnvironment(authState.accessToken); + if (ranchHand.accessToken) { + const env = getRelayEnvironment(ranchHand.accessToken); setRelayEnvironment(env); } else { resetRelayEnvironment(); setRelayEnvironment(null); } - }, [authState.accessToken]); + }, [ranchHand.accessToken]); - const handleLogout = () => { + const hitTheTrail = () => { clearAuthState(); resetRelayEnvironment(); - setAuthState({ accessToken: null, user: null }); + setRanchHand({ accessToken: null, user: null }); }; return ( @@ -36,36 +40,35 @@ function App() { -
Loading...
+
🐴 Saddlin' up...
} >
) : ( - + ) } /> } + element={} /> } /> ); -} - -export default App; +}; +export default GitRanch; diff --git a/src/components/ErrorBoundary.tsx b/src/components/ErrorBoundary.tsx index d7cae9e..28e71eb 100644 --- a/src/components/ErrorBoundary.tsx +++ b/src/components/ErrorBoundary.tsx @@ -5,43 +5,91 @@ interface Props { } interface State { - hasError: boolean; + hasTumbleweed: boolean; error?: Error; + showRollingTumbleweed: boolean; } +// 🌿 A tumbleweed that randomly rolls across the screen +const RollingTumbleweed = () => ( +
+ Tumbleweed +
+); + +// 🌿 ErrorBoundary - Catches errors like a tumbleweed catches the wind export class ErrorBoundary extends Component { + private tumbleweedInterval: ReturnType | null = null; + constructor(props: Props) { super(props); - this.state = { hasError: false }; + this.state = { hasTumbleweed: false, showRollingTumbleweed: false }; } - static getDerivedStateFromError(error: Error): State { - return { hasError: true, error }; + componentDidMount() { + // Random tumbleweed every 30-90 seconds + this.scheduleTumbleweed(); + } + + componentWillUnmount() { + if (this.tumbleweedInterval) { + clearTimeout(this.tumbleweedInterval); + } + } + + scheduleTumbleweed = () => { + const randomDelay = Math.random() * 60000 + 30000; // 30-90 seconds + this.tumbleweedInterval = setTimeout(() => { + this.setState({ showRollingTumbleweed: true }); + // Hide after animation completes (5 seconds) + setTimeout(() => { + this.setState({ showRollingTumbleweed: false }); + this.scheduleTumbleweed(); + }, 5000); + }, randomDelay); + }; + + static getDerivedStateFromError(error: Error): Partial { + return { hasTumbleweed: true, error }; } render() { - if (this.state.hasError) { + if (this.state.hasTumbleweed) { return (

- Something went wrong + 🌵 Well, Shucks!

- {this.state.error?.message || "An unexpected error occurred"} + {this.state.error?.message || + "A tumbleweed done blown through and spooked the horses"}

); } - return this.props.children; + return ( + <> + {this.state.showRollingTumbleweed && } + {this.props.children} + + ); } } - diff --git a/src/components/PullRequestList.tsx b/src/components/PullRequestList.tsx index f8cb065..35d596d 100644 --- a/src/components/PullRequestList.tsx +++ b/src/components/PullRequestList.tsx @@ -1,71 +1,82 @@ import { graphql, useLazyLoadQuery } from "react-relay"; -import type { PullRequestListQuery as PullRequestListQueryType } from "@/__generated__/PullRequestListQuery.graphql"; +import type { PullRequestListQuery as PullRequestListQueryType } from "./__generated__/PullRequestListQuery.graphql"; -const PullRequestListQuery = graphql` - query PullRequestListQuery($first: Int!) { - viewer { - login - pullRequests(first: $first, states: [OPEN], orderBy: { field: UPDATED_AT, direction: DESC }) { - totalCount - nodes { - id - number - title - url - state - isDraft - createdAt - updatedAt - repository { - name - nameWithOwner - } - baseRefName - headRefName - additions - deletions - reviewDecision - } - } - } - } -`; - -interface PullRequestListProps { - count?: number; -} - -type PullRequest = NonNullable< +type Roundup = NonNullable< NonNullable< PullRequestListQueryType["response"]["viewer"]["pullRequests"]["nodes"] >[number] >; -export default function PullRequestList({ count = 20 }: PullRequestListProps) { - const data = useLazyLoadQuery(PullRequestListQuery, { - first: count, - }); +// 🐄 PullRequestList - Round up them cattle (PRs) from the range +export const PullRequestList = ({ headCount = 20 }: { headCount?: number }) => { + const data = useLazyLoadQuery( + graphql` + query PullRequestListQuery($first: Int!) { + viewer { + login + pullRequests( + first: $first + states: [OPEN] + orderBy: { field: UPDATED_AT, direction: DESC } + ) { + totalCount + nodes { + id + number + title + url + state + isDraft + createdAt + updatedAt + repository { + name + nameWithOwner + } + baseRefName + headRefName + additions + deletions + reviewDecision + } + } + } + } + `, + { first: headCount } + ); const { viewer } = data; - const pullRequests = (viewer.pullRequests.nodes?.filter( - (pr): pr is PullRequest => pr !== null && pr !== undefined - ) ?? []) as PullRequest[]; + const roundups = (viewer.pullRequests.nodes?.filter( + (pr): pr is Roundup => pr !== null && pr !== undefined + ) ?? []) as Roundup[]; - const getReviewDecisionBadge = (decision: string | null | undefined) => { + const getBrandingBadge = (decision: string | null | undefined) => { if (!decision) return null; - const badges = { - APPROVED: { text: "Approved", color: "bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200" }, - CHANGES_REQUESTED: { text: "Changes Requested", color: "bg-red-100 dark:bg-red-900 text-red-800 dark:text-red-200" }, - REVIEW_REQUIRED: { text: "Review Required", color: "bg-yellow-100 dark:bg-yellow-900 text-yellow-800 dark:text-yellow-200" }, + const brands = { + APPROVED: { + text: "🏷️ Branded & Ready", + color: + "bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200", + }, + CHANGES_REQUESTED: { + text: "🔧 Needs Re-shoein'", + color: "bg-red-100 dark:bg-red-900 text-red-800 dark:text-red-200", + }, + REVIEW_REQUIRED: { + text: "👀 Needs Inspectin'", + color: + "bg-yellow-100 dark:bg-yellow-900 text-yellow-800 dark:text-yellow-200", + }, }; - const badge = badges[decision as keyof typeof badges]; - if (!badge) return null; + const brand = brands[decision as keyof typeof brands]; + if (!brand) return null; return ( - - {badge.text} + + {brand.text} ); }; @@ -74,23 +85,24 @@ export default function PullRequestList({ count = 20 }: PullRequestListProps) {

- {viewer.login}'s Open Pull Requests + 🐄 {viewer.login}'s Cattle Roundup

- {viewer.pullRequests.totalCount} open pull request{viewer.pullRequests.totalCount !== 1 ? 's' : ''} + {viewer.pullRequests.totalCount} head of cattle need wranglin' + {viewer.pullRequests.totalCount !== 1 ? "" : ""}

- {pullRequests.length === 0 ? ( + {roundups.length === 0 ? (
- No open pull requests found + 🌵 No cattle on the range, partner. The herd's all accounted for!
) : (
- {pr.isDraft && ( + {cattle.isDraft && ( - Draft + 📝 Still Ropin' )} - {getReviewDecisionBadge(pr.reviewDecision)} + {getBrandingBadge(cattle.reviewDecision)}
- {pr.headRefName} + {cattle.headRefName} - {pr.baseRefName} + {cattle.baseRefName}
- +{pr.additions} - -{pr.deletions} + + +{cattle.additions} + + + -{cattle.deletions} +
- Created {new Date(pr.createdAt).toLocaleDateString()} + 🌅 Started {new Date(cattle.createdAt).toLocaleDateString()}
- Updated {new Date(pr.updatedAt).toLocaleDateString()} + 🔄 Last wrangled{" "} + {new Date(cattle.updatedAt).toLocaleDateString()}
@@ -146,5 +163,6 @@ export default function PullRequestList({ count = 20 }: PullRequestListProps) { )} ); -} +}; +export default PullRequestList; diff --git a/src/index.css b/src/index.css index 8567d6e..7740a2d 100644 --- a/src/index.css +++ b/src/index.css @@ -20,8 +20,43 @@ body { background: var(--background); color: var(--foreground); - font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif; + font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, + Oxygen, Ubuntu, Cantarell, sans-serif; margin: 0; padding: 0; } +/* 🌿 Tumbleweed rolling animation */ +@keyframes tumbleweed-roll { + 0% { + left: -100px; + opacity: 0; + } + 5% { + opacity: 1; + } + 95% { + opacity: 1; + } + 100% { + left: calc(100vw + 100px); + opacity: 0; + } +} + +@keyframes spin-slow { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} + +.animate-tumbleweed { + animation: tumbleweed-roll 5s linear forwards; +} + +.animate-spin-slow { + animation: spin-slow 1s linear infinite; +} diff --git a/src/lib/auth.ts b/src/lib/auth.ts index 038c1c6..5f9791c 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -1,4 +1,6 @@ -// GitHub OAuth configuration +// 🤠 Git Ranch - Cowboy Authentication +// GitHub OAuth configuration for saddlin' up with the ranch + const GITHUB_CLIENT_ID = import.meta.env.VITE_GITHUB_CLIENT_ID; const REDIRECT_URI = import.meta.env.VITE_REDIRECT_URI || "http://localhost:3000/callback"; @@ -15,25 +17,22 @@ export interface AuthState { user: GitHubUser | null; } -// Generate a random state for OAuth security -function generateState(): string { +// Generate a random trail token for OAuth security +function generateTrailToken(): string { return ( Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15) ); } -// Initiate GitHub OAuth flow +// 🐴 Saddle up and ride to GitHub for authentication export function initiateGitHubLogin(): void { - const state = generateState(); - // Use localStorage instead of sessionStorage for better reliability across redirects - localStorage.setItem("oauth_state", state); - - console.log("Initiating OAuth login with state:", state); - console.log( - "LocalStorage after setting:", - localStorage.getItem("oauth_state") - ); + const state = generateTrailToken(); + // Stash the trail token in the bunkhouse for when we return + localStorage.setItem("trail_token", state); + + console.log("🐴 Headin' out to GitHub with trail token:", state); + console.log("🏠 Bunkhouse storage:", localStorage.getItem("trail_token")); const params = new URLSearchParams({ client_id: GITHUB_CLIENT_ID, @@ -45,31 +44,32 @@ export function initiateGitHubLogin(): void { window.location.href = `https://github.com/login/oauth/authorize?${params.toString()}`; } -// Handle OAuth callback +// 🏤 Check in at the tradin' post with yer authorization papers export async function handleOAuthCallback( code: string, state: string ): Promise { - const savedState = localStorage.getItem("oauth_state"); + const savedState = localStorage.getItem("trail_token"); - console.log("OAuth State Check:", { - receivedState: state, - savedState: savedState, - match: state === savedState, + console.log("🏤 Trail Post Check:", { + receivedPapers: state, + expectedPapers: savedState, + papersMatch: state === savedState, }); if (state !== savedState) { - console.error("State mismatch - possible CSRF attack"); - console.error("Received state:", state); - console.error("Saved state:", savedState); + console.error( + "🚨 Whoa there! Trail papers don't match - possible cattle rustler!" + ); + console.error("Received papers:", state); + console.error("Expected papers:", savedState); return null; } - localStorage.removeItem("oauth_state"); + localStorage.removeItem("trail_token"); try { - // Note: In production, this should go through your backend - // For development, you'll need to set up a simple proxy server + // Trade the authorization code for a ranch pass at the tradin' post const response = await fetch("http://localhost:3001/auth/github/callback", { method: "POST", headers: { @@ -79,18 +79,18 @@ export async function handleOAuthCallback( }); if (!response.ok) { - throw new Error("Failed to exchange code for token"); + throw new Error("🌵 Failed to get yer ranch pass from the tradin' post"); } const data = await response.json(); return data.access_token; } catch (error) { - console.error("Error exchanging code for token:", error); + console.error("🌪️ Dust storm at the tradin' post:", error); return null; } } -// Fetch user info from GitHub +// 🤠 Fetch cowboy info from GitHub export async function fetchGitHubUser( accessToken: string ): Promise { @@ -103,23 +103,23 @@ export async function fetchGitHubUser( }); if (!response.ok) { - throw new Error("Failed to fetch user info"); + throw new Error("🤔 Couldn't find yer cowboy records"); } return await response.json(); } catch (error) { - console.error("Error fetching user info:", error); + console.error("🌵 Error fetchin' cowboy info:", error); return null; } } -// Storage helpers +// 🏠 Bunkhouse storage helpers export function saveAuthState(state: AuthState): void { - localStorage.setItem("auth_state", JSON.stringify(state)); + localStorage.setItem("ranch_hand", JSON.stringify(state)); } export function loadAuthState(): AuthState { - const stored = localStorage.getItem("auth_state"); + const stored = localStorage.getItem("ranch_hand"); if (stored) { try { return JSON.parse(stored); @@ -131,5 +131,5 @@ export function loadAuthState(): AuthState { } export function clearAuthState(): void { - localStorage.removeItem("auth_state"); + localStorage.removeItem("ranch_hand"); } diff --git a/src/pages/CallbackPage.tsx b/src/pages/CallbackPage.tsx index 25882e4..b784ab9 100644 --- a/src/pages/CallbackPage.tsx +++ b/src/pages/CallbackPage.tsx @@ -1,68 +1,74 @@ -import { useEffect, useState } from 'react'; -import { useNavigate, useSearchParams } from 'react-router-dom'; -import { handleOAuthCallback, fetchGitHubUser, saveAuthState, type AuthState } from '../lib/auth'; +import { useEffect, useState } from "react"; +import { useNavigate, useSearchParams } from "react-router-dom"; +import { + handleOAuthCallback, + fetchGitHubUser, + saveAuthState, + type AuthState, +} from "../lib/auth"; interface CallbackPageProps { - setAuthState: (state: AuthState) => void; + setBrandedCowboy: (state: AuthState) => void; } -export default function CallbackPage({ setAuthState }: CallbackPageProps) { +// 🏤 CallbackPage - The trail post where cowboys check in after OAuth +export const CallbackPage = ({ setBrandedCowboy }: CallbackPageProps) => { const [searchParams] = useSearchParams(); const navigate = useNavigate(); - const [error, setError] = useState(null); + const [trouble, setTrouble] = useState(null); useEffect(() => { - const code = searchParams.get('code'); - const state = searchParams.get('state'); + const code = searchParams.get("code"); + const state = searchParams.get("state"); if (!code || !state) { - setError('Missing authorization code or state'); + setTrouble("🌵 Whoa there partner! Missing yer trail papers"); return; } - async function processCallback() { + async function checkInAtPost() { try { const accessToken = await handleOAuthCallback(code!, state!); - + if (!accessToken) { - setError('Failed to obtain access token'); + setTrouble("🐎 Dagnabbit! Failed to get yer ranch pass"); return; } const user = await fetchGitHubUser(accessToken); - + if (!user) { - setError('Failed to fetch user information'); + setTrouble("🤔 Couldn't find yer cowboy credentials, partner"); return; } const newAuthState = { accessToken, user }; saveAuthState(newAuthState); - setAuthState(newAuthState); - - navigate('/', { replace: true }); + setBrandedCowboy(newAuthState); + + navigate("/", { replace: true }); } catch (err) { - console.error('OAuth callback error:', err); - setError('An error occurred during authentication'); + console.error("Trail post error:", err); + setTrouble("🌪️ A dust storm blew through during authentication"); } } - processCallback(); - }, [searchParams, navigate, setAuthState]); + checkInAtPost(); + }, [searchParams, navigate, setBrandedCowboy]); - if (error) { + if (trouble) { return (

- Authentication Error + 🚫 Trail Trouble

-

{error}

+

{trouble}

@@ -72,10 +78,11 @@ export default function CallbackPage({ setAuthState }: CallbackPageProps) { return (
-
Completing authentication...
+
🐴 Ridin' into the ranch...
); -} +}; +export default CallbackPage; diff --git a/src/pages/HomePage.tsx b/src/pages/HomePage.tsx index 488776f..75819e1 100644 --- a/src/pages/HomePage.tsx +++ b/src/pages/HomePage.tsx @@ -1,27 +1,29 @@ -import { initiateGitHubLogin, type GitHubUser } from '../lib/auth'; -import PullRequestList from '../components/PullRequestList'; +import { initiateGitHubLogin, type GitHubUser } from "../lib/auth"; +import { PullRequestList } from "@/components/PullRequestList"; interface HomePageProps { - user: GitHubUser | null; - onLogout: () => void; + cowboy: GitHubUser | null; + onHitTheTrail: () => void; } -export default function HomePage({ user, onLogout }: HomePageProps) { - if (!user) { +// 🤠 HomePage - The main saloon where cowboys gather +export const HomePage = ({ cowboy, onHitTheTrail }: HomePageProps) => { + if (!cowboy) { return (

- GitHub Pull Request Viewer + 🤠 Welcome to the Git Ranch

- Sign in with GitHub to view your open pull requests + Howdy partner! Saddle up with GitHub to wrangle yer open pull + requests

@@ -34,17 +36,15 @@ export default function HomePage({ user, onLogout }: HomePageProps) {

- Welcome, {user.name || user.login} + 🤠 Howdy, {cowboy.name || cowboy.login}!

-

- {user.email} -

+

{cowboy.email}

@@ -52,5 +52,6 @@ export default function HomePage({ user, onLogout }: HomePageProps) { ); -} +}; +export default HomePage; From 2705b434fa8c7a8ca962677de8c506cb74a644d6 Mon Sep 17 00:00:00 2001 From: Taz Singh Date: Thu, 11 Dec 2025 06:28:34 +0000 Subject: [PATCH 2/5] Cowboy emoji as favicon --- index.html | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/index.html b/index.html index 3f2f3b6..66b8a1b 100644 --- a/index.html +++ b/index.html @@ -2,9 +2,9 @@ - + - 🤠 Git Ranch - Wrangle Yer Pull Requests + Git Ranch - Wrangle Yer Pull Requests
From 8a02aac79bdd5c52fbed42c2d982f4e20b4a9a10 Mon Sep 17 00:00:00 2001 From: Taz Singh Date: Thu, 11 Dec 2025 12:45:05 +0000 Subject: [PATCH 3/5] Almost ready demo --- src/App.tsx | 2 + src/components/AddReactionButton.tsx | 126 ++++++++++++++++++++++++++ src/components/ErrorBoundary.tsx | 124 ++++++++++++------------- src/components/PullRequestList.tsx | 40 +++++++- src/components/ReactableReactions.tsx | 39 ++++++++ src/components/ReactionGroup.tsx | 91 +++++++++++++++++++ src/components/TumbleweedSpawner.tsx | 85 +++++++++++++++++ src/lib/reactions.ts | 37 ++++++++ 8 files changed, 477 insertions(+), 67 deletions(-) create mode 100644 src/components/AddReactionButton.tsx create mode 100644 src/components/ReactableReactions.tsx create mode 100644 src/components/ReactionGroup.tsx create mode 100644 src/components/TumbleweedSpawner.tsx create mode 100644 src/lib/reactions.ts diff --git a/src/App.tsx b/src/App.tsx index 8defdac..095c16a 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -7,6 +7,7 @@ import { } from "./lib/relay/environment"; import { loadAuthState, clearAuthState, type AuthState } from "./lib/auth"; import { ErrorBoundary } from "./components/ErrorBoundary"; +import { TumbleweedSpawner } from "./components/TumbleweedSpawner"; import { HomePage } from "./pages/HomePage"; import { CallbackPage } from "./pages/CallbackPage"; @@ -35,6 +36,7 @@ const GitRanch = () => { return ( + { + const data = useFragment( + graphql` + fragment AddReactionButton_reactable on Reactable { + id + reactionGroups { + content + viewerHasReacted + ...AddReactionButton_updatable + } + } + `, + reactable + ); + + const [showPicker, setShowPicker] = useState(false); + + const [commitAdd, isAddInFlight] = useMutation(graphql` + mutation AddReactionButtonAddReactionMutation($input: AddReactionInput!) { + addReaction(input: $input) { + reaction { + content + reactable { + ...ReactableReactions_reactable + } + } + } + } + `); + + const handleAddReaction = (content: ReactionContent) => { + const reactionGroup = data.reactionGroups?.find( + (group) => group?.content === content + ); + + if (reactionGroup?.viewerHasReacted) { + return; + } + + commitAdd({ + variables: { + input: { + subjectId: data.id, + content: content, + }, + }, + optimisticUpdater: (store) => { + const reactionGroup = data.reactionGroups?.find( + (group) => group?.content === content + ); + if (!reactionGroup || reactionGroup?.viewerHasReacted) { + setShowPicker(false); + return; + } + + const { updatableData } = + store.readUpdatableFragment( + graphql` + fragment AddReactionButton_updatable on ReactionGroup @updatable { + content + viewerHasReacted + reactors { + totalCount + } + } + `, + reactionGroup + ); + + updatableData.viewerHasReacted = true; + updatableData.reactors.totalCount++; + }, + onCompleted: () => setShowPicker(false), + }); + }; + + return ( +
+ + + {showPicker && ( +
+ {REACTION_TYPES.map((type) => ( + + ))} +
+ )} +
+ ); +}; + +export default AddReactionButton; diff --git a/src/components/ErrorBoundary.tsx b/src/components/ErrorBoundary.tsx index 28e71eb..1f1bc38 100644 --- a/src/components/ErrorBoundary.tsx +++ b/src/components/ErrorBoundary.tsx @@ -1,95 +1,89 @@ import { Component, ReactNode } from "react"; +interface ErrorFallbackProps { + error?: Error; + resetError: () => void; +} + interface Props { children: ReactNode; + fallback?: ReactNode | ((props: ErrorFallbackProps) => ReactNode); } interface State { - hasTumbleweed: boolean; + hasError: boolean; error?: Error; - showRollingTumbleweed: boolean; } -// 🌿 A tumbleweed that randomly rolls across the screen -const RollingTumbleweed = () => ( -
- Tumbleweed +// 🌵 Default fallback - the cowboy-themed error display +const DefaultErrorFallback = ({ error, resetError }: ErrorFallbackProps) => ( +
+
+

+ 🌵 Well, Shucks! +

+

+ {error?.message || + "A tumbleweed done blown through and spooked the horses"} +

+ +
); // 🌿 ErrorBoundary - Catches errors like a tumbleweed catches the wind export class ErrorBoundary extends Component { - private tumbleweedInterval: ReturnType | null = null; - constructor(props: Props) { super(props); - this.state = { hasTumbleweed: false, showRollingTumbleweed: false }; + this.state = { + hasError: false, + }; } - componentDidMount() { - // Random tumbleweed every 30-90 seconds - this.scheduleTumbleweed(); - } - - componentWillUnmount() { - if (this.tumbleweedInterval) { - clearTimeout(this.tumbleweedInterval); - } + static getDerivedStateFromError(error: Error): State { + return { hasError: true, error }; } - scheduleTumbleweed = () => { - const randomDelay = Math.random() * 60000 + 30000; // 30-90 seconds - this.tumbleweedInterval = setTimeout(() => { - this.setState({ showRollingTumbleweed: true }); - // Hide after animation completes (5 seconds) - setTimeout(() => { - this.setState({ showRollingTumbleweed: false }); - this.scheduleTumbleweed(); - }, 5000); - }, randomDelay); + resetError = () => { + this.setState({ hasError: false, error: undefined }); }; - static getDerivedStateFromError(error: Error): Partial { - return { hasTumbleweed: true, error }; - } - render() { - if (this.state.hasTumbleweed) { + if (this.state.hasError) { + const { fallback } = this.props; + + // If fallback is null, render nothing + if (fallback === null) { + return null; + } + + // If fallback is a function, call it with error props + if (typeof fallback === "function") { + return fallback({ + error: this.state.error, + resetError: this.resetError, + }); + } + + // If fallback is a ReactNode, render it + if (fallback !== undefined) { + return fallback; + } + + // Default fallback return ( -
-
-

- 🌵 Well, Shucks! -

-

- {this.state.error?.message || - "A tumbleweed done blown through and spooked the horses"} -

- -
-
+ ); } - return ( - <> - {this.state.showRollingTumbleweed && } - {this.props.children} - - ); + return this.props.children; } } diff --git a/src/components/PullRequestList.tsx b/src/components/PullRequestList.tsx index 35d596d..24263b0 100644 --- a/src/components/PullRequestList.tsx +++ b/src/components/PullRequestList.tsx @@ -1,5 +1,6 @@ import { graphql, useLazyLoadQuery } from "react-relay"; import type { PullRequestListQuery as PullRequestListQueryType } from "./__generated__/PullRequestListQuery.graphql"; +import ReactableReactions from "./ReactableReactions"; type Roundup = NonNullable< NonNullable< @@ -38,6 +39,13 @@ export const PullRequestList = ({ headCount = 20 }: { headCount?: number }) => { additions deletions reviewDecision + assignees(first: 5) { + nodes { + login + avatarUrl(size: 32) + } + } + ...ReactableReactions_reactable } } } @@ -132,7 +140,7 @@ export const PullRequestList = ({ headCount = 20 }: { headCount?: number }) => {
-
+
{cattle.headRefName} @@ -140,7 +148,7 @@ export const PullRequestList = ({ headCount = 20 }: { headCount?: number }) => {
-
+
+{cattle.additions} @@ -156,6 +164,34 @@ export const PullRequestList = ({ headCount = 20 }: { headCount?: number }) => { 🔄 Last wrangled{" "} {new Date(cattle.updatedAt).toLocaleDateString()}
+ {cattle.assignees.nodes && + cattle.assignees.nodes.length > 0 && ( +
+ + 🤠 Wranglers + +
+ {cattle.assignees.nodes.map( + (assignee) => + assignee && ( +
+ {assignee.login} + + {assignee.login} + +
+ ) + )} +
+
+ )}
))} diff --git a/src/components/ReactableReactions.tsx b/src/components/ReactableReactions.tsx new file mode 100644 index 0000000..56157f1 --- /dev/null +++ b/src/components/ReactableReactions.tsx @@ -0,0 +1,39 @@ +import { graphql, useFragment } from "react-relay"; +import AddReactionButton from "./AddReactionButton"; +import ReactionGroup from "./ReactionGroup"; +import { ReactableReactions_reactable$key } from "./__generated__/ReactableReactions_reactable.graphql"; + +type Props = { + reactable: ReactableReactions_reactable$key; +}; + +const ReactableReactions = ({ reactable }: Props) => { + const data = useFragment( + graphql` + fragment ReactableReactions_reactable on Reactable { + ...AddReactionButton_reactable + reactionGroups { + content + ...ReactionGroup_group + } + } + `, + reactable + ); + + if (!data.reactionGroups) { + return null; + } + + return ( +
+ {data.reactionGroups.map((group) => ( + + ))} + + +
+ ); +}; + +export default ReactableReactions; diff --git a/src/components/ReactionGroup.tsx b/src/components/ReactionGroup.tsx new file mode 100644 index 0000000..5defa9b --- /dev/null +++ b/src/components/ReactionGroup.tsx @@ -0,0 +1,91 @@ +import { graphql, useFragment, useMutation } from "react-relay"; +import { + ReactionGroup_group$key, + ReactionContent, +} from "./__generated__/ReactionGroup_group.graphql"; +import { getReactionEmoji } from "@/lib/reactions"; + +type Props = { + group: ReactionGroup_group$key; +}; + +const ReactionGroup = ({ group }: Props) => { + const data = useFragment( + graphql` + fragment ReactionGroup_group on ReactionGroup { + # ...ReactionGroup_updatable + content + viewerHasReacted + reactors { + totalCount + } + subject { + id + } + } + `, + group + ); + + const [commitRemove, isRemoveInFlight] = useMutation(graphql` + mutation ReactionGroupRemoveReactionMutation($input: RemoveReactionInput!) { + removeReaction(input: $input) { + reaction { + content + reactable { + ...ReactableReactions_reactable + } + } + } + } + `); + + const handleRemoveReaction = (content: ReactionContent) => { + commitRemove({ + variables: { + input: { + subjectId: data.subject.id, + content: content, + }, + }, + optimisticUpdater: (store) => { + const subject = store.get(data.subject.id); + const groups = subject?.getLinkedRecords("reactionGroups"); + const group = groups?.find( + (g) => g.getValue("content") === data.content + ); + const reactors = group?.getLinkedRecord("reactors"); + const totalCount = Number(reactors?.getValue("totalCount")) ?? 0; + reactors?.setValue(totalCount - 1, "totalCount"); + group?.setValue(false, "viewerHasReacted"); + }, + }); + }; + + if (data.reactors.totalCount === 0) { + return null; + } + + return ( + + ); +}; + +export default ReactionGroup; diff --git a/src/components/TumbleweedSpawner.tsx b/src/components/TumbleweedSpawner.tsx new file mode 100644 index 0000000..74edd0f --- /dev/null +++ b/src/components/TumbleweedSpawner.tsx @@ -0,0 +1,85 @@ +import { useEffect, useState, useRef, useCallback } from "react"; + +interface TumbleweedInstance { + id: number; + top: number; +} + +// 🌿 A tumbleweed that randomly rolls across the screen +const RollingTumbleweed = ({ top }: { top: number }) => ( +
+ Tumbleweed +
+); + +// 🌿 TumbleweedSpawner - Spawns tumbleweeds that roll across the range +export const TumbleweedSpawner = () => { + const [activeTumbleweeds, setActiveTumbleweeds] = useState< + TumbleweedInstance[] + >([]); + const [tumbleweedCount, setTumbleweedCount] = useState(1); + const tumbleweedIdCounter = useRef(0); + const timeoutRef = useRef | null>(null); + + const scheduleTumbleweed = useCallback(() => { + const randomDelay = Math.random() * 60000 + 30000; // 30-90 seconds + timeoutRef.current = setTimeout(() => { + // Spawn multiple tumbleweeds based on current count + const newTumbleweeds: TumbleweedInstance[] = []; + for (let i = 0; i < tumbleweedCount; i++) { + tumbleweedIdCounter.current++; + newTumbleweeds.push({ + id: tumbleweedIdCounter.current, + top: Math.random() * 60 + 20, // 20-80% from top + }); + } + + setActiveTumbleweeds((prev) => [...prev, ...newTumbleweeds]); + + // Remove tumbleweeds after animation completes (5 seconds) + setTimeout(() => { + setActiveTumbleweeds((prev) => + prev.filter((t) => !newTumbleweeds.find((nt) => nt.id === t.id)) + ); + // Increase count for next time (max 10 tumbleweeds) + setTumbleweedCount((prev) => Math.min(prev + 1, 10)); + }, 5000); + }, randomDelay); + }, [tumbleweedCount]); + + useEffect(() => { + scheduleTumbleweed(); + + return () => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + }; + }, [scheduleTumbleweed]); + + // Re-schedule when count changes (after tumbleweeds are removed) + useEffect(() => { + if (activeTumbleweeds.length === 0 && tumbleweedCount > 1) { + scheduleTumbleweed(); + } + }, [activeTumbleweeds.length, tumbleweedCount, scheduleTumbleweed]); + + return ( + <> + {activeTumbleweeds.map((tumbleweed) => ( + + ))} + + ); +}; + +export default TumbleweedSpawner; diff --git a/src/lib/reactions.ts b/src/lib/reactions.ts new file mode 100644 index 0000000..dc5d2f5 --- /dev/null +++ b/src/lib/reactions.ts @@ -0,0 +1,37 @@ +import { ReactionContent } from "@/components/__generated__/ReactableReactions_reactable.graphql"; + +export const REACTION_TYPES: ReactionContent[] = [ + "THUMBS_UP", + "THUMBS_DOWN", + "LAUGH", + "HOORAY", + "CONFUSED", + "HEART", + "ROCKET", + "EYES", +]; + +export type { ReactionContent }; + +export const getReactionEmoji = (content: ReactionContent): string => { + switch (content) { + case "CONFUSED": + return "😕"; + case "EYES": + return "👀"; + case "HEART": + return "❤️"; + case "HOORAY": + return "🎉"; + case "LAUGH": + return "😄"; + case "ROCKET": + return "🚀"; + case "THUMBS_DOWN": + return "👎"; + case "THUMBS_UP": + return "👍"; + default: + throw new Error(`Unknown reaction content: ${content}`); + } +}; From d26400273a972dfbcd0d636c299ec88794c1720c Mon Sep 17 00:00:00 2001 From: Taz Singh Date: Thu, 11 Dec 2025 13:00:53 +0000 Subject: [PATCH 4/5] Add demo error for assignees --- src/components/PullRequestList.tsx | 60 +++++++++++++++--------------- src/lib/relay/environment.ts | 42 ++++++++++++++++++++- 2 files changed, 71 insertions(+), 31 deletions(-) diff --git a/src/components/PullRequestList.tsx b/src/components/PullRequestList.tsx index 24263b0..8567c3d 100644 --- a/src/components/PullRequestList.tsx +++ b/src/components/PullRequestList.tsx @@ -1,6 +1,6 @@ import { graphql, useLazyLoadQuery } from "react-relay"; import type { PullRequestListQuery as PullRequestListQueryType } from "./__generated__/PullRequestListQuery.graphql"; -import ReactableReactions from "./ReactableReactions"; +import { ErrorBoundary } from "./ErrorBoundary"; type Roundup = NonNullable< NonNullable< @@ -148,7 +148,7 @@ export const PullRequestList = ({ headCount = 20 }: { headCount?: number }) => {
-
+
+{cattle.additions} @@ -164,34 +164,36 @@ export const PullRequestList = ({ headCount = 20 }: { headCount?: number }) => { 🔄 Last wrangled{" "} {new Date(cattle.updatedAt).toLocaleDateString()}
- {cattle.assignees.nodes && - cattle.assignees.nodes.length > 0 && ( -
- - 🤠 Wranglers - -
- {cattle.assignees.nodes.map( - (assignee) => - assignee && ( -
- {assignee.login} - - {assignee.login} - -
- ) - )} + + {cattle.assignees.nodes && + cattle.assignees.nodes.length > 0 && ( +
+ + 🤠 Wranglers + +
+ {cattle.assignees.nodes.map( + (assignee) => + assignee && ( +
+ {assignee.login} + + {assignee.login} + +
+ ) + )} +
-
- )} + )} +
))} diff --git a/src/lib/relay/environment.ts b/src/lib/relay/environment.ts index cffd3fd..6bc3ef9 100644 --- a/src/lib/relay/environment.ts +++ b/src/lib/relay/environment.ts @@ -10,6 +10,42 @@ const HTTP_ENDPOINT = "https://api.github.com/graphql"; let clientEnvironment: Environment | null = null; +// Demo flag: set to true to inject fake field errors for every 3rd PR +const DEMO_FIELD_ERRORS = true; + +function injectFieldErrors(response: any): any { + if (!DEMO_FIELD_ERRORS) return response; + + // Check if this is a pull request query response + const pullRequests = response?.data?.viewer?.pullRequests?.nodes; + if (!Array.isArray(pullRequests) || pullRequests.length === 0) + return response; + + const errors: any[] = response.errors || []; + + // Only affect the first PR in the list + const firstPr = pullRequests[0]; + if (firstPr) { + // Inject a field error for the first PR's title field + errors.push({ + message: `Demo error: Failed to fetch title for PR #${firstPr.number}`, + path: ["viewer", "pullRequests", "nodes", 0, "assignees"], + extensions: { + code: "DEMO_ERROR", + }, + }); + // Set the field to null to simulate a field-level error + firstPr.title = null; + } + + if (errors.length > 0) { + response.errors = errors; + console.log("Injected field errors:", response.errors); + } + + return response; +} + export function createRelayEnvironment(accessToken: string): Environment { const fetchFn: FetchFunction = async (request, variables) => { const resp = await fetch(HTTP_ENDPOINT, { @@ -25,7 +61,10 @@ export function createRelayEnvironment(accessToken: string): Environment { }), }); - return await resp.json(); + const json = await resp.json(); + + // Inject fake field errors for demo purposes + return injectFieldErrors(json); }; return new Environment({ @@ -44,4 +83,3 @@ export function getRelayEnvironment(accessToken: string): Environment { export function resetRelayEnvironment(): void { clientEnvironment = null; } - From 83c14269defd2ac615a0952060f1de4f81ec471e Mon Sep 17 00:00:00 2001 From: Taz Singh Date: Fri, 12 Dec 2025 09:18:43 +0000 Subject: [PATCH 5/5] Final code from demo --- src/components/BranchInfo.tsx | 29 +++ src/components/DiffStats.tsx | 38 ++++ src/components/PullRequestCard.tsx | 85 +++++++ src/components/PullRequestErrorBoundary.tsx | 54 +++++ src/components/PullRequestList.tsx | 234 ++++++-------------- src/components/ReviewStatusBadge.tsx | 34 +++ src/components/Wranglers.tsx | 60 +++++ src/lib/relay/environment.ts | 54 +++-- src/pages/HomePage.tsx | 19 +- 9 files changed, 424 insertions(+), 183 deletions(-) create mode 100644 src/components/BranchInfo.tsx create mode 100644 src/components/DiffStats.tsx create mode 100644 src/components/PullRequestCard.tsx create mode 100644 src/components/PullRequestErrorBoundary.tsx create mode 100644 src/components/ReviewStatusBadge.tsx create mode 100644 src/components/Wranglers.tsx diff --git a/src/components/BranchInfo.tsx b/src/components/BranchInfo.tsx new file mode 100644 index 0000000..9ff9288 --- /dev/null +++ b/src/components/BranchInfo.tsx @@ -0,0 +1,29 @@ +import { graphql, useFragment } from "react-relay"; +import type { BranchInfo_pullRequest$key } from "./__generated__/BranchInfo_pullRequest.graphql"; + +type BranchInfoProps = { + pullRequest: BranchInfo_pullRequest$key; +}; + +// 🌿 BranchInfo - Shows which trails this cattle's takin' +export const BranchInfo = ({ pullRequest }: BranchInfoProps) => { + const data = useFragment( + graphql` + fragment BranchInfo_pullRequest on PullRequest { + headRefName + baseRefName + } + `, + pullRequest + ); + + return ( +
+
+ {data.headRefName} + + {data.baseRefName} +
+
+ ); +}; diff --git a/src/components/DiffStats.tsx b/src/components/DiffStats.tsx new file mode 100644 index 0000000..53f320e --- /dev/null +++ b/src/components/DiffStats.tsx @@ -0,0 +1,38 @@ +import { graphql, useFragment } from "react-relay"; +import type { DiffStats_pullRequest$key } from "./__generated__/DiffStats_pullRequest.graphql"; + +type DiffStatsProps = { + pullRequest: DiffStats_pullRequest$key; +}; + +// 📊 DiffStats - Count the head of cattle bein' moved +export const DiffStats = ({ pullRequest }: DiffStatsProps) => { + const data = useFragment( + graphql` + fragment DiffStats_pullRequest on PullRequest { + additions + deletions + createdAt + updatedAt + } + `, + pullRequest + ); + + return ( +
+
+ + +{data.additions} + + + -{data.deletions} + +
+
🌅 Started {new Date(data.createdAt).toLocaleDateString()}
+
+ 🔄 Last wrangled {new Date(data.updatedAt).toLocaleDateString()} +
+
+ ); +}; diff --git a/src/components/PullRequestCard.tsx b/src/components/PullRequestCard.tsx new file mode 100644 index 0000000..8e82125 --- /dev/null +++ b/src/components/PullRequestCard.tsx @@ -0,0 +1,85 @@ +import { graphql, useFragment } from "react-relay"; +import type { PullRequestCard_pullRequest$key } from "./__generated__/PullRequestCard_pullRequest.graphql"; +import { ReviewStatusBadge } from "./ReviewStatusBadge"; +import { BranchInfo } from "./BranchInfo"; +import { DiffStats } from "./DiffStats"; +import { Wranglers } from "./Wranglers"; +import ReactableReactions from "./ReactableReactions"; +import { ErrorBoundary } from "./ErrorBoundary"; + +type PullRequestCardProps = { + pullRequest: PullRequestCard_pullRequest$key; +}; + +// 🐮 PullRequestCard - One head of cattle on the range +export const PullRequestCard = ({ pullRequest }: PullRequestCardProps) => { + const data = useFragment( + graphql` + fragment PullRequestCard_pullRequest on PullRequest @throwOnFieldError { + id + number + title + url + isDraft + reviewDecision + repository { + nameWithOwner + } + author { + ... on User { + id + name + } + } + ...BranchInfo_pullRequest + ...DiffStats_pullRequest + ...Wranglers_assignable + ...ReactableReactions_reactable + } + `, + pullRequest + ); + + return ( + +
+
+
+ + 🐮 #{data.number} + + + + {data.repository.nameWithOwner} + +
+

+ {data.title} +

+
+
+ {data.isDraft && ( + + 📝 Still Ropin' + + )} + +
+
+ + + +
+ + + + +
+
+ ); +}; diff --git a/src/components/PullRequestErrorBoundary.tsx b/src/components/PullRequestErrorBoundary.tsx new file mode 100644 index 0000000..5badc06 --- /dev/null +++ b/src/components/PullRequestErrorBoundary.tsx @@ -0,0 +1,54 @@ +import { Component, ReactNode } from "react"; + +interface Props { + children: ReactNode; + prNumber?: number; +} + +interface State { + hasError: boolean; + error?: Error; +} + +export class PullRequestErrorBoundary extends Component { + constructor(props: Props) { + super(props); + this.state = { hasError: false }; + } + + static getDerivedStateFromError(error: Error): State { + return { hasError: true, error }; + } + + componentDidCatch(error: Error, errorInfo: React.ErrorInfo) { + console.error("PullRequest render error:", error, errorInfo); + } + + render() { + if (this.state.hasError) { + return ( +
+
+
+

+ Failed to load pull request + {this.props.prNumber && ` #${this.props.prNumber}`} +

+

+ {this.state.error?.message || "An unexpected error occurred"} +

+
+ +
+
+ ); + } + + return this.props.children; + } +} diff --git a/src/components/PullRequestList.tsx b/src/components/PullRequestList.tsx index 8567c3d..2dba8e9 100644 --- a/src/components/PullRequestList.tsx +++ b/src/components/PullRequestList.tsx @@ -1,103 +1,71 @@ -import { graphql, useLazyLoadQuery } from "react-relay"; +import { graphql, useLazyLoadQuery, usePaginationFragment } from "react-relay"; import type { PullRequestListQuery as PullRequestListQueryType } from "./__generated__/PullRequestListQuery.graphql"; -import { ErrorBoundary } from "./ErrorBoundary"; - -type Roundup = NonNullable< - NonNullable< - PullRequestListQueryType["response"]["viewer"]["pullRequests"]["nodes"] - >[number] ->; +import type { PullRequestList_viewer$key } from "./__generated__/PullRequestList_viewer.graphql"; +import { PullRequestCard } from "./PullRequestCard"; +import { PullRequestErrorBoundary } from "./PullRequestErrorBoundary"; // 🐄 PullRequestList - Round up them cattle (PRs) from the range -export const PullRequestList = ({ headCount = 20 }: { headCount?: number }) => { - const data = useLazyLoadQuery( +export const PullRequestList = ({ headCount = 10 }: { headCount?: number }) => { + const queryData = useLazyLoadQuery( graphql` query PullRequestListQuery($first: Int!) { viewer { - login - pullRequests( - first: $first - states: [OPEN] - orderBy: { field: UPDATED_AT, direction: DESC } - ) { - totalCount - nodes { - id - number - title - url - state - isDraft - createdAt - updatedAt - repository { - name - nameWithOwner - } - baseRefName - headRefName - additions - deletions - reviewDecision - assignees(first: 5) { - nodes { - login - avatarUrl(size: 32) - } - } - ...ReactableReactions_reactable - } - } + ...PullRequestList_viewer @arguments(first: $first) } } `, { first: headCount } ); - const { viewer } = data; - const roundups = (viewer.pullRequests.nodes?.filter( - (pr): pr is Roundup => pr !== null && pr !== undefined - ) ?? []) as Roundup[]; - - const getBrandingBadge = (decision: string | null | undefined) => { - if (!decision) return null; + return ; +}; - const brands = { - APPROVED: { - text: "🏷️ Branded & Ready", - color: - "bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200", - }, - CHANGES_REQUESTED: { - text: "🔧 Needs Re-shoein'", - color: "bg-red-100 dark:bg-red-900 text-red-800 dark:text-red-200", - }, - REVIEW_REQUIRED: { - text: "👀 Needs Inspectin'", - color: - "bg-yellow-100 dark:bg-yellow-900 text-yellow-800 dark:text-yellow-200", - }, - }; +type PullRequestListContentProps = { + viewer: PullRequestList_viewer$key; +}; - const brand = brands[decision as keyof typeof brands]; - if (!brand) return null; +const PullRequestListContent = ({ viewer }: PullRequestListContentProps) => { + const { data, loadNext, hasNext, isLoadingNext } = usePaginationFragment( + graphql` + fragment PullRequestList_viewer on User + @refetchable(queryName: "PullRequestListPaginationQuery") + @argumentDefinitions( + first: { type: "Int", defaultValue: 10 } + after: { type: "String" } + ) { + login + pullRequests( + first: $first + after: $after + orderBy: { field: UPDATED_AT, direction: DESC } + ) @connection(key: "PullRequestList_pullRequests") { + totalCount + edges { + node { + id + ...PullRequestCard_pullRequest + } + } + } + } + `, + viewer + ); - return ( - - {brand.text} - - ); - }; + const roundups = + data.pullRequests.edges?.filter( + (edge): edge is NonNullable => + edge !== null && edge?.node !== null + ) ?? []; return (

- 🐄 {viewer.login}'s Cattle Roundup + 🐄 {data.login}'s Cattle Roundup

- {viewer.pullRequests.totalCount} head of cattle need wranglin' - {viewer.pullRequests.totalCount !== 1 ? "" : ""} + {data.pullRequests.totalCount} head of cattle need wranglin'

@@ -107,96 +75,28 @@ export const PullRequestList = ({ headCount = 20 }: { headCount?: number }) => {
) : ( + )} -
-
- - +{cattle.additions} - - - -{cattle.deletions} - -
-
- 🌅 Started {new Date(cattle.createdAt).toLocaleDateString()} -
-
- 🔄 Last wrangled{" "} - {new Date(cattle.updatedAt).toLocaleDateString()} -
- - {cattle.assignees.nodes && - cattle.assignees.nodes.length > 0 && ( -
- - 🤠 Wranglers - -
- {cattle.assignees.nodes.map( - (assignee) => - assignee && ( -
- {assignee.login} - - {assignee.login} - -
- ) - )} -
-
- )} -
-
-
- ))} + {hasNext && ( +
+
)}
diff --git a/src/components/ReviewStatusBadge.tsx b/src/components/ReviewStatusBadge.tsx new file mode 100644 index 0000000..abd88fd --- /dev/null +++ b/src/components/ReviewStatusBadge.tsx @@ -0,0 +1,34 @@ +// 🏷️ ReviewStatusBadge - Shows the branding status of this here cattle + +type ReviewStatusBadgeProps = { + decision: string | null | undefined; +}; + +const brands = { + APPROVED: { + text: "🏷️ Branded & Ready", + color: "bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200", + }, + CHANGES_REQUESTED: { + text: "🔧 Needs Re-shoein'", + color: "bg-red-100 dark:bg-red-900 text-red-800 dark:text-red-200", + }, + REVIEW_REQUIRED: { + text: "👀 Needs Inspectin'", + color: + "bg-yellow-100 dark:bg-yellow-900 text-yellow-800 dark:text-yellow-200", + }, +} as const; + +export const ReviewStatusBadge = ({ decision }: ReviewStatusBadgeProps) => { + if (!decision) return null; + + const brand = brands[decision as keyof typeof brands]; + if (!brand) return null; + + return ( + + {brand.text} + + ); +}; diff --git a/src/components/Wranglers.tsx b/src/components/Wranglers.tsx new file mode 100644 index 0000000..3f33f28 --- /dev/null +++ b/src/components/Wranglers.tsx @@ -0,0 +1,60 @@ +import { graphql, useFragment } from "react-relay"; +import type { Wranglers_assignable$key } from "./__generated__/Wranglers_assignable.graphql"; + +type WranglersProps = { + cattle: Wranglers_assignable$key; +}; + +// 🤠 Wranglers - Show who's wranglin' this here cattle +export const Wranglers = ({ cattle }: WranglersProps) => { + const data = useFragment( + graphql` + fragment Wranglers_assignable on Assignable @throwOnFieldError { + assignees(first: 5) @connection(key: "Wranglers_assignees") { + edges { + node { + name + avatarUrl(size: 32) + } + } + } + } + `, + cattle + ); + + const assignees = + data.assignees.edges?.filter( + (edge): edge is NonNullable => + edge !== null && edge.node !== null + ) ?? []; + + if (assignees.length === 0) { + return null; + } + + return ( +
+ 🤠 Wranglers +
+ {assignees.map( + (edge) => + edge.node && ( +
+ {edge.node.name + + {edge.node.name} + +
+ ) + )} +
+
+ ); +}; + +export default Wranglers; diff --git a/src/lib/relay/environment.ts b/src/lib/relay/environment.ts index 6bc3ef9..758f79d 100644 --- a/src/lib/relay/environment.ts +++ b/src/lib/relay/environment.ts @@ -16,26 +16,52 @@ const DEMO_FIELD_ERRORS = true; function injectFieldErrors(response: any): any { if (!DEMO_FIELD_ERRORS) return response; - // Check if this is a pull request query response - const pullRequests = response?.data?.viewer?.pullRequests?.nodes; + // Check if this is a pull request query response (handles both nodes and edges patterns) + const pullRequestsContainer = response?.data?.viewer?.pullRequests; + const pullRequests = + pullRequestsContainer?.edges ?? pullRequestsContainer?.nodes; if (!Array.isArray(pullRequests) || pullRequests.length === 0) return response; const errors: any[] = response.errors || []; + const usesEdges = !!pullRequestsContainer?.edges; // Only affect the first PR in the list - const firstPr = pullRequests[0]; - if (firstPr) { - // Inject a field error for the first PR's title field - errors.push({ - message: `Demo error: Failed to fetch title for PR #${firstPr.number}`, - path: ["viewer", "pullRequests", "nodes", 0, "assignees"], - extensions: { - code: "DEMO_ERROR", - }, - }); - // Set the field to null to simulate a field-level error - firstPr.title = null; + const firstPr = usesEdges ? pullRequests[0]?.node : pullRequests[0]; + if (firstPr && firstPr.assignees) { + // The Wranglers fragment uses @connection with edges pattern + const assigneesEdges = firstPr.assignees.edges; + if (Array.isArray(assigneesEdges) && assigneesEdges.length > 0) { + const firstAssignee = assigneesEdges[0]?.node; + if (firstAssignee) { + // Build the correct path for the error + const basePath = usesEdges + ? ["viewer", "pullRequests", "edges", 0, "node"] + : ["viewer", "pullRequests", "nodes", 0]; + + // Target the name field of the first assignee to trigger @throwOnFieldError + const errorPath = [ + ...basePath, + "assignees", + "edges", + 0, + "node", + "name", + ]; + + errors.push({ + message: `Demo error: Failed to fetch assignee name for PR #${firstPr.number}`, + path: errorPath, + extensions: { + code: "DEMO_ERROR", + }, + }); + + // Set the name field to null to simulate a field-level error + // This combined with the error path will trigger @throwOnFieldError + firstAssignee.name = null; + } + } } if (errors.length > 0) { diff --git a/src/pages/HomePage.tsx b/src/pages/HomePage.tsx index 75819e1..d89db5f 100644 --- a/src/pages/HomePage.tsx +++ b/src/pages/HomePage.tsx @@ -1,5 +1,8 @@ import { initiateGitHubLogin, type GitHubUser } from "../lib/auth"; import { PullRequestList } from "@/components/PullRequestList"; +import { useLazyLoadQuery, graphql } from "react-relay"; + +import { HomePageQuery } from "./__generated__/HomePageQuery.graphql"; interface HomePageProps { cowboy: GitHubUser | null; @@ -8,7 +11,19 @@ interface HomePageProps { // 🤠 HomePage - The main saloon where cowboys gather export const HomePage = ({ cowboy, onHitTheTrail }: HomePageProps) => { - if (!cowboy) { + const data = useLazyLoadQuery( + graphql` + query HomePageQuery { + viewer { + id + name + } + } + `, + {} + ); + + if (!data.viewer) { return (
@@ -36,7 +51,7 @@ export const HomePage = ({ cowboy, onHitTheTrail }: HomePageProps) => {

- 🤠 Howdy, {cowboy.name || cowboy.login}! + 🤠 Howdy, {data.viewer.name || data.viewer.login}!

{cowboy.email}