diff --git a/scripts/admin/deploy_onboarding_status_web.sh b/scripts/admin/deploy_onboarding_status_web.sh index 7650922..491a642 100755 --- a/scripts/admin/deploy_onboarding_status_web.sh +++ b/scripts/admin/deploy_onboarding_status_web.sh @@ -12,9 +12,14 @@ # --project PROJECT_ID GCP project ID (default: coderd) # --region REGION Cloud Run region (default: us-central1) # --service-name NAME Service name (default: onboarding-status-web) +# --github-token TOKEN GitHub personal access token with read:org scope (required) # --allow-unauthenticated Allow unauthenticated requests (default: true for dashboards) # --dry-run Show commands without executing # +# Environment Variables: +# GITHUB_TOKEN GitHub token (alternative to --github-token flag) +# GCP_PROJECT GCP project ID (alternative to --project flag) +# set -euo pipefail @@ -25,6 +30,7 @@ SERVICE_NAME="onboarding-status-web" ALLOW_UNAUTHENTICATED="true" # Public dashboard by default DRY_RUN="false" FIRESTORE_DATABASE="onboarding" +GITHUB_TOKEN="${GITHUB_TOKEN:-}" # Colors for output RED='\033[0;31m' @@ -48,6 +54,10 @@ while [[ $# -gt 0 ]]; do SERVICE_NAME="$2" shift 2 ;; + --github-token) + GITHUB_TOKEN="$2" + shift 2 + ;; --allow-unauthenticated) ALLOW_UNAUTHENTICATED="true" shift @@ -76,6 +86,28 @@ echo -e "${BLUE}━━━━━━━━━━━━━━━━━━━━━ echo -e "${BLUE}Onboarding Status Web Dashboard Deployment${NC}" echo -e "${BLUE}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" echo "" + +# Validate GitHub token +if [[ -z "${GITHUB_TOKEN}" ]]; then + echo -e "${RED}✗ GitHub token not provided${NC}" + echo "" + echo "A GitHub token is required to check participant GitHub invite status." + echo "Please provide a token using one of these methods:" + echo "" + echo " 1. Set the GITHUB_TOKEN environment variable:" + echo -e " ${BLUE}export GITHUB_TOKEN=ghp_your_token_here${NC}" + echo "" + echo " 2. Pass it as a command line argument:" + echo -e " ${BLUE}./deploy_onboarding_status_web.sh --github-token ghp_your_token_here${NC}" + echo "" + echo "The token needs the following permissions:" + echo " • read:org (to read organization membership and invitations)" + echo "" + echo "Create a token at: https://github.com/settings/tokens" + echo "" + exit 1 +fi + echo -e "${YELLOW}Configuration:${NC}" echo " Project ID: ${PROJECT_ID}" echo " Region: ${REGION}" @@ -83,6 +115,7 @@ echo " Service Name: ${SERVICE_NAME}" echo " Firestore Database: ${FIRESTORE_DATABASE}" echo " Service Directory: ${SERVICE_DIR}" echo " Allow Unauth: ${ALLOW_UNAUTHENTICATED}" +echo " GitHub Token: ${GREEN}✓ Set${NC} (${#GITHUB_TOKEN} chars)" echo " Dry Run: ${DRY_RUN}" echo "" @@ -145,7 +178,7 @@ DEPLOY_CMD=( --platform=managed --region="${REGION}" --project="${PROJECT_ID}" - --set-env-vars="GCP_PROJECT_ID=${PROJECT_ID},FIRESTORE_DATABASE_ID=${FIRESTORE_DATABASE}" + --set-env-vars="GCP_PROJECT_ID=${PROJECT_ID},FIRESTORE_DATABASE_ID=${FIRESTORE_DATABASE},GITHUB_TOKEN=${GITHUB_TOKEN}" --memory=1Gi --cpu=1 --timeout=300s diff --git a/services/onboarding-status-web/Dockerfile b/services/onboarding-status-web/Dockerfile index 8429270..996baec 100644 --- a/services/onboarding-status-web/Dockerfile +++ b/services/onboarding-status-web/Dockerfile @@ -29,6 +29,9 @@ WORKDIR /app ENV NODE_ENV=production ENV NEXT_TELEMETRY_DISABLED=1 +# Install GitHub CLI +RUN apk add --no-cache github-cli + # Create a non-root user for security RUN addgroup --system --gid 1001 nodejs RUN adduser --system --uid 1001 nextjs diff --git a/services/onboarding-status-web/app/api/github-status/route.ts b/services/onboarding-status-web/app/api/github-status/route.ts new file mode 100644 index 0000000..4aedee1 --- /dev/null +++ b/services/onboarding-status-web/app/api/github-status/route.ts @@ -0,0 +1,179 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { exec } from 'child_process'; +import { promisify } from 'util'; + +const execAsync = promisify(exec); + +const ORG_NAME = 'AI-Engineering-Platform'; + +// Cache for GitHub status to avoid rate limits +// Cache expires after 5 minutes +const statusCache = new Map(); +const CACHE_TTL = 5 * 60 * 1000; // 5 minutes + +export type GitHubStatus = 'member' | 'pending' | 'not_invited'; + +interface GitHubStatusResponse { + github_handle: string; + status: GitHubStatus; +} + +/** + * Check if gh CLI is available and authenticated + */ +async function checkGhCli(): Promise<{ available: boolean; error?: string }> { + try { + // Check if gh CLI is installed + await execAsync('gh --version'); + + // Check if GitHub token is available + const token = process.env.GITHUB_TOKEN || process.env.GH_TOKEN; + if (!token) { + return { + available: false, + error: 'GitHub token not configured (GITHUB_TOKEN or GH_TOKEN environment variable not set)' + }; + } + + // Test authentication by making a simple API call + await execAsync('gh api user', { + env: { ...process.env, GH_TOKEN: token } + }); + + return { available: true }; + } catch (error) { + return { + available: false, + error: `GitHub CLI not available or authentication failed: ${error instanceof Error ? error.message : 'Unknown error'}` + }; + } +} + +/** + * Get pending invitations from GitHub org + * Returns usernames in lowercase for case-insensitive comparison + */ +async function getPendingInvitations(): Promise> { + try { + const token = process.env.GITHUB_TOKEN || process.env.GH_TOKEN; + const { stdout } = await execAsync( + `gh api "orgs/${ORG_NAME}/invitations" --jq '.[].login // empty'`, + { + env: { ...process.env, GH_TOKEN: token } + } + ); + const logins = stdout + .trim() + .split('\n') + .filter(login => login.length > 0) + .map(login => login.toLowerCase()); + return new Set(logins); + } catch (error) { + console.error('Error fetching pending invitations:', error); + return new Set(); + } +} + +/** + * Check if a user is a member of the organization + */ +async function isOrgMember(username: string): Promise { + try { + const token = process.env.GITHUB_TOKEN || process.env.GH_TOKEN; + await execAsync(`gh api "orgs/${ORG_NAME}/members/${username}" 2>/dev/null`, { + env: { ...process.env, GH_TOKEN: token } + }); + return true; + } catch { + return false; + } +} + +/** + * Determine GitHub status for a single user + */ +async function getGitHubStatus( + username: string, + pendingInvitations: Set +): Promise { + // Normalize username for cache key (case-insensitive) + const normalizedUsername = username.toLowerCase(); + + // Check cache first + const cached = statusCache.get(normalizedUsername); + if (cached && Date.now() - cached.timestamp < CACHE_TTL) { + return cached.status as GitHubStatus; + } + + let status: GitHubStatus; + + // Check if user is a member + const isMember = await isOrgMember(username); + if (isMember) { + status = 'member'; + } else if (pendingInvitations.has(normalizedUsername)) { + status = 'pending'; + } else { + status = 'not_invited'; + } + + // Update cache with normalized username + statusCache.set(normalizedUsername, { status, timestamp: Date.now() }); + + return status; +} + +export async function POST(request: NextRequest) { + try { + const body = await request.json(); + const { github_handles } = body; + + if (!Array.isArray(github_handles)) { + return NextResponse.json( + { error: 'github_handles must be an array' }, + { status: 400 } + ); + } + + // Check if gh CLI is available + const ghCheck = await checkGhCli(); + if (!ghCheck.available) { + console.warn('GitHub CLI not available:', ghCheck.error); + // Return all as not_invited if gh CLI is not available + const statuses: GitHubStatusResponse[] = github_handles.map(handle => ({ + github_handle: handle, + status: 'not_invited' as GitHubStatus + })); + return NextResponse.json({ statuses, warning: ghCheck.error }); + } + + // Fetch pending invitations once for efficiency + const pendingInvitations = await getPendingInvitations(); + + // Check status for each user + const statusPromises = github_handles.map(async (handle) => { + const status = await getGitHubStatus(handle, pendingInvitations); + return { + github_handle: handle, + status + }; + }); + + const statuses = await Promise.all(statusPromises); + + return NextResponse.json({ statuses }); + } catch (error) { + console.error('Error checking GitHub status:', error); + return NextResponse.json( + { + error: 'Failed to check GitHub status', + details: error instanceof Error ? error.message : 'Unknown error' + }, + { status: 500 } + ); + } +} + +// Force dynamic behavior +export const dynamic = 'force-dynamic'; +export const revalidate = 0; diff --git a/services/onboarding-status-web/app/page.tsx b/services/onboarding-status-web/app/page.tsx index 3650966..44c9707 100644 --- a/services/onboarding-status-web/app/page.tsx +++ b/services/onboarding-status-web/app/page.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useEffect, useState } from 'react'; +import { useCallback, useEffect, useState } from 'react'; interface Participant { github_handle: string; @@ -9,6 +9,7 @@ interface Participant { onboarded_at?: string; first_name?: string; last_name?: string; + github_status?: 'member' | 'pending' | 'not_invited'; } interface Summary { @@ -31,7 +32,7 @@ export default function Home() { const [statusFilter, setStatusFilter] = useState<'all' | 'onboarded' | 'not_onboarded'>('all'); const [roleFilter, setRoleFilter] = useState<'participants' | 'facilitators'>('participants'); - const fetchData = async () => { + const fetchData = useCallback(async () => { try { setError(null); const response = await fetch(`/onboarding/api/participants?role=${roleFilter}`, { @@ -43,6 +44,37 @@ export default function Home() { } const result = await response.json(); + + // Fetch GitHub status for all participants + try { + const github_handles = result.participants.map((p: Participant) => p.github_handle); + const statusResponse = await fetch('/onboarding/api/github-status', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ github_handles }), + cache: 'no-store' + }); + + if (statusResponse.ok) { + const statusData = await statusResponse.json(); + const statusMap = new Map( + statusData.statuses.map((s: { github_handle: string; status: string }) => [ + s.github_handle, + s.status + ]) + ); + + // Merge GitHub status with participant data + result.participants = result.participants.map((p: Participant) => ({ + ...p, + github_status: statusMap.get(p.github_handle) || 'not_invited' + })); + } + } catch (statusErr) { + console.warn('Failed to fetch GitHub status:', statusErr); + // Continue without GitHub status if it fails + } + setData(result); setLastUpdated(new Date()); } catch (err) { @@ -51,7 +83,7 @@ export default function Home() { } finally { setLoading(false); } - }; + }, [roleFilter]); useEffect(() => { fetchData(); @@ -60,7 +92,7 @@ export default function Home() { const interval = setInterval(fetchData, 30000); return () => clearInterval(interval); - }, [roleFilter]); + }, [fetchData]); if (loading) { return ( @@ -104,20 +136,72 @@ export default function Home() { return true; // 'all' }); + // Helper function to render GitHub status badge + const renderGitHubStatus = (status?: 'member' | 'pending' | 'not_invited') => { + if (!status) { + return ( + + + + + Unknown + + ); + } + + switch (status) { + case 'member': + return ( + + + + + Member + + ); + case 'pending': + return ( + + + + + Pending + + ); + case 'not_invited': + return ( + + + + + Not Invited + + ); + } + }; + // CSV export function const exportToCSV = () => { - const headers = ['#', 'Name', 'GitHub Handle', 'Team Name', 'Status', 'Onboarded At']; + const headers = ['#', 'Name', 'GitHub Handle', 'GitHub Status', 'Team Name', 'Status', 'Onboarded At']; const rows = filteredParticipants.map((participant, index) => { const name = participant.first_name && participant.last_name ? `${participant.first_name} ${participant.last_name}` : participant.first_name || participant.last_name || 'N/A'; const status = participant.onboarded ? 'Onboarded' : 'Not Onboarded'; const onboardedAt = participant.onboarded_at || 'N/A'; + const githubStatus = participant.github_status + ? participant.github_status === 'member' + ? 'Member' + : participant.github_status === 'pending' + ? 'Pending' + : 'Not Invited' + : 'Unknown'; return [ index + 1, name, participant.github_handle, + githubStatus, participant.team_name, status, onboardedAt @@ -307,6 +391,9 @@ export default function Home() { GitHub Handle + + GitHub Invite + Team Name @@ -338,6 +425,9 @@ export default function Home() { {participant.github_handle} + + {renderGitHubStatus(participant.github_status)} + {participant.team_name}