diff --git a/.env.example b/.env.example index ab84d410..db1e8fb1 100644 --- a/.env.example +++ b/.env.example @@ -1,9 +1,5 @@ -# GitHub Personal Access Token -# Generate at: https://github.com/settings/tokens -# Required scope: public_repo -# With authentication: 5000 requests/hour -# Without authentication: 60 requests/hour -NEXT_PUBLIC_GITHUB_TOKEN= +# GitHub data is fetched at build time, not in the browser. Run +# `GITHUB_TOKEN=$(gh auth token) yarn fetch-data` before `yarn dev`. # Google Analytics Measurement ID # Get your GA4 Measurement ID from: https://analytics.google.com diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml index 5c101059..c1ee7f62 100644 --- a/.github/workflows/cd.yml +++ b/.github/workflows/cd.yml @@ -3,6 +3,11 @@ name: CD on: push: branches: [main] + # Refresh the GitHub data snapshot every hour (offset from the top of + # the hour, when scheduled runs are most often delayed). During the session, + # use "Run workflow" for an immediate refresh. + schedule: + - cron: '17 * * * *' workflow_dispatch: # Cancel in-progress deployments for same branch @@ -19,8 +24,10 @@ permissions: jobs: # Wait for CI to complete before deploying + # Scheduled and manual runs rebuild an already-tested commit, so only pushes wait for CI check-ci: name: Wait for CI + if: github.event_name == 'push' runs-on: ubuntu-latest steps: - name: Wait for CI workflow @@ -34,6 +41,7 @@ jobs: build: name: Build needs: check-ci + if: ${{ !cancelled() && (needs.check-ci.result == 'success' || needs.check-ci.result == 'skipped') }} runs-on: ubuntu-latest timeout-minutes: 15 @@ -67,6 +75,12 @@ jobs: - name: Install dependencies run: yarn install --immutable + # Fails the run on any GitHub error, so Pages keeps serving the last good deploy + - name: Fetch GitHub data snapshot + run: yarn fetch-data + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Build run: yarn build diff --git a/.gitignore b/.gitignore index b8006cb0..2ebe51af 100644 --- a/.gitignore +++ b/.gitignore @@ -21,6 +21,9 @@ # production /build +# GitHub data snapshot (generated by `yarn fetch-data`) +/public/data/ + # misc .DS_Store *.pem diff --git a/README.md b/README.md index 7b635c0b..b5fc38c7 100644 --- a/README.md +++ b/README.md @@ -26,13 +26,25 @@ cp .env.example .env.local yarn install ``` -4. Run the development server: +4. Fetch the GitHub data snapshot used by the issues and Contrib Champs pages: + +```bash +GITHUB_TOKEN=$(gh auth token) yarn fetch-data + +# Optionally add mock issues (see mocks/issues.example.json) +GITHUB_TOKEN=$(gh auth token) yarn fetch-data mocks/issues.example.json + +# Or use only mock issues, with no GitHub token needed +yarn fetch-data --no-github mocks/issues.example.json +``` + +5. Run the development server: ```bash yarn dev ``` -Open [http://localhost:3000](http://localhost:3000) to view the application. +Open [http://localhost:3000](http://localhost:3000) to view the application. See [docs/deployment.md](docs/deployment.md) for how the snapshot is refreshed in production, and [docs/organizer-runbook.md](docs/organizer-runbook.md) for running an event. ## Build diff --git a/app/contrib-champs/page.tsx b/app/contrib-champs/page.tsx index eb7bd254..4ad47449 100644 --- a/app/contrib-champs/page.tsx +++ b/app/contrib-champs/page.tsx @@ -4,9 +4,10 @@ import { usePullRequests } from '@/hooks/usePullRequests' import { PullRequestTable } from '@/components/PullRequestTable' import { SessionsSidebar } from '@/components/SessionsSidebar' import { sessions } from '@/lib/sessions' +import { formatUpdatedAgo } from '@/lib/snapshot' export default function ContribChampsPage() { - const { pullRequests, loading, error } = usePullRequests() + const { pullRequests, generatedAt, loading, error } = usePullRequests() return (
@@ -34,6 +35,19 @@ export default function ContribChampsPage() { Backstage and Community Plugins repositories โ€” huge thanks to everyone who made them happen!

+ {generatedAt && ( +

+ GitHub data updated {formatUpdatedAgo(generatedAt)} +

+ )}
{/* Loading State */} diff --git a/app/getting-started/page.tsx b/app/getting-started/page.tsx index 0a38b9bb..204e8ddf 100644 --- a/app/getting-started/page.tsx +++ b/app/getting-started/page.tsx @@ -160,6 +160,12 @@ const sidebarResources: ResourceCard[] = [ description: 'Details about the Backend System used by Backstage.', url: 'https://backstage.io/docs/backend-system/', isExternal: true + }, + { + title: 'Using AI Tools', + description: 'AI assistants are welcome, but you must understand and test every change, and mark largely AI-generated PRs as such.', + url: 'https://github.com/backstage/backstage/blob/master/CONTRIBUTING.md#ai-use-policy-and-guidelines', + isExternal: true } ]; diff --git a/app/ideas/layout.tsx b/app/ideas/layout.tsx new file mode 100644 index 00000000..eb9db9f8 --- /dev/null +++ b/app/ideas/layout.tsx @@ -0,0 +1,13 @@ +import type { Metadata } from 'next' + +export const metadata: Metadata = { + title: 'Your Ideas', +} + +export default function IdeasLayout({ + children, +}: { + children: React.ReactNode +}) { + return <>{children} +} diff --git a/app/ideas/page.tsx b/app/ideas/page.tsx new file mode 100644 index 00000000..ae74bc12 --- /dev/null +++ b/app/ideas/page.tsx @@ -0,0 +1,196 @@ +import { RiExternalLinkLine } from '@remixicon/react' +import { PathChooser } from '@/components/ideas/PathChooser' +import { IdeaCanvas } from '@/components/ideas/IdeaCanvas' +import { CopyButton } from '@/components/ideas/CopyButton' +import { aiPolicyPoints, aiPolicyUrl, coachPrompts, nextSteps, topicAreas } from '@/lib/ideas' + +export default function IdeasPage() { + return ( +
+
+

+ ๐Ÿ’ก Your Ideas +

+

+ ContribFest isn't only about closing issues. Your experience running and building on Backstage is + the best source of ideas for where it should go next. This page helps you shape an idea and find the + right way to propose it. The writing is yours, and the hosts are here to help. +

+
+ +
+ +
+ +
+
+ {topicAreas.map((topic) => ( +
+

+ {topic.title} +

+

+ {topic.summary} +

+
    + {topic.questions.map((question) => ( +
  • {question}
  • + ))} +
+
+ Docs + Code + Top requests +
+
+ ))} +
+
+ +
+ +
+ +
+
+

+ Backstage AI Use Policy +

+
    + {aiPolicyPoints.map((point) => ( +
  • {point}
  • + ))} +
+ Read the full policy +
+ +
+ {coachPrompts.map((coachPrompt) => ( +
+

+ {coachPrompt.title} +

+

+ {coachPrompt.purpose} +

+
+ {coachPrompt.prompt} +
+
+ +
+
+ ))} +
+
+ +
+
    + {nextSteps.map((step, index) => ( +
  1. + +
    +

    + {step.title} +

    +

    + {step.description} +

    + {step.link && ( +
    + {step.link.label} +
    + )} +
    +
  2. + ))} +
+
+
+ ) +} + +const cardStyle: React.CSSProperties = { + border: '1px solid var(--bui-border-1, #d5d5d5)', + borderRadius: '8px', + padding: '20px', + background: 'var(--bui-bg-popover, #fff)', + display: 'flex', + flexDirection: 'column', +} + +function Section({ title, intro, children }: { title: string; intro: string; children: React.ReactNode }) { + return ( +
+

+ {title} +

+

+ {intro} +

+ {children} +
+ ) +} + +function ExternalLink({ href, children }: { href: string; children: React.ReactNode }) { + return ( + + {children} + + ) +} diff --git a/app/issues/IssuesPageContent.tsx b/app/issues/IssuesPageContent.tsx index c45bc2d0..adc7a61c 100644 --- a/app/issues/IssuesPageContent.tsx +++ b/app/issues/IssuesPageContent.tsx @@ -6,9 +6,9 @@ import { CountdownModal } from '@/components/CountdownModal' import { InactiveIssuesPage } from '@/components/InactiveIssuesPage' import { useSearchParams } from 'next/navigation' import { useMemo } from 'react' - -// Set to true when ContribFest is active -const CONTRIBFEST_ACTIVE = true +import Link from 'next/link' +import { CONTRIBFEST_ACTIVE, EVENT_TIMEZONE, ISSUES_UNLOCK_AT } from '@/lib/event' +import { formatUpdatedAgo } from '@/lib/snapshot' export default function IssuesPageContent() { const searchParams = useSearchParams() @@ -17,19 +17,10 @@ export default function IssuesPageContent() { const showCuratedList = CONTRIBFEST_ACTIVE || isAdmin - // Check if access is allowed (after November 11, 2026 or admin bypass) - const { accessAllowed, targetDate } = useMemo(() => { - const target = new Date('2026-11-11T00:00:00') - const now = new Date() - const allowed = now >= target || isAdmin - - return { - accessAllowed: allowed, - targetDate: target, - } - }, [isAdmin]) + // Check if access is allowed (after the unlock date or admin bypass) + const accessAllowed = useMemo(() => new Date() >= ISSUES_UNLOCK_AT || isAdmin, [isAdmin]) - const { issues, loading, error, progress } = useIssues(showCuratedList && accessAllowed) + const { issues, generatedAt, loading, error } = useIssues(showCuratedList && accessAllowed) if (!showCuratedList) { return @@ -58,7 +49,25 @@ export default function IssuesPageContent() { > Browse {issues.length > 0 && `${issues.length} `}hand-picked GitHub issues from Backstage and Community Plugins repositories. Use filters to find issues that match your interests and skill level. + Have something else in mind?{' '} + + Bring your own idea + + .

+ {generatedAt && ( +

+ GitHub data updated {formatUpdatedAgo(generatedAt)} +

+ )} {/* Loading State */} @@ -81,37 +90,6 @@ export default function IssuesPageContent() { > Loading issues... - {progress && ( -
-
- Fetching metadata: {progress.current} / {progress.total} -
-
-
-
-
- )}
)} @@ -149,12 +127,14 @@ export default function IssuesPageContent() { color: 'var(--bui-fg-secondary, #666)', }} > - No issues found. + {accessAllowed + ? 'No issues found.' + : `The curated issues list unlocks on ${ISSUES_UNLOCK_AT.toLocaleDateString(undefined, { month: 'long', day: 'numeric', year: 'numeric', timeZone: EVENT_TIMEZONE })}.`} )} {/* Countdown Modal - shown if access is not allowed */} - {!accessAllowed && } + {!accessAllowed && } ) } diff --git a/app/page.tsx b/app/page.tsx index 3fed229b..6386f590 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,26 +1,31 @@ import { WelcomeCard } from '@/components/WelcomeCard' import type { ResourceCard } from '@/lib/types' - -// Set to true when ContribFest is active -const CONTRIBFEST_ACTIVE = true +import { CONTRIBFEST_ACTIVE, currentSession, offSeasonMessage } from '@/lib/event' const sessionResources: ResourceCard[] = [ { title: 'ContribFest Slide Deck', description: 'Presentation slides with workshop overview, contribution tips, and resources. A handy reference for workshop structure and guidance throughout the session.', - url: `/KubeCon-SLC-Backstage-ContribFest-2026.pdf`, + url: currentSession.slideDeckPath, isExternal: true, note: 'The slide deck will be available before the event.', }, { title: 'Assignment Sheet', description: - 'Google Sheet for participants to assign themselves to issues they want to work on. Claim your issue and track progress during the session.', - url: 'https://docs.google.com/spreadsheets/d/1Bw3fclWcIt7_x7NlpWnJ0DTiaEQqjQ2KkvySiofoHZQ/edit?gid=232343915#gid=232343915', + 'Google Sheet for participants to assign themselves to issues they want to work on. Check the availability badge on Curated Issues first, then claim your issue and track progress during the session.', + url: currentSession.assignmentSheetUrl, isExternal: true, note: 'Access to the Assignment Sheet will be provided on the day of the event.', }, + { + title: 'Bring Your Own Idea', + description: + 'Have an idea for Backstage? Explore topic areas, pick the right path (feature request, BEP, or plugin proposal), and shape it with help from the hosts.', + url: '/ideas/', + isExternal: false, + }, ] const repositoryResources: ResourceCard[] = [ @@ -106,8 +111,7 @@ export default function Page() { lineHeight: '1.6', }} > - Backstage ContribFest will return at KubeCon Barcelona in March 2027, with more - details to be shared nearer the date. + {offSeasonMessage} )} @@ -123,10 +127,10 @@ export default function Page() { lineHeight: '1.6', }} > - The next Backstage ContribFest session takes place at KubeCon in Salt Lake City on - November 11, 2026 at 4:10 PM MST in Room 255 D (Salt Palace, Level 2), be sure to{' '} + The next Backstage ContribFest session takes place at KubeCon in {currentSession.city} on{' '} + {currentSession.details}, be sure to{' '} diff --git a/app/sitemap.ts b/app/sitemap.ts index ebc34223..8f8d8ddc 100644 --- a/app/sitemap.ts +++ b/app/sitemap.ts @@ -22,6 +22,10 @@ export default function sitemap(): MetadataRoute.Sitemap { url: `${BASE_URL}/hall-of-hosts/`, lastModified: new Date(), }, + { + url: `${BASE_URL}/ideas/`, + lastModified: new Date(), + }, { url: `${BASE_URL}/issues/`, lastModified: new Date(), diff --git a/components/IssueTable.tsx b/components/IssueTable.tsx index 6818e7a1..c27a0fe6 100644 --- a/components/IssueTable.tsx +++ b/components/IssueTable.tsx @@ -1,9 +1,12 @@ 'use client' import { useState, useMemo } from 'react' -import type { EnrichedIssue } from '@/lib/types' +import Link from 'next/link' +import type { EnrichedIssue, IssueAvailability } from '@/lib/types' import { + availabilityOptions, filterIssues, + getAvailabilitySortOrder, getUniqueRepositories, getUniqueLevels, getUniqueLabels, @@ -17,14 +20,14 @@ interface IssueTableProps { initialRepository?: string } -type SortColumn = 'rowNumber' | 'repository' | 'level' | 'issueId' | 'title' | 'state' +type SortColumn = 'rowNumber' | 'repository' | 'level' | 'issueId' | 'title' | 'availability' type SortDirection = 'asc' | 'desc' export function IssueTable({ issues, initialRepository }: IssueTableProps) { const [filters, setFilters] = useState({ search: '', repository: initialRepository || 'all', - state: 'all', + availability: 'available', level: 'all', label: 'all', }) @@ -42,6 +45,15 @@ export function IssueTable({ issues, initialRepository }: IssueTableProps) { [issues, filters] ) + // How many issues are still up for grabs given the other filters + const availableCount = useMemo( + () => + filterIssues(issues, { ...filters, availability: 'all' }).filter( + (issue) => issue.availability === 'available' + ).length, + [issues, filters] + ) + // Handle column sort const handleSort = (column: SortColumn) => { if (sortColumn === column) { @@ -83,22 +95,26 @@ export function IssueTable({ issues, initialRepository }: IssueTableProps) { aValue = a.githubData?.title || '' bValue = b.githubData?.title || '' break - case 'state': - aValue = a.githubData?.state || '' - bValue = b.githubData?.state || '' + case 'availability': + aValue = getAvailabilitySortOrder(a.availability) + bValue = getAvailabilitySortOrder(b.availability) break } if (typeof aValue === 'number' && typeof bValue === 'number') { - return sortDirection === 'asc' ? aValue - bValue : bValue - aValue + if (aValue !== bValue) { + return sortDirection === 'asc' ? aValue - bValue : bValue - aValue + } + } else { + // String comparison + const aStr = String(aValue).toLowerCase() + const bStr = String(bValue).toLowerCase() + if (aStr < bStr) return sortDirection === 'asc' ? -1 : 1 + if (aStr > bStr) return sortDirection === 'asc' ? 1 : -1 } - // String comparison - const aStr = String(aValue).toLowerCase() - const bStr = String(bValue).toLowerCase() - if (aStr < bStr) return sortDirection === 'asc' ? -1 : 1 - if (aStr > bStr) return sortDirection === 'asc' ? 1 : -1 - return 0 + // Tie-break so available issues are listed first + return getAvailabilitySortOrder(a.availability) - getAvailabilitySortOrder(b.availability) }) return sorted }, [filteredIssues, sortColumn, sortDirection]) @@ -321,7 +337,7 @@ export function IssueTable({ issues, initialRepository }: IssueTableProps) {
@@ -382,9 +401,31 @@ export function IssueTable({ issues, initialRepository }: IssueTableProps) { color: 'var(--bui-fg-secondary, #666)', }} > - Showing {sortedIssues.length} of {issues.length} issues + Showing {sortedIssues.length} of {issues.length} issues ยท {availableCount} available + {/* Fallback when the small issues have all been picked up */} + {availableCount <= 3 && ( +
+ {availableCount === 0 ? 'All of these issues have been picked up' : 'Only a few of these issues are still available'} + {' '}โ€” nice work, everyone! Small issues aren't the only way to contribute:{' '} + + bring your own idea โ†’ + +
+ )} + {/* Table */}
handleSort('state')} + onClick={() => handleSort('availability')} > - State {sortColumn === 'state' && (sortDirection === 'asc' ? 'โ†‘' : 'โ†“')} + Availability {sortColumn === 'availability' && (sortDirection === 'asc' ? 'โ†‘' : 'โ†“')} @@ -490,24 +531,7 @@ export function IssueTable({ issues, initialRepository }: IssueTableProps) { )}
Labels
- {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 ( + + ) +} 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.prompt} +

+