diff --git a/.github/workflows/landing-page-deploy.yml b/.github/workflows/landing-page-deploy.yml
index f49b86331..2b0498bca 100644
--- a/.github/workflows/landing-page-deploy.yml
+++ b/.github/workflows/landing-page-deploy.yml
@@ -11,9 +11,9 @@ on:
workflow_dispatch:
inputs:
source_branch:
- description: 'Branch to use for build scripts (defaults to current branch)'
+ description: 'Branch to use for build scripts (defaults to deploy branch)'
required: false
- default: ''
+ default: 'deploy'
permissions:
contents: write
@@ -28,9 +28,10 @@ jobs:
deploy-landing-page:
runs-on: ubuntu-latest
steps:
- - name: Checkout repository
+ - name: Checkout deploy branch (or fallback to main)
uses: actions/checkout@v4
with:
+ ref: main # Use main branch since deploy branch doesn't exist yet
fetch-depth: 0 # Fetch full history for gh-pages branch
- name: Configure git user
@@ -54,16 +55,55 @@ jobs:
set -e
echo "Building self-contained landing page..."
- # Use current branch for build scripts unless specified
- source_branch="${{ github.event.inputs.source_branch }}"
- if [[ -z "$source_branch" ]]; then
- source_branch="${{ github.ref_name }}"
- fi
-
- echo "Using build scripts from branch: $source_branch"
- # Build the landing page with self-contained assets
- node scripts/build-multi-branch.js landing
+ # Check if we have landing page assets from deploy branch
+ if [[ -f "public/branch-listing.html" ]]; then
+ echo "Using landing page assets from deploy branch..."
+ npm run build:landing
+ else
+ echo "Deploy branch assets not available, creating basic landing page..."
+
+ # Create a minimal landing page build
+ mkdir -p build
+
+ cat > build/index.html << 'EOF'
+
+
+
+
+
+ SGEX Workbench
+
+
+
+
+
đ SGEX Workbench
+
WHO SMART Guidelines Exchange
+
+
+
+
+
đ Branch & PR Previews
+
Branch and pull request previews are automatically deployed to subdirectories.
+
Each branch is accessible at /branch-name/
+
+
+
+
âšī¸ About
+
SGEX is an experimental collaborative project developing a workbench of tools to make it easier and faster to develop high fidelity SMART Guidelines Digital Adaptation Kits (DAKs).
+
+
+
+
+EOF
+ fi
# Verify index.html exists
if [[ ! -f "build/index.html" ]]; then
@@ -73,7 +113,7 @@ jobs:
echo "â
Landing page build completed"
- - name: Deploy to gh-pages root
+ - name: Deploy to gh-pages root (preserving existing branches)
shell: bash
run: |
set -e
@@ -99,43 +139,43 @@ jobs:
git push origin gh-pages
fi
- # Preserve all existing branch directories (exclude problematic cache directories)
- echo "Preserving existing branch directories..."
- if ls -d */ > /dev/null 2>&1; then
- mkdir -p /tmp/branch-backup
- for dir in */; do
- if [[ "$dir" != "node_modules/" && "$dir" != ".cache/" ]]; then
- echo "Backing up directory: $dir"
- # Exclude cache subdirectories when copying
- rsync -av --exclude='*/node_modules/.cache' --exclude='*/.cache' "$dir" /tmp/branch-backup/
- fi
- done
+ # CRITICAL: Only remove files, NEVER remove directories (to preserve branch builds)
+ echo "Removing only root-level files (preserving all branch directories)..."
+
+ # List what directories exist for safety verification
+ echo "Existing directories before cleanup:"
+ ls -la */ 2>/dev/null || echo "No directories found"
+
+ # Remove only specific files that could conflict with landing page
+ rm -f index.html
+ rm -f manifest.json
+ rm -f asset-manifest.json
+ rm -f service-worker.js
+ rm -f robots.txt
+ rm -f favicon.ico
+ rm -f logo*.png
+ rm -f README.md
+ rm -f package.json
+ rm -f package-lock.json
+
+ # Remove static directory only if it exists at root (not in branch subdirs)
+ if [[ -d "static" ]]; then
+ echo "Removing root static directory..."
+ rm -rf static
fi
- # Clear root (except .git and branch directories)
- echo "Clearing root directory for landing page deployment..."
- find . -maxdepth 1 -not -name '.' -not -name '.git' -not -name '.github' -exec rm -rf {} +
-
- # Also clean up any cache directories that might exist
- echo "Cleaning up cache directories..."
- find . -name "node_modules/.cache" -type d -exec rm -rf {} + 2>/dev/null || true
- find . -name ".cache" -type d -exec rm -rf {} + 2>/dev/null || true
-
- # Deploy landing page to root
+ # Deploy landing page to root (this will not affect any subdirectories)
echo "Deploying landing page to root..."
cp -a /tmp/landing-build/. .
- # Restore all branch directories
- if [[ -d /tmp/branch-backup ]]; then
- echo "Restoring branch directories..."
- cp -r /tmp/branch-backup/* .
- rm -rf /tmp/branch-backup
- fi
+ # Verify existing directories are still there
+ echo "Directories after deployment:"
+ ls -la */ 2>/dev/null || echo "No directories found"
# Clean up temporary build
rm -rf /tmp/landing-build
- echo "â
Landing page deployed to gh-pages root"
+ echo "â
Landing page deployed to gh-pages root while preserving all branch directories"
- name: Commit and push changes
shell: bash
@@ -161,7 +201,7 @@ jobs:
# Determine deployment type for commit message
if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
deployment_type="manual"
- trigger_info="Triggered from branch: ${{ github.ref_name }}"
+ trigger_info="Triggered from deploy branch"
else
deployment_type="automatic"
trigger_info="Auto-triggered by push to main"
@@ -169,7 +209,7 @@ jobs:
git commit -m "đ Deploy landing page (${deployment_type})
- - Updated landing page with self-contained assets
+ - Updated landing page with self-contained assets from deploy branch
- ${trigger_info}
- Deployed at $(date -u '+%Y-%m-%d %H:%M:%S UTC')
- Commit: ${{ github.sha }}"
@@ -189,7 +229,7 @@ jobs:
# Determine deployment type for output message
if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
deployment_type="Manual"
- trigger_info="Triggered from branch: ${{ github.ref_name }}"
+ trigger_info="Triggered from deploy branch"
else
deployment_type="Automatic"
trigger_info="Auto-triggered by push to main"
@@ -200,4 +240,5 @@ jobs:
echo "- Deployment Type: $deployment_type"
echo "- $trigger_info"
echo "- Deployed at: $(date -u '+%Y-%m-%d %H:%M:%S UTC')"
+ echo "- Source Branch: ${{ github.event.inputs.source_branch || 'deploy' }}"
echo "- Commit: ${{ github.sha }}"
\ No newline at end of file
diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml
index 9de6edc78..d52228024 100644
--- a/.github/workflows/pages.yml
+++ b/.github/workflows/pages.yml
@@ -73,6 +73,44 @@ jobs:
export BASE_PATH="/${safe_branch_name}/"
fi
echo "Building with BASE_PATH: $BASE_PATH"
+
+ # Get build script from deploy branch (fallback to local if deploy branch doesn't exist)
+ echo "Fetching build script from deploy branch..."
+ if git fetch origin deploy 2>/dev/null; then
+ echo "Deploy branch found, using build script from deploy branch"
+ git checkout origin/deploy -- scripts/build-multi-branch.js
+ else
+ echo "Deploy branch not found, checking if build script exists locally..."
+ if [[ ! -f "scripts/build-multi-branch.js" ]]; then
+ echo "ERROR: Build script not found and deploy branch doesn't exist"
+ echo "Creating minimal build script as fallback..."
+ mkdir -p scripts
+ cat > scripts/build-multi-branch.js << 'EOF'
+const { execSync } = require('child_process');
+const fs = require('fs');
+const path = require('path');
+
+console.log('Building React app for deployment...');
+
+// Simplified build process
+try {
+ // Set environment variables
+ process.env.NODE_ENV = 'production';
+ process.env.PUBLIC_URL = process.env.BASE_PATH || '/';
+
+ // Run the build
+ execSync('npm run build', { stdio: 'inherit' });
+
+ console.log('â
Build completed successfully');
+} catch (error) {
+ console.error('â Build failed:', error.message);
+ process.exit(1);
+}
+EOF
+ fi
+ fi
+
+ # Run the build script
node scripts/build-multi-branch.js branch
env:
CI: false
@@ -224,10 +262,12 @@ jobs:
branch_dir="${{ steps.validate_branch.outputs.branch_dir }}"
repo_root="$(pwd)"
+ target_subdir="${{ steps.validate_branch.outputs.target_subdir }}"
echo "Cleaning old deployment"
echo "Branch directory: $branch_dir"
echo "Repository root: $repo_root"
+ echo "Target subdirectory: $target_subdir"
# Validate before any destructive operations
if [[ "$branch_dir" != "$repo_root"* ]] || [[ ${#branch_dir} -le ${#repo_root} ]]; then
@@ -235,10 +275,22 @@ jobs:
exit 1
fi
- # Use git to safely remove the branch directory
+ # Additional safety: ensure we're only removing the specific branch directory
+ if [[ "$target_subdir" == "." || "$target_subdir" == ".." || -z "$target_subdir" ]]; then
+ echo "ERROR: Invalid target subdirectory: '$target_subdir'"
+ exit 1
+ fi
+
+ # Use git to safely remove the specific branch directory only
if [[ -d "$branch_dir" ]]; then
echo "Removing existing deployment: $branch_dir"
- git rm -rf "$branch_dir" || echo "Directory didn't exist in git"
+ # Additional safety: only remove if it matches our expected pattern
+ if [[ "$branch_dir" == "$repo_root/$target_subdir" ]]; then
+ git rm -rf "$target_subdir" 2>/dev/null || echo "Directory didn't exist in git"
+ else
+ echo "ERROR: Branch directory path doesn't match expected pattern"
+ exit 1
+ fi
else
echo "No existing deployment to clean"
fi
diff --git a/.gitignore b/.gitignore
index 28842349b..77730a93f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -95,4 +95,7 @@ temp_serve/
# Generated QA reports (auto-generated by CI, should not be committed)
docs/qa-report.html
public/docs/qa-report.html
-docs/github-issues-analysis.md
\ No newline at end of file
+docs/github-issues-analysis.md
+
+# Deployment structure (generated by CI/CD)
+sgex/
\ No newline at end of file
diff --git a/package.json b/package.json
index 79b890f96..3ffe2f430 100644
--- a/package.json
+++ b/package.json
@@ -26,7 +26,6 @@
"scripts": {
"start": "craco start",
"build": "react-scripts build",
- "build:multi-branch": "node scripts/build-multi-branch.js",
"test": "react-scripts test",
"eject": "react-scripts eject",
"serve": "npm run build && cd build && python3 -m http.server 3000",
diff --git a/public/branch-listing.html b/public/branch-listing.html
deleted file mode 100644
index 640ab1bae..000000000
--- a/public/branch-listing.html
+++ /dev/null
@@ -1,2021 +0,0 @@
-
-
-
-
-
- SGEX Branch & PR Previews
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/scripts/build-multi-branch.js b/scripts/build-multi-branch.js
deleted file mode 100755
index 085d523fd..000000000
--- a/scripts/build-multi-branch.js
+++ /dev/null
@@ -1,221 +0,0 @@
-#!/usr/bin/env node
-
-/**
- * Multi-branch build script for SGEX
- *
- * This script handles two different build scenarios:
- * 1. Branch-specific builds (for deployment to subdirectories)
- * 2. Root landing page build (for branch listing at gh-pages root)
- */
-
-const fs = require('fs');
-const path = require('path');
-const { execSync } = require('child_process');
-
-// Get build mode from environment or command line
-const buildMode = process.env.BUILD_MODE || process.argv[2] || 'branch';
-const branchName = process.env.GITHUB_REF_NAME || 'main';
-const basePath = process.env.BASE_PATH || process.argv[3] || null;
-
-console.log(`đ Starting ${buildMode} build for branch: ${branchName}`);
-
-function ensureDependencies() {
- const nodeModulesPath = path.join(__dirname, '..', 'node_modules');
- if (!fs.existsSync(nodeModulesPath)) {
- console.log('đĻ Installing dependencies...');
- execSync('npm ci', { stdio: 'inherit' });
- }
-}
-
-function createBranchSpecificBuild() {
- console.log('đĻ Building branch-specific React app...');
-
- // Ensure dependencies are available
- ensureDependencies();
-
- // Update package.json homepage for branch-specific deployment
- const packageJsonPath = path.join(__dirname, '..', 'package.json');
- const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
- const originalHomepage = packageJson.homepage;
-
- try {
- // Set homepage based on branch and base path
- let deploymentPath;
- if (basePath) {
- // For GitHub Pages, ensure the repository name is included in the path
- // basePath comes in format like "/copilot-fix-418/" but needs to be "/sgex/copilot-fix-418/"
- if (basePath.startsWith('/') && !basePath.startsWith('/sgex/')) {
- deploymentPath = `/sgex${basePath}`;
- } else {
- deploymentPath = basePath;
- }
- packageJson.homepage = deploymentPath;
- console.log(`đ§ Setting homepage to: ${deploymentPath}`);
- } else {
- // Default path structure: /sgex/main/ for main, /sgex/safe-branch-name/ for others
- const safeBranchName = branchName === 'main' ? 'main' : branchName.replace(/[^a-zA-Z0-9._-]/g, '-');
- deploymentPath = `/sgex/${safeBranchName}/`;
- packageJson.homepage = deploymentPath;
- console.log(`đ§ Setting homepage to: ${deploymentPath}`);
- }
-
- fs.writeFileSync(packageJsonPath, JSON.stringify(packageJson, null, 2));
-
- // Update manifest.json for subdirectory deployment
- const manifestPath = path.join(__dirname, '..', 'public', 'manifest.json');
- const manifestBackupPath = path.join(__dirname, '..', 'public', 'manifest.json.backup');
-
- if (fs.existsSync(manifestPath)) {
- // Backup original manifest.json
- fs.copyFileSync(manifestPath, manifestBackupPath);
-
- // Update manifest.json paths for subdirectory deployment
- const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
- const originalManifest = { ...manifest };
-
- // Update start_url for subdirectory deployment
- manifest.start_url = deploymentPath;
-
- // Update icon paths for subdirectory deployment
- if (manifest.icons && Array.isArray(manifest.icons)) {
- manifest.icons = manifest.icons.map(icon => ({
- ...icon,
- src: icon.src.startsWith('/') ? icon.src : `${deploymentPath}${icon.src}`
- }));
- }
-
- fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2));
- console.log(`đ§ Updated manifest.json for subdirectory deployment`);
- }
-
- // Standard React build for the branch
- execSync('npm run build', { stdio: 'inherit' });
-
- console.log(`â
Branch-specific build completed for: ${branchName}`);
-
- } finally {
- // Always restore original package.json
- packageJson.homepage = originalHomepage;
- fs.writeFileSync(packageJsonPath, JSON.stringify(packageJson, null, 2));
-
- // Restore original manifest.json if backup exists
- const manifestPath = path.join(__dirname, '..', 'public', 'manifest.json');
- const manifestBackupPath = path.join(__dirname, '..', 'public', 'manifest.json.backup');
-
- if (fs.existsSync(manifestBackupPath)) {
- fs.copyFileSync(manifestBackupPath, manifestPath);
- fs.unlinkSync(manifestBackupPath);
- console.log(`đ§ Restored original manifest.json`);
- }
- }
-}
-
-function createRootLandingPageApp() {
- console.log('đ Creating self-contained root landing page application...');
-
- // Ensure dependencies are available
- ensureDependencies();
-
- // Create a temporary React app that only renders BranchListing with self-contained assets
- const tempAppContent = `
-import React from 'react';
-import { BrowserRouter as Router } from 'react-router-dom';
-import BranchListing from './components/BranchListing';
-import './App.css';
-
-function App() {
- return (
-
-
-
-
-
- );
-}
-
-export default App;
-`;
-
- // Backup original App.js
- const appJsPath = path.join(__dirname, '..', 'src', 'App.js');
- const appJsBackupPath = path.join(__dirname, '..', 'src', 'App.js.backup');
-
- if (fs.existsSync(appJsPath)) {
- fs.copyFileSync(appJsPath, appJsBackupPath);
- }
-
- try {
- // Write temporary App.js for landing page
- fs.writeFileSync(appJsPath, tempAppContent.trim());
-
- // Update package.json homepage for root deployment
- const packageJsonPath = path.join(__dirname, '..', 'package.json');
- const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
- const originalHomepage = packageJson.homepage;
-
- // For root landing page, we want it to be at the GitHub Pages root for this repository
- packageJson.homepage = '/sgex/';
- fs.writeFileSync(packageJsonPath, JSON.stringify(packageJson, null, 2));
-
- // Update manifest.json for root deployment
- const manifestPath = path.join(__dirname, '..', 'public', 'manifest.json');
- const manifestBackupPath = path.join(__dirname, '..', 'public', 'manifest.json.backup-landing');
-
- if (fs.existsSync(manifestPath)) {
- // Backup original manifest.json
- fs.copyFileSync(manifestPath, manifestBackupPath);
-
- // Update manifest.json for root deployment
- const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
-
- // Update start_url for root deployment
- manifest.start_url = '/sgex/';
-
- // Update icon paths for root deployment - ensure they're self-contained
- if (manifest.icons && Array.isArray(manifest.icons)) {
- manifest.icons = manifest.icons.map(icon => ({
- ...icon,
- src: icon.src.startsWith('/') ? `/sgex${icon.src}` : `/sgex/${icon.src}`
- }));
- }
-
- fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2));
- console.log(`đ§ Updated manifest.json for self-contained root deployment`);
- }
-
- // Build the landing page app
- execSync('npm run build', { stdio: 'inherit' });
-
- // Restore original package.json
- packageJson.homepage = originalHomepage;
- fs.writeFileSync(packageJsonPath, JSON.stringify(packageJson, null, 2));
-
- console.log('â
Self-contained root landing page build completed');
-
- } finally {
- // Always restore original App.js
- if (fs.existsSync(appJsBackupPath)) {
- fs.copyFileSync(appJsBackupPath, appJsPath);
- fs.unlinkSync(appJsBackupPath);
- }
-
- // Restore original manifest.json if backup exists
- const manifestPath = path.join(__dirname, '..', 'public', 'manifest.json');
- const manifestBackupPath = path.join(__dirname, '..', 'public', 'manifest.json.backup-landing');
-
- if (fs.existsSync(manifestBackupPath)) {
- fs.copyFileSync(manifestBackupPath, manifestPath);
- fs.unlinkSync(manifestBackupPath);
- console.log(`đ§ Restored original manifest.json`);
- }
- }
-}
-
-// Execute based on build mode
-if (buildMode === 'root' || buildMode === 'landing') {
- createRootLandingPageApp();
-} else {
- createBranchSpecificBuild();
-}
-
-console.log(`đ Build process completed successfully!`);
\ No newline at end of file
diff --git a/scripts/test-deployment.sh b/scripts/test-deployment.sh
index 15e66d253..a7d925bfc 100755
--- a/scripts/test-deployment.sh
+++ b/scripts/test-deployment.sh
@@ -69,6 +69,14 @@ echo "-------------------------"
echo "Testing branch-specific build..."
export GITHUB_REF_NAME="test-branch"
+
+# Fetch build script from deploy branch (if available)
+if git show-ref --verify --quiet refs/remotes/origin/deploy; then
+ echo "Fetching build script from deploy branch..."
+ git fetch origin deploy
+ git checkout origin/deploy -- scripts/build-multi-branch.js
+fi
+
if node scripts/build-multi-branch.js branch > /dev/null 2>&1; then
echo "â
Branch-specific build: Success"
if [[ -f "build/index.html" ]]; then
@@ -82,6 +90,12 @@ fi
echo ""
echo "Testing root landing page build..."
+
+# Fetch landing page from deploy branch (if available)
+if git show-ref --verify --quiet refs/remotes/origin/deploy; then
+ git checkout origin/deploy -- public/branch-listing.html
+fi
+
if node scripts/build-multi-branch.js root > /dev/null 2>&1; then
echo "â
Root landing page build: Success"
if [[ -f "build/index.html" ]]; then
@@ -123,24 +137,24 @@ echo ""
echo "đ§Ē Test 4: Component validation"
echo "------------------------------"
-echo "Testing BranchListing component..."
-if [[ -f "src/components/BranchListing.js" ]] && [[ -f "src/components/BranchListing.css" ]]; then
- echo "â
BranchListing component files exist"
+echo "Testing deployment components..."
+if [[ -f "public/branch-listing.html" ]] && [[ -f "public/sgex-mascot.png" ]]; then
+ echo "â
Landing page assets exist"
- # Check for key elements in component
- if grep -q "GitHub API" src/components/BranchListing.js; then
- echo "â
Component includes GitHub API integration"
+ # Check for key elements in landing page
+ if grep -q "GitHub API" public/branch-listing.html; then
+ echo "â
Landing page includes GitHub API integration"
fi
- if grep -q "branch-card" src/components/BranchListing.css; then
- echo "â
Component includes card styling"
+ if grep -q "branch-card" public/branch-listing.html; then
+ echo "â
Landing page includes card styling"
fi
- if grep -q "safeName" src/components/BranchListing.js; then
- echo "â
Component handles safe branch names"
+ if grep -q "safeBranchName" public/branch-listing.html; then
+ echo "â
Landing page handles safe branch names"
fi
else
- echo "â BranchListing component files missing"
+ echo "â ī¸ Landing page assets may need to be fetched from deploy branch"
fi
# Test 5: Workflow file validation
diff --git a/src/App.js b/src/App.js
index a1f81ec84..bbc21000e 100644
--- a/src/App.js
+++ b/src/App.js
@@ -27,7 +27,6 @@ import DAKDashboardWithFramework from './components/DAKDashboardWithFramework';
import DashboardRedirect from './components/DashboardRedirect';
import TestDocumentationPage from './components/TestDocumentationPage';
import AssetEditorTest from './components/AssetEditorTest';
-import BranchListing from './components/BranchListing';
import logger from './utils/logger';
import './App.css';
@@ -112,7 +111,6 @@ function App() {
} />
} />
} />
- } />
} />
diff --git a/src/components/BranchListing.css b/src/components/BranchListing.css
deleted file mode 100644
index 18d11cb07..000000000
--- a/src/components/BranchListing.css
+++ /dev/null
@@ -1,1315 +0,0 @@
-.branch-listing {
- max-width: 1200px;
- margin: 0 auto;
- padding: 2rem;
-}
-
-.branch-listing-header {
- text-align: center;
- margin-bottom: 2rem;
-}
-
-.branch-listing-header h1 {
- font-size: 2.5rem;
- color: #2c3e50;
- margin-bottom: 0.5rem;
- display: flex;
- align-items: center;
- justify-content: center;
- gap: 1rem;
-}
-
-.sgex-icon {
- width: 48px;
- height: 48px;
- object-fit: contain;
-}
-
-.branch-listing-header .subtitle {
- font-size: 1.2rem;
- color: #666;
- margin: 0 0 1rem 0;
- font-style: italic;
-}
-
-.prominent-info {
- background: var(--who-secondary-bg, #f8f9fa);
- border: 2px solid var(--who-blue, #0366d6);
- border-radius: 12px;
- padding: 1.5rem;
- margin: 1.5rem 0;
- text-align: center;
-}
-
-.info-text {
- font-size: 1.1rem;
- font-weight: 600;
- color: var(--who-text-primary, #333);
- margin: 0;
-}
-
-.cache-status {
- display: flex;
- align-items: center;
- justify-content: center;
- gap: 1rem;
- margin-top: 1rem;
- padding-top: 1rem;
- border-top: 1px solid var(--who-border, #e1e4e8);
-}
-
-.cache-info {
- font-size: 0.9rem;
- color: var(--who-text-secondary, #666);
- display: flex;
- align-items: center;
- gap: 0.5rem;
-}
-
-.refresh-btn {
- display: flex;
- align-items: center;
- gap: 0.5rem;
- padding: 0.5rem 1rem;
- background: var(--who-blue, #0366d6);
- color: white;
- border: none;
- border-radius: 6px;
- font-size: 0.9rem;
- font-weight: 500;
- cursor: pointer;
- transition: all 0.2s ease;
-}
-
-.refresh-btn:hover:not(:disabled) {
- background: var(--who-blue-dark, #0256c2);
- transform: translateY(-1px);
-}
-
-.refresh-btn:disabled {
- opacity: 0.7;
- cursor: not-allowed;
- transform: none;
-}
-
-.refresh-btn:disabled:hover {
- background: var(--who-blue, #0366d6);
-}
-
-/* Authentication section */
-.auth-section {
- background: var(--who-tertiary-bg, #f8f9fa);
- border: 1px solid var(--who-border-color, #e1e5e9);
- border-radius: 12px;
- padding: 1.5rem;
- margin: 1.5rem 0;
- text-align: center;
-}
-
-.login-section h3 {
- color: var(--who-text-primary, #333);
- margin-bottom: 1rem;
-}
-
-.login-section p {
- color: var(--who-text-secondary, #586069);
- margin-bottom: 1rem;
-}
-
-.authenticated-section {
- display: flex;
- align-items: center;
- justify-content: center;
- gap: 1rem;
- flex-wrap: wrap;
-}
-
-.authenticated-section p {
- color: #28a745;
- font-weight: 500;
- margin: 0;
-}
-
-.logout-btn {
- background: #dc3545;
- color: white;
- border: none;
- padding: 0.5rem 1rem;
- border-radius: 6px;
- cursor: pointer;
- font-weight: 500;
- transition: background-color 0.2s ease;
-}
-
-.logout-btn:hover {
- background: #c82333;
-}
-
-/* PR Filter section */
-.pr-filter-section {
- display: flex;
- align-items: center;
- gap: 0.5rem;
-}
-
-.pr-filter-section label {
- font-weight: 500;
- color: var(--who-text-primary, #333);
-}
-
-.filter-select {
- padding: 0.5rem;
- border: 1px solid var(--who-border-color, #e1e5e9);
- border-radius: 6px;
- background: var(--who-card-bg, #ffffff);
- color: var(--who-text-primary, #333);
- cursor: pointer;
-}
-
-.filter-select:focus {
- outline: none;
- border-color: var(--who-blue, #0366d6);
- box-shadow: 0 0 0 3px rgba(3, 102, 214, 0.1);
-}
-
-/* Comments section */
-.pr-comments-section {
- margin-top: 1.5rem;
- padding-top: 1rem;
- border-top: 1px solid var(--who-border-color, #e1e5e9);
-}
-
-.pr-comments-section h4 {
- margin: 0 0 1rem 0;
- color: var(--who-text-primary, #333);
- font-size: 1rem;
-}
-
-.comments-loading {
- color: var(--who-text-secondary, #586069);
- font-style: italic;
- padding: 0.5rem;
-}
-
-.comments-list {
- margin-bottom: 1rem;
-}
-
-.comment-item {
- background: var(--who-secondary-bg, #f8f9fa);
- border: 1px solid var(--who-border-color, #e1e5e9);
- border-radius: 8px;
- padding: 0.75rem;
- margin-bottom: 0.5rem;
-}
-
-.comment-header {
- display: flex;
- align-items: center;
- gap: 0.5rem;
- margin-bottom: 0.5rem;
-}
-
-.comment-avatar {
- width: 20px;
- height: 20px;
- border-radius: 50%;
-}
-
-.comment-author {
- font-weight: 600;
- color: var(--who-blue, #0366d6);
- font-size: 0.9rem;
-}
-
-.comment-date {
- color: var(--who-text-secondary, #586069);
- font-size: 0.8rem;
- margin-left: auto;
-}
-
-.comment-body {
- color: var(--who-text-primary, #333);
- font-size: 0.9rem;
- line-height: 1.4;
- white-space: pre-wrap;
- word-break: break-word;
-}
-
-.no-comments {
- color: var(--who-text-secondary, #586069);
- font-style: italic;
- padding: 0.5rem;
- text-align: center;
-}
-
-.comment-input-section {
- display: flex;
- flex-direction: column;
- gap: 0.5rem;
-}
-
-.comment-input {
- width: 100%;
- padding: 0.75rem;
- border: 1px solid var(--who-border-color, #e1e5e9);
- border-radius: 6px;
- font-family: inherit;
- font-size: 0.9rem;
- resize: vertical;
- min-height: 60px;
-}
-
-.comment-input:focus {
- outline: none;
- border-color: var(--who-blue, #0366d6);
- box-shadow: 0 0 0 3px rgba(3, 102, 214, 0.1);
-}
-
-.submit-comment-btn {
- align-self: flex-start;
- background: var(--who-blue, #0366d6);
- color: white;
- border: none;
- padding: 0.5rem 1rem;
- border-radius: 6px;
- cursor: pointer;
- font-weight: 500;
- transition: background-color 0.2s ease;
-}
-
-.submit-comment-btn:hover:not(:disabled) {
- background: #0256cc;
-}
-
-.submit-comment-btn:disabled {
- background: var(--who-text-muted, #6a737d);
- cursor: not-allowed;
-}
-
-.comment-error-message {
- background: #fff5f5;
- border: 1px solid #fed7d7;
- border-radius: 6px;
- padding: 0.75rem;
- color: #c53030;
- font-size: 0.9rem;
- margin: 0.25rem 0;
- display: flex;
- align-items: center;
- gap: 0.5rem;
-}
-
-.comment-auth-message {
- background: var(--who-tertiary-bg, #f8f9fa);
- border: 1px solid var(--who-border-color, #e1e5e9);
- border-radius: 8px;
- padding: 1rem;
- margin-bottom: 1rem;
- text-align: center;
-}
-
-.comment-auth-message p {
- margin: 0;
- color: var(--who-text-secondary, #586069);
-}
-
-.comment-auth-message a {
- color: var(--who-blue, #0366d6);
- text-decoration: none;
- font-weight: 500;
-}
-
-.comment-auth-message a:hover {
- text-decoration: underline;
-}
-
-/* Main action buttons */
-.main-actions {
- display: flex;
- justify-content: center;
- gap: 1rem;
- margin-bottom: 2rem;
- flex-wrap: wrap;
-}
-
-.contribute-btn {
- display: inline-flex;
- align-items: center;
- gap: 0.5rem;
- padding: 0.75rem 1.5rem;
- border-radius: 8px;
- font-weight: 500;
- text-decoration: none;
- border: none;
- cursor: pointer;
- transition: all 0.2s ease;
- font-size: 1rem;
-}
-
-.contribute-btn.primary {
- background: #28a745;
- color: white;
-}
-
-.contribute-btn.primary:hover {
- background: #218838;
- color: white;
- text-decoration: none;
-}
-
-.contribute-btn.secondary {
- background: #0366d6;
- color: white;
-}
-
-.contribute-btn.secondary:hover {
- background: #0256cc;
- color: white;
- text-decoration: none;
-}
-
-.contribute-btn.tertiary {
- background: #6f42c1;
- color: white;
-}
-
-.contribute-btn.tertiary:hover {
- background: #5a32a3;
- color: white;
- text-decoration: none;
-}
-
-/* Top section with two cards */
-.top-section {
- display: grid;
- grid-template-columns: 2fr 1fr;
- gap: 2rem;
- margin-bottom: 2rem;
-}
-
-.mascot-card, .main-branch-card {
- background: var(--who-card-bg, #ffffff);
- border: 1px solid var(--who-border-color, #e1e5e9);
- border-radius: 12px;
- padding: 2rem;
- box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
- transition: all 0.3s ease;
-}
-
-.mascot-card:hover, .main-branch-card:hover {
- transform: translateY(-2px);
- box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
-}
-
-.mascot-content {
- display: flex;
- align-items: flex-start;
- gap: 2rem;
-}
-
-.large-mascot {
- width: 150px;
- height: 150px;
- object-fit: contain;
- flex-shrink: 0;
-}
-
-.explainer-content h2, .main-branch-content h2 {
- color: var(--who-blue, #0366d6);
- margin: 0 0 1rem 0;
- font-size: 1.5rem;
-}
-
-.explainer-content p {
- color: var(--who-text-secondary, #586069);
- line-height: 1.6;
- margin-bottom: 1rem;
-}
-
-.source-code-link {
- display: inline-flex;
- align-items: center;
- gap: 0.5rem;
- color: var(--who-blue, #0366d6);
- text-decoration: none;
- font-weight: 500;
- padding: 0.5rem 1rem;
- border: 1px solid var(--who-blue, #0366d6);
- border-radius: 6px;
- transition: all 0.2s ease;
-}
-
-.source-code-link:hover {
- background: var(--who-blue, #0366d6);
- color: white;
- text-decoration: none;
-}
-
-.main-branch-content {
- text-align: center;
-}
-
-.main-branch-content p {
- color: var(--who-text-secondary, #586069);
- line-height: 1.6;
- margin-bottom: 1.5rem;
-}
-
-.main-branch-actions {
- display: flex;
- flex-direction: column;
- gap: 1rem;
-}
-
-.main-branch-link {
- display: inline-flex;
- align-items: center;
- justify-content: center;
- gap: 0.5rem;
- background: var(--who-blue, #0366d6);
- color: white;
- text-decoration: none;
- padding: 1rem 1.5rem;
- border-radius: 8px;
- font-weight: 500;
- font-size: 1.1rem;
- transition: background-color 0.2s ease;
-}
-
-.main-branch-link:hover {
- background: #0256cc;
- color: white;
- text-decoration: none;
-}
-
-.docs-link {
- display: inline-flex;
- align-items: center;
- justify-content: center;
- gap: 0.5rem;
- color: var(--who-blue, #0366d6);
- text-decoration: none;
- padding: 0.75rem 1rem;
- border: 1px solid var(--who-blue, #0366d6);
- border-radius: 6px;
- font-weight: 500;
- transition: all 0.2s ease;
-}
-
-.docs-link:hover {
- background: var(--who-blue, #0366d6);
- color: white;
- text-decoration: none;
-}
-
-/* PR section header */
-.pr-section-header {
- text-align: center;
- margin: 3rem 0 2rem 0;
- padding: 2rem;
- background: var(--who-secondary-bg, #f8f9fa);
- border-radius: 12px;
- border: 1px solid var(--who-border-color, #e1e5e9);
-}
-
-.pr-header-content {
- display: flex;
- justify-content: space-between;
- align-items: center;
- flex-wrap: wrap;
- gap: 1rem;
-}
-
-.pr-header-text {
- flex: 1;
- text-align: left;
-}
-
-.pr-header-actions {
- display: flex;
- flex-direction: column;
- align-items: flex-end;
- gap: 0.5rem;
-}
-
-.refresh-btn {
- background: var(--who-blue, #0366d6);
- color: white;
- border: none;
- padding: 0.5rem 1rem;
- border-radius: 6px;
- cursor: pointer;
- font-size: 0.9rem;
- font-weight: 500;
- transition: background-color 0.2s ease;
- min-width: 120px;
-}
-
-.refresh-btn:hover:not(:disabled) {
- background: var(--who-blue-dark, #0256cc);
-}
-
-.refresh-btn:disabled {
- background: #6c757d;
- cursor: not-allowed;
-}
-
-.cache-info {
- font-size: 0.8rem;
- color: var(--who-text-secondary, #586069);
-}
-
-@media (max-width: 768px) {
- .pr-header-content {
- flex-direction: column;
- text-align: center;
- }
-
- .pr-header-text {
- text-align: center;
- }
-
- .pr-header-actions {
- align-items: center;
- }
-}
-
-.pr-section-header h2 {
- color: var(--who-blue, #0366d6);
- margin: 0 0 0.5rem 0;
- font-size: 2rem;
-}
-
-.pr-section-header p {
- color: var(--who-text-secondary, #586069);
- font-size: 1.1rem;
- margin: 0;
-}
-
-/* Responsive adjustments for top section */
-@media (max-width: 768px) {
- .top-section {
- grid-template-columns: 1fr;
- gap: 1rem;
- }
-
- .mascot-content {
- flex-direction: column;
- text-align: center;
- gap: 1rem;
- }
-
- .large-mascot {
- width: 120px;
- height: 120px;
- align-self: center;
- }
-
- .main-branch-actions {
- gap: 0.75rem;
- }
-
- .pr-section-header {
- padding: 1.5rem;
- }
-
- .pr-section-header h2 {
- font-size: 1.5rem;
- }
-}
-
-/* Cards styling - unified for PRs */
-.pr-cards {
- display: grid;
- grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
- gap: 1.5rem;
- margin-bottom: 3rem;
-}
-
-/* Branch and PR section specific styles */
-.pr-section {
- margin-bottom: 3rem;
-}
-
-.pr-controls {
- margin-bottom: 2rem;
- display: flex;
- justify-content: center;
- align-items: center;
- gap: 1rem;
- flex-wrap: wrap;
-}
-
-.pr-search {
- width: 100%;
- max-width: 500px;
- padding: 0.75rem 1rem;
- border: 1px solid #e1e5e9;
- border-radius: 8px;
- font-size: 1rem;
- background: #ffffff;
-}
-
-.pr-search:focus {
- outline: none;
- border-color: #0366d6;
- box-shadow: 0 0 0 3px rgba(3, 102, 214, 0.1);
-}
-
-.sort-select {
- padding: 0.75rem 1rem;
- border: 1px solid var(--who-border-color, #e1e5e9);
- border-radius: 8px;
- font-size: 1rem;
- background: var(--who-card-bg, #ffffff);
- color: var(--who-text-primary, #333);
- cursor: pointer;
- min-width: 200px;
-}
-
-.sort-select:focus {
- outline: none;
- border-color: var(--who-blue, #0366d6);
- box-shadow: 0 0 0 3px rgba(3, 102, 214, 0.1);
-}
-
-.status-checking {
- color: #0366d6;
- font-size: 0.9rem;
- font-weight: 500;
- display: flex;
- align-items: center;
- gap: 0.5rem;
-}
-
-.preview-card {
- border: 1px solid var(--who-border-color, #e1e5e9);
- border-radius: 12px;
- padding: 1.5rem;
- background: var(--who-card-bg, #ffffff);
- box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
- transition: all 0.3s ease;
- position: relative;
-}
-
-.preview-card:hover {
- transform: translateY(-4px);
- box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
- border-color: var(--who-blue, #0366d6);
-}
-
-.card-header {
- display: flex;
- justify-content: space-between;
- align-items: flex-start;
- margin-bottom: 1rem;
- gap: 1rem;
-}
-
-.card-badges {
- display: flex;
- flex-direction: column;
- gap: 0.5rem;
- align-items: flex-end;
- flex-shrink: 0;
-}
-
-.item-name {
- margin: 0;
- font-size: 1.4rem;
- color: var(--who-blue, #0366d6);
- font-weight: 600;
- word-break: break-word;
- flex: 1;
-}
-
-.commit-badge, .state-badge, .status-badge {
- background: var(--who-hover-bg, #f1f3f4);
- color: var(--who-text-secondary, #586069);
- padding: 0.25rem 0.5rem;
- border-radius: 6px;
- font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
- font-size: 0.875rem;
- font-weight: 500;
- flex-shrink: 0;
- white-space: nowrap;
-}
-
-.status-badge.active {
- background: #dcfce7;
- color: #166534;
-}
-
-.status-badge.not-found {
- background: #fef3c7;
- color: #92400e;
-}
-
-.status-badge.errored {
- background: #fee2e2;
- color: #991b1b;
-}
-
-.state-badge.open {
- background: #dcfce7;
- color: #166534;
-}
-
-.state-badge.closed {
- background: #fee2e2;
- color: #991b1b;
-}
-
-.card-body {
- margin-bottom: 1rem;
-}
-
-.item-date, .pr-meta {
- color: var(--who-text-secondary, #586069);
- font-size: 0.9rem;
- margin: 0 0 1rem 0;
-}
-
-.pr-actions {
- display: flex;
- gap: 0.5rem;
- flex-wrap: wrap;
-}
-
-.pr-top-actions {
- display: flex;
- gap: 0.5rem;
- margin: 0.75rem 0;
- padding: 0.5rem 0;
- border-bottom: 1px solid rgba(255, 255, 255, 0.1);
-}
-
-.view-files-btn {
- display: inline-flex;
- align-items: center;
- gap: 0.5rem;
- color: #60a5fa;
- text-decoration: none;
- padding: 0.5rem 1rem;
- border-radius: 6px;
- font-weight: 500;
- font-size: 0.875rem;
- background: rgba(96, 165, 250, 0.1);
- border: 1px solid rgba(96, 165, 250, 0.3);
- transition: all 0.2s ease;
-}
-
-.view-files-btn:hover {
- background: rgba(96, 165, 250, 0.2);
- border-color: rgba(96, 165, 250, 0.5);
- color: #93c5fd;
- text-decoration: none;
- transform: translateY(-1px);
-}
-
-.preview-link, .pr-link, .copy-btn, .actions-link {
- display: inline-flex;
- align-items: center;
- gap: 0.5rem;
- color: white;
- text-decoration: none;
- padding: 0.75rem 1.5rem;
- border-radius: 8px;
- font-weight: 500;
- transition: background-color 0.2s ease;
- font-size: 0.9rem;
- border: none;
- cursor: pointer;
-}
-
-.preview-link {
- background: #0366d6;
-}
-
-.preview-link:hover {
- background: #0256cc;
- text-decoration: none;
- color: white;
-}
-
-.copy-btn {
- background: #28a745;
-}
-
-.copy-btn:hover {
- background: #218838;
- color: white;
-}
-
-.pr-link {
- background: #6f42c1;
-}
-
-.pr-link:hover {
- background: #5a32a3;
-}
-
-.actions-link {
- background: #dc3545;
- font-size: 0.8rem;
- padding: 0.5rem 1rem;
-}
-
-.actions-link:hover {
- background: #c82333;
- color: white;
- text-decoration: none;
-}
-
-.deployment-message {
- display: flex;
- flex-direction: column;
- gap: 0.5rem;
- padding: 0.75rem;
- border-radius: 8px;
- font-size: 0.9rem;
-}
-
-.building-message {
- color: #92400e;
- background: #fef3c7;
- padding: 0.5rem;
- border-radius: 6px;
- display: flex;
- align-items: center;
- gap: 0.5rem;
-}
-
-.error-message {
- color: #991b1b;
- background: #fee2e2;
- padding: 0.5rem;
- border-radius: 6px;
- display: flex;
- align-items: center;
- gap: 0.5rem;
-}
-
-.card-footer {
- border-top: 1px solid #e1e5e9;
- padding-top: 1rem;
- margin-top: 1rem;
-}
-
-.preview-path {
- color: #6a737d;
- font-size: 0.8rem;
- word-break: break-all;
-}
-
-.preview-url-link {
- color: #0366d6;
- text-decoration: none;
- font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
-}
-
-.preview-url-link:hover {
- text-decoration: underline;
-}
-
-/* PR section specific styles moved to branch-section, pr-section above */
-
-.pr-card {
- border-left: 4px solid #0366d6;
-}
-
-.pr-card .item-name {
- font-size: 1.2rem;
- line-height: 1.4;
-}
-
-/* Pagination */
-.pagination {
- display: flex;
- justify-content: center;
- align-items: center;
- gap: 1rem;
- margin-top: 2rem;
-}
-
-.pagination-btn {
- background: var(--who-hover-bg, #f8f9fa);
- border: 1px solid var(--who-border-color, #e1e5e9);
- color: var(--who-blue, #0366d6);
- padding: 0.5rem 1rem;
- border-radius: 6px;
- cursor: pointer;
- font-weight: 500;
- transition: all 0.2s ease;
-}
-
-.pagination-btn:hover:not(:disabled) {
- background: var(--who-selected-bg, #e1e7fd);
- border-color: var(--who-blue, #0366d6);
-}
-
-.pagination-btn:disabled {
- color: var(--who-text-muted, #6a737d);
- cursor: not-allowed;
- opacity: 0.6;
-}
-
-.pagination-info {
- color: var(--who-text-secondary, #586069);
- font-size: 0.9rem;
-}
-
-.no-items {
- grid-column: 1 / -1;
- text-align: center;
- padding: 3rem;
- color: #6a737d;
-}
-
-.no-items p {
- margin: 0.5rem 0;
-}
-
-.loading, .error {
- text-align: center;
- padding: 3rem;
- color: #6a737d;
-}
-
-.error {
- color: #d73a49;
-}
-
-/* Footer */
-.branch-listing-footer {
- border-top: 1px solid var(--who-border-color, #e1e5e9);
- padding: 2rem 0;
- margin-top: 3rem;
-}
-
-.footer-content {
- display: flex;
- justify-content: space-between;
- align-items: center;
- gap: 2rem;
-}
-
-.footer-left {
- flex-shrink: 0;
-}
-
-.footer-center {
- text-align: center;
- color: var(--who-text-secondary, #6a737d);
-}
-
-.footer-center p {
- margin: 0.5rem 0;
-}
-
-.source-link, .footer-center a {
- color: var(--who-blue, #0366d6);
- text-decoration: none;
- font-weight: 500;
- display: inline-flex;
- align-items: center;
- gap: 0.5rem;
-}
-
-.source-link:hover, .footer-center a:hover {
- text-decoration: underline;
-}
-
-/* Discussion Summary Status Bar */
-.discussion-summary-bar {
- background: var(--who-secondary-bg, #f8f9fa);
- border: 1px solid var(--who-border-color, #e1e5e9);
- border-radius: 8px;
- padding: 0.75rem 1rem;
- margin-top: 1rem;
- cursor: pointer;
- transition: all 0.2s ease;
- display: flex;
- justify-content: space-between;
- align-items: center;
-}
-
-.discussion-summary-bar:hover {
- background: var(--who-hover-bg, #f1f3f4);
- border-color: var(--who-blue, #0366d6);
-}
-
-.discussion-summary-text {
- color: var(--who-text-primary, #333);
- font-size: 0.9rem;
- display: flex;
- align-items: center;
- gap: 0.5rem;
-}
-
-.discussion-summary-icon {
- color: var(--who-blue, #0366d6);
- font-weight: bold;
-}
-
-.discussion-expand-icon {
- color: var(--who-text-secondary, #586069);
- transition: transform 0.2s ease;
-}
-
-.discussion-expand-icon.expanded {
- transform: rotate(90deg);
-}
-
-/* Expanded Discussion Section */
-.discussion-expanded-section {
- margin-top: 0.5rem;
- border: 1px solid var(--who-border-color, #e1e5e9);
- border-radius: 8px;
- background: var(--who-card-bg, #ffffff);
- overflow: hidden;
-}
-
-.discussion-header {
- background: var(--who-secondary-bg, #f8f9fa);
- padding: 1rem;
- border-bottom: 1px solid var(--who-border-color, #e1e5e9);
- display: flex;
- justify-content: space-between;
- align-items: center;
- flex-wrap: wrap;
- gap: 0.5rem;
-}
-
-.discussion-title {
- font-weight: 600;
- color: var(--who-text-primary, #333);
- margin: 0;
-}
-
-.discussion-actions {
- display: flex;
- gap: 0.5rem;
- flex-wrap: wrap;
-}
-
-.discussion-action-btn {
- background: var(--who-blue, #0366d6);
- color: white;
- border: none;
- padding: 0.375rem 0.75rem;
- border-radius: 4px;
- font-size: 0.8rem;
- text-decoration: none;
- cursor: pointer;
- transition: background-color 0.2s ease;
- display: inline-flex;
- align-items: center;
- gap: 0.25rem;
-}
-
-.discussion-action-btn:hover {
- background: #0256cc;
- color: white;
- text-decoration: none;
-}
-
-.discussion-action-btn.secondary {
- background: #6f42c1;
-}
-
-.discussion-action-btn.secondary:hover {
- background: #5a32a3;
-}
-
-.comment-input-section {
- padding: 1rem;
- border-bottom: 1px solid var(--who-border-color, #e1e5e9);
- background: var(--who-tertiary-bg, #f8f9fa);
-}
-
-.discussion-scroll-area {
- max-height: 400px;
- overflow-y: auto;
- padding: 1rem;
-}
-
-/* Contribute modal styles */
-.contribute-slide {
- text-align: center;
- padding: 1rem;
-}
-
-.mascot-container {
- margin-bottom: 1.5rem;
- position: relative;
- display: flex;
- justify-content: center;
- align-items: center;
-}
-
-.contribute-mascot {
- width: 120px;
- height: 120px;
- object-fit: contain;
- margin: 0 0.5rem;
-}
-
-.contribute-mascot.bug-report {
- filter: sepia(1) hue-rotate(320deg) saturate(2);
-}
-
-.contribute-mascot.coding-agent {
- filter: sepia(1) hue-rotate(180deg) saturate(1.5);
-}
-
-.contribute-mascot.community {
- width: 80px;
- height: 80px;
-}
-
-.contribute-mascot.celebrate {
- animation: bounce 2s infinite;
-}
-
-@keyframes bounce {
- 0%, 20%, 50%, 80%, 100% {
- transform: translateY(0);
- }
- 40% {
- transform: translateY(-10px);
- }
- 60% {
- transform: translateY(-5px);
- }
-}
-
-.mascot-group {
- display: flex;
- justify-content: center;
- gap: 1rem;
- margin-bottom: 1rem;
-}
-
-.thought-bubble {
- position: absolute;
- top: -20px;
- right: 20%;
- background: white;
- border: 2px solid #0366d6;
- border-radius: 50%;
- width: 40px;
- height: 40px;
- display: flex;
- align-items: center;
- justify-content: center;
- font-size: 1.5rem;
- animation: float 3s ease-in-out infinite;
-}
-
-@keyframes float {
- 0%, 100% {
- transform: translateY(0px);
- }
- 50% {
- transform: translateY(-10px);
- }
-}
-
-.action-buttons {
- display: flex;
- flex-direction: column;
- gap: 1rem;
- margin: 2rem 0;
- align-items: center;
-}
-
-.contribute-footer {
- background: #f8f9fa;
- padding: 1.5rem;
- border-radius: 8px;
- border-left: 4px solid #28a745;
- margin-top: 2rem;
-}
-
-/* Responsive design */
-@media (max-width: 768px) {
- .branch-listing {
- padding: 1rem;
- }
-
- .pr-cards {
- grid-template-columns: 1fr;
- gap: 1rem;
- }
-
- .preview-card {
- padding: 1rem;
- }
-
- .card-header {
- flex-direction: column;
- gap: 0.5rem;
- align-items: flex-start;
- }
-
- .card-badges {
- flex-direction: row;
- align-items: flex-start;
- gap: 0.5rem;
- }
-
- .main-actions {
- flex-direction: column;
- align-items: center;
- }
-
- .contribute-btn {
- width: 100%;
- max-width: 300px;
- justify-content: center;
- }
-
- .pr-actions {
- justify-content: center;
- }
-
- .pr-controls {
- flex-direction: column;
- align-items: stretch;
- }
-
- .pr-filter-section {
- justify-content: center;
- }
-
- .authenticated-section {
- flex-direction: column;
- gap: 0.5rem;
- }
-
- .copy-btn, .actions-link {
- width: 100%;
- justify-content: center;
- }
-
- .pagination {
- flex-direction: column;
- gap: 0.5rem;
- }
-
- .mascot-group {
- flex-direction: column;
- align-items: center;
- }
-
- .action-buttons {
- width: 100%;
- }
-
- .contribute-btn {
- width: 100%;
- max-width: none;
- }
-}
\ No newline at end of file
diff --git a/src/components/BranchListing.js b/src/components/BranchListing.js
deleted file mode 100644
index 57e31bd09..000000000
--- a/src/components/BranchListing.js
+++ /dev/null
@@ -1,1142 +0,0 @@
-import React, { useState, useEffect, useCallback } from 'react';
-import { PageLayout } from './framework';
-import HelpModal from './HelpModal';
-import PATLogin from './PATLogin';
-import WorkflowStatus from './WorkflowStatus';
-import githubActionsService from '../services/githubActionsService';
-import branchListingCacheService from '../services/branchListingCacheService';
-import useThemeImage from '../hooks/useThemeImage';
-import './BranchListing.css';
-
-const BranchListing = () => {
- const [pullRequests, setPullRequests] = useState([]);
- const [loading, setLoading] = useState(true);
- const [error, setError] = useState(null);
- const [prPage, setPrPage] = useState(1);
- const [prSearchTerm, setPrSearchTerm] = useState('');
-
- const [prSortBy, setPrSortBy] = useState('updated'); // updated, number, alphabetical
- const [showContributeModal, setShowContributeModal] = useState(false);
- const [deploymentStatuses, setDeploymentStatuses] = useState({});
- const [prFilter, setPrFilter] = useState('open'); // 'open', 'closed', 'all'
- const [isAuthenticated, setIsAuthenticated] = useState(false);
- const [githubToken, setGithubToken] = useState(null);
- const [prComments, setPrComments] = useState({});
- const [commentInputs, setCommentInputs] = useState({});
- const [submittingComments, setSubmittingComments] = useState({});
- const [expandedDiscussions, setExpandedDiscussions] = useState({});
- const [discussionSummaries, setDiscussionSummaries] = useState({});
- const [loadingSummaries, setLoadingSummaries] = useState(false);
- const [workflowStatuses, setWorkflowStatuses] = useState({});
- const [loadingWorkflowStatuses, setLoadingWorkflowStatuses] = useState(false);
- const [isRefreshing, setIsRefreshing] = useState(false);
- const [cacheInfo, setCacheInfo] = useState(null);
-
- // Theme-aware mascot image
- const mascotImage = useThemeImage('sgex-mascot.png');
-
- const ITEMS_PER_PAGE = 10;
- const GITHUB_OWNER = 'litlfred';
- const GITHUB_REPO = 'sgex';
-
- // Function to manually refresh cache and reload data
- const handleManualRefresh = useCallback(async () => {
- setIsRefreshing(true);
-
- // Clear the cache to force fresh data
- branchListingCacheService.forceRefresh(GITHUB_OWNER, GITHUB_REPO);
-
- // The fetchData function will be called by the useEffect when isRefreshing changes
- }, []);
-
- // GitHub authentication functions
- const handleAuthSuccess = (token, octokitInstance) => {
- setGithubToken(token);
- setIsAuthenticated(true);
- // Store token for session
- sessionStorage.setItem('github_token', token);
- // Set token for GitHub Actions service
- githubActionsService.setToken(token);
- };
-
- const handleLogout = () => {
- setGithubToken(null);
- setIsAuthenticated(false);
- sessionStorage.removeItem('github_token');
- // Clear token from GitHub Actions service
- githubActionsService.setToken(null);
- };
-
- // Function to fetch PR comments summary
- const fetchPRCommentsSummary = useCallback(async (prNumber) => {
- // Allow fetching comments even without authentication for read-only access
-
- try {
- const headers = {
- 'Accept': 'application/vnd.github.v3+json'
- };
-
- // Add authorization header only if token is available
- if (githubToken) {
- headers['Authorization'] = `token ${githubToken}`;
- }
-
- const response = await fetch(
- `https://api.github.com/repos/litlfred/sgex/issues/${prNumber}/comments`,
- { headers }
- );
-
- if (!response.ok) {
- throw new Error(`Failed to fetch comments: ${response.status}`);
- }
-
- const comments = await response.json();
- if (comments.length === 0) {
- return { count: 0, lastComment: null };
- }
-
- const lastComment = comments[comments.length - 1];
- return {
- count: comments.length,
- lastComment: {
- author: lastComment.user.login,
- created_at: new Date(lastComment.created_at),
- avatar_url: lastComment.user.avatar_url
- }
- };
- } catch (error) {
- console.error(`Error fetching comment summary for PR ${prNumber}:`, error);
- return null;
- }
- }, [githubToken]);
-
- // Function to fetch all PR comments (for expanded view)
- const fetchAllPRComments = useCallback(async (prNumber) => {
- // Allow fetching comments even without authentication for read-only access
-
- try {
- const headers = {
- 'Accept': 'application/vnd.github.v3+json'
- };
-
- // Add authorization header only if token is available
- if (githubToken) {
- headers['Authorization'] = `token ${githubToken}`;
- }
-
- const response = await fetch(
- `https://api.github.com/repos/litlfred/sgex/issues/${prNumber}/comments`,
- { headers }
- );
-
- if (!response.ok) {
- throw new Error(`Failed to fetch comments: ${response.status}`);
- }
-
- const comments = await response.json();
- return comments.map(comment => ({
- id: comment.id,
- author: comment.user.login,
- body: comment.body,
- created_at: new Date(comment.created_at).toLocaleDateString(),
- avatar_url: comment.user.avatar_url
- }));
- } catch (error) {
- console.error(`Error fetching all comments for PR ${prNumber}:`, error);
- return [];
- }
- }, [githubToken]);
-
- // Function to load discussion summaries for visible PRs
- const loadDiscussionSummaries = useCallback(async (prs) => {
- if (prs.length === 0) return;
-
- setLoadingSummaries(true);
- const summaries = {};
-
- for (const pr of prs) {
- summaries[pr.number] = await fetchPRCommentsSummary(pr.number);
- }
-
- setDiscussionSummaries(summaries);
- setLoadingSummaries(false);
- }, [fetchPRCommentsSummary]);
-
- // Function to toggle discussion expansion
- const toggleDiscussion = async (prNumber) => {
- const isExpanded = expandedDiscussions[prNumber];
-
- if (!isExpanded) {
- // Load all comments when expanding
- const comments = await fetchAllPRComments(prNumber);
- setPrComments(prev => ({ ...prev, [prNumber]: comments }));
- }
-
- setExpandedDiscussions(prev => ({
- ...prev,
- [prNumber]: !isExpanded
- }));
- };
-
- // Function to get discussion summary text
- const getDiscussionSummaryText = (prNumber) => {
- const summary = discussionSummaries[prNumber];
-
- if (loadingSummaries) {
- return "Loading discussion...";
- }
-
- if (!summary || summary.count === 0) {
- return "No comments yet";
- }
-
- const { count, lastComment } = summary;
- const timeAgo = lastComment ? getTimeAgo(lastComment.created_at) : '';
-
- return `${count} comment${count > 1 ? 's' : ''}, last by ${lastComment.author} ${timeAgo}`;
- };
-
- // Helper function to get relative time
- const getTimeAgo = (date) => {
- const now = new Date();
- const diffMs = now - date;
- const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
-
- if (diffDays === 0) return 'today';
- if (diffDays === 1) return '1 day ago';
- if (diffDays < 7) return `${diffDays} days ago`;
- if (diffDays < 30) return `${Math.floor(diffDays / 7)} week${Math.floor(diffDays / 7) > 1 ? 's' : ''} ago`;
- return `${Math.floor(diffDays / 30)} month${Math.floor(diffDays / 30) > 1 ? 's' : ''} ago`;
- };
-
- // Function to submit a comment
- const submitComment = async (prNumber, commentText) => {
- if (!githubToken || !commentText.trim()) return false;
-
- setSubmittingComments(prev => ({ ...prev, [prNumber]: true }));
-
- try {
- const response = await fetch(
- `https://api.github.com/repos/litlfred/sgex/issues/${prNumber}/comments`,
- {
- method: 'POST',
- headers: {
- 'Authorization': `token ${githubToken}`,
- 'Accept': 'application/vnd.github.v3+json',
- 'Content-Type': 'application/json'
- },
- body: JSON.stringify({
- body: commentText
- })
- }
- );
-
- if (!response.ok) {
- throw new Error(`Failed to submit comment: ${response.status}`);
- }
-
- // Clear the comment input
- setCommentInputs(prev => ({ ...prev, [prNumber]: '' }));
-
- // Refresh both full comments (if expanded) and summary
- if (expandedDiscussions[prNumber]) {
- const updatedComments = await fetchAllPRComments(prNumber);
- setPrComments(prev => ({ ...prev, [prNumber]: updatedComments }));
- }
-
- // Refresh the discussion summary
- const updatedSummary = await fetchPRCommentsSummary(prNumber);
- setDiscussionSummaries(prev => ({ ...prev, [prNumber]: updatedSummary }));
-
- return true;
- } catch (error) {
- console.error(`Error submitting comment for PR ${prNumber}:`, error);
- return false;
- } finally {
- setSubmittingComments(prev => ({ ...prev, [prNumber]: false }));
- }
- };
-
- // Function to load workflow statuses for PR branches
- const loadWorkflowStatuses = useCallback(async (prData) => {
- if (!githubToken) return;
-
- setLoadingWorkflowStatuses(true);
-
- try {
- // Get all PR branch names
- const branchNames = prData.map(pr => pr.branchName);
-
- const uniqueBranchNames = [...new Set(branchNames)];
- const statuses = await githubActionsService.getWorkflowStatusForBranches(uniqueBranchNames);
-
- setWorkflowStatuses(statuses);
- } catch (error) {
- console.error('Error loading workflow statuses:', error);
- } finally {
- setLoadingWorkflowStatuses(false);
- }
- }, [githubToken]);
-
- // Function to trigger workflow for a branch
- const triggerWorkflow = useCallback(async (branchName) => {
- if (!githubToken) {
- alert('Please authenticate to trigger workflows');
- return;
- }
-
- try {
- const success = await githubActionsService.triggerWorkflow(branchName);
- if (success) {
- alert(`Workflow triggered for branch: ${branchName}`);
- // Refresh workflow statuses after a short delay
- setTimeout(() => {
- const currentPRs = pullRequests.length > 0 ? pullRequests : [];
- loadWorkflowStatuses(currentPRs);
- }, 2000);
- } else {
- alert(`Failed to trigger workflow for branch: ${branchName}`);
- }
- } catch (error) {
- console.error('Error triggering workflow:', error);
- alert(`Error triggering workflow: ${error.message}`);
- }
- }, [githubToken, pullRequests, loadWorkflowStatuses]);
-
- // Check for existing authentication on component mount
- useEffect(() => {
- const token = sessionStorage.getItem('github_token');
- if (token) {
- setGithubToken(token);
- setIsAuthenticated(true);
- // Set token for GitHub Actions service
- githubActionsService.setToken(token);
- }
- }, []);
-
- // Function to check deployment status
- const checkDeploymentStatus = async (url) => {
- try {
- const response = await fetch(url, { method: 'HEAD' });
- if (response.ok) {
- return 'active';
- } else if (response.status === 404) {
- return 'not-found';
- } else {
- return 'errored';
- }
- } catch (error) {
- return 'errored';
- }
- };
-
- // Function to check deployment statuses for PRs only
- const checkAllDeploymentStatuses = useCallback(async (prData) => {
- const statuses = {};
-
- // Check PRs only
- for (const pr of prData) {
- const status = await checkDeploymentStatus(pr.url);
- statuses[`pr-${pr.id}`] = status;
- }
-
- setDeploymentStatuses(statuses);
- }, []);
-
- // Function to copy URL to clipboard
- const copyToClipboard = async (url, type, name) => {
- try {
- await navigator.clipboard.writeText(url);
- // You could add a toast notification here
- console.log(`Copied ${type} URL for ${name} to clipboard`);
- } catch (error) {
- // Fallback for browsers that don't support clipboard API
- const textArea = document.createElement('textarea');
- textArea.value = url;
- document.body.appendChild(textArea);
- textArea.select();
- document.execCommand('copy');
- document.body.removeChild(textArea);
- console.log(`Copied ${type} URL for ${name} to clipboard (fallback)`);
- }
- };
-
- // Sorting function for PRs
- const sortPRs = (prs, sortBy) => {
- return [...prs].sort((a, b) => {
- switch (sortBy) {
- case 'number':
- return b.number - a.number; // Highest number first
- case 'alphabetical':
- return a.title.localeCompare(b.title);
- case 'updated':
- default:
- const dateA = new Date(a.updatedAt);
- const dateB = new Date(b.updatedAt);
- return dateB - dateA; // Most recent first
- }
- });
- };
-
- // "How to contribute" slideshow content
- const contributeHelpTopic = {
- id: 'how-to-contribute',
- title: 'How to Contribute to SGEX',
- type: 'slideshow',
- content: [
- {
- title: 'Welcome to SGEX - A Collaborative Workbench',
- content: `
-
-
-
-
-
What is SGEX?
-
SGEX is an experimental collaborative project developing a workbench of tools to make it easier and faster to develop high fidelity SMART Guidelines Digital Adaptation Kits.
-
Our mission is to empower healthcare organizations worldwide to create and maintain standards-compliant digital health implementations.
-
- `
- },
- {
- title: 'Step 1: Report a Bug or Make a Feature Request',
- content: `
-
-
-
-
-
đ Found something that needs fixing?
-
Every great contribution starts with identifying what can be improved:
-
- Bug reports: Help us identify and fix issues
- Feature requests: Share ideas for new functionality
- Documentation improvements: Make our guides clearer
- User experience feedback: Tell us what's confusing
-
-
Click the mascot's help button on any page to quickly report issues!
-
- `
- },
- {
- title: 'Step 2: Assignment to a Coding Agent',
- content: `
-
-
-
-
-
đ¤ AI-Powered Development
-
Once your issue is triaged, it may be assigned to one of our coding agents:
-
- Automated analysis: AI agents analyze the requirements
- Code generation: Initial implementations are created
- Testing integration: Automated tests validate changes
- Documentation updates: Keep documentation in sync
-
-
This hybrid approach combines human insight with AI efficiency.
-
- `
- },
- {
- title: 'Step 3: Community Collaboration',
- content: `
-
-
-
đ Real-time Evolution
-
The community drives continuous improvement through collaborative discussion:
-
- Code reviews: Community members review and suggest improvements
- Testing feedback: Real-world testing by healthcare professionals
- Knowledge sharing: Best practices emerge from collective experience
- Iterative refinement: The workbench evolves based on actual usage
-
-
Together, we're building the future of digital health tooling!
-
- `
- },
- {
- title: 'Get Started Today!',
- content: `
-
-
-
-
-
đ Ready to Contribute?
-
-
-
- `
- }
- ]
- };
-
- useEffect(() => {
- const fetchData = async () => {
- try {
- setLoading(true);
-
- // First, try to get cached data
- const cachedData = branchListingCacheService.getCachedData(GITHUB_OWNER, GITHUB_REPO);
-
- if (cachedData && !isRefreshing) {
- console.log('Using cached data for PR listing');
-
- // Filter PRs based on current filter state
- const filteredCachedPRs = cachedData.pullRequests.filter(pr => {
- if (prFilter === 'all') return true;
- return pr.state === prFilter;
- });
-
- setPullRequests(filteredCachedPRs);
- setCacheInfo(branchListingCacheService.getCacheInfo(GITHUB_OWNER, GITHUB_REPO));
-
- // Still need to check deployment statuses as these change frequently
- await checkAllDeploymentStatuses(filteredCachedPRs);
-
- // Load workflow statuses if authenticated
- if (githubToken) {
- await loadWorkflowStatuses(filteredCachedPRs);
- }
-
- // Load discussion summaries for first page
- await loadDiscussionSummaries(filteredCachedPRs.slice(0, ITEMS_PER_PAGE));
- return;
- }
-
- // If no cached data or refreshing, fetch fresh data
- console.log('Fetching fresh data from GitHub API');
-
- // Fetch pull requests based on filter
- const prState = prFilter === 'all' ? 'all' : prFilter;
- console.log(`Fetching PRs with state: ${prState} from GitHub API`);
-
- const prResponse = await fetch(`https://api.github.com/repos/${GITHUB_OWNER}/${GITHUB_REPO}/pulls?state=${prState}&sort=updated&per_page=100`);
- if (!prResponse.ok) {
- const errorText = await prResponse.text();
- console.error(`GitHub API error: ${prResponse.status} - ${errorText}`);
- throw new Error(`Failed to fetch pull requests: ${prResponse.status} - ${prResponse.statusText}`);
- }
- const prData = await prResponse.json();
- console.log(`Fetched ${prData.length} PRs from GitHub API`);
-
- // Format PR data
- const formattedPRs = prData.map(pr => {
- const safeBranchName = pr.head.ref.replace(/\//g, '-');
- return {
- id: pr.id,
- number: pr.number,
- title: pr.title,
- state: pr.state,
- author: pr.user.login,
- branchName: pr.head.ref,
- safeBranchName: safeBranchName,
- url: `./${safeBranchName}/`,
- prUrl: pr.html_url,
- updatedAt: new Date(pr.updated_at).toLocaleDateString(),
- createdAt: new Date(pr.created_at).toLocaleDateString()
- };
- });
-
- // Cache the fresh data (we only cache PRs since branches were removed)
- branchListingCacheService.setCachedData(GITHUB_OWNER, GITHUB_REPO, [], formattedPRs);
- setCacheInfo(branchListingCacheService.getCacheInfo(GITHUB_OWNER, GITHUB_REPO));
-
- setPullRequests(formattedPRs);
-
- // Check deployment statuses for PRs only
- await checkAllDeploymentStatuses(formattedPRs);
-
- // Load workflow statuses if authenticated
- if (githubToken) {
- await loadWorkflowStatuses(formattedPRs);
- }
-
- // Load discussion summaries for PRs - available for all users
- await loadDiscussionSummaries(formattedPRs.slice(0, ITEMS_PER_PAGE));
- } catch (err) {
- console.error('Error fetching data:', err);
- setError(err.message);
-
- // Check if this is a network/CORS issue
- if (err.message.includes('Failed to fetch') || err.message.includes('CORS')) {
- console.log('Network/CORS error detected, checking if we can use cache or fallback data...');
-
- // Try to use any existing cached data even if stale
- const cachedDataRaw = localStorage.getItem(branchListingCacheService.getCacheKey(GITHUB_OWNER, GITHUB_REPO));
- if (cachedDataRaw) {
- try {
- const parsed = JSON.parse(cachedDataRaw);
- console.log('Using stale cached data due to network error');
- setPullRequests(parsed.pullRequests.filter(pr => {
- if (prFilter === 'all') return true;
- return pr.state === prFilter;
- }));
- setCacheInfo({
- exists: true,
- stale: true,
- ageMinutes: Math.round((Date.now() - parsed.timestamp) / (60 * 1000)),
- prCount: parsed.pullRequests?.length || 0
- });
- setError('Using cached data (network error occurred)');
- return;
- } catch (parseError) {
- console.error('Error parsing stale cache:', parseError);
- }
- }
- }
-
- // Only use fallback data in development or when GitHub API is blocked
- if (process.env.NODE_ENV === 'development' || err.message.includes('Failed to fetch')) {
- console.log('Using fallback mock data for demonstration...');
- const mockPRs = [
- {
- id: 1,
- number: 123,
- title: 'Improve multi-page selector landing page for GitHub deployment',
- state: 'open',
- author: 'copilot',
- branchName: 'copilot/fix-459',
- safeBranchName: 'copilot-fix-459',
- url: './copilot-fix-459/',
- prUrl: 'https://github.com/litlfred/sgex/pull/123',
- updatedAt: new Date().toLocaleDateString(),
- createdAt: new Date(Date.now() - 86400000).toLocaleDateString()
- },
- {
- id: 2,
- number: 122,
- title: 'Add dark mode support',
- state: 'closed',
- author: 'developer',
- branchName: 'feature/dark-mode',
- safeBranchName: 'feature-dark-mode',
- url: './feature-dark-mode/',
- prUrl: 'https://github.com/litlfred/sgex/pull/122',
- updatedAt: new Date(Date.now() - 172800000).toLocaleDateString(),
- createdAt: new Date(Date.now() - 345600000).toLocaleDateString()
- },
- {
- id: 3,
- number: 121,
- title: 'Fix authentication flow',
- state: 'open',
- author: 'contributor',
- branchName: 'fix/auth-flow',
- safeBranchName: 'fix-auth-flow',
- url: './fix-auth-flow/',
- prUrl: 'https://github.com/litlfred/sgex/pull/121',
- updatedAt: new Date(Date.now() - 259200000).toLocaleDateString(),
- createdAt: new Date(Date.now() - 432000000).toLocaleDateString()
- }
- ];
-
- setPullRequests(mockPRs);
- setError(null); // Clear error since we have fallback data
- }
- } finally {
- setLoading(false);
- setIsRefreshing(false); // Reset refresh state
- }
- };
-
- fetchData();
- }, [checkAllDeploymentStatuses, prFilter, githubToken, loadWorkflowStatuses, loadDiscussionSummaries, isRefreshing]);
-
- // Load summaries for visible PRs when page changes
- useEffect(() => {
- if (pullRequests.length > 0) {
- const filtered = pullRequests.filter(pr =>
- pr.title.toLowerCase().includes(prSearchTerm.toLowerCase()) ||
- pr.author.toLowerCase().includes(prSearchTerm.toLowerCase())
- );
- const sorted = sortPRs(filtered, prSortBy);
- const paginated = sorted.slice((prPage - 1) * ITEMS_PER_PAGE, prPage * ITEMS_PER_PAGE);
- loadDiscussionSummaries(paginated);
- }
- }, [prPage, prSearchTerm, prSortBy, pullRequests, loadDiscussionSummaries]);
-
- // Filter and sort PRs based on search and sorting
- const filteredPRs = pullRequests.filter(pr =>
- pr.title.toLowerCase().includes(prSearchTerm.toLowerCase()) ||
- pr.author.toLowerCase().includes(prSearchTerm.toLowerCase()) ||
- pr.branchName.toLowerCase().includes(prSearchTerm.toLowerCase())
- );
- const sortedPRs = sortPRs(filteredPRs, prSortBy);
- const paginatedPRs = sortedPRs.slice((prPage - 1) * ITEMS_PER_PAGE, prPage * ITEMS_PER_PAGE);
- const totalPRPages = Math.ceil(sortedPRs.length / ITEMS_PER_PAGE);
-
- if (loading) {
- return (
-
-
-
SGEX
-
a collaborative workbench for WHO SMART Guidelines
-
Loading previews...
-
-
- );
- }
-
- if (error) {
- return (
-
-
-
SGEX
-
a collaborative workbench for WHO SMART Guidelines
-
-
Failed to load previews: {error}
-
Please try refreshing the page or check back later.
-
-
-
- );
- }
-
- return (
-
-
- {/* Top Section: Two cards side by side */}
-
- {/* Mascot and Explainer Card */}
-
-
-
-
-
SGEX Deployment Selection
-
Welcome to the SGEX deployment selection page. Here you can browse and access all available pull request previews for the WHO SMART Guidelines Exchange collaborative workbench.
-
Each pull request is automatically deployed to its own preview environment for testing and collaboration.
-
- đĻ View Source Code
-
-
-
-
-
- {/* Main Branch Access Card */}
-
-
-
đ Main Branch
-
Access the stable main branch of the SGEX workbench with the latest published features.
-
-
-
-
-
- {/* Authentication Section */}
-
- {!isAuthenticated ? (
-
-
đ GitHub Authentication
-
Login with your GitHub Personal Access Token to view and add comments to pull requests:
-
-
- ) : (
-
-
â
Authenticated - You can now view and add comments to pull requests
-
- Logout
-
-
- )}
-
-
- {/* Main Actions */}
-
-
- {/* PR Section Header */}
-
-
-
-
đ Pull Request Previews ({sortedPRs.length})
-
Browse and test pull request changes in isolated preview environments
-
-
-
- {isRefreshing ? 'đ Refreshing...' : 'đ Refresh'}
-
- {cacheInfo && (
-
-
- {cacheInfo.exists
- ? `đ Data cached (${cacheInfo.ageMinutes}m old)`
- : 'Fresh data'
- }
-
-
- )}
-
-
-
-
- {/* PR Section */}
-
-
-
- Filter PRs:
- {
- setPrFilter(e.target.value);
- setPrPage(1); // Reset to first page when filtering
- }}
- className="filter-select"
- >
- Open PRs Only
- Closed PRs Only
- All PRs
-
-
-
{
- setPrSearchTerm(e.target.value);
- setPrPage(1); // Reset to first page when searching
- }}
- className="pr-search"
- />
-
{
- setPrSortBy(e.target.value);
- setPrPage(1); // Reset to first page when sorting
- }}
- className="sort-select"
- >
- Sort by Recent Updates
- Sort by PR Number
- Sort Alphabetically
-
-
-
-
- {paginatedPRs.length === 0 ? (
-
- {prSearchTerm ? (
-
No pull requests match your search "{prSearchTerm}".
- ) : (
-
No pull request previews available at the moment.
- )}
-
- ) : (
- paginatedPRs.map((pr) => {
- const statusKey = `pr-${pr.id}`;
- const deploymentStatus = deploymentStatuses[statusKey];
-
- return (
-
-
-
#{pr.number}: {pr.title}
-
-
- {pr.state === 'open' ? 'đĸ' : 'đ´'} {pr.state}
-
- {deploymentStatus && (
-
- {deploymentStatus === 'active' && 'đĸ Active'}
- {deploymentStatus === 'not-found' && 'đĄ Building'}
- {deploymentStatus === 'errored' && 'đ´ Error'}
-
- )}
-
-
-
-
-
- Branch: {pr.branchName} âĸ Author: {pr.author}
-
-
- Created: {pr.createdAt} âĸ Updated: {pr.updatedAt}
-
-
- {/* PR Actions - View Files button at the top */}
-
-
- {/* Discussion Summary Section - Show for all users */}
-
- {/* Discussion Summary Status Bar */}
-
toggleDiscussion(pr.number)}
- >
-
- đŦ
- {getDiscussionSummaryText(pr.number)}
-
-
- âļ
-
-
-
- {/* Expanded Discussion Section */}
- {expandedDiscussions[pr.number] && (
-
-
-
- {/* Comment Input at Top - Only show for authenticated users */}
- {isAuthenticated ? (
-
-
- ) : (
-
- )}
-
- {/* Scrollable Comments Area */}
-
- {!prComments[pr.number] ? (
-
Loading full discussion...
- ) : prComments[pr.number].length > 0 ? (
-
- {prComments[pr.number].map((comment) => (
-
-
-
-
{comment.author}
-
{comment.created_at}
-
-
{comment.body}
-
- ))}
-
- ) : (
-
- No comments yet. {isAuthenticated ? 'Be the first to comment!' : 'Sign in to be the first to comment!'}
-
- )}
-
-
- )}
-
-
-
- {deploymentStatus === 'active' ? (
-
- đ View Preview
-
- ) : deploymentStatus === 'not-found' ? (
-
-
- đ Deployment in progress. Please check back in a few minutes.
-
-
- ) : deploymentStatus === 'errored' ? (
-
-
- â Deployment failed. Please check the GitHub Actions logs or contact support.
-
-
- View Actions Log
-
-
- ) : (
-
- đ View Preview
-
- )}
-
-
copyToClipboard(pr.url, 'PR', `#${pr.number}`)}
- title="Copy URL to clipboard"
- >
- đ Copy URL
-
-
-
- đ View PR
-
-
-
- {/* Workflow Status */}
-
-
-
-
-
- );
- })
- )}
-
-
- {totalPRPages > 1 && (
-
- setPrPage(Math.max(1, prPage - 1))}
- disabled={prPage === 1}
- >
- â Previous
-
-
- Page {prPage} of {totalPRPages} ({sortedPRs.length} total)
-
- setPrPage(Math.min(totalPRPages, prPage + 1))}
- disabled={prPage === totalPRPages}
- >
- Next â
-
-
- )}
-
-
- {showContributeModal && (
-
setShowContributeModal(false)}
- />
- )}
-
-
- );
-};
-
-export default BranchListing;
\ No newline at end of file
diff --git a/src/services/branchListingCacheService.js b/src/services/branchListingCacheService.js
deleted file mode 100644
index d649ebdc1..000000000
--- a/src/services/branchListingCacheService.js
+++ /dev/null
@@ -1,187 +0,0 @@
-/**
- * Branch Listing Cache Service
- * Manages caching of branch and PR preview data with 5-minute expiry
- */
-
-import logger from '../utils/logger.js';
-
-class BranchListingCacheService {
- constructor() {
- this.CACHE_KEY_PREFIX = 'sgex_branch_listing_cache_';
- this.CACHE_EXPIRY_MINUTES = 5; // Cache expires after 5 minutes as per requirements
- this.logger = logger.getLogger('BranchListingCacheService');
- this.logger.debug('BranchListingCacheService initialized', {
- cacheExpiryMinutes: this.CACHE_EXPIRY_MINUTES
- });
- }
-
- /**
- * Generate cache key for branch listing data
- */
- getCacheKey(owner, repo) {
- return `${this.CACHE_KEY_PREFIX}${owner}_${repo}`;
- }
-
- /**
- * Check if cached data is stale (older than 5 minutes)
- */
- isStale(timestamp) {
- const now = Date.now();
- const cacheAge = now - timestamp;
- const maxAge = this.CACHE_EXPIRY_MINUTES * 60 * 1000; // 5 minutes in milliseconds
- return cacheAge > maxAge;
- }
-
- /**
- * Get cached branch listing data (branches and PRs)
- * Returns null if cache doesn't exist or is stale
- */
- getCachedData(owner, repo) {
- try {
- const cacheKey = this.getCacheKey(owner, repo);
- this.logger.cache('get', cacheKey);
-
- const cachedData = localStorage.getItem(cacheKey);
-
- if (!cachedData) {
- this.logger.cache('miss', cacheKey, 'No cached data found');
- return null;
- }
-
- const parsed = JSON.parse(cachedData);
-
- // Check if cache is stale
- if (this.isStale(parsed.timestamp)) {
- // Remove stale cache
- this.logger.cache('expired', cacheKey, { age: Date.now() - parsed.timestamp });
- localStorage.removeItem(cacheKey);
- return null;
- }
-
- this.logger.cache('hit', cacheKey, {
- branchCount: parsed.branches?.length || 0,
- prCount: parsed.pullRequests?.length || 0,
- age: Date.now() - parsed.timestamp
- });
-
- return {
- branches: parsed.branches,
- pullRequests: parsed.pullRequests,
- timestamp: parsed.timestamp,
- owner: parsed.owner,
- repo: parsed.repo
- };
- } catch (error) {
- const cacheKey = this.getCacheKey(owner, repo);
- this.logger.error('Error reading branch listing cache', { cacheKey, error: error.message });
- console.warn('Error reading branch listing cache:', error);
- return null;
- }
- }
-
- /**
- * Cache branch listing data (branches and PRs)
- */
- setCachedData(owner, repo, branches, pullRequests) {
- try {
- const cacheKey = this.getCacheKey(owner, repo);
- const cacheData = {
- branches,
- pullRequests,
- timestamp: Date.now(),
- owner,
- repo
- };
-
- this.logger.cache('set', cacheKey, {
- branchCount: branches?.length || 0,
- prCount: pullRequests?.length || 0,
- owner,
- repo
- });
-
- localStorage.setItem(cacheKey, JSON.stringify(cacheData));
- return true;
- } catch (error) {
- const cacheKey = this.getCacheKey(owner, repo);
- this.logger.error('Error caching branch listing data', { cacheKey, error: error.message });
- console.warn('Error caching branch listing data:', error);
- return false;
- }
- }
-
- /**
- * Clear cache for a specific repository
- */
- clearCache(owner, repo) {
- try {
- const cacheKey = this.getCacheKey(owner, repo);
- this.logger.cache('clear', cacheKey, { owner, repo });
- localStorage.removeItem(cacheKey);
- return true;
- } catch (error) {
- const cacheKey = this.getCacheKey(owner, repo);
- this.logger.error('Error clearing branch listing cache', { cacheKey, error: error.message });
- console.warn('Error clearing branch listing cache:', error);
- return false;
- }
- }
-
- /**
- * Clear all branch listing caches
- */
- clearAllCaches() {
- try {
- const keys = Object.keys(localStorage);
- let clearedCount = 0;
- keys.forEach(key => {
- if (key.startsWith(this.CACHE_KEY_PREFIX)) {
- localStorage.removeItem(key);
- clearedCount++;
- }
- });
- this.logger.debug('Cleared all branch listing caches', { clearedCount });
- return true;
- } catch (error) {
- this.logger.error('Error clearing all branch listing caches', { error: error.message });
- console.warn('Error clearing all branch listing caches:', error);
- return false;
- }
- }
-
- /**
- * Get cache info for debugging
- */
- getCacheInfo(owner, repo) {
- const cached = this.getCachedData(owner, repo);
- if (!cached) {
- return { exists: false, stale: true };
- }
-
- const age = Date.now() - cached.timestamp;
- const ageMinutes = Math.round(age / (60 * 1000));
-
- return {
- exists: true,
- stale: this.isStale(cached.timestamp),
- age: age,
- ageMinutes: ageMinutes,
- branchCount: cached.branches?.length || 0,
- prCount: cached.pullRequests?.length || 0,
- timestamp: new Date(cached.timestamp).toISOString()
- };
- }
-
- /**
- * Force refresh cache - clear existing cache to force fresh data fetch
- */
- forceRefresh(owner, repo) {
- this.logger.info('Force refresh requested', { owner, repo });
- return this.clearCache(owner, repo);
- }
-}
-
-// Create singleton instance
-const branchListingCacheService = new BranchListingCacheService();
-
-export default branchListingCacheService;
\ No newline at end of file
diff --git a/src/services/branchListingCacheService.test.js b/src/services/branchListingCacheService.test.js
deleted file mode 100644
index 95e9a36dd..000000000
--- a/src/services/branchListingCacheService.test.js
+++ /dev/null
@@ -1,230 +0,0 @@
-/**
- * Branch Listing Cache Service Tests
- */
-
-// Create a working localStorage mock FIRST
-let mockStore = {};
-
-const localStorageMock = {
- getItem: jest.fn((key) => {
- return mockStore[key] || null;
- }),
- setItem: jest.fn((key, value) => {
- if (mockStore._simulateQuotaExceeded) {
- delete mockStore._simulateQuotaExceeded;
- throw new Error('Storage quota exceeded');
- }
- mockStore[key] = value.toString();
- }),
- removeItem: jest.fn((key) => {
- delete mockStore[key];
- }),
- clear: jest.fn(() => {
- mockStore = {};
- }),
- get length() {
- return Object.keys(mockStore).length;
- },
- key: jest.fn((index) => Object.keys(mockStore)[index] || null),
- // Test helpers
- _reset() {
- mockStore = {};
- this.getItem.mockClear();
- this.setItem.mockClear();
- this.removeItem.mockClear();
- this.clear.mockClear();
- }
-};
-
-// Replace global localStorage BEFORE any imports
-Object.defineProperty(global, 'localStorage', {
- value: localStorageMock,
- writable: true
-});
-
-// Also mock window.localStorage if it exists
-if (typeof window !== 'undefined') {
- Object.defineProperty(window, 'localStorage', {
- value: localStorageMock,
- writable: true
- });
-}
-
-// Mock logger to avoid localStorage conflicts
-jest.mock('../utils/logger', () => ({
- __esModule: true,
- default: {
- getLogger: jest.fn(() => ({
- debug: jest.fn(),
- info: jest.fn(),
- warn: jest.fn(),
- error: jest.fn(),
- cache: jest.fn()
- }))
- }
-}));
-
-// NOW import the cache service after mocks are set up
-import branchListingCacheService from './branchListingCacheService';
-
-describe('BranchListingCacheService', () => {
- const testOwner = 'litlfred';
- const testRepo = 'sgex';
- const testBranches = [
- { name: 'main', commit: { sha: 'abc123' } },
- { name: 'feature-branch', commit: { sha: 'def456' } }
- ];
- const testPRs = [
- { id: 1, number: 123, title: 'Test PR', state: 'open' },
- { id: 2, number: 124, title: 'Another PR', state: 'closed' }
- ];
-
- beforeEach(() => {
- localStorageMock._reset();
- });
-
- describe('Cache Key Generation', () => {
- test('should generate correct cache key', () => {
- const key = branchListingCacheService.getCacheKey(testOwner, testRepo);
- expect(key).toBe('sgex_branch_listing_cache_litlfred_sgex');
- });
- });
-
- describe('Cache Expiry', () => {
- test('should detect stale cache (older than 5 minutes)', () => {
- const oldTimestamp = Date.now() - (6 * 60 * 1000); // 6 minutes ago
- const recentTimestamp = Date.now() - (3 * 60 * 1000); // 3 minutes ago
-
- expect(branchListingCacheService.isStale(oldTimestamp)).toBe(true);
- expect(branchListingCacheService.isStale(recentTimestamp)).toBe(false);
- });
- });
-
- describe('Cache Operations', () => {
- test('should cache and retrieve data successfully', () => {
- // Cache data
- const result = branchListingCacheService.setCachedData(
- testOwner,
- testRepo,
- testBranches,
- testPRs
- );
- expect(result).toBe(true);
- expect(localStorageMock.setItem).toHaveBeenCalledTimes(1);
-
- // Retrieve cached data
- const cached = branchListingCacheService.getCachedData(testOwner, testRepo);
- expect(cached).not.toBeNull();
- expect(cached.branches).toEqual(testBranches);
- expect(cached.pullRequests).toEqual(testPRs);
- expect(cached.owner).toBe(testOwner);
- expect(cached.repo).toBe(testRepo);
- expect(cached.timestamp).toBeCloseTo(Date.now(), -2);
- });
-
- test('should return null for non-existent cache', () => {
- const cached = branchListingCacheService.getCachedData('nonexistent', 'repo');
- expect(cached).toBeNull();
- });
-
- test('should return null and remove stale cache', () => {
- const cacheKey = branchListingCacheService.getCacheKey(testOwner, testRepo);
-
- // Set stale data directly in localStorage
- const staleData = {
- branches: testBranches,
- pullRequests: testPRs,
- timestamp: Date.now() - (6 * 60 * 1000), // 6 minutes ago
- owner: testOwner,
- repo: testRepo
- };
- mockStore[cacheKey] = JSON.stringify(staleData);
-
- const cached = branchListingCacheService.getCachedData(testOwner, testRepo);
- expect(cached).toBeNull();
- expect(localStorageMock.removeItem).toHaveBeenCalledWith(cacheKey);
- });
-
- test('should clear specific cache', () => {
- // Set cache first
- branchListingCacheService.setCachedData(testOwner, testRepo, testBranches, testPRs);
- expect(branchListingCacheService.getCachedData(testOwner, testRepo)).not.toBeNull();
-
- // Clear cache
- const result = branchListingCacheService.clearCache(testOwner, testRepo);
- expect(result).toBe(true);
- expect(branchListingCacheService.getCachedData(testOwner, testRepo)).toBeNull();
- });
-
- test('should clear all caches', () => {
- // Set multiple caches
- branchListingCacheService.setCachedData(testOwner, testRepo, testBranches, testPRs);
- branchListingCacheService.setCachedData('other', 'repo', [], []);
-
- const result = branchListingCacheService.clearAllCaches();
- expect(result).toBe(true);
- expect(branchListingCacheService.getCachedData(testOwner, testRepo)).toBeNull();
- expect(branchListingCacheService.getCachedData('other', 'repo')).toBeNull();
- });
- });
-
- describe('Cache Info', () => {
- test('should provide correct cache info for existing cache', () => {
- // Set cache
- branchListingCacheService.setCachedData(testOwner, testRepo, testBranches, testPRs);
-
- const info = branchListingCacheService.getCacheInfo(testOwner, testRepo);
- expect(info.exists).toBe(true);
- expect(info.stale).toBe(false);
- expect(info.branchCount).toBe(2);
- expect(info.prCount).toBe(2);
- expect(info.ageMinutes).toBe(0);
- });
-
- test('should provide correct cache info for non-existent cache', () => {
- const info = branchListingCacheService.getCacheInfo('nonexistent', 'repo');
- expect(info.exists).toBe(false);
- expect(info.stale).toBe(true);
- });
- });
-
- describe('Force Refresh', () => {
- test('should clear cache when force refresh is called', () => {
- // Set cache
- branchListingCacheService.setCachedData(testOwner, testRepo, testBranches, testPRs);
- expect(branchListingCacheService.getCachedData(testOwner, testRepo)).not.toBeNull();
-
- // Force refresh
- const result = branchListingCacheService.forceRefresh(testOwner, testRepo);
- expect(result).toBe(true);
-
- // Verify cache is cleared
- expect(branchListingCacheService.getCachedData(testOwner, testRepo)).toBeNull();
- });
- });
-
- describe('Error Handling', () => {
- test('should handle localStorage errors gracefully', () => {
- // Trigger storage quota exceeded error
- mockStore._simulateQuotaExceeded = true;
-
- const result = branchListingCacheService.setCachedData(
- testOwner,
- testRepo,
- testBranches,
- testPRs
- );
- expect(result).toBe(false);
- });
-
- test('should handle JSON parsing errors gracefully', () => {
- const cacheKey = branchListingCacheService.getCacheKey(testOwner, testRepo);
-
- // Set invalid JSON data
- mockStore[cacheKey] = 'invalid json{';
-
- const cached = branchListingCacheService.getCachedData(testOwner, testRepo);
- expect(cached).toBeNull();
- });
- });
-});
\ No newline at end of file
diff --git a/src/services/branchListingCacheService.test.js.bak b/src/services/branchListingCacheService.test.js.bak
deleted file mode 100644
index b14d794af..000000000
--- a/src/services/branchListingCacheService.test.js.bak
+++ /dev/null
@@ -1,254 +0,0 @@
-/**
- * Branch Listing Cache Service Tests
- */
-
-// Mock logger to avoid localStorage conflicts
-jest.mock('../utils/logger', () => ({
- __esModule: true,
- default: {
- getLogger: jest.fn(() => ({
- debug: jest.fn(),
- info: jest.fn(),
- warn: jest.fn(),
- error: jest.fn(),
- cache: jest.fn()
- }))
- }
-}));
-
-import branchListingCacheService from './branchListingCacheService';
-
-// Mock localStorage
-let mockLocalStorage = {};
-
-const localStorageMock = (() => {
- let store = {};
-
- return {
- getItem(key) {
- console.log('getItem called with key:', key, 'found:', key in store);
- return store[key] || null;
- },
- setItem(key, value) {
- console.log('setItem called with key:', key, 'value length:', value.length);
- // Simulate storage quota exceeded for specific test
- if (store._simulateQuotaExceeded) {
- delete store._simulateQuotaExceeded;
- throw new Error('Storage quota exceeded');
- }
- store[key] = value;
- console.log('store after setItem:', Object.keys(store));
- },
- removeItem(key) {
- delete store[key];
- },
- clear() {
- store = {};
- },
- get length() {
- return Object.keys(store).length;
- },
- key(index) {
- return Object.keys(store)[index] || null;
- },
- // For testing
- _getStore() {
- return store;
- },
- _setStore(newStore) {
- store = newStore;
- }
- };
-})();
-
-// Create spies for the mock methods
-const setItemSpy = jest.spyOn(localStorageMock, 'setItem');
-const getItemSpy = jest.spyOn(localStorageMock, 'getItem');
-const removeItemSpy = jest.spyOn(localStorageMock, 'removeItem');
-
-Object.defineProperty(global, 'localStorage', {
- value: localStorageMock,
- writable: true
-});
-
-describe('BranchListingCacheService', () => {
- const testOwner = 'litlfred';
- const testRepo = 'sgex';
- const testBranches = [
- { name: 'main', commit: { sha: 'abc123' } },
- { name: 'feature-branch', commit: { sha: 'def456' } }
- ];
- const testPRs = [
- { id: 1, number: 123, title: 'Test PR', state: 'open' },
- { id: 2, number: 124, title: 'Another PR', state: 'closed' }
- ];
-
- beforeEach(() => {
- localStorageMock.clear();
- jest.clearAllMocks();
- });
-
- describe('Cache Key Generation', () => {
- test('should generate correct cache key', () => {
- const key = branchListingCacheService.getCacheKey(testOwner, testRepo);
- expect(key).toBe('sgex_branch_listing_cache_litlfred_sgex');
- });
- });
-
- describe('Cache Expiry', () => {
- test('should detect stale cache (older than 5 minutes)', () => {
- const now = Date.now();
- const fiveMinutesAgo = now - (5 * 60 * 1000);
- const sixMinutesAgo = now - (6 * 60 * 1000);
-
- expect(branchListingCacheService.isStale(fiveMinutesAgo)).toBe(false);
- expect(branchListingCacheService.isStale(sixMinutesAgo)).toBe(true);
- });
- });
-
- describe('Cache Operations', () => {
- test('should cache and retrieve data successfully', () => {
- // Cache data
- const result = branchListingCacheService.setCachedData(
- testOwner,
- testRepo,
- testBranches,
- testPRs
- );
- expect(result).toBe(true);
-
- // Debug: Check what's in localStorage and if setItem was called
- const cacheKey = branchListingCacheService.getCacheKey(testOwner, testRepo);
- console.log('Cache key:', cacheKey);
- console.log('setItem calls:', setItemSpy.mock.calls);
- console.log('localStorageStore:', localStorageMock._getStore());
-
- const rawData = localStorageMock.getItem(cacheKey);
- console.log('Raw localStorage data:', rawData);
-
- if (rawData) {
- const parsedData = JSON.parse(rawData);
- console.log('Parsed data:', parsedData);
- }
-
- // Retrieve cached data
- const cached = branchListingCacheService.getCachedData(testOwner, testRepo);
- expect(cached).not.toBeNull();
- expect(cached.branches).toEqual(testBranches);
- expect(cached.pullRequests).toEqual(testPRs);
- expect(cached.owner).toBe(testOwner);
- expect(cached.repo).toBe(testRepo);
- expect(cached.timestamp).toBeCloseTo(Date.now(), -2); // Within 100ms
- });
-
- test('should return null for non-existent cache', () => {
- const cached = branchListingCacheService.getCachedData('nonexistent', 'repo');
- expect(cached).toBeNull();
- });
-
- test('should return null and remove stale cache', () => {
- // Mock old timestamp (6 minutes ago)
- const oldTimestamp = Date.now() - (6 * 60 * 1000);
- const staleData = {
- branches: testBranches,
- pullRequests: testPRs,
- timestamp: oldTimestamp,
- owner: testOwner,
- repo: testRepo
- };
-
- const cacheKey = branchListingCacheService.getCacheKey(testOwner, testRepo);
- mockLocalStorage[cacheKey] = JSON.stringify(staleData);
-
- // Should return null and remove stale cache
- const cached = branchListingCacheService.getCachedData(testOwner, testRepo);
- expect(cached).toBeNull();
- expect(removeItemSpy).toHaveBeenCalledWith(cacheKey);
- });
-
- test('should clear specific cache', () => {
- // Set cache
- branchListingCacheService.setCachedData(testOwner, testRepo, testBranches, testPRs);
-
- // Clear cache
- const result = branchListingCacheService.clearCache(testOwner, testRepo);
- expect(result).toBe(true);
-
- // Verify cache is gone
- const cached = branchListingCacheService.getCachedData(testOwner, testRepo);
- expect(cached).toBeNull();
- });
-
- test('should clear all caches', () => {
- // Set multiple caches
- branchListingCacheService.setCachedData('owner1', 'repo1', testBranches, testPRs);
- branchListingCacheService.setCachedData('owner2', 'repo2', testBranches, testPRs);
-
- // Clear all caches
- const result = branchListingCacheService.clearAllCaches();
- expect(result).toBe(true);
-
- // Verify all caches are gone
- expect(branchListingCacheService.getCachedData('owner1', 'repo1')).toBeNull();
- expect(branchListingCacheService.getCachedData('owner2', 'repo2')).toBeNull();
- });
- });
-
- describe('Cache Info', () => {
- test('should provide correct cache info for existing cache', () => {
- branchListingCacheService.setCachedData(testOwner, testRepo, testBranches, testPRs);
-
- const info = branchListingCacheService.getCacheInfo(testOwner, testRepo);
- expect(info.exists).toBe(true);
- expect(info.stale).toBe(false);
- expect(info.branchCount).toBe(2);
- expect(info.prCount).toBe(2);
- expect(info.ageMinutes).toBe(0);
- });
-
- test('should provide correct cache info for non-existent cache', () => {
- const info = branchListingCacheService.getCacheInfo('nonexistent', 'repo');
- expect(info.exists).toBe(false);
- expect(info.stale).toBe(true);
- });
- });
-
- describe('Force Refresh', () => {
- test('should clear cache when force refresh is called', () => {
- // Set cache
- branchListingCacheService.setCachedData(testOwner, testRepo, testBranches, testPRs);
- expect(branchListingCacheService.getCachedData(testOwner, testRepo)).not.toBeNull();
-
- // Force refresh
- const result = branchListingCacheService.forceRefresh(testOwner, testRepo);
- expect(result).toBe(true);
-
- // Verify cache is cleared
- expect(branchListingCacheService.getCachedData(testOwner, testRepo)).toBeNull();
- });
- });
-
- describe('Error Handling', () => {
- test('should handle localStorage errors gracefully', () => {
- // Trigger storage quota exceeded error
- localStorageMock._getStore()._simulateQuotaExceeded = true;
-
- const result = branchListingCacheService.setCachedData(
- testOwner,
- testRepo,
- testBranches,
- testPRs
- );
- expect(result).toBe(false);
- });
-
- test('should handle JSON parsing errors gracefully', () => {
- // Set invalid JSON in cache
- const cacheKey = branchListingCacheService.getCacheKey(testOwner, testRepo);
- mockLocalStorage[cacheKey] = 'invalid json';
-
- const cached = branchListingCacheService.getCachedData(testOwner, testRepo);
- expect(cached).toBeNull();
- });
- });
-});
\ No newline at end of file
diff --git a/src/tests/BranchListing.test.js b/src/tests/BranchListing.test.js
deleted file mode 100644
index ef588126a..000000000
--- a/src/tests/BranchListing.test.js
+++ /dev/null
@@ -1,208 +0,0 @@
-import React from 'react';
-import { render, screen, fireEvent, waitFor } from '@testing-library/react';
-import { BrowserRouter } from 'react-router-dom';
-import BranchListing from '../components/BranchListing';
-
-// Mock fetch globally
-global.fetch = jest.fn();
-
-// Mock localStorage for cache service
-const localStorageMock = {
- getItem: jest.fn(() => null),
- setItem: jest.fn(),
- removeItem: jest.fn(),
- clear: jest.fn()
-};
-global.localStorage = localStorageMock;
-
-// Mock the cache service
-jest.mock('../services/branchListingCacheService', () => ({
- getCachedData: jest.fn(() => null),
- setCachedData: jest.fn(() => true),
- getCacheInfo: jest.fn(() => ({ exists: false, stale: true })),
- forceRefresh: jest.fn(() => true)
-}));
-
-// Mock PageLayout component
-jest.mock('../components/framework', () => ({
- PageLayout: ({ children, pageName, showMascot }) => (
-
- {children}
-
- )
-}));
-
-// Mock HelpModal component
-jest.mock('../components/HelpModal', () => {
- return function MockHelpModal({ helpTopic, onClose }) {
- return (
-
-
{helpTopic.title}
- Close
-
- );
- };
-});
-
-describe('BranchListing Component', () => {
- beforeEach(() => {
- fetch.mockClear();
- });
-
- it('renders loading state initially', () => {
- // Mock fetch to never resolve to test loading state
- fetch.mockImplementation(() => new Promise(() => {}));
-
- render(
-
-
-
- );
-
- expect(screen.getByText('Loading previews...')).toBeInTheDocument();
- });
-
- it('renders deployment selection page with PR functionality', async () => {
- // Mock successful API responses
- const mockPRs = [
- {
- id: 1,
- number: 123,
- title: 'Test PR',
- state: 'open',
- user: { login: 'testuser' },
- head: { ref: 'feature/test-pr' },
- html_url: 'https://github.com/litlfred/sgex/pull/123',
- updated_at: '2023-01-01T00:00:00Z',
- created_at: '2023-01-01T00:00:00Z'
- }
- ];
-
- fetch
- .mockResolvedValueOnce({
- ok: true,
- json: () => Promise.resolve(mockPRs)
- })
- // Mock deployment status checks (will return HEAD requests)
- .mockResolvedValue({
- ok: true,
- status: 200
- });
-
- render(
-
-
-
- );
-
- // Wait for data to load
- await waitFor(() => {
- expect(screen.getByText(/SGEX Deployment Selection/)).toBeInTheDocument();
- expect(screen.getByText(/Pull Request Previews \(\d+\)/)).toBeInTheDocument();
- });
-
- // Check that sections are properly displayed
- expect(screen.getByText(/đ Main Branch/)).toBeInTheDocument();
- expect(screen.getByText(/Pull Request Previews \(\d+\)/)).toBeInTheDocument();
- });
-
- it('allows PR filtering', async () => {
- const mockPRs = [
- {
- id: 1,
- number: 123,
- title: 'Add new feature',
- state: 'open',
- user: { login: 'testuser' },
- head: { ref: 'feature/new-feature' },
- html_url: 'https://github.com/test/repo/pull/123',
- updated_at: '2023-01-01T00:00:00Z',
- created_at: '2023-01-01T00:00:00Z'
- },
- {
- id: 2,
- number: 124,
- title: 'Fix authentication bug',
- state: 'open',
- user: { login: 'developer' },
- head: { ref: 'fix/auth-bug' },
- html_url: 'https://github.com/test/repo/pull/124',
- updated_at: '2023-01-02T00:00:00Z',
- created_at: '2023-01-02T00:00:00Z'
- }
- ];
-
- fetch
- .mockResolvedValueOnce({
- ok: true,
- json: () => Promise.resolve(mockPRs)
- })
- .mockResolvedValue({
- ok: true,
- status: 200
- });
-
- render(
-
-
-
- );
-
- await waitFor(() => {
- expect(screen.getByText(/Add new feature/)).toBeInTheDocument();
- expect(screen.getByText(/Fix authentication bug/)).toBeInTheDocument();
- });
-
- // Test PR filtering
- const prSearchInput = screen.getByPlaceholderText('Search pull requests by title or author...');
- fireEvent.change(prSearchInput, { target: { value: 'authentication' } });
-
- // Should still see authentication PR, but not the feature PR
- expect(screen.getByText(/Fix authentication bug/)).toBeInTheDocument();
- expect(screen.queryByText(/Add new feature/)).not.toBeInTheDocument();
- });
-
- it('handles API errors gracefully', async () => {
- fetch.mockRejectedValue(new Error('Network error'));
-
- render(
-
-
-
- );
-
- await waitFor(() => {
- expect(screen.getByText(/Failed to load previews/)).toBeInTheDocument();
- expect(screen.getByText(/Please try refreshing the page/)).toBeInTheDocument();
- });
- });
-
- it('shows contribute modal when button is clicked', async () => {
- fetch
- .mockResolvedValueOnce({
- ok: true,
- json: () => Promise.resolve([])
- })
- .mockResolvedValueOnce({
- ok: true,
- json: () => Promise.resolve([])
- });
-
- render(
-
-
-
- );
-
- await waitFor(() => {
- expect(screen.getByText(/How to Contribute/)).toBeInTheDocument();
- });
-
- // Click the contribute button
- fireEvent.click(screen.getByText(/How to Contribute/));
-
- // Should show the modal
- expect(screen.getByTestId('help-modal')).toBeInTheDocument();
- expect(screen.getByText('How to Contribute to SGEX')).toBeInTheDocument();
- });
-});
\ No newline at end of file
diff --git a/src/tests/BranchListingCaching.test.js b/src/tests/BranchListingCaching.test.js
deleted file mode 100644
index 3fb2d788a..000000000
--- a/src/tests/BranchListingCaching.test.js
+++ /dev/null
@@ -1,223 +0,0 @@
-/**
- * Integration test for BranchListing caching functionality
- */
-
-import React from 'react';
-import { render, screen, waitFor } from '@testing-library/react';
-import { MemoryRouter } from 'react-router-dom';
-import BranchListing from '../components/BranchListing';
-import branchListingCacheService from '../services/branchListingCacheService';
-
-// Mock GitHub API responses
-const mockPRData = [
- {
- id: 1,
- number: 123,
- title: 'Test PR',
- state: 'open',
- user: { login: 'testuser' },
- head: { ref: 'test-branch' },
- html_url: 'https://github.com/litlfred/sgex/pull/123',
- updated_at: new Date().toISOString(),
- created_at: new Date(Date.now() - 86400000).toISOString()
- }
-];
-
-const mockBranchData = [
- {
- name: 'main',
- commit: {
- sha: 'abc123',
- commit: {
- committer: {
- date: new Date().toISOString()
- }
- }
- }
- }
-];
-
-// Mock fetch
-global.fetch = jest.fn();
-
-// Mock GitHub Actions service
-jest.mock('../services/githubActionsService', () => ({
- setToken: jest.fn(),
- getWorkflowStatusForBranches: jest.fn(() => Promise.resolve({})),
- triggerWorkflow: jest.fn(() => Promise.resolve(true))
-}));
-
-// Mock hooks
-jest.mock('../hooks/useThemeImage', () => ({
- __esModule: true,
- default: () => '/mock-mascot.png'
-}));
-
-describe('BranchListing Caching Integration', () => {
- beforeEach(() => {
- // Clear cache before each test
- branchListingCacheService.clearAllCaches();
- jest.clearAllMocks();
-
- // Reset fetch mock
- fetch.mockClear();
- });
-
- test('should cache PR data after initial fetch', async () => {
- // Setup fetch mocks - only PR data needed now
- fetch.mockResolvedValueOnce({
- ok: true,
- json: () => Promise.resolve(mockPRData)
- });
-
- // Render component
- render(
-
-
-
- );
-
- // Wait for data to load
- await waitFor(() => {
- expect(screen.getByText('#123: Test PR')).toBeInTheDocument();
- });
-
- // Verify cache contains data
- const cachedData = branchListingCacheService.getCachedData('litlfred', 'sgex');
- expect(cachedData).not.toBeNull();
- expect(cachedData.pullRequests).toHaveLength(1);
- expect(cachedData.pullRequests[0].title).toBe('Test PR');
- });
-
- test('should use cached data on subsequent renders', async () => {
- // Pre-populate cache
- const testBranches = []; // Empty branches since we don't use them anymore
-
- const testPRs = [{
- id: 1,
- number: 124,
- title: 'Cached PR',
- state: 'open',
- author: 'cacheduser',
- branchName: 'cached-branch',
- safeBranchName: 'cached-branch',
- url: './cached-branch/index.html',
- prUrl: 'https://github.com/litlfred/sgex/pull/124',
- updatedAt: new Date().toLocaleDateString(),
- createdAt: new Date(Date.now() - 86400000).toLocaleDateString()
- }];
-
- branchListingCacheService.setCachedData('litlfred', 'sgex', testBranches, testPRs);
-
- // Render component
- render(
-
-
-
- );
-
- // Wait for cached data to load
- await waitFor(() => {
- expect(screen.getByText('#124: Cached PR')).toBeInTheDocument();
- });
-
- // Verify fetch was not called for PR data (using cache)
- // Note: fetch may be called for deployment status checks and comments, which is expected
- const prFetchCalls = fetch.mock.calls.filter(call =>
- call[0].includes('/pulls?state=') || call[0].includes('/repos/litlfred/sgex/pulls')
- );
- expect(prFetchCalls).toHaveLength(0);
- });
-
- test('should show cache status information', async () => {
- // Pre-populate cache with recent data
- const testBranches = []; // Empty branches
- const testPRs = [{
- id: 1,
- number: 125,
- title: 'Cache Status Test PR',
- state: 'open',
- author: 'testuser',
- branchName: 'test-cache-status',
- safeBranchName: 'test-cache-status',
- url: './test-cache-status/index.html',
- prUrl: 'https://github.com/litlfred/sgex/pull/125',
- updatedAt: new Date().toLocaleDateString(),
- createdAt: new Date().toLocaleDateString()
- }];
-
- branchListingCacheService.setCachedData('litlfred', 'sgex', testBranches, testPRs);
-
- // Render component
- render(
-
-
-
- );
-
- // Wait for cache status to appear
- await waitFor(() => {
- expect(screen.getByText(/đ Data cached/)).toBeInTheDocument();
- expect(screen.getByText(/Refresh/)).toBeInTheDocument();
- });
- });
-
- test('should expire stale cache after 5 minutes', () => {
- // Create stale cache data (6 minutes old)
- const staleTimestamp = Date.now() - (6 * 60 * 1000);
- const staleData = {
- branches: [],
- pullRequests: [],
- timestamp: staleTimestamp,
- owner: 'litlfred',
- repo: 'sgex'
- };
-
- // Manually set stale data in localStorage
- localStorage.setItem('sgex_branch_listing_cache_litlfred_sgex', JSON.stringify(staleData));
-
- // Try to get cached data
- const cachedData = branchListingCacheService.getCachedData('litlfred', 'sgex');
-
- // Should return null for stale data
- expect(cachedData).toBeNull();
-
- // Should remove stale data from localStorage
- expect(localStorage.getItem('sgex_branch_listing_cache_litlfred_sgex')).toBeNull();
- });
-
- test('should provide cache info correctly', () => {
- // Cache some test data
- const testBranches = [];
- const testPRs = [{
- id: 1,
- number: 126,
- title: 'Cache Info Test',
- state: 'open'
- }];
-
- branchListingCacheService.setCachedData('litlfred', 'sgex', testBranches, testPRs);
-
- // Get cache info
- const cacheInfo = branchListingCacheService.getCacheInfo('litlfred', 'sgex');
-
- expect(cacheInfo.exists).toBe(true);
- expect(cacheInfo.stale).toBe(false);
- expect(cacheInfo.prCount).toBe(1);
- expect(cacheInfo.ageMinutes).toBe(0);
- });
-
- test('should clear cache on force refresh', () => {
- // Cache some test data
- branchListingCacheService.setCachedData('litlfred', 'sgex', [], []);
-
- // Verify data is cached
- expect(branchListingCacheService.getCachedData('litlfred', 'sgex')).not.toBeNull();
-
- // Force refresh
- branchListingCacheService.forceRefresh('litlfred', 'sgex');
-
- // Verify cache is cleared
- expect(branchListingCacheService.getCachedData('litlfred', 'sgex')).toBeNull();
- });
-});
\ No newline at end of file
diff --git a/src/tests/BranchListingComments.test.js b/src/tests/BranchListingComments.test.js
deleted file mode 100644
index e3f97265e..000000000
--- a/src/tests/BranchListingComments.test.js
+++ /dev/null
@@ -1,176 +0,0 @@
-import React from 'react';
-import { render, screen, fireEvent, waitFor } from '@testing-library/react';
-import { BrowserRouter } from 'react-router-dom';
-import BranchListing from '../components/BranchListing';
-
-// Mock fetch globally
-global.fetch = jest.fn();
-
-// Mock PageLayout component
-jest.mock('../components/framework', () => ({
- PageLayout: ({ children, pageName, showMascot }) => (
-
- {children}
-
- )
-}));
-
-// Mock HelpModal component
-jest.mock('../components/HelpModal', () => {
- return function MockHelpModal({ helpTopic, onClose }) {
- return (
-
-
{helpTopic.title}
- Close
-
- );
- };
-});
-
-// Mock WorkflowStatus component
-jest.mock('../components/WorkflowStatus', () => {
- return function MockWorkflowStatus() {
- return Workflow Status
;
- };
-});
-
-describe('BranchListing Comments Feature', () => {
- beforeEach(() => {
- fetch.mockClear();
- // Clear any stored tokens
- sessionStorage.clear();
- localStorage.clear();
- });
-
- it('shows discussion summaries for unauthenticated users', async () => {
- const mockBranches = [];
- const mockPRs = [
- {
- id: 1,
- number: 123,
- title: 'Test PR with Comments',
- state: 'open',
- user: { login: 'testuser' },
- head: { ref: 'feature/test-pr' },
- html_url: 'https://github.com/litlfred/sgex/pull/123',
- updated_at: '2023-01-01T00:00:00Z',
- created_at: '2023-01-01T00:00:00Z'
- }
- ];
-
- const mockComments = [
- {
- id: 1,
- user: {
- login: 'commenter1',
- avatar_url: 'https://github.com/commenter1.png'
- },
- body: 'This is a test comment',
- created_at: '2023-01-01T12:00:00Z'
- }
- ];
-
- fetch
- .mockResolvedValueOnce({
- ok: true,
- json: () => Promise.resolve(mockBranches)
- })
- .mockResolvedValueOnce({
- ok: true,
- json: () => Promise.resolve(mockPRs)
- })
- // Mock deployment status checks
- .mockResolvedValueOnce({
- ok: true,
- status: 200
- })
- // Mock comment summary fetch
- .mockResolvedValueOnce({
- ok: true,
- json: () => Promise.resolve(mockComments)
- });
-
- render(
-
-
-
- );
-
- // Wait for data to load and switch to PR tab
- await waitFor(() => {
- expect(screen.getByText(/Pull Request Previews/)).toBeInTheDocument();
- });
-
- // Click on PR tab to show PRs
- fireEvent.click(screen.getByText(/Pull Request Previews/));
-
- await waitFor(() => {
- expect(screen.getByText('Test PR with Comments')).toBeInTheDocument();
- });
-
- // Should show discussion summary even without authentication
- await waitFor(() => {
- expect(screen.getByText(/đŦ/)).toBeInTheDocument();
- });
- });
-
- it('shows sign-in message for unauthenticated users in expanded discussion', async () => {
- const mockPRs = [
- {
- id: 1,
- number: 123,
- title: 'Test PR',
- state: 'open',
- user: { login: 'testuser' },
- head: { ref: 'feature/test-pr' },
- html_url: 'https://github.com/litlfred/sgex/pull/123',
- updated_at: '2023-01-01T00:00:00Z',
- created_at: '2023-01-01T00:00:00Z'
- }
- ];
-
- fetch
- .mockResolvedValueOnce({
- ok: true,
- json: () => Promise.resolve([])
- })
- .mockResolvedValueOnce({
- ok: true,
- json: () => Promise.resolve(mockPRs)
- })
- .mockResolvedValue({
- ok: true,
- json: () => Promise.resolve([]),
- status: 200
- });
-
- render(
-
-
-
- );
-
- // Wait for PR tab to be available and click it
- await waitFor(() => {
- expect(screen.getByText(/Pull Request Previews/)).toBeInTheDocument();
- });
-
- fireEvent.click(screen.getByText(/Pull Request Previews/));
-
- // Wait for PR to appear and look for discussion section
- await waitFor(() => {
- expect(screen.getByText('Test PR')).toBeInTheDocument();
- });
-
- // Find and click the discussion summary bar to expand
- const discussionBar = screen.getByText(/đŦ/);
- if (discussionBar) {
- fireEvent.click(discussionBar.closest('.discussion-summary-bar'));
-
- // Should show sign-in message for unauthenticated users
- await waitFor(() => {
- expect(screen.getByText(/Sign in to add comments/)).toBeInTheDocument();
- });
- }
- });
-});
\ No newline at end of file
diff --git a/src/tests/BranchListingSearchByBranch.test.js b/src/tests/BranchListingSearchByBranch.test.js
deleted file mode 100644
index 12bffae23..000000000
--- a/src/tests/BranchListingSearchByBranch.test.js
+++ /dev/null
@@ -1,173 +0,0 @@
-import React from 'react';
-import { render, screen, fireEvent } from '@testing-library/react';
-
-// Mock BranchListing component to test search functionality
-const mockPullRequests = [
- {
- id: 1,
- number: 123,
- title: 'Improve multi-page selector landing page for GitHub deployment',
- state: 'open',
- author: 'copilot',
- branchName: 'copilot/fix-459',
- safeBranchName: 'copilot-fix-459',
- url: './copilot-fix-459/index.html',
- prUrl: 'https://github.com/litlfred/sgex/pull/123',
- updatedAt: '8/4/2025',
- createdAt: '8/3/2025'
- },
- {
- id: 2,
- number: 122,
- title: 'Add dark mode support',
- state: 'closed',
- author: 'developer',
- branchName: 'feature/dark-mode',
- safeBranchName: 'feature-dark-mode',
- url: './feature-dark-mode/index.html',
- prUrl: 'https://github.com/litlfred/sgex/pull/122',
- updatedAt: '8/2/2025',
- createdAt: '7/31/2025'
- },
- {
- id: 3,
- number: 121,
- title: 'Fix authentication flow',
- state: 'open',
- author: 'contributor',
- branchName: 'fix/auth-flow',
- safeBranchName: 'fix-auth-flow',
- url: './fix-auth-flow/index.html',
- prUrl: 'https://github.com/litlfred/sgex/pull/121',
- updatedAt: '8/1/2025',
- createdAt: '7/30/2025'
- }
-];
-
-// Test search filter logic
-const searchFilter = (pullRequests, searchTerm) => {
- return pullRequests.filter(pr =>
- pr.title.toLowerCase().includes(searchTerm.toLowerCase()) ||
- pr.author.toLowerCase().includes(searchTerm.toLowerCase()) ||
- pr.branchName.toLowerCase().includes(searchTerm.toLowerCase())
- );
-};
-
-describe('BranchListing Search Functionality', () => {
- describe('Branch Name Search', () => {
- it('should filter PRs by exact branch name match', () => {
- const results = searchFilter(mockPullRequests, 'copilot/fix-459');
-
- expect(results).toHaveLength(1);
- expect(results[0].number).toBe(123);
- expect(results[0].branchName).toBe('copilot/fix-459');
- });
-
- it('should filter PRs by partial branch name match', () => {
- const results = searchFilter(mockPullRequests, 'dark-mode');
-
- expect(results).toHaveLength(1);
- expect(results[0].number).toBe(122);
- expect(results[0].branchName).toBe('feature/dark-mode');
- });
-
- it('should find multiple PRs when branch names contain the search term', () => {
- const results = searchFilter(mockPullRequests, 'fix');
-
- expect(results).toHaveLength(2);
- expect(results.map(pr => pr.number)).toContain(123); // copilot/fix-459
- expect(results.map(pr => pr.number)).toContain(121); // fix/auth-flow
- });
-
- it('should be case insensitive for branch names', () => {
- const results = searchFilter(mockPullRequests, 'FEATURE');
-
- expect(results).toHaveLength(1);
- expect(results[0].branchName).toBe('feature/dark-mode');
- });
-
- it('should search by branch name prefix', () => {
- const results = searchFilter(mockPullRequests, 'copilot');
-
- expect(results).toHaveLength(1);
- expect(results[0].branchName).toBe('copilot/fix-459');
- });
-
- it('should search by branch name suffix', () => {
- const results = searchFilter(mockPullRequests, 'auth-flow');
-
- expect(results).toHaveLength(1);
- expect(results[0].branchName).toBe('fix/auth-flow');
- });
- });
-
- describe('Combined Search (Title, Author, Branch)', () => {
- it('should find PRs matching title OR author OR branch name', () => {
- // Search for "fix" should match:
- // - PR 123: branch "copilot/fix-459" (branch match)
- // - PR 121: title "Fix authentication flow" AND branch "fix/auth-flow" (both match)
- const results = searchFilter(mockPullRequests, 'fix');
-
- expect(results).toHaveLength(2);
- expect(results.map(pr => pr.number).sort()).toEqual([121, 123]);
- });
-
- it('should maintain existing title search functionality', () => {
- const results = searchFilter(mockPullRequests, 'authentication');
-
- expect(results).toHaveLength(1);
- expect(results[0].number).toBe(121);
- expect(results[0].title).toContain('authentication');
- });
-
- it('should maintain existing author search functionality', () => {
- const results = searchFilter(mockPullRequests, 'developer');
-
- expect(results).toHaveLength(1);
- expect(results[0].number).toBe(122);
- expect(results[0].author).toBe('developer');
- });
-
- it('should return no results for non-matching search terms', () => {
- const results = searchFilter(mockPullRequests, 'nonexistent');
-
- expect(results).toHaveLength(0);
- });
-
- it('should return all PRs for empty search term', () => {
- const results = searchFilter(mockPullRequests, '');
-
- expect(results).toHaveLength(3);
- });
- });
-
- describe('Edge Cases', () => {
- it('should handle special characters in branch names', () => {
- const specialPR = {
- ...mockPullRequests[0],
- branchName: 'feature/fix-#123-bug',
- number: 999
- };
- const prList = [...mockPullRequests, specialPR];
-
- const results = searchFilter(prList, '#123');
-
- expect(results).toHaveLength(1);
- expect(results[0].number).toBe(999);
- });
-
- it('should handle slashes in branch names', () => {
- const results = searchFilter(mockPullRequests, 'feature/');
-
- expect(results).toHaveLength(1);
- expect(results[0].branchName).toBe('feature/dark-mode');
- });
-
- it('should handle dashes in branch names', () => {
- const results = searchFilter(mockPullRequests, '-mode');
-
- expect(results).toHaveLength(1);
- expect(results[0].branchName).toBe('feature/dark-mode');
- });
- });
-});
\ No newline at end of file
diff --git a/src/tests/CommentErrorHandling.test.js b/src/tests/CommentErrorHandling.test.js
deleted file mode 100644
index cb233b0a0..000000000
--- a/src/tests/CommentErrorHandling.test.js
+++ /dev/null
@@ -1,278 +0,0 @@
-import React from 'react';
-import { render, screen, fireEvent, waitFor } from '@testing-library/react';
-import { BrowserRouter } from 'react-router-dom';
-import BranchListing from '../components/BranchListing';
-
-// Mock fetch globally
-global.fetch = jest.fn();
-
-// Mock PageLayout component
-jest.mock('../components/framework', () => ({
- PageLayout: ({ children, pageName, showMascot }) => (
-
- {children}
-
- )
-}));
-
-// Mock HelpModal component
-jest.mock('../components/HelpModal', () => {
- return function MockHelpModal({ helpTopic, onClose }) {
- return (
-
-
{helpTopic.title}
- Close
-
- );
- };
-});
-
-// Mock WorkflowStatus component
-jest.mock('../components/WorkflowStatus', () => {
- return function MockWorkflowStatus() {
- return Workflow Status
;
- };
-});
-
-describe('Comment Error Handling', () => {
- beforeEach(() => {
- fetch.mockClear();
- // Clear any stored tokens
- sessionStorage.clear();
- localStorage.clear();
- });
-
- it('shows error message when comment submission fails with 401 (authentication)', async () => {
- // Set up authentication
- sessionStorage.setItem('github_token', 'test-token');
-
- const mockPRs = [
- {
- id: 1,
- number: 123,
- title: 'Test PR',
- state: 'open',
- user: { login: 'testuser' },
- head: { ref: 'feature/test-pr' },
- html_url: 'https://github.com/litlfred/sgex/pull/123',
- updated_at: '2023-01-01T00:00:00Z',
- created_at: '2023-01-01T00:00:00Z'
- }
- ];
-
- // Mock successful initial API calls
- fetch
- .mockResolvedValueOnce({
- ok: true,
- json: () => Promise.resolve([])
- })
- .mockResolvedValueOnce({
- ok: true,
- json: () => Promise.resolve(mockPRs)
- })
- .mockResolvedValue({
- ok: true,
- json: () => Promise.resolve([]),
- status: 200
- });
-
- render(
-
-
-
- );
-
- // Wait for PR tab and click it
- await waitFor(() => {
- expect(screen.getByText(/Pull Request Previews/)).toBeInTheDocument();
- });
-
- fireEvent.click(screen.getByText(/Pull Request Previews/));
-
- // Wait for PR to appear
- await waitFor(() => {
- expect(screen.getByText('Test PR')).toBeInTheDocument();
- });
-
- // Find and click the discussion summary to expand
- const discussionBar = screen.getByText(/đŦ/);
- fireEvent.click(discussionBar.closest('.discussion-summary-bar'));
-
- // Wait for comment input to appear
- await waitFor(() => {
- expect(screen.getByPlaceholderText('Add a comment...')).toBeInTheDocument();
- });
-
- // Type a comment
- const commentInput = screen.getByPlaceholderText('Add a comment...');
- fireEvent.change(commentInput, { target: { value: 'Test comment' } });
-
- // Mock failed comment submission with 401 error
- fetch.mockResolvedValueOnce({
- ok: false,
- status: 401
- });
-
- // Click submit button
- const submitButton = screen.getByText('Add Comment');
- fireEvent.click(submitButton);
-
- // Wait for error message to appear
- await waitFor(() => {
- expect(screen.getByText(/Authentication failed. Please check your token permissions./)).toBeInTheDocument();
- });
- });
-
- it('shows error message when comment submission fails with 403 (permission denied)', async () => {
- sessionStorage.setItem('github_token', 'test-token');
-
- const mockPRs = [
- {
- id: 1,
- number: 123,
- title: 'Test PR',
- state: 'open',
- user: { login: 'testuser' },
- head: { ref: 'feature/test-pr' },
- html_url: 'https://github.com/litlfred/sgex/pull/123',
- updated_at: '2023-01-01T00:00:00Z',
- created_at: '2023-01-01T00:00:00Z'
- }
- ];
-
- fetch
- .mockResolvedValueOnce({
- ok: true,
- json: () => Promise.resolve([])
- })
- .mockResolvedValueOnce({
- ok: true,
- json: () => Promise.resolve(mockPRs)
- })
- .mockResolvedValue({
- ok: true,
- json: () => Promise.resolve([]),
- status: 200
- });
-
- render(
-
-
-
- );
-
- await waitFor(() => {
- expect(screen.getByText(/Pull Request Previews/)).toBeInTheDocument();
- });
-
- fireEvent.click(screen.getByText(/Pull Request Previews/));
-
- await waitFor(() => {
- expect(screen.getByText('Test PR')).toBeInTheDocument();
- });
-
- const discussionBar = screen.getByText(/đŦ/);
- fireEvent.click(discussionBar.closest('.discussion-summary-bar'));
-
- await waitFor(() => {
- expect(screen.getByPlaceholderText('Add a comment...')).toBeInTheDocument();
- });
-
- const commentInput = screen.getByPlaceholderText('Add a comment...');
- fireEvent.change(commentInput, { target: { value: 'Test comment' } });
-
- // Mock failed comment submission with 403 error
- fetch.mockResolvedValueOnce({
- ok: false,
- status: 403
- });
-
- const submitButton = screen.getByText('Add Comment');
- fireEvent.click(submitButton);
-
- await waitFor(() => {
- expect(screen.getByText(/Permission denied. You may not have write access to this repository./)).toBeInTheDocument();
- });
- });
-
- it('clears error message when user starts typing', async () => {
- sessionStorage.setItem('github_token', 'test-token');
-
- const mockPRs = [
- {
- id: 1,
- number: 123,
- title: 'Test PR',
- state: 'open',
- user: { login: 'testuser' },
- head: { ref: 'feature/test-pr' },
- html_url: 'https://github.com/litlfred/sgex/pull/123',
- updated_at: '2023-01-01T00:00:00Z',
- created_at: '2023-01-01T00:00:00Z'
- }
- ];
-
- fetch
- .mockResolvedValueOnce({
- ok: true,
- json: () => Promise.resolve([])
- })
- .mockResolvedValueOnce({
- ok: true,
- json: () => Promise.resolve(mockPRs)
- })
- .mockResolvedValue({
- ok: true,
- json: () => Promise.resolve([]),
- status: 200
- });
-
- render(
-
-
-
- );
-
- await waitFor(() => {
- expect(screen.getByText(/Pull Request Previews/)).toBeInTheDocument();
- });
-
- fireEvent.click(screen.getByText(/Pull Request Previews/));
-
- await waitFor(() => {
- expect(screen.getByText('Test PR')).toBeInTheDocument();
- });
-
- const discussionBar = screen.getByText(/đŦ/);
- fireEvent.click(discussionBar.closest('.discussion-summary-bar'));
-
- await waitFor(() => {
- expect(screen.getByPlaceholderText('Add a comment...')).toBeInTheDocument();
- });
-
- const commentInput = screen.getByPlaceholderText('Add a comment...');
- fireEvent.change(commentInput, { target: { value: 'Test comment' } });
-
- // Mock failed comment submission
- fetch.mockResolvedValueOnce({
- ok: false,
- status: 500
- });
-
- const submitButton = screen.getByText('Add Comment');
- fireEvent.click(submitButton);
-
- // Wait for error message to appear
- await waitFor(() => {
- expect(screen.getByText(/GitHub server error. Please try again later./)).toBeInTheDocument();
- });
-
- // Start typing again - error should clear
- fireEvent.change(commentInput, { target: { value: 'New comment text' } });
-
- // Error message should no longer be visible
- await waitFor(() => {
- expect(screen.queryByText(/GitHub server error. Please try again later./)).not.toBeInTheDocument();
- });
- });
-});
\ No newline at end of file
diff --git a/src/tests/preview-url-linkable.test.js b/src/tests/preview-url-linkable.test.js
deleted file mode 100644
index 964d2e893..000000000
--- a/src/tests/preview-url-linkable.test.js
+++ /dev/null
@@ -1,71 +0,0 @@
-import React from 'react';
-import { render, screen } from '@testing-library/react';
-import { BrowserRouter } from 'react-router-dom';
-import BranchListing from '../components/BranchListing';
-
-// Mock fetch globally
-global.fetch = jest.fn();
-
-// Mock PageLayout component
-jest.mock('../components/framework', () => ({
- PageLayout: ({ children }) => {children}
-}));
-
-// Mock HelpModal component
-jest.mock('../components/HelpModal', () => {
- return function MockHelpModal({ onClose }) {
- return Close
;
- };
-});
-
-describe('BranchListing Preview URL Links', () => {
- beforeEach(() => {
- fetch.mockClear();
- });
-
- it('should make preview URLs clickable links', async () => {
- const mockPRs = [
- {
- id: 1,
- number: 123,
- title: 'Test PR',
- state: 'open',
- user: { login: 'testuser' },
- head: { ref: 'feature/test-pr' },
- html_url: 'https://github.com/litlfred/sgex/pull/123',
- updated_at: '2023-01-01T00:00:00Z',
- created_at: '2023-01-01T00:00:00Z'
- }
- ];
-
- fetch
- .mockResolvedValueOnce({
- ok: true,
- json: () => Promise.resolve(mockPRs)
- })
- .mockResolvedValue({ ok: true, status: 200 });
-
- render(
-
-
-
- );
-
- // Wait for data to load and find preview URL links
- await screen.findByText(/Pull Request Previews \(\d+\)/);
-
- // Check that preview URLs in card footers are clickable links
- const previewUrlLinks = screen.getAllByText(/Preview URL:/);
-
- // Each preview URL should be within a link element or should be a clickable link
- previewUrlLinks.forEach((element) => {
- const parent = element.closest('.card-footer');
- expect(parent).toBeInTheDocument();
-
- // Look for a link within the same card footer
- const linkElement = parent.querySelector('a[href]');
- expect(linkElement).toBeInTheDocument();
- expect(linkElement.getAttribute('href')).toMatch(/\.\/.*\/index\.html/);
- });
- });
-});
\ No newline at end of file