Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 34 additions & 1 deletion scripts/admin/deploy_onboarding_status_web.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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'
Expand All @@ -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
Expand Down Expand Up @@ -76,13 +86,36 @@ 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}"
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 ""

Expand Down Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions services/onboarding-status-web/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
179 changes: 179 additions & 0 deletions services/onboarding-status-web/app/api/github-status/route.ts
Original file line number Diff line number Diff line change
@@ -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<string, { status: string; timestamp: number }>();
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<Set<string>> {
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<boolean> {
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<string>
): Promise<GitHubStatus> {
// 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;
Loading