handleSort('state')}
+ onClick={() => handleSort('availability')}
>
- State {sortColumn === 'state' && (sortDirection === 'asc' ? 'โ' : 'โ')}
+ Availability {sortColumn === 'availability' && (sortDirection === 'asc' ? 'โ' : 'โ')}
Labels
@@ -490,24 +531,7 @@ export function IssueTable({ issues, initialRepository }: IssueTableProps) {
)}
- {issue.githubData?.state && (
-
- {issue.githubData.state}
-
- )}
+
@@ -558,6 +582,60 @@ export function IssueTable({ issues, initialRepository }: IssueTableProps) {
)
}
+function AvailabilityBadge({ issue }: { issue: EnrichedIssue }) {
+ if (!issue.availability) return null
+
+ const style = {
+ padding: '4px 8px',
+ borderRadius: '4px',
+ fontSize: '12px',
+ fontWeight: 500,
+ whiteSpace: 'nowrap' as const,
+ ...getAvailabilityBadgeColor(issue.availability),
+ }
+ const label = availabilityOptions.find((option) => option.value === issue.availability)?.label
+
+ // Link straight to the PR so people can see who is already on it
+ const pr = issue.linkedPRs?.[0]
+ if (issue.availability === 'has-pr' && pr) {
+ return (
+
+ {label} #{pr.number}
+
+ )
+ }
+
+ const title =
+ issue.availability === 'assigned' && issue.assignees?.length
+ ? `Assigned to ${issue.assignees.map((login) => `@${login}`).join(', ')}`
+ : undefined
+
+ return (
+
+ {label}
+
+ )
+}
+
+function getAvailabilityBadgeColor(availability: IssueAvailability): { background: string; color: string } {
+ switch (availability) {
+ case 'available':
+ return { background: '#d4edda', color: '#155724' }
+ case 'assigned':
+ return { background: '#fef3c7', color: '#92400e' }
+ case 'has-pr':
+ return { background: '#dbeafe', color: '#1e40af' }
+ case 'closed':
+ return { background: '#e5e7eb', color: '#374151' }
+ }
+}
+
const thStyle: React.CSSProperties = {
padding: '12px',
textAlign: 'left',
diff --git a/components/Sidebar.tsx b/components/Sidebar.tsx
index ec6f3fc0..6af70164 100644
--- a/components/Sidebar.tsx
+++ b/components/Sidebar.tsx
@@ -10,6 +10,7 @@ const navigationLinks = [
{ href: '/', label: 'Welcome', emoji: '๐', image: 'waving_drk.png' },
{ href: '/getting-started/', label: 'Getting Started', emoji: '๐', image: 'walking_drk.png' },
{ href: '/issues/', label: 'Curated Issues', emoji: '๐', image: 'looking_drk.png' },
+ { href: '/ideas/', label: 'Your Ideas', emoji: '๐ก', image: 'building_drk.png' },
{ href: '/contrib-champs/', label: 'Contrib Champs', emoji: '๐', image: 'love_backstage_drk.png' },
{ href: '/hall-of-hosts/', label: 'Hall of Hosts', emoji: '๐', image: 'community_organizer_drk.png' },
]
diff --git a/components/ideas/CopyButton.tsx b/components/ideas/CopyButton.tsx
new file mode 100644
index 00000000..95bf8d86
--- /dev/null
+++ b/components/ideas/CopyButton.tsx
@@ -0,0 +1,50 @@
+'use client'
+
+import { useState } from 'react'
+import { RiCheckLine, RiFileCopyLine } from '@remixicon/react'
+
+interface CopyButtonProps {
+ text: string
+ label?: string
+ disabled?: boolean
+}
+
+export function CopyButton({ text, label = 'Copy', disabled = false }: CopyButtonProps) {
+ const [copied, setCopied] = useState(false)
+
+ const handleCopy = async () => {
+ try {
+ await navigator.clipboard.writeText(text)
+ setCopied(true)
+ setTimeout(() => setCopied(false), 2000)
+ } catch (error) {
+ console.error('Failed to copy to clipboard:', error)
+ }
+ }
+
+ return (
+
+ {copied ? : }
+ {copied ? 'Copied!' : label}
+
+ )
+}
diff --git a/components/ideas/IdeaCanvas.tsx b/components/ideas/IdeaCanvas.tsx
new file mode 100644
index 00000000..0e91f496
--- /dev/null
+++ b/components/ideas/IdeaCanvas.tsx
@@ -0,0 +1,85 @@
+'use client'
+
+import { useLocalStorage } from '@/hooks/useLocalStorage'
+import { canvasQuestions } from '@/lib/ideas'
+import { CopyButton } from './CopyButton'
+
+// Formats only what the attendee typed, under the canvas headings
+function formatNotes(answers: Record
): string {
+ return canvasQuestions
+ .filter((question) => answers[question.id]?.trim())
+ .map((question) => `## ${question.heading}\n\n${answers[question.id].trim()}`)
+ .join('\n\n')
+}
+
+export function IdeaCanvas() {
+ const [answers, setAnswers] = useLocalStorage>('contribfest-idea-canvas', {})
+ const notes = formatNotes(answers)
+
+ const handleReset = () => {
+ if (window.confirm('Clear all of your idea notes? This cannot be undone.')) {
+ setAnswers({})
+ }
+ }
+
+ return (
+
+
+ {canvasQuestions.map((question) => (
+
+
+ {question.heading}
+
+
+ {question.prompt}
+
+
+ ))}
+
+
+
+
+
+ Clear notes
+
+
+ Saved in this browser only.
+
+
+
+ )
+}
diff --git a/components/ideas/PathChooser.tsx b/components/ideas/PathChooser.tsx
new file mode 100644
index 00000000..35c8201d
--- /dev/null
+++ b/components/ideas/PathChooser.tsx
@@ -0,0 +1,141 @@
+'use client'
+
+import { useState } from 'react'
+import { RiExternalLinkLine } from '@remixicon/react'
+import { ideaPaths } from '@/lib/ideas'
+import type { IdeaPathId } from '@/lib/ideas'
+
+type Location = 'core' | 'existing-plugin' | 'new-plugin'
+type Size = 'focused' | 'large'
+
+const locationOptions: Array<{ value: Location; label: string }> = [
+ { value: 'core', label: 'In Backstage itself: the framework or a core feature like the catalog, scaffolder, or auth' },
+ { value: 'existing-plugin', label: 'In a plugin that already exists' },
+ { value: 'new-plugin', label: 'In a brand-new plugin' },
+]
+
+const sizeOptions: Array<{ value: Size; label: string }> = [
+ { value: 'focused', label: 'A focused improvement I can describe in an issue' },
+ { value: 'large', label: 'A larger design that spans several PRs and needs maintainer sign-off' },
+]
+
+function getRecommendedPath(location: Location | null, size: Size | null): IdeaPathId | null {
+ if (location === 'existing-plugin' || location === 'new-plugin') return location
+ if (location === 'core' && size === 'focused') return 'feature-request'
+ if (location === 'core' && size === 'large') return 'bep'
+ return null
+}
+
+export function PathChooser() {
+ const [location, setLocation] = useState(null)
+ const [size, setSize] = useState(null)
+ const pathId = getRecommendedPath(location, size)
+ const path = pathId ? ideaPaths[pathId] : null
+
+ return (
+
+
{
+ setLocation(value)
+ setSize(null)
+ }}
+ />
+
+ {location === 'core' && (
+
+ )}
+
+ {location === 'core' && !size && (
+
+ Not sure? Start with a suggestion issue. It can always be turned into a BEP later.
+
+ )}
+
+ {path && (
+
+
+ Recommended path
+
+
+ {path.title}
+
+
+ {path.summary}
+
+
+ {path.steps.map((step) => (
+ {step}
+ ))}
+
+
+
+ )}
+
+ )
+}
+
+interface OptionGroupProps {
+ legend: string
+ options: Array<{ value: T; label: string }>
+ selected: T | null
+ onSelect: (value: T) => void
+}
+
+function OptionGroup({ legend, options, selected, onSelect }: OptionGroupProps) {
+ return (
+
+
+ {legend}
+
+
+ {options.map((option) => {
+ const isSelected = option.value === selected
+ return (
+ onSelect(option.value)}
+ style={{
+ textAlign: 'left',
+ padding: '12px 16px',
+ borderRadius: '8px',
+ border: `${isSelected ? 2 : 1}px solid ${isSelected ? 'var(--bui-bg-solid, #1f5493)' : 'var(--bui-border-1, #d5d5d5)'}`,
+ background: isSelected ? 'var(--contribfest-progress-bg, #dcfce7)' : 'var(--bui-bg-popover, #fff)',
+ color: 'var(--bui-fg-primary, #000)',
+ fontSize: '15px',
+ cursor: 'pointer',
+ }}
+ >
+ {option.label}
+
+ )
+ })}
+
+
+ )
+}
diff --git a/docs/deployment.md b/docs/deployment.md
index e21df1dd..2535a65d 100644
--- a/docs/deployment.md
+++ b/docs/deployment.md
@@ -1,6 +1,6 @@
# Deployment Guide
-This project automatically deploys to GitHub Pages when code is merged to the `main` branch.
+This project deploys to GitHub Pages at `https://contribfest.backstage.io` when code is merged to `main`, and every hour on a schedule to refresh GitHub data.
## GitHub Pages Setup
@@ -8,7 +8,18 @@ This project automatically deploys to GitHub Pages when code is merged to the `m
1. Go to repository Settings โ Pages
2. Under "Build and deployment" โ "Source", select **"GitHub Actions"**
-3. Save the settings
+3. Set the custom domain to `contribfest.backstage.io`
+
+## GitHub Data Snapshot
+
+The site is a static export with no backend, so the browser never calls the GitHub API. Instead, `scripts/fetch-github-data.mjs` runs during the CD build and writes:
+
+- `public/data/issues.json`: every issue in `public/issues.csv`, with title, labels, assignees, linked PRs, and an availability status (`available`, `assigned`, `has-pr`, `closed`)
+- `public/data/pull-requests.json`: merged PRs labeled `contribfest` (Contrib Champs)
+
+Issues are looked up by number, so the issues page keeps working after the `contribfest` label is removed. Only PRs that would close an issue (`Fixes #123`, or linked in the sidebar) mark it as "Has PR"; plain mentions don't.
+
+The script uses the workflow's `GITHUB_TOKEN` and a few GraphQL points per run. If it fails, the CD run fails and Pages keeps serving the previous deployment.
## Deployment Process
@@ -18,42 +29,66 @@ When you merge a PR to `main`:
1. CI workflow runs (type check, lint, test, build)
2. CD workflow waits for CI to complete
-3. If CI passes:
- - CD builds the app with `/contribfest` base path
- - Deploys to GitHub Pages
- - Site updates at `https://backstage.github.io/contribfest`
-4. If CI fails:
- - CD workflow is skipped
- - No deployment occurs
+3. If CI passes, CD fetches the GitHub data snapshot, builds the app, and deploys to GitHub Pages
+4. If CI fails, no deployment occurs
**Timeline:** ~3-5 minutes from merge to live
-### Manual Deployment
+### Scheduled Refresh
-You can trigger a deployment manually:
+CD also runs every hour (`17 * * * *`) to refresh the GitHub data. Scheduled runs rebuild the current `main`, which has already passed CI, so they skip the CI wait. GitHub can delay or drop scheduled runs during busy periods, so don't rely on the schedule during the session.
-1. Go to Actions tab
-2. Select "CD" workflow
-3. Click "Run workflow"
-4. Choose `main` branch
-5. Click "Run workflow"
+### Manual Deployment / Refresh
-## Local Development
+To refresh the GitHub data immediately (for example, during the session):
+
+1. Go to the Actions tab
+2. Select the "CD" workflow
+3. Click "Run workflow" on `main`
-The app uses `/contribfest` base path to match production:
+The site shows "GitHub data updated X min ago" on the issues and Contrib Champs pages.
+
+## Local Development
```bash
-# Start dev server
+# Fetch the GitHub data snapshot (needs the GitHub CLI, or any token with public read access)
+GITHUB_TOKEN=$(gh auth token) yarn fetch-data
+
+# Start dev server and visit http://localhost:3000
yarn dev
+```
+
+Add `?admin=true` to the issues page URL to bypass the countdown.
+
+### Mock Issues
+
+Pass one or more JSON files to add made-up issues, for example to build or demo the issues page before the real list is curated:
+
+```bash
+# Real issues from GitHub, plus mocks
+GITHUB_TOKEN=$(gh auth token) yarn fetch-data mocks/issues.example.json
-# Visit the app at:
-# http://localhost:3000/contribfest
+# Mocks only: skips GitHub, so no token is needed (Contrib Champs will be empty)
+yarn fetch-data --no-github mocks/issues.example.json mocks/my-extra.json
```
+Each file is a JSON array. Only `repository`, `issueId`, `level`, and `title` are required:
+
+| Field | Default | Notes |
+| --- | --- | --- |
+| `availability` | `available` | `available`, `assigned`, `has-pr`, or `closed` |
+| `labels` | `[]` | Strings, or `{ "name", "color" }` objects |
+| `assignees` | `[]` | GitHub logins, shown in the Assigned tooltip |
+| `linkedPRs` | `[]` | `{ "number", "url"?, "state"?, "author"? }`; the first one is linked from the Has PR badge |
+
+Mocks are added after the real issues. A mock with the same repository and issue number as a real issue (or an earlier mock) replaces it, which is handy for simulating an issue being picked up. Invalid entries stop the script with the file and index of the problem.
+
+`mocks/issues.example.json` covers every availability state, level, and repository, using issue numbers from 90001 so they don't collide with real issues. CD runs `yarn fetch-data` with no arguments, so mock data never reaches production.
+
## Monitoring
- **Deployment Status:** Check the Actions tab for workflow runs
-- **Live Site:** https://backstage.github.io/contribfest
+- **Live Site:** https://contribfest.backstage.io
- **Environment:** View deployment history in Settings โ Environments โ github-pages
## Troubleshooting
@@ -62,10 +97,9 @@ yarn dev
- Verify Pages source is set to "GitHub Actions" in repository settings
- Check workflow has `pages: write` and `id-token: write` permissions
-**Site shows 404 or assets don't load:**
-- Verify `basePath: '/contribfest'` is set in `next.config.ts`
-- Check build output includes all necessary files in the `out` directory
-- Ensure trailing slash is enabled in config
+**"Fetch GitHub data snapshot" step fails:**
+- Check the step logs for the GraphQL error. Issue numbers in `public/issues.csv` that don't exist are only warnings; other errors fail the run.
+- The live site keeps serving the last good deployment, so re-run the workflow once fixed.
**CI passes but CD doesn't run:**
- Check the `check-ci` job logs for the CI workflow status
diff --git a/docs/organizer-runbook.md b/docs/organizer-runbook.md
new file mode 100644
index 00000000..04325103
--- /dev/null
+++ b/docs/organizer-runbook.md
@@ -0,0 +1,46 @@
+# Organizer Runbook
+
+How to run the site for each ContribFest. Event-specific settings live in `lib/event.ts`.
+
+## Kickoff (a few months before the event)
+
+1. In `lib/event.ts`:
+ - Set `CONTRIBFEST_ACTIVE = true`
+ - Set `ISSUES_UNLOCK_AT` to the event day, including the venue's UTC offset, and `EVENT_TIMEZONE` to the venue's timezone
+ - Update `currentSession` (city, date/time/room, session link, slide deck path, assignment sheet URL)
+ - Update `offSeasonMessage` to point at the following event
+2. In `lib/sessions.ts`, add the new session as `comingSoon` and move the previous one to past events.
+3. In `lib/hosts.ts`, add new hosts (avatars in `public/img/avatars/`) and add them to `.github/CODEOWNERS`.
+4. Add the slide deck PDF to `public/` once it's ready.
+
+## Curating Issues
+
+1. Find small, well-scoped issues in `backstage/backstage` and `backstage/community-plugins`.
+2. Add the `contribfest` label. Don't use `good first issue` before the event; those get picked up quickly.
+3. Leave a comment so nobody picks it up early:
+
+ > Do not work on this issue: it's reserved for the Backstage ContribFest at KubeCon on . If it's still open afterwards, it'll be opened up to everyone.
+
+4. Add a row to `public/issues.csv`: `repo,level,issueId` (level is `Beginner`, `Intermediate`, or `Advanced`).
+5. Optionally pin umbrella issues in `lib/pinned-issues.ts`.
+
+## Health Check (week before, and the morning of)
+
+Open `/issues/?admin=true`, set **Availability** to **All**, and look for issues that are no longer available:
+
+- **Closed**: remove from `public/issues.csv`
+- **Has PR**: someone is already working on it; remove or leave as a reference
+- **Assigned**: check whether the assignee is a host or someone else
+
+Replace them with fresh issues if needed. If the list runs low, that's fine: the issues page sends people to `/ideas/` to bring their own ideas.
+
+## During the Session
+
+- GitHub data refreshes every hour. For an immediate refresh, run the **CD** workflow from the Actions tab (takes about 2-3 minutes).
+- Attendees claim issues in the assignment sheet; the site's availability badges show what's already taken on GitHub.
+
+## After the Event
+
+1. Set `CONTRIBFEST_ACTIVE = false` in `lib/event.ts`.
+2. Run the `remove-contribfest-label` Claude skill to remove the label and add `good first issue` to Beginner issues.
+3. Merged PRs labeled `contribfest` will keep appearing on Contrib Champs.
diff --git a/hooks/useIssues.ts b/hooks/useIssues.ts
index 015346f3..35521eb3 100644
--- a/hooks/useIssues.ts
+++ b/hooks/useIssues.ts
@@ -1,57 +1,41 @@
'use client'
-import { useState, useEffect, useCallback } from 'react'
-import { parseIssuesCSV } from '@/lib/csv-parser'
-import { enrichIssuesWithGitHubData } from '@/lib/github-api'
-import type { EnrichedIssue } from '@/lib/types'
+import { useState, useEffect } from 'react'
+import { fetchSnapshot } from '@/lib/snapshot'
+import type { EnrichedIssue, Snapshot } from '@/lib/types'
interface UseIssuesResult {
issues: EnrichedIssue[]
+ generatedAt: string | null
loading: boolean
error: string | null
- progress: { current: number; total: number } | null
}
export function useIssues(shouldLoad = true): UseIssuesResult {
- const [issues, setIssues] = useState([])
- const [loading, setLoading] = useState(shouldLoad)
+ const [snapshot, setSnapshot] = useState | null>(null)
const [error, setError] = useState(null)
- const [progress, setProgress] = useState<{ current: number; total: number } | null>(null)
-
- const loadIssues = useCallback(async () => {
- if (!shouldLoad) {
- setLoading(false)
- return
- }
- try {
- setLoading(true)
- setError(null)
-
- // Step 1: Parse CSV
- const csvIssues = await parseIssuesCSV()
-
- // Step 2: Enrich with GitHub data
- const enrichedIssues = await enrichIssuesWithGitHubData(
- csvIssues,
- (current, total) => {
- setProgress({ current, total })
- }
- )
-
- setIssues(enrichedIssues)
- } catch (err) {
- setError(err instanceof Error ? err.message : 'Failed to load issues')
- } finally {
- setLoading(false)
- setProgress(null)
- }
- }, [shouldLoad])
useEffect(() => {
- if (shouldLoad) {
- loadIssues()
+ if (!shouldLoad) return
+
+ let cancelled = false
+ fetchSnapshot('issues')
+ .then((result) => {
+ if (!cancelled) setSnapshot(result)
+ })
+ .catch((err) => {
+ if (!cancelled) setError(err instanceof Error ? err.message : 'Failed to load issues')
+ })
+
+ return () => {
+ cancelled = true
}
- }, [loadIssues, shouldLoad])
+ }, [shouldLoad])
- return { issues, loading, error, progress }
+ return {
+ issues: snapshot?.items ?? [],
+ generatedAt: snapshot?.generatedAt ?? null,
+ loading: shouldLoad && !snapshot && !error,
+ error,
+ }
}
diff --git a/hooks/usePullRequests.ts b/hooks/usePullRequests.ts
index 6fb5327a..1c02772a 100644
--- a/hooks/usePullRequests.ts
+++ b/hooks/usePullRequests.ts
@@ -1,120 +1,35 @@
'use client'
import { useState, useEffect, useCallback } from 'react'
+import { fetchSnapshot } from '@/lib/snapshot'
import type { GitHubPullRequest } from '@/lib/types'
interface UsePullRequestsReturn {
pullRequests: GitHubPullRequest[]
+ generatedAt: string | null
loading: boolean
error: string | null
refresh: () => void
}
-async function fetchContribfestPullRequests(
- owner: string,
- repo: string
-): Promise {
- const allPRs: GitHubPullRequest[] = []
- let page = 1
- const perPage = 100
-
- console.log(`Searching for merged contribfest PRs in ${owner}/${repo}...`)
-
- try {
- const headers: HeadersInit = {
- Accept: 'application/vnd.github.v3+json',
- }
-
- // Token is optional - works without it but with lower rate limits
- const token = process.env.NEXT_PUBLIC_GITHUB_TOKEN
- if (token && token !== 'your_github_personal_access_token_here') {
- headers.Authorization = `Bearer ${token}`
- console.log('Using GitHub token for authentication')
- } else {
- console.log('No GitHub token - using unauthenticated requests (lower rate limits)')
- }
-
- while (true) {
- // Use search API to filter for merged PRs with contribfest label
- const query = `repo:${owner}/${repo} is:pr is:merged label:contribfest`
- const url = `https://api.github.com/search/issues?q=${encodeURIComponent(query)}&per_page=${perPage}&page=${page}`
- console.log(`Searching page ${page} from ${owner}/${repo}`)
-
- const response = await fetch(url, {
- headers,
- cache: 'no-store',
- })
-
- if (!response.ok) {
- console.error(
- `Failed to search PRs from ${owner}/${repo}: ${response.status} ${response.statusText}`
- )
- break
- }
-
- const data = await response.json() as { total_count: number; items: GitHubPullRequest[] }
- const prs = data.items
- console.log(`Found ${prs.length} PRs from ${owner}/${repo} page ${page} (total: ${data.total_count})`)
-
- if (prs.length === 0) {
- break
- }
-
- // Add repository identifier
- prs.forEach(pr => {
- pr.repository = `${owner}/${repo}`
- })
-
- allPRs.push(...prs)
-
- // If we got fewer than perPage results, we've reached the end
- if (prs.length < perPage) {
- break
- }
-
- page++
- }
-
- console.log(`Total merged contribfest PRs from ${owner}/${repo}: ${allPRs.length}`)
- return allPRs
- } catch (error) {
- console.error(`Error searching PRs from ${owner}/${repo}:`, error)
- return []
- }
-}
-
export function usePullRequests(): UsePullRequestsReturn {
const [pullRequests, setPullRequests] = useState([])
+ const [generatedAt, setGeneratedAt] = useState(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
const fetchPRs = useCallback(async () => {
- console.log('Starting to fetch pull requests...')
setLoading(true)
setError(null)
try {
- const [backstagePRs, communityPluginsPRs] = await Promise.all([
- fetchContribfestPullRequests('backstage', 'backstage'),
- fetchContribfestPullRequests('backstage', 'community-plugins'),
- ])
-
- const allPRs = [...backstagePRs, ...communityPluginsPRs]
-
- // Sort by created_at descending (newest first)
- allPRs.sort((a, b) => {
- return new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
- })
-
- console.log(`Total PRs after combining and sorting: ${allPRs.length}`)
- setPullRequests(allPRs)
+ const snapshot = await fetchSnapshot('pull-requests')
+ setPullRequests(snapshot.items)
+ setGeneratedAt(snapshot.generatedAt)
} catch (err) {
- const errorMessage = err instanceof Error ? err.message : 'Failed to fetch pull requests'
- setError(errorMessage)
- console.error('Error fetching PRs:', err)
+ setError(err instanceof Error ? err.message : 'Failed to load pull requests')
} finally {
setLoading(false)
- console.log('Finished fetching pull requests')
}
}, [])
@@ -122,14 +37,11 @@ export function usePullRequests(): UsePullRequestsReturn {
fetchPRs()
}, [fetchPRs])
- const refresh = useCallback(() => {
- fetchPRs()
- }, [fetchPRs])
-
return {
pullRequests,
+ generatedAt,
loading,
error,
- refresh,
+ refresh: fetchPRs,
}
}
diff --git a/lib/csv-parser.ts b/lib/csv-parser.ts
deleted file mode 100644
index 74f889ca..00000000
--- a/lib/csv-parser.ts
+++ /dev/null
@@ -1,82 +0,0 @@
-import Papa from 'papaparse'
-import type { IssueRow } from './types'
-
-export async function parseIssuesCSV(): Promise {
- try {
- const response = await fetch(`/issues.csv`)
- if (!response.ok) {
- throw new Error(`Failed to fetch CSV: ${response.statusText}`)
- }
-
- const csvText = await response.text()
-
- return new Promise((resolve, reject) => {
- Papa.parse>(csvText, {
- header: true, // Parse first row as column headers
- complete: (results) => {
- try {
- const issues: IssueRow[] = results.data
- .map((row, index) => {
- // Skip empty rows
- if (!row || !row.repo) {
- return null
- }
-
- // Format: repo,level,issueId (with header row)
- const repository = row.repo?.trim()
- const level = row.level?.trim()
- const issueId = parseInt(row.issueId?.trim() || '0', 10)
-
- if (!repository || !level || !issueId) {
- console.warn(`Skipping invalid row ${index + 2}:`, row)
- return null
- }
-
- // Validate level value
- const validLevels = ['Beginner', 'Intermediate', 'Advanced']
- if (!validLevels.includes(level)) {
- console.warn(`Unexpected level value "${level}" at row ${index + 2}`)
- }
-
- return {
- rowNumber: index + 2, // +2 because header is row 1, and 0-indexed
- repository,
- level,
- issueId,
- }
- })
- .filter((issue): issue is IssueRow => issue !== null)
-
- // Remove duplicates based on repository + issueId combination
- const seen = new Set()
- const uniqueIssues = issues.filter(issue => {
- const key = `${issue.repository}:${issue.issueId}`
- if (seen.has(key)) {
- return false
- }
- seen.add(key)
- return true
- })
-
- // Reassign row numbers sequentially starting from 1 for display purposes
- const issuesWithSequentialRows = uniqueIssues.map((issue, index) => ({
- ...issue,
- rowNumber: index + 1,
- }))
-
- resolve(issuesWithSequentialRows)
- } catch (error) {
- reject(error)
- }
- },
- error: (error: unknown) => {
- reject(error)
- },
- skipEmptyLines: true,
- })
- })
- } catch (error) {
- console.error('Error parsing CSV:', error)
- throw error
- }
-}
diff --git a/lib/event.ts b/lib/event.ts
new file mode 100644
index 00000000..08ed659c
--- /dev/null
+++ b/lib/event.ts
@@ -0,0 +1,25 @@
+// Everything that changes from one ContribFest to the next lives here.
+// See docs/organizer-runbook.md for the full kickoff checklist.
+
+// Set to true when ContribFest is active
+export const CONTRIBFEST_ACTIVE = true
+
+// When the curated issues list unlocks. Include the venue's UTC offset so
+// the countdown is the same for everyone regardless of their timezone.
+export const ISSUES_UNLOCK_AT = new Date('2026-11-11T00:00:00-07:00')
+
+// The venue's IANA timezone, used to display event dates
+export const EVENT_TIMEZONE = 'America/Denver'
+
+export const currentSession = {
+ city: 'Salt Lake City',
+ details: 'November 11, 2026 at 4:10 PM MST in Room 255 D (Salt Palace, Level 2)',
+ sessionUrl: 'https://kubecon-cloudnativecon-north-america-2026.sessionize.com/session/1294770',
+ slideDeckPath: '/KubeCon-SLC-Backstage-ContribFest-2026.pdf',
+ assignmentSheetUrl:
+ 'https://docs.google.com/spreadsheets/d/1Bw3fclWcIt7_x7NlpWnJ0DTiaEQqjQ2KkvySiofoHZQ/edit?gid=232343915#gid=232343915',
+}
+
+// Shown on the welcome page while CONTRIBFEST_ACTIVE is false
+export const offSeasonMessage =
+ 'Backstage ContribFest will return at KubeCon Barcelona in March 2027, with more details to be shared nearer the date.'
diff --git a/lib/filters.ts b/lib/filters.ts
index 17526686..358c79bd 100644
--- a/lib/filters.ts
+++ b/lib/filters.ts
@@ -1,13 +1,26 @@
-import type { EnrichedIssue } from './types'
+import type { EnrichedIssue, IssueAvailability } from './types'
export interface FilterOptions {
search: string
repository: string // 'all' | 'backstage/backstage' | 'backstage/community-plugins'
- state: string // 'all' | 'open' | 'closed'
+ availability: string // 'all' | IssueAvailability
level: string // 'all' | 'Beginner' | 'Intermediate' | 'Advanced'
label: string // 'all' | specific label name
}
+export const availabilityOptions: Array<{ value: IssueAvailability; label: string }> = [
+ { value: 'available', label: 'Available' },
+ { value: 'assigned', label: 'Assigned' },
+ { value: 'has-pr', label: 'Has PR' },
+ { value: 'closed', label: 'Closed' },
+]
+
+// Sort order used to list available issues first
+export function getAvailabilitySortOrder(availability?: IssueAvailability): number {
+ const index = availabilityOptions.findIndex((option) => option.value === availability)
+ return index === -1 ? availabilityOptions.length : index
+}
+
export function filterIssues(
issues: EnrichedIssue[],
filters: FilterOptions
@@ -29,9 +42,9 @@ export function filterIssues(
}
}
- // Filter by state
- if (filters.state !== 'all' && issue.githubData) {
- if (issue.githubData.state !== filters.state) {
+ // Filter by availability
+ if (filters.availability !== 'all') {
+ if (issue.availability !== filters.availability) {
return false
}
}
diff --git a/lib/github-api.ts b/lib/github-api.ts
deleted file mode 100644
index 9ac0f019..00000000
--- a/lib/github-api.ts
+++ /dev/null
@@ -1,147 +0,0 @@
-import type { GitHubIssue, IssueRow, EnrichedIssue } from './types'
-
-const GITHUB_API_BASE = 'https://api.github.com'
-
-export async function fetchContribfestIssues(
- owner: string,
- repo: string
-): Promise {
- const allIssues: GitHubIssue[] = []
- let page = 1
- const perPage = 100
-
- try {
- const headers: HeadersInit = {
- Accept: 'application/vnd.github.v3+json',
- }
-
- const token = process.env.NEXT_PUBLIC_GITHUB_TOKEN
- if (token && token !== 'your_github_personal_access_token_here') {
- headers.Authorization = `Bearer ${token}`
- }
-
- while (true) {
- const url = `${GITHUB_API_BASE}/repos/${owner}/${repo}/issues?labels=contribfest&state=all&per_page=${perPage}&page=${page}`
-
- const response = await fetch(url, {
- headers,
- cache: 'force-cache',
- })
-
- if (!response.ok) {
- console.error(
- `Failed to fetch contribfest issues from ${owner}/${repo}: ${response.status} ${response.statusText}`
- )
- break
- }
-
- const issues = await response.json() as GitHubIssue[]
-
- if (issues.length === 0) {
- break
- }
-
- allIssues.push(...issues)
-
- // If we got fewer than perPage results, we've reached the end
- if (issues.length < perPage) {
- break
- }
-
- page++
- }
-
- console.log(`Fetched ${allIssues.length} contribfest issues from ${owner}/${repo}`)
- return allIssues
- } catch (error) {
- console.error(`Error fetching contribfest issues from ${owner}/${repo}:`, error)
- return []
- }
-}
-
-function buildIssueLookupMap(
- backstageIssues: GitHubIssue[],
- communityPluginsIssues: GitHubIssue[]
-): Map {
- const map = new Map()
-
- // Add backstage/backstage issues
- backstageIssues.forEach((issue) => {
- const key = `backstage/backstage#${issue.number}`
- map.set(key, issue)
- })
-
- // Add backstage/community-plugins issues
- communityPluginsIssues.forEach((issue) => {
- const key = `backstage/community-plugins#${issue.number}`
- map.set(key, issue)
- })
-
- return map
-}
-
-export async function enrichIssuesWithGitHubData(
- issues: IssueRow[],
- onProgress?: (current: number, total: number) => void
-): Promise {
- const enrichedIssues: EnrichedIssue[] = []
-
- // Report initial progress
- if (onProgress) {
- onProgress(0, issues.length)
- }
-
- try {
- // Step 1: Bulk fetch all contribfest issues from both repos
- console.log('Fetching contribfest issues from GitHub...')
-
- const [backstageIssues, communityPluginsIssues] = await Promise.all([
- fetchContribfestIssues('backstage', 'backstage'),
- fetchContribfestIssues('backstage', 'community-plugins'),
- ])
-
- console.log(`Total issues fetched: ${backstageIssues.length + communityPluginsIssues.length}`)
-
- // Step 2: Build lookup map
- const issueMap = buildIssueLookupMap(backstageIssues, communityPluginsIssues)
-
- // Step 3: Match CSV issues with GitHub data
- for (let i = 0; i < issues.length; i++) {
- const issue = issues[i]
- const lookupKey = `${issue.repository}#${issue.issueId}`
- const githubData = issueMap.get(lookupKey)
-
- enrichedIssues.push({
- rowNumber: issue.rowNumber,
- repository: issue.repository,
- level: issue.level,
- issueId: issue.issueId,
- githubData: githubData || undefined,
- error: githubData ? undefined : 'Issue not found in GitHub or missing contribfest label',
- })
-
- // Report progress
- if (onProgress) {
- onProgress(i + 1, issues.length)
- }
- }
-
- console.log(`Matched ${enrichedIssues.filter(e => e.githubData).length}/${issues.length} issues with GitHub data`)
- } catch (error) {
- console.error('Error enriching issues with GitHub data:', error)
-
- // Fallback: return CSV data only
- for (let i = 0; i < issues.length; i++) {
- const issue = issues[i]
- enrichedIssues.push({
- rowNumber: issue.rowNumber,
- repository: issue.repository,
- level: issue.level,
- issueId: issue.issueId,
- error: error instanceof Error ? error.message : 'Failed to fetch GitHub data',
- })
- }
- }
-
- return enrichedIssues
-}
diff --git a/lib/github-pr-api.ts b/lib/github-pr-api.ts
deleted file mode 100644
index 30d95568..00000000
--- a/lib/github-pr-api.ts
+++ /dev/null
@@ -1,92 +0,0 @@
-import type { GitHubPullRequest } from './types'
-
-const GITHUB_API_BASE = 'https://api.github.com'
-
-export async function fetchContribfestPullRequests(
- owner: string,
- repo: string
-): Promise {
- const allPRs: GitHubPullRequest[] = []
- let page = 1
- const perPage = 100
-
- try {
- const headers: HeadersInit = {
- Accept: 'application/vnd.github.v3+json',
- }
-
- const token = process.env.NEXT_PUBLIC_GITHUB_TOKEN
- if (token && token !== 'your_github_personal_access_token_here') {
- headers.Authorization = `Bearer ${token}`
- }
-
- while (true) {
- const url = `${GITHUB_API_BASE}/repos/${owner}/${repo}/pulls?state=closed&per_page=${perPage}&page=${page}`
-
- const response = await fetch(url, {
- headers,
- cache: 'no-store',
- })
-
- if (!response.ok) {
- console.error(
- `Failed to fetch PRs from ${owner}/${repo}: ${response.status} ${response.statusText}`
- )
- break
- }
-
- const prs = await response.json() as GitHubPullRequest[]
-
- if (prs.length === 0) {
- break
- }
-
- // Filter to only merged PRs with contribfest label
- const mergedContribfestPRs = prs.filter(pr => {
- const hasContribfestLabel = pr.labels?.some(label =>
- label.name === 'contribfest'
- )
- return pr.merged_at !== null && hasContribfestLabel
- })
-
- // Add repository identifier
- mergedContribfestPRs.forEach(pr => {
- pr.repository = `${owner}/${repo}`
- })
-
- allPRs.push(...mergedContribfestPRs)
-
- // If we got fewer than perPage results, we've reached the end
- if (prs.length < perPage) {
- break
- }
-
- page++
- }
-
- console.log(`Fetched ${allPRs.length} merged contribfest PRs from ${owner}/${repo}`)
- return allPRs
- } catch (error) {
- console.error(`Error fetching PRs from ${owner}/${repo}:`, error)
- return []
- }
-}
-
-export async function fetchAllContribfestPullRequests(): Promise {
- console.log('Fetching contribfest PRs from GitHub...')
-
- const [backstagePRs, communityPluginsPRs] = await Promise.all([
- fetchContribfestPullRequests('backstage', 'backstage'),
- fetchContribfestPullRequests('backstage', 'community-plugins'),
- ])
-
- const allPRs = [...backstagePRs, ...communityPluginsPRs]
-
- // Sort by created_at descending (newest first)
- allPRs.sort((a, b) => {
- return new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
- })
-
- console.log(`Total merged contribfest PRs: ${allPRs.length}`)
- return allPRs
-}
diff --git a/lib/ideas.ts b/lib/ideas.ts
new file mode 100644
index 00000000..933695ee
--- /dev/null
+++ b/lib/ideas.ts
@@ -0,0 +1,366 @@
+// Content for the "Your Ideas" page. Everything here helps attendees shape
+// their own ideas. Nothing on the page writes proposals for them.
+
+const BACKSTAGE_ISSUES = 'https://github.com/backstage/backstage/issues'
+
+// Most-upvoted open issues are a good signal of what adopters want
+function popularIssuesUrl(query: string): string {
+ return `${BACKSTAGE_ISSUES}?q=${encodeURIComponent(`is:issue is:open ${query} sort:reactions-+1-desc`)}`
+}
+
+export type IdeaPathId = 'feature-request' | 'bep' | 'existing-plugin' | 'new-plugin'
+
+export interface IdeaPath {
+ id: IdeaPathId
+ title: string
+ summary: string
+ steps: string[]
+ links: Array<{ label: string; url: string }>
+}
+
+export const ideaPaths: Record = {
+ 'feature-request': {
+ id: 'feature-request',
+ title: 'Suggest a change',
+ summary:
+ 'Most ideas start here. A suggestion issue gets your idea in front of maintainers and adopters quickly, and can be converted into a BEP later if it grows.',
+ steps: [
+ 'Search existing issues first. Adding your use case to an existing thread is a contribution too.',
+ 'Describe the problem before the solution: who hits it, how often, and what they do today.',
+ 'Keep it short. Maintainers respond best to clear, concise proposals.',
+ ],
+ links: [
+ { label: 'Open a suggestion issue', url: `${BACKSTAGE_ISSUES}/new?template=03_suggestion.yaml` },
+ { label: 'Most-upvoted open suggestions', url: popularIssuesUrl('label:type:suggestion') },
+ ],
+ },
+ bep: {
+ id: 'bep',
+ title: 'Write a Backstage Enhancement Proposal (BEP)',
+ summary:
+ 'For larger changes to the Backstage framework or core features, where design decisions need to be agreed up front and the work spans several PRs.',
+ steps: [
+ 'Discuss the idea first on Discord, in a SIG meeting, or in a suggestion issue, and find maintainers who think it is worth doing.',
+ 'Copy the BEP template folder to beps/NNNN-short-descriptive-title and fill it in yourself.',
+ 'Open a PR titled "BEP: ". Focus on clarifying the goals; details can be iterated on.',
+ ],
+ links: [
+ { label: 'BEP process', url: 'https://github.com/backstage/backstage/blob/master/beps/README.md' },
+ { label: 'BEP template', url: 'https://github.com/backstage/backstage/tree/master/beps/NNNN-template' },
+ { label: 'Existing BEPs', url: 'https://github.com/backstage/backstage/tree/master/beps' },
+ { label: 'Issues that need a BEP', url: `${BACKSTAGE_ISSUES}?q=${encodeURIComponent('is:issue is:open label:needs:bep')}` },
+ ],
+ },
+ 'existing-plugin': {
+ id: 'existing-plugin',
+ title: 'Improve an existing plugin',
+ summary:
+ 'Plugin ideas do not need a BEP. Open a feature request on the repository the plugin lives in, then work toward a PR with its maintainers.',
+ steps: [
+ 'Find where the plugin lives: backstage/backstage, backstage/community-plugins, or a third-party repository.',
+ 'Check the plugin\'s open issues and recent PRs to see what its maintainers are focused on.',
+ 'Open a feature request describing the problem, and offer to implement it.',
+ ],
+ links: [
+ { label: 'Community Plugins feature request', url: 'https://github.com/backstage/community-plugins/issues/new?template=2-feature.yaml' },
+ { label: 'Backstage suggestion issue', url: `${BACKSTAGE_ISSUES}/new?template=03_suggestion.yaml` },
+ { label: 'Plugin directory', url: 'https://backstage.io/plugins' },
+ ],
+ },
+ 'new-plugin': {
+ id: 'new-plugin',
+ title: 'Propose a new community plugin',
+ summary:
+ 'New plugins in the Community Plugins repository start with a proposal issue, so the community can confirm there is interest and nothing similar already exists.',
+ steps: [
+ 'Check the plugin directory and Community Plugins for anything similar you could extend instead.',
+ 'Read the "Contributing a New Plugin" guide, including the maintainer expectations.',
+ 'Open a plugin proposal issue describing what it does and who would use it.',
+ ],
+ links: [
+ { label: 'Contributing a new plugin', url: 'https://github.com/backstage/community-plugins/blob/main/docs/contributing-new-plugin.md' },
+ { label: 'Open a plugin proposal', url: 'https://github.com/backstage/community-plugins/issues/new?template=3-plugin.yaml' },
+ { label: 'Plugin directory', url: 'https://backstage.io/plugins' },
+ ],
+ },
+}
+
+export interface TopicArea {
+ title: string
+ emoji: string
+ summary: string
+ questions: string[]
+ docsUrl: string
+ codeUrl: string
+ issuesUrl: string
+}
+
+export const topicAreas: TopicArea[] = [
+ {
+ title: 'Software Catalog',
+ emoji: '๐',
+ summary: 'The heart of Backstage: entities, relations, processors, and providers.',
+ questions: [
+ 'What metadata does your organization wish the catalog modeled?',
+ 'Which ingestion source is missing or painful to configure?',
+ ],
+ docsUrl: 'https://backstage.io/docs/features/software-catalog/',
+ codeUrl: 'https://github.com/backstage/backstage/tree/master/plugins/catalog-backend',
+ issuesUrl: popularIssuesUrl('label:area:catalog'),
+ },
+ {
+ title: 'Software Templates',
+ emoji: '๐๏ธ',
+ summary: 'The Scaffolder: templates, actions, and the task engine behind them.',
+ questions: [
+ 'Which scaffolder action do you keep rewriting in-house?',
+ 'What would make templates easier to test, debug, or maintain?',
+ ],
+ docsUrl: 'https://backstage.io/docs/features/software-templates/',
+ codeUrl: 'https://github.com/backstage/backstage/tree/master/plugins/scaffolder-backend',
+ issuesUrl: popularIssuesUrl('label:area:scaffolder'),
+ },
+ {
+ title: 'TechDocs',
+ emoji: '๐',
+ summary: 'Docs-like-code: building, publishing, and reading documentation in Backstage.',
+ questions: [
+ 'What stops your teams from keeping docs next to their code?',
+ 'Which reader experience would make docs easier to navigate?',
+ ],
+ docsUrl: 'https://backstage.io/docs/features/techdocs/',
+ codeUrl: 'https://github.com/backstage/backstage/tree/master/plugins/techdocs',
+ issuesUrl: popularIssuesUrl('label:area:techdocs'),
+ },
+ {
+ title: 'Search',
+ emoji: '๐',
+ summary: 'Collators, search engines, and the search experience across plugins.',
+ questions: [
+ 'What do your users search for and fail to find?',
+ 'Which search engine or collator integration is missing?',
+ ],
+ docsUrl: 'https://backstage.io/docs/features/search/',
+ codeUrl: 'https://github.com/backstage/backstage/tree/master/plugins/search-backend',
+ issuesUrl: popularIssuesUrl('label:area:search'),
+ },
+ {
+ title: 'New Frontend System',
+ emoji: '๐งฉ',
+ summary: 'Extensions, blueprints, and the declarative app that replaces the legacy frontend.',
+ questions: [
+ 'What was hard about migrating your app or plugins?',
+ 'Which extension point or blueprint would you add?',
+ ],
+ docsUrl: 'https://backstage.io/docs/frontend-system/',
+ codeUrl: 'https://github.com/backstage/backstage/tree/master/packages/frontend-plugin-api',
+ issuesUrl: popularIssuesUrl('label:area:framework'),
+ },
+ {
+ title: 'Backend System',
+ emoji: 'โ๏ธ',
+ summary: 'Services, extension points, and plugin modules on the backend.',
+ questions: [
+ 'Which core service would make your backend plugins simpler?',
+ 'What do you wish you could observe or configure at runtime?',
+ ],
+ docsUrl: 'https://backstage.io/docs/backend-system/',
+ codeUrl: 'https://github.com/backstage/backstage/tree/master/packages/backend-plugin-api',
+ issuesUrl: popularIssuesUrl('label:area:framework'),
+ },
+ {
+ title: 'Backstage UI (BUI)',
+ emoji: '๐จ',
+ summary: 'The new design system and component library replacing Material UI.',
+ questions: [
+ 'Which component is missing when you build plugin UIs?',
+ 'Where do BUI docs or theming fall short for your use case?',
+ ],
+ docsUrl: 'https://ui.backstage.io/',
+ codeUrl: 'https://github.com/backstage/backstage/tree/master/packages/ui',
+ issuesUrl: popularIssuesUrl('label:area:design-system'),
+ },
+ {
+ title: 'Auth & Permissions',
+ emoji: '๐',
+ summary: 'Sign-in providers, identity resolution, and the permission framework.',
+ questions: [
+ 'Which identity provider or sign-in flow is hard to set up?',
+ 'What authorization rule can you not express today?',
+ ],
+ docsUrl: 'https://backstage.io/docs/permissions/overview',
+ codeUrl: 'https://github.com/backstage/backstage/tree/master/plugins/permission-backend',
+ issuesUrl: popularIssuesUrl('label:area:permission'),
+ },
+ {
+ title: 'Notifications',
+ emoji: '๐',
+ summary: 'Sending, routing, and displaying notifications from plugins.',
+ questions: [
+ 'Which events should notify your developers but don\'t?',
+ 'Which delivery channel or preference setting is missing?',
+ ],
+ docsUrl: 'https://backstage.io/docs/notifications/',
+ codeUrl: 'https://github.com/backstage/backstage/tree/master/plugins/notifications-backend',
+ issuesUrl: popularIssuesUrl('label:area:notifications'),
+ },
+ {
+ title: 'AI & MCP Actions',
+ emoji: '๐ค',
+ summary: 'Exposing Backstage capabilities to AI agents through the actions registry and MCP.',
+ questions: [
+ 'Which Backstage data or workflow would you want an agent to use safely?',
+ 'How should permissions and auditing apply to AI-driven actions?',
+ ],
+ docsUrl: 'https://backstage.io/docs/ai/',
+ codeUrl: 'https://github.com/backstage/backstage/tree/master/plugins/mcp-actions-backend',
+ issuesUrl: popularIssuesUrl('MCP in:title,body'),
+ },
+ {
+ title: 'Accessibility & i18n',
+ emoji: '๐',
+ summary: 'Making Backstage usable for everyone, in every language.',
+ questions: [
+ 'Which screens fail keyboard or screen reader use?',
+ 'Which plugins still have hard-coded strings that need translation refs?',
+ ],
+ docsUrl: 'https://backstage.io/docs/accessibility/',
+ codeUrl: 'https://backstage.io/docs/plugins/internationalization',
+ issuesUrl: popularIssuesUrl('accessibility OR i18n in:title,body'),
+ },
+ {
+ title: 'CLI & Tooling',
+ emoji: '๐ ๏ธ',
+ summary: 'The backstage-cli, repo tooling, and the developer experience of building Backstage apps.',
+ questions: [
+ 'Which upgrade or build step wastes the most time for your team?',
+ 'What would make creating and testing plugins faster?',
+ ],
+ docsUrl: 'https://backstage.io/docs/tooling/cli/overview',
+ codeUrl: 'https://github.com/backstage/backstage/tree/master/packages/cli',
+ issuesUrl: popularIssuesUrl('label:area:tooling'),
+ },
+]
+
+// Prompts to think through an idea. They mirror the BEP template sections so
+// the notes carry over, but the attendee writes every word.
+export interface CanvasQuestion {
+ id: string
+ heading: string
+ prompt: string
+}
+
+export const canvasQuestions: CanvasQuestion[] = [
+ {
+ id: 'problem',
+ heading: 'Problem',
+ prompt: 'What problem have you run into? Describe it without mentioning your solution.',
+ },
+ {
+ id: 'who',
+ heading: 'Who is affected',
+ prompt: 'Who hits this problem (adopters, plugin authors, end users), and how often?',
+ },
+ {
+ id: 'goals',
+ heading: 'Goals',
+ prompt: 'What would be true once this is solved?',
+ },
+ {
+ id: 'non-goals',
+ heading: 'Non-goals',
+ prompt: 'What are you deliberately leaving out of scope?',
+ },
+ {
+ id: 'proposal',
+ heading: 'Rough proposal',
+ prompt: 'How might it work? A few sentences is plenty for now.',
+ },
+ {
+ id: 'alternatives',
+ heading: 'Alternatives considered',
+ prompt: 'What else could solve this, including workarounds people use today?',
+ },
+ {
+ id: 'questions',
+ heading: 'Open questions',
+ prompt: 'What don\'t you know yet? Who could you ask?',
+ },
+]
+
+// Prompts for the attendee's own AI assistant. Each one asks the AI to
+// explain, search, question, or critique, never to write the proposal.
+export interface CoachPrompt {
+ title: string
+ purpose: string
+ prompt: string
+}
+
+export const coachPrompts: CoachPrompt[] = [
+ {
+ title: 'Understand the code',
+ purpose: 'Get oriented in an unfamiliar part of the codebase.',
+ prompt:
+ 'I\'m exploring the Backstage codebase (github.com/backstage/backstage). Explain how [area, e.g. catalog entity processing] works and point me to the specific packages and files I should read. Flag anything you are unsure about so I can verify it in the docs at backstage.io/docs.',
+ },
+ {
+ title: 'Find prior art',
+ purpose: 'Avoid duplicating work that already exists or was rejected.',
+ prompt:
+ 'Before I propose [your idea] to the Backstage project, help me look for prior art: existing BEPs in the beps/ folder, open or closed GitHub issues, community plugins, and docs that already cover something similar. List what you find with links, and say clearly when you could not verify something.',
+ },
+ {
+ title: 'Challenge my thinking',
+ purpose: 'Stress-test your notes before sharing them with maintainers.',
+ prompt:
+ 'Here are my notes for a Backstage proposal: [paste your Idea Canvas notes]. Act as a skeptical Backstage maintainer. Do not rewrite my notes. Ask me the five hardest questions you would want answered before supporting this, focusing on motivation, scope, and alternatives.',
+ },
+ {
+ title: 'Find a first step',
+ purpose: 'Turn a big idea into something you can start on today.',
+ prompt:
+ 'My idea for Backstage is: [your idea]. Help me break it down. What is the smallest independently useful first step I could ship as a single PR, and which parts would need a design discussion or BEP first? Ask me questions rather than writing code.',
+ },
+ {
+ title: 'Check my understanding',
+ purpose: 'Make sure you can explain every line before you open a PR.',
+ prompt:
+ 'I\'m about to open a PR to Backstage. Here is a summary of my change: [describe your change]. Quiz me on it. Ask questions until you are confident I can explain why every part of the change is needed and how I tested it.',
+ },
+]
+
+export const aiPolicyUrl =
+ 'https://github.com/backstage/backstage/blob/master/CONTRIBUTING.md#ai-use-policy-and-guidelines'
+
+export const aiPolicyPoints = [
+ 'AI tools are welcome, but you must understand and be able to explain every change you propose.',
+ 'Never submit an AI-generated PR you haven\'t personally understood and tested.',
+ 'If a PR is largely AI-generated, say so clearly in the description.',
+ 'Write PR descriptions and review replies yourself, focused on the why. Keep them concise.',
+]
+
+export const nextSteps: Array<{ title: string; description: string; link?: { label: string; url: string } }> = [
+ {
+ title: 'Talk to a host',
+ description: 'Grab one of today\'s hosts to sanity-check your idea and find the right people to talk to.',
+ },
+ {
+ title: 'Discuss with the community',
+ description: 'Share the problem on Discord or bring it to a SIG meeting before writing a formal proposal.',
+ link: { label: 'Join the Backstage Discord', url: 'https://discord.gg/backstage-687207715902193673' },
+ },
+ {
+ title: 'Write it up yourself',
+ description: 'Open the suggestion issue, plugin proposal, or BEP in your own words. Your notes from the canvas are a starting point.',
+ },
+ {
+ title: 'Ship a first small PR',
+ description: 'Small, focused PRs that move toward the idea build trust and momentum.',
+ link: { label: 'Contribution guide', url: 'https://github.com/backstage/backstage/blob/master/CONTRIBUTING.md' },
+ },
+ {
+ title: 'Keep going after KubeCon',
+ description: 'Respond to review feedback, join community sessions, and keep your idea moving.',
+ link: { label: 'Community sessions & SIGs', url: 'https://github.com/backstage/community' },
+ },
+]
diff --git a/lib/snapshot.ts b/lib/snapshot.ts
new file mode 100644
index 00000000..834fb81a
--- /dev/null
+++ b/lib/snapshot.ts
@@ -0,0 +1,20 @@
+import type { Snapshot } from './types'
+
+// Loads a JSON snapshot written at build time by scripts/fetch-github-data.mjs.
+// `no-cache` revalidates with the CDN so a fresh deploy shows up right away.
+export async function fetchSnapshot(name: 'issues' | 'pull-requests'): Promise> {
+ const response = await fetch(`/data/${name}.json`, { cache: 'no-cache' })
+ if (!response.ok) {
+ throw new Error(`Couldn't load the latest GitHub data (${response.status}). Please refresh in a minute.`)
+ }
+ return response.json() as Promise>
+}
+
+export function formatUpdatedAgo(generatedAt: string, now = Date.now()): string {
+ const minutes = Math.max(0, Math.round((now - new Date(generatedAt).getTime()) / 60000))
+ if (minutes < 1) return 'just now'
+ if (minutes < 60) return `${minutes} min ago`
+ const hours = Math.round(minutes / 60)
+ if (hours < 24) return `${hours} hr ago`
+ return new Date(generatedAt).toLocaleDateString()
+}
diff --git a/lib/types.ts b/lib/types.ts
index 10a386b4..fd42f8b8 100644
--- a/lib/types.ts
+++ b/lib/types.ts
@@ -1,14 +1,5 @@
-// CSV row format: {row_number}โ{repo},{level},{issue_id}
-export interface IssueRow {
- rowNumber: number
- repository: string
- level: string
- issueId: number
-}
-
-// GitHub API issue response
+// GitHub issue data from the build-time snapshot (scripts/fetch-github-data.mjs)
export interface GitHubIssue {
- id: number
number: number
title: string
state: 'open' | 'closed'
@@ -23,7 +14,17 @@ export interface GitHubIssue {
}>
created_at: string
updated_at: string
- body?: string
+}
+
+// Whether someone has already picked up a curated issue
+export type IssueAvailability = 'available' | 'assigned' | 'has-pr' | 'closed'
+
+// A pull request that would close a curated issue
+export interface LinkedPullRequest {
+ number: number
+ url: string
+ state: 'open' | 'merged'
+ author: string
}
// Combined data: CSV + GitHub metadata
@@ -32,10 +33,19 @@ export interface EnrichedIssue {
repository: string
level: string
issueId: number
+ availability?: IssueAvailability
+ assignees?: string[]
+ linkedPRs?: LinkedPullRequest[]
githubData?: GitHubIssue
error?: string
}
+// Shape of the JSON files in public/data/
+export interface Snapshot {
+ generatedAt: string
+ items: T[]
+}
+
// Checklist item
export interface ChecklistItem {
id: string
@@ -59,7 +69,7 @@ export interface ResourceCard {
note?: string
}
-// GitHub API pull request response
+// GitHub pull request data from the build-time snapshot
export interface GitHubPullRequest {
id: number
number: number
diff --git a/mocks/issues.example.json b/mocks/issues.example.json
new file mode 100644
index 00000000..75c48ab1
--- /dev/null
+++ b/mocks/issues.example.json
@@ -0,0 +1,65 @@
+[
+ {
+ "repository": "backstage/backstage",
+ "issueId": 90001,
+ "level": "Beginner",
+ "title": "Mock: Fix typos in the Software Catalog docs",
+ "labels": ["contribfest", "area:documentation"]
+ },
+ {
+ "repository": "backstage/backstage",
+ "issueId": 90002,
+ "level": "Intermediate",
+ "title": "Mock: Add keyboard navigation to the entity table",
+ "labels": ["contribfest", { "name": "area:catalog", "color": "c2e0c6" }],
+ "assignees": ["mock-contributor"],
+ "availability": "assigned"
+ },
+ {
+ "repository": "backstage/backstage",
+ "issueId": 90003,
+ "level": "Advanced",
+ "title": "Mock: Support pagination in the catalog search collator",
+ "labels": ["contribfest", "area:search"]
+ },
+ {
+ "repository": "backstage/backstage",
+ "issueId": 90004,
+ "level": "Beginner",
+ "title": "Mock: Add missing alt text to TechDocs images",
+ "labels": ["contribfest", "area:techdocs"],
+ "availability": "closed"
+ },
+ {
+ "repository": "backstage/community-plugins",
+ "issueId": 90005,
+ "level": "Beginner",
+ "title": "Mock: Migrate the badges plugin to Jest 30",
+ "labels": ["contribfest", "good first issue"]
+ },
+ {
+ "repository": "backstage/community-plugins",
+ "issueId": 90006,
+ "level": "Intermediate",
+ "title": "Mock: Migrate the tech-radar plugin from MUI to BUI",
+ "labels": ["contribfest", "help wanted"],
+ "linkedPRs": [{ "number": 90106, "author": "mock-contributor" }],
+ "availability": "has-pr"
+ },
+ {
+ "repository": "backstage/community-plugins",
+ "issueId": 90007,
+ "level": "Intermediate",
+ "title": "Mock: Add New Frontend System support to the linguist plugin",
+ "labels": ["contribfest", "help wanted"]
+ },
+ {
+ "repository": "backstage/community-plugins",
+ "issueId": 90008,
+ "level": "Advanced",
+ "title": "Mock: Add translation refs to the Jenkins plugin",
+ "labels": ["contribfest"],
+ "linkedPRs": [{ "number": 90108, "state": "merged" }],
+ "availability": "has-pr"
+ }
+]
diff --git a/package.json b/package.json
index ac5408db..15bdb92a 100644
--- a/package.json
+++ b/package.json
@@ -7,12 +7,12 @@
"dev": "next dev",
"build": "next build",
"start": "next start",
- "lint": "eslint ."
+ "lint": "eslint .",
+ "fetch-data": "node scripts/fetch-github-data.mjs"
},
"dependencies": {
"@backstage/ui": "^0.17.0",
"next": "16.3.3",
- "papaparse": "^5.5.4",
"react": "^19.2.7",
"react-dom": "^19.2.7"
},
@@ -20,7 +20,6 @@
"@eslint/compat": "^2.1.0",
"@eslint/eslintrc": "^3.3.6",
"@types/node": "^24.13.3",
- "@types/papaparse": "^5.5.2",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"eslint": "^10.8.1",
diff --git a/scripts/fetch-github-data.mjs b/scripts/fetch-github-data.mjs
new file mode 100644
index 00000000..d04b31ea
--- /dev/null
+++ b/scripts/fetch-github-data.mjs
@@ -0,0 +1,435 @@
+// Builds a snapshot of GitHub data for the static site so the browser never
+// calls api.github.com directly (shared conference Wi-Fi IPs hit the
+// unauthenticated rate limit almost immediately).
+//
+// Usage:
+// GITHUB_TOKEN=$(gh auth token) yarn fetch-data [mock.json ...]
+// yarn fetch-data --no-github mock.json [mock.json ...]
+//
+// Mock files add made-up issues for local development (see
+// mocks/issues.example.json). --no-github skips GitHub, so no token is needed.
+//
+// Writes public/data/issues.json and public/data/pull-requests.json.
+// Exits non-zero on any failure so CD never deploys a broken snapshot.
+
+import { readFile, writeFile, mkdir } from 'node:fs/promises'
+import path from 'node:path'
+import { parseArgs } from 'node:util'
+
+const ROOT = path.resolve(import.meta.dirname, '..')
+const CSV_PATH = path.join(ROOT, 'public', 'issues.csv')
+const OUT_DIR = path.join(ROOT, 'public', 'data')
+const GRAPHQL_URL = 'https://api.github.com/graphql'
+const VALID_LEVELS = ['Beginner', 'Intermediate', 'Advanced']
+const AVAILABILITIES = ['available', 'assigned', 'has-pr', 'closed']
+const MOCK_AUTHOR = 'mock-user'
+const DEFAULT_LABEL_COLOR = 'ededed'
+const ISSUES_PER_QUERY = 50
+const PR_SEARCH_QUERY =
+ 'repo:backstage/backstage repo:backstage/community-plugins is:pr is:merged label:contribfest'
+
+let token
+async function graphql(query, variables = {}, { allowNotFound = false } = {}) {
+ const response = await fetch(GRAPHQL_URL, {
+ method: 'POST',
+ headers: {
+ Authorization: `Bearer ${token}`,
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({ query, variables }),
+ })
+ if (!response.ok) {
+ throw new Error(`GitHub GraphQL request failed: ${response.status} ${response.statusText}`)
+ }
+ const body = await response.json()
+ const errors = (body.errors ?? []).filter(error => {
+ if (allowNotFound && error.type === 'NOT_FOUND') {
+ console.warn(`Not found: ${error.message}`)
+ return false
+ }
+ return true
+ })
+ if (errors.length || !body.data) {
+ throw new Error(`GitHub GraphQL errors: ${JSON.stringify(errors, null, 2)}`)
+ }
+ return body.data
+}
+
+// Same rules as the old client-side parser: header row, skip incomplete rows,
+// warn on unknown levels, de-dupe on repo + issue number.
+function warnOnUnknownLevel(level, location) {
+ if (!VALID_LEVELS.includes(level)) {
+ console.warn(`Unexpected level value "${level}" at ${location}`)
+ }
+}
+
+async function readCuratedIssues() {
+ const text = await readFile(CSV_PATH, 'utf8')
+ const [header, ...lines] = text.split(/\r?\n/).filter(line => line.trim())
+ const columns = header.split(',').map(column => column.trim())
+ const seen = new Set()
+ const rows = []
+
+ lines.forEach((line, index) => {
+ const values = line.split(',').map(value => value.trim())
+ const row = Object.fromEntries(columns.map((column, i) => [column, values[i]]))
+ const issueId = parseInt(row.issueId || '0', 10)
+
+ if (!row.repo || !row.level || !issueId) {
+ console.warn(`Skipping invalid row ${index + 2}: ${line}`)
+ return
+ }
+ warnOnUnknownLevel(row.level, `row ${index + 2}`)
+
+ const key = `${row.repo}#${issueId}`
+ if (seen.has(key)) return
+ seen.add(key)
+ rows.push({ repository: row.repo, level: row.level, issueId })
+ })
+
+ return rows
+}
+
+const ISSUE_FIELDS = `
+ number
+ title
+ state
+ url
+ createdAt
+ updatedAt
+ author { login avatarUrl }
+ labels(first: 20) { nodes { name color } }
+ assignees(first: 5) { nodes { login } }
+ closedByPullRequestsReferences(first: 5, includeClosedPrs: true) {
+ nodes { number url state author { login } }
+ }
+ timelineItems(itemTypes: [CROSS_REFERENCED_EVENT], last: 20) {
+ nodes {
+ ... on CrossReferencedEvent {
+ willCloseTarget
+ source { ... on PullRequest { number url state author { login } } }
+ }
+ }
+ }
+`
+
+async function fetchIssueNodes(rows) {
+ const nodes = new Map()
+
+ for (let start = 0; start < rows.length; start += ISSUES_PER_QUERY) {
+ const chunk = rows.slice(start, start + ISSUES_PER_QUERY)
+ const query = `query {\n${chunk
+ .map((row, i) => {
+ const [owner, name] = row.repository.split('/')
+ return `i${i}: repository(owner: "${owner}", name: "${name}") { issue(number: ${row.issueId}) { ${ISSUE_FIELDS} } }`
+ })
+ .join('\n')}\n}`
+
+ const data = await graphql(query, {}, { allowNotFound: true })
+ chunk.forEach((row, i) => {
+ nodes.set(`${row.repository}#${row.issueId}`, data[`i${i}`]?.issue ?? null)
+ })
+ }
+
+ return nodes
+}
+
+// Only PRs that would close the issue count as "someone is working on it".
+// Plain mentions are common on umbrella issues and would be false positives.
+function getLinkedPullRequests(node) {
+ const linked = new Map()
+ const add = pr => {
+ if (pr?.number && (pr.state === 'OPEN' || pr.state === 'MERGED')) {
+ linked.set(pr.url, {
+ number: pr.number,
+ url: pr.url,
+ state: pr.state.toLowerCase(),
+ author: pr.author?.login ?? 'ghost',
+ })
+ }
+ }
+
+ node.closedByPullRequestsReferences.nodes.forEach(add)
+ node.timelineItems.nodes
+ .filter(item => item.willCloseTarget)
+ .forEach(item => add(item.source))
+
+ return [...linked.values()]
+}
+
+function getAvailability(state, assignees, linkedPRs) {
+ if (state === 'closed') return 'closed'
+ if (linkedPRs.length > 0) return 'has-pr'
+ if (assignees.length > 0) return 'assigned'
+ return 'available'
+}
+
+function toEnrichedIssue(row, index, node) {
+ const base = {
+ rowNumber: index + 1,
+ repository: row.repository,
+ level: row.level,
+ issueId: row.issueId,
+ }
+
+ if (!node) {
+ return { ...base, error: 'Issue not found on GitHub' }
+ }
+
+ const state = node.state === 'OPEN' ? 'open' : 'closed'
+ const assignees = node.assignees.nodes.map(assignee => assignee.login)
+ const linkedPRs = getLinkedPullRequests(node)
+
+ return {
+ ...base,
+ availability: getAvailability(state, assignees, linkedPRs),
+ assignees,
+ linkedPRs,
+ githubData: {
+ number: node.number,
+ title: node.title,
+ state,
+ html_url: node.url,
+ user: { login: node.author?.login ?? 'ghost', avatar_url: node.author?.avatarUrl },
+ labels: node.labels.nodes,
+ created_at: node.createdAt,
+ updated_at: node.updatedAt,
+ },
+ }
+}
+
+async function fetchMergedContribfestPullRequests() {
+ const pullRequests = []
+ let cursor = null
+
+ do {
+ const data = await graphql(
+ `query($q: String!, $cursor: String) {
+ search(type: ISSUE, query: $q, first: 100, after: $cursor) {
+ pageInfo { hasNextPage endCursor }
+ nodes {
+ ... on PullRequest {
+ databaseId
+ number
+ title
+ url
+ createdAt
+ mergedAt
+ author { login url }
+ labels(first: 20) { nodes { name color } }
+ repository { nameWithOwner }
+ }
+ }
+ }
+ }`,
+ { q: PR_SEARCH_QUERY, cursor }
+ )
+
+ data.search.nodes.forEach(pr => {
+ pullRequests.push({
+ id: pr.databaseId,
+ number: pr.number,
+ title: pr.title,
+ state: 'closed',
+ html_url: pr.url,
+ user: {
+ login: pr.author?.login ?? 'ghost',
+ html_url: pr.author?.url ?? 'https://github.com/ghost',
+ },
+ labels: pr.labels.nodes,
+ created_at: pr.createdAt,
+ merged_at: pr.mergedAt,
+ repository: pr.repository.nameWithOwner,
+ })
+ })
+
+ cursor = data.search.pageInfo.hasNextPage ? data.search.pageInfo.endCursor : null
+ } while (cursor)
+
+ pullRequests.sort((a, b) => new Date(b.created_at) - new Date(a.created_at))
+ return pullRequests
+}
+
+// Turns a minimal mock entry into the same shape as a real snapshot issue.
+// Throws with a readable reason when the entry is invalid.
+function toMockIssue(entry, generatedAt) {
+ if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
+ throw new Error('must be an object')
+ }
+ const { repository, issueId, level, title } = entry
+ if (typeof repository !== 'string' || !/^[\w.-]+\/[\w.-]+$/.test(repository)) {
+ throw new Error('"repository" must be a string like "backstage/backstage"')
+ }
+ if (!Number.isInteger(issueId) || issueId <= 0) {
+ throw new Error('"issueId" must be a positive integer')
+ }
+ if (typeof level !== 'string' || !level) {
+ throw new Error('"level" must be a string such as "Beginner"')
+ }
+ if (typeof title !== 'string' || !title) {
+ throw new Error('"title" must be a non-empty string')
+ }
+
+ const availability = entry.availability ?? 'available'
+ if (!AVAILABILITIES.includes(availability)) {
+ throw new Error(`"availability" must be one of ${AVAILABILITIES.join(', ')}`)
+ }
+
+ const labels = (entry.labels ?? []).map(label => {
+ if (typeof label === 'string') return { name: label, color: DEFAULT_LABEL_COLOR }
+ if (label && typeof label.name === 'string') {
+ return { name: label.name, color: label.color ?? DEFAULT_LABEL_COLOR }
+ }
+ throw new Error('"labels" must contain strings or { name, color } objects')
+ })
+
+ const assignees = entry.assignees ?? []
+ if (!Array.isArray(assignees) || !assignees.every(login => typeof login === 'string')) {
+ throw new Error('"assignees" must be an array of strings')
+ }
+
+ const linkedPRs = (entry.linkedPRs ?? []).map(pr => {
+ if (!pr || !Number.isInteger(pr.number) || pr.number <= 0) {
+ throw new Error('"linkedPRs" entries need a positive integer "number"')
+ }
+ if (pr.state !== undefined && pr.state !== 'open' && pr.state !== 'merged') {
+ throw new Error('"linkedPRs" state must be "open" or "merged"')
+ }
+ return {
+ number: pr.number,
+ url: pr.url ?? `https://github.com/${repository}/pull/${pr.number}`,
+ state: pr.state ?? 'open',
+ author: pr.author ?? MOCK_AUTHOR,
+ }
+ })
+
+ return {
+ repository,
+ level,
+ issueId,
+ availability,
+ assignees,
+ linkedPRs,
+ githubData: {
+ number: issueId,
+ title,
+ state: availability === 'closed' ? 'closed' : 'open',
+ html_url: `https://github.com/${repository}/issues/${issueId}`,
+ user: { login: MOCK_AUTHOR },
+ labels,
+ created_at: generatedAt,
+ updated_at: generatedAt,
+ },
+ }
+}
+
+async function loadMockIssues(paths, generatedAt) {
+ const issues = []
+
+ for (const file of paths) {
+ let entries
+ try {
+ entries = JSON.parse(await readFile(path.resolve(file), 'utf8'))
+ } catch (error) {
+ throw new Error(`Couldn't read mock file ${file}: ${error.message}`)
+ }
+ if (!Array.isArray(entries)) {
+ throw new Error(`${file}: expected a JSON array of mock issues`)
+ }
+
+ entries.forEach((entry, index) => {
+ try {
+ const issue = toMockIssue(entry, generatedAt)
+ warnOnUnknownLevel(issue.level, `${file}[${index}]`)
+ issues.push(issue)
+ } catch (error) {
+ throw new Error(`${file}[${index}]: ${error.message}`)
+ }
+ })
+ }
+
+ return issues
+}
+
+// Mocks are added after the real issues. A mock with the same repo and issue
+// number replaces the earlier entry in place, to simulate a real issue changing.
+function mergeIssues(realIssues, mockIssues) {
+ const byKey = new Map()
+ const keyOf = issue => `${issue.repository}#${issue.issueId}`
+
+ realIssues.forEach(issue => byKey.set(keyOf(issue), issue))
+ mockIssues.forEach(issue => {
+ if (byKey.has(keyOf(issue))) console.log(`Mock overrides ${keyOf(issue)}`)
+ byKey.set(keyOf(issue), issue)
+ })
+
+ return [...byKey.values()].map((issue, index) => ({ ...issue, rowNumber: index + 1 }))
+}
+
+function parseCommandLine() {
+ const { values, positionals } = parseArgs({
+ options: { 'no-github': { type: 'boolean', default: false } },
+ allowPositionals: true,
+ })
+ const useGitHub = !values['no-github']
+ if (!useGitHub && positionals.length === 0) {
+ throw new Error('--no-github needs at least one mock file, otherwise there is nothing to write')
+ }
+ return { useGitHub, mockFiles: positionals }
+}
+
+async function main() {
+ const { useGitHub, mockFiles } = parseCommandLine()
+ const generatedAt = new Date().toISOString()
+ const mockIssues = await loadMockIssues(mockFiles, generatedAt)
+
+ let realIssues = []
+ let pullRequests = []
+ if (useGitHub) {
+ token = process.env.GITHUB_TOKEN
+ if (!token) {
+ throw new Error(
+ 'GITHUB_TOKEN is required. Locally: GITHUB_TOKEN=$(gh auth token) yarn fetch-data, ' +
+ 'or pass --no-github with mock files'
+ )
+ }
+
+ const rows = await readCuratedIssues()
+ const [issueNodes, fetchedPullRequests] = await Promise.all([
+ fetchIssueNodes(rows),
+ fetchMergedContribfestPullRequests(),
+ ])
+ realIssues = rows.map((row, index) =>
+ toEnrichedIssue(row, index, issueNodes.get(`${row.repository}#${row.issueId}`))
+ )
+ pullRequests = fetchedPullRequests
+ } else {
+ console.log('Skipping GitHub (--no-github): only mock issues, and Contrib Champs will be empty')
+ }
+
+ const issues = mergeIssues(realIssues, mockIssues)
+
+ await mkdir(OUT_DIR, { recursive: true })
+ await writeFile(
+ path.join(OUT_DIR, 'issues.json'),
+ JSON.stringify({ generatedAt, items: issues }, null, 2)
+ )
+ await writeFile(
+ path.join(OUT_DIR, 'pull-requests.json'),
+ JSON.stringify({ generatedAt, items: pullRequests }, null, 2)
+ )
+
+ const counts = issues.reduce((acc, issue) => {
+ const key = issue.availability ?? 'missing'
+ acc[key] = (acc[key] ?? 0) + 1
+ return acc
+ }, {})
+ const fromMocks = mockIssues.length ? ` (${mockIssues.length} from mocks)` : ''
+ console.log(`Wrote ${issues.length} curated issues${fromMocks}:`, counts)
+ console.log(`Wrote ${pullRequests.length} merged contribfest pull requests`)
+}
+
+main().catch(error => {
+ console.error(error.message ?? error)
+ process.exit(1)
+})
diff --git a/yarn.lock b/yarn.lock
index 9d8fe137..b3473d76 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -906,15 +906,6 @@ __metadata:
languageName: node
linkType: hard
-"@types/node@npm:*":
- version: 25.6.0
- resolution: "@types/node@npm:25.6.0"
- dependencies:
- undici-types: "npm:~7.19.0"
- checksum: 10/99b18690a4be55904cbf8f6a6ac8eed5ec5b8d791fdd8ee2ae598b46c0fa9b83cda7b70dd7f00dbfb18189dcfc67648fdc7fdd3fcced2619a5a6453d9aec107d
- languageName: node
- linkType: hard
-
"@types/node@npm:^24.13.3":
version: 24.13.3
resolution: "@types/node@npm:24.13.3"
@@ -924,15 +915,6 @@ __metadata:
languageName: node
linkType: hard
-"@types/papaparse@npm:^5.5.2":
- version: 5.5.2
- resolution: "@types/papaparse@npm:5.5.2"
- dependencies:
- "@types/node": "npm:*"
- checksum: 10/35c38d8c789a0512465665fa6472e8eda7a20413227aafc25b55955a0ced8a061dfbeb0c75658393ebe61670cb95064cc3753ce1a0bac93a12c53ff569d15170
- languageName: node
- linkType: hard
-
"@types/react-dom@npm:^19.2.3":
version: 19.2.3
resolution: "@types/react-dom@npm:19.2.3"
@@ -1566,13 +1548,11 @@ __metadata:
"@eslint/compat": "npm:^2.1.0"
"@eslint/eslintrc": "npm:^3.3.6"
"@types/node": "npm:^24.13.3"
- "@types/papaparse": "npm:^5.5.2"
"@types/react": "npm:^19.2.17"
"@types/react-dom": "npm:^19.2.3"
eslint: "npm:^10.8.1"
eslint-config-next: "npm:16.2.10"
next: "npm:16.3.3"
- papaparse: "npm:^5.5.4"
react: "npm:^19.2.7"
react-dom: "npm:^19.2.7"
typescript: "npm:^6.0.3"
@@ -3256,13 +3236,6 @@ __metadata:
languageName: node
linkType: hard
-"papaparse@npm:^5.5.4":
- version: 5.5.4
- resolution: "papaparse@npm:5.5.4"
- checksum: 10/929466b2acb464b2ac4ad5542d58097967a5c0ef0ca8d9e05e0e8effadbb4077692f19c7601bb32dc56bd982941b38a8e83d7db9e5025483ca859abf7e9a5031
- languageName: node
- linkType: hard
-
"parent-module@npm:^1.0.0":
version: 1.0.1
resolution: "parent-module@npm:1.0.1"
@@ -4088,13 +4061,6 @@ __metadata:
languageName: node
linkType: hard
-"undici-types@npm:~7.19.0":
- version: 7.19.2
- resolution: "undici-types@npm:7.19.2"
- checksum: 10/05c34c63444c8caca7137f122b29ed50c1d7d05d1e0b2337f423575d3264054c4a0139e47e82e65723d09b97fcad6d8b0223b3550430a9006cc00e72a1e035bf
- languageName: node
- linkType: hard
-
"unrs-resolver@npm:^1.6.2":
version: 1.11.1
resolution: "unrs-resolver@npm:1.11.1"