diff --git a/app/contributors/page.tsx b/app/contributors/page.tsx index 301b35f..7dea599 100644 --- a/app/contributors/page.tsx +++ b/app/contributors/page.tsx @@ -1,11 +1,165 @@ -export default function Page() { +import type { Metadata } from "next"; +import { + CODE_OF_CONDUCT_URL, + CONTRIBUTOR_REPOSITORIES, + getContributorIssues, + type ContributorIssue, + type RepositoryIssueResult, +} from "@/lib/contributor-issues"; + +export const metadata: Metadata = { + title: "Contributors", + description: "Find open ModelTrace issues and learn how to contribute across the project.", +}; + +function displayDifficulty(difficulty: string): string { + return difficulty.charAt(0).toLocaleUpperCase() + difficulty.slice(1); +} + +function IssueCard({ issue }: { issue: ContributorIssue }) { + return ( +
  • +
    + {issue.repository} + Issue #{issue.number} + {issue.difficulty ? ( + Difficulty: {displayDifficulty(issue.difficulty)} + ) : null} +
    +

    + + {issue.title} + +

    + {issue.labels.length > 0 ? ( + + ) : null} +
  • + ); +} + +function RepositoryIssues({ result }: { result: RepositoryIssueResult }) { + const { repository, issues, status } = result; + + return ( +
    +
    +
    +

    {repository.displayName}

    +

    {repository.name}

    +
    + + View repository + +
    +

    {repository.description}

    + + {status === "unavailable" ? ( +

    + Live issues for this repository are temporarily unavailable. +

    + ) : issues.length === 0 ? ( +

    No matching open issues right now.

    + ) : ( + <> + {status === "partial" ? ( +

    + Some live issues may be temporarily unavailable. +

    + ) : null} + + + )} +
    + ); +} + +export default async function Page() { + const repositoryIssues = await getContributorIssues(); + const allUnavailable = repositoryIssues.every((result) => result.status === "unavailable"); + return ( -
    - Contributors -

    Contributors surface — product definition TBD.

    -

    - Scaffold page — replace with production content, data loaders, and analytics. -

    -
    +
    +
    + Contributors +

    Help build verifiable AI infrastructure.

    +

    + Pick a scoped issue across the ModelTrace frontend, backend, or smart contracts. Live + openings are grouped below so you can start where your experience fits best. +

    +
    + +
    +
    +
    + Live from GitHub +

    Good first issues and help wanted

    +
    +

    Open issues refresh hourly.

    +
    + {allUnavailable ? ( +

    + Live issues are temporarily unavailable. You can still visit each repository and use + the contribution guides below. +

    + ) : null} +
    + {repositoryIssues.map((result) => ( + + ))} +
    +
    + +
    + Contribution workflow +

    From issue to review

    +
      +
    1. + Choose an issue. Read its scope and the target repository's guide + before creating a focused branch. +
    2. +
    3. + Build and verify. Follow that repository's prerequisites and run its + complete local quality checks. +
    4. +
    5. + Prepare a scoped review. Use a Conventional Commit and explain any + architectural or interface impact in the pull request. +
    6. +
    + +
    + {CONTRIBUTOR_REPOSITORIES.map((repository) => ( + + ))} +
    + +

    + Every contribution must follow the{" "} + + ModelTrace Code of Conduct + . +

    +
    +
    ); } diff --git a/app/globals.css b/app/globals.css index ae18896..44e354e 100644 --- a/app/globals.css +++ b/app/globals.css @@ -290,3 +290,263 @@ a { color: inherit; text-decoration: none; } background: color-mix(in srgb, var(--surface) 65%, transparent); letter-spacing: 0.04em; } + +/* Contributors */ +.contributors-page { + padding-bottom: 28px; +} + +.contributors-page a:focus-visible { + outline: 3px solid var(--accent-2); + outline-offset: 3px; + border-radius: 3px; +} + +.contributors-hero { + padding-top: 52px; +} + +.contributors-hero h1 { + max-width: 760px; + margin: 0 0 12px; + font-size: clamp(2rem, 5vw, 3.4rem); + line-height: 1.12; + letter-spacing: -0.035em; +} + +.contributors-hero p { + max-width: 720px; + margin: 0; + color: var(--muted); + font-size: 1.05rem; +} + +.live-issues, +.contribution-workflow { + border-top: 1px solid color-mix(in srgb, var(--accent) 18%, transparent); + padding-top: 36px; +} + +.section-heading, +.repository-heading { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 20px; +} + +.section-heading h2, +.repository-heading h2 { + margin: 0; +} + +.section-heading > p { + margin: 34px 0 0; + color: var(--muted); + white-space: nowrap; +} + +.repository-groups { + display: grid; + gap: 20px; + margin-top: 22px; +} + +.repository-group, +.contribution-guide { + padding: 22px; + border: 1px solid color-mix(in srgb, var(--accent) 24%, transparent); + border-radius: 14px; + background: linear-gradient( + 165deg, + color-mix(in srgb, var(--surface) 90%, var(--bg)) 0%, + var(--surface) 100% + ); +} + +.repository-kicker { + margin: 0 0 2px; + color: var(--accent-2); + font-size: 0.78rem; + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; +} + +.repository-heading > a, +.contribution-guide > a, +.conduct-note a { + color: var(--accent); + font-weight: 650; + text-decoration: underline; + text-decoration-thickness: 1px; + text-underline-offset: 3px; +} + +.repository-description { + margin: 8px 0 18px; + color: var(--muted); +} + +.issues-message { + margin: 14px 0 0; + padding: 14px 16px; + border-left: 3px solid var(--accent); + background: color-mix(in srgb, var(--accent) 9%, transparent); + color: var(--muted); +} + +.issues-message-wide { + margin-bottom: 20px; +} + +.contributor-issues, +.issue-labels { + list-style: none; + padding: 0; +} + +.contributor-issues { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(min(100%, 280px), 1fr)); + gap: 12px; + margin: 0; +} + +.contributor-issue { + min-width: 0; + padding: 16px; + border: 1px solid color-mix(in srgb, var(--muted) 22%, transparent); + border-radius: 10px; + background: color-mix(in srgb, var(--bg) 48%, transparent); +} + +.contributor-issue h3 { + margin: 8px 0 12px; + font-size: 1rem; + line-height: 1.45; +} + +.contributor-issue h3 a:hover { + color: var(--accent); +} + +.contributor-issue-meta, +.issue-labels { + display: flex; + flex-wrap: wrap; + gap: 6px 10px; + color: var(--muted); + font-size: 0.76rem; +} + +.contributor-issue-meta .difficulty { + color: var(--accent-2); + font-weight: 650; +} + +.issue-labels { + margin: 0; + gap: 6px; +} + +.issue-labels li { + max-width: 100%; + padding: 3px 8px; + overflow-wrap: anywhere; + border: 1px solid color-mix(in srgb, var(--accent) 25%, transparent); + border-radius: 999px; + background: color-mix(in srgb, var(--accent) 8%, transparent); + color: var(--text); +} + +.workflow-steps { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 14px; + margin: 20px 0 24px; + padding: 0; + list-style: none; + counter-reset: workflow; +} + +.workflow-steps li { + counter-increment: workflow; + padding: 18px; + border-radius: 12px; + background: color-mix(in srgb, var(--surface) 82%, transparent); + color: var(--muted); +} + +.workflow-steps li::before { + content: counter(workflow, decimal-leading-zero); + display: block; + margin-bottom: 8px; + color: var(--accent-2); + font-weight: 800; +} + +.workflow-steps strong { + color: var(--text); +} + +.contribution-guides { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 14px; +} + +.contribution-guide h3 { + margin: 0 0 14px; + font-size: 1rem; +} + +.contribution-guide dl, +.contribution-guide dd { + margin: 0; +} + +.contribution-guide dl { + display: grid; + gap: 12px; + margin-bottom: 16px; +} + +.contribution-guide dt { + color: var(--muted); + font-size: 0.76rem; + font-weight: 700; + letter-spacing: 0.04em; + text-transform: uppercase; +} + +.contribution-guide dd { + font-size: 0.9rem; +} + +.conduct-note { + margin: 22px 0 0; + color: var(--muted); +} + +@media (max-width: 760px) { + .section-heading, + .repository-heading { + display: block; + } + + .section-heading > p { + margin: 8px 0 0; + white-space: normal; + } + + .repository-heading > a { + display: inline-block; + margin-top: 8px; + } + + .workflow-steps, + .contribution-guides { + grid-template-columns: 1fr; + } +} diff --git a/components/expected-pages.tsx b/components/expected-pages.tsx index c666f8c..82fad19 100644 --- a/components/expected-pages.tsx +++ b/components/expected-pages.tsx @@ -23,7 +23,7 @@ export function ExpectedPages() { /operatorsDashboard preview for AI gatewaysPlanned /complianceAudit exports and policy packsPlanned /roadmapMilestones vs grantsScaffold - /contributorsGood first issues and guild rolesPlanned + /contributorsGood first issues and contribution workflowShipped /docsTechnical reference hubScaffold diff --git a/lib/contributor-issues.ts b/lib/contributor-issues.ts new file mode 100644 index 0000000..fa87e03 --- /dev/null +++ b/lib/contributor-issues.ts @@ -0,0 +1,224 @@ +const GITHUB_API_URL = "https://api.github.com"; +const GITHUB_ORGANIZATION = "FinesseStudioLab"; +const ISSUE_LABELS = ["good first issue", "help wanted"] as const; +const REVALIDATE_SECONDS = 60 * 60; + +export type ContributorRepository = { + name: string; + displayName: string; + description: string; + prerequisite: string; + checks: string; + url: string; + contributingUrl: string; +}; + +export const CONTRIBUTOR_REPOSITORIES = [ + { + name: "modeltrace-frontend", + displayName: "Frontend", + description: "Next.js application, public protocol story, dashboards, and audit exports.", + prerequisite: "Node.js 20+", + checks: "Lint, typecheck, unit tests, production build, and browser tests", + url: `https://github.com/${GITHUB_ORGANIZATION}/modeltrace-frontend`, + contributingUrl: `https://github.com/${GITHUB_ORGANIZATION}/modeltrace-frontend/blob/main/CONTRIBUTING.md`, + }, + { + name: "modeltrace-backend", + displayName: "Backend", + description: "Fastify API, Soroban RPC integration, and privileged gateway workflows.", + prerequisite: "Node.js 20+", + checks: "Typecheck and production build", + url: `https://github.com/${GITHUB_ORGANIZATION}/modeltrace-backend`, + contributingUrl: `https://github.com/${GITHUB_ORGANIZATION}/modeltrace-backend/blob/main/CONTRIBUTING.md`, + }, + { + name: "modeltrace-contract", + displayName: "Smart contracts", + description: "Soroban contracts for attestation, metering, and dispute-aware settlement.", + prerequisite: "Rust 1.84+ and the wasm32v1-none target", + checks: "Format, Clippy, workspace tests, and release WASM build", + url: `https://github.com/${GITHUB_ORGANIZATION}/modeltrace-contract`, + contributingUrl: `https://github.com/${GITHUB_ORGANIZATION}/modeltrace-contract/blob/main/CONTRIBUTING.md`, + }, +] as const satisfies readonly ContributorRepository[]; + +export const CODE_OF_CONDUCT_URL = + `https://github.com/${GITHUB_ORGANIZATION}/modeltrace-frontend/blob/main/CODE_OF_CONDUCT.md`; + +export type ContributorIssue = { + id: number; + number: number; + title: string; + htmlUrl: string; + repository: string; + labels: string[]; + difficulty?: string; +}; + +export type RepositoryIssueResult = { + repository: ContributorRepository; + issues: ContributorIssue[]; + status: "available" | "partial" | "unavailable"; +}; + +type GitHubFetch = ( + input: string, + init: RequestInit & { next: { revalidate: number } }, +) => Promise; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +function normalizeLabels(value: unknown): string[] { + if (!Array.isArray(value)) { + return []; + } + + const labels = value.flatMap((label) => { + if (typeof label === "string") { + return [label.trim()]; + } + + if (isRecord(label) && typeof label.name === "string") { + return [label.name.trim()]; + } + + return []; + }); + + const seen = new Set(); + return labels.filter((label) => { + const key = label.toLocaleLowerCase(); + if (!label || seen.has(key)) { + return false; + } + + seen.add(key); + return true; + }); +} + +export function extractDifficulty(labels: readonly string[]): string | undefined { + for (const label of labels) { + const match = /^difficulty:\s*(.+)$/i.exec(label); + if (match?.[1]) { + return match[1].trim().toLocaleLowerCase(); + } + } + + return undefined; +} + +export function normalizeGitHubIssues( + payload: unknown, + repository: ContributorRepository, +): ContributorIssue[] { + if (!Array.isArray(payload)) { + throw new Error("Unexpected GitHub response"); + } + + return payload.flatMap((value) => { + if (!isRecord(value) || "pull_request" in value) { + return []; + } + + const { id, number, title, html_url: htmlUrl } = value; + if ( + typeof id !== "number" || + typeof number !== "number" || + typeof title !== "string" || + typeof htmlUrl !== "string" + ) { + return []; + } + + const labels = normalizeLabels(value.labels); + return [{ + id, + number, + title, + htmlUrl, + repository: repository.name, + labels, + difficulty: extractDifficulty(labels), + }]; + }); +} + +function githubHeaders(): HeadersInit { + const headers: Record = { + Accept: "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + }; + + if (process.env.GITHUB_TOKEN) { + headers.Authorization = `Bearer ${process.env.GITHUB_TOKEN}`; + } + + return headers; +} + +async function fetchIssuesForLabel( + repository: ContributorRepository, + label: string, + fetcher: GitHubFetch, +): Promise { + const query = new URLSearchParams({ + state: "open", + labels: label, + per_page: "100", + }); + const response = await fetcher( + `${GITHUB_API_URL}/repos/${GITHUB_ORGANIZATION}/${repository.name}/issues?${query}`, + { + headers: githubHeaders(), + next: { revalidate: REVALIDATE_SECONDS }, + }, + ); + + if (!response.ok) { + throw new Error("GitHub request failed"); + } + + return normalizeGitHubIssues(await response.json(), repository); +} + +async function fetchRepositoryIssues( + repository: ContributorRepository, + fetcher: GitHubFetch, +): Promise { + const responses = await Promise.allSettled( + ISSUE_LABELS.map((label) => fetchIssuesForLabel(repository, label, fetcher)), + ); + const successful = responses.filter( + (response): response is PromiseFulfilledResult => + response.status === "fulfilled", + ); + + if (successful.length === 0) { + return { repository, issues: [], status: "unavailable" }; + } + + const issuesById = new Map(); + for (const response of successful) { + for (const issue of response.value) { + issuesById.set(issue.id, issue); + } + } + + return { + repository, + issues: [...issuesById.values()].sort((a, b) => b.number - a.number), + status: successful.length === ISSUE_LABELS.length ? "available" : "partial", + }; +} + +export async function getContributorIssues( + fetcher: GitHubFetch = fetch, +): Promise { + return Promise.all( + CONTRIBUTOR_REPOSITORIES.map((repository) => fetchRepositoryIssues(repository, fetcher)), + ); +} diff --git a/tests/unit/contributor-issues.test.ts b/tests/unit/contributor-issues.test.ts new file mode 100644 index 0000000..fa7c92f --- /dev/null +++ b/tests/unit/contributor-issues.test.ts @@ -0,0 +1,121 @@ +import { describe, expect, it, vi } from "vitest"; +import { + CONTRIBUTOR_REPOSITORIES, + extractDifficulty, + getContributorIssues, + normalizeGitHubIssues, +} from "../../lib/contributor-issues"; + +function response(body: unknown, ok = true): Response { + return { + ok, + json: async () => body, + } as Response; +} + +describe("contributor issue normalization", () => { + it("filters pull requests, normalizes labels, and extracts difficulty", () => { + const issues = normalizeGitHubIssues([ + { + id: 1, + number: 57, + title: "Build the contributors page", + html_url: "https://github.com/FinesseStudioLab/modeltrace-frontend/issues/57", + labels: [ + { name: "good first issue" }, + { name: " difficulty:Easy " }, + "GOOD FIRST ISSUE", + { name: null }, + ], + }, + { + id: 2, + number: 58, + title: "A pull request", + html_url: "https://github.com/FinesseStudioLab/modeltrace-frontend/pull/58", + labels: [], + pull_request: {}, + }, + { id: "invalid" }, + ], CONTRIBUTOR_REPOSITORIES[0]); + + expect(issues).toHaveLength(1); + expect(issues[0]).toMatchObject({ + id: 1, + labels: ["good first issue", "difficulty:Easy"], + difficulty: "easy", + repository: "modeltrace-frontend", + }); + expect(extractDifficulty(["help wanted"])).toBeUndefined(); + }); + + it("rejects a malformed top-level API response", () => { + expect(() => normalizeGitHubIssues({}, CONTRIBUTOR_REPOSITORIES[0])).toThrow(); + }); +}); + +describe("contributor issue fetching", () => { + it("combines both labels, removes duplicate issues, and configures caching", async () => { + const issue = { + id: 10, + number: 10, + title: "Shared issue", + html_url: "https://github.com/FinesseStudioLab/modeltrace-frontend/issues/10", + labels: [{ name: "good first issue" }, { name: "help wanted" }], + }; + const fetcher = vi.fn(async (_url: string, _init: RequestInit) => response([issue])); + + const results = await getContributorIssues(fetcher); + + expect(results).toHaveLength(3); + expect(results.every((result) => result.issues.length === 1)).toBe(true); + expect(results.every((result) => result.status === "available")).toBe(true); + expect(fetcher).toHaveBeenCalledTimes(6); + expect(fetcher.mock.calls[0]?.[1]).toMatchObject({ next: { revalidate: 3600 } }); + expect(fetcher.mock.calls.map(([url]) => url)).toEqual( + expect.arrayContaining([ + expect.stringContaining("labels=good+first+issue"), + expect.stringContaining("labels=help+wanted"), + ]), + ); + }); + + it("keeps successful repositories when another repository fails", async () => { + const fetcher = vi.fn(async (url: string) => { + if (url.includes("modeltrace-backend")) { + throw new Error("rate limited"); + } + return response([]); + }); + + const results = await getContributorIssues(fetcher); + + expect(results.find(({ repository }) => repository.name === "modeltrace-backend")?.status) + .toBe("unavailable"); + expect(results.filter(({ status }) => status === "available")).toHaveLength(2); + expect(results.filter(({ status }) => status === "available").every(({ issues }) => + issues.length === 0)).toBe(true); + }); + + it("marks a repository partial when only one label request succeeds", async () => { + const fetcher = vi.fn(async (url: string) => { + if (url.includes("labels=help+wanted")) { + return response({}, false); + } + return response([]); + }); + + const results = await getContributorIssues(fetcher); + + expect(results.every(({ status }) => status === "partial")).toBe(true); + }); + + it("degrades safely when all GitHub requests fail", async () => { + const fetcher = vi.fn(async () => response({ message: "rate limit" }, false)); + + const results = await getContributorIssues(fetcher); + + expect(results.every(({ status, issues }) => status === "unavailable" && issues.length === 0)) + .toBe(true); + }); +});