--- template/app/src/landing-page/operations.ts +++ opensaas-sh/app/src/landing-page/operations.ts @@ -0,0 +1,247 @@ +import type { GetGithubRoadmap } from "wasp/server/operations"; + +import * as z from "zod"; +import { assertUnreachable } from "../shared/utils"; + +const GITHUB_ORG_NAME = "wasp-lang"; +const OPEN_SAAS_GITHUB_PROJECT_NUMBER = 6; + +export enum GithubEpicStatus { + Ideas = "Ideas", + Planned = "Planned", + InProgress = "In progress", + Done = "Done", +} + +export type GithubEpic = { + name: string; + totalIssues: number; + doneIssues: number; + status: GithubEpicStatus; + url: string; +}; + +let cachedGithubEpicsData: GithubEpic[] | null = null; +let lastFetchTime = 0; +const CACHE_DURATION_4_HOURS = 4 * 60 * 60 * 1000; + +export const getGithubRoadmap: GetGithubRoadmap< + void, + GithubEpic[] +> = async () => { + const currentTime = Date.now(); + + if ( + cachedGithubEpicsData && + currentTime - lastFetchTime < CACHE_DURATION_4_HOURS + ) { + return cachedGithubEpicsData; + } else { + lastFetchTime = currentTime; + } + + const githubRoadmapToken = process.env.GITHUB_ROADMAP_TOKEN; + if (!githubRoadmapToken) { + console.warn("GITHUB_ROADMAP_TOKEN is not set"); + return []; + } + + const githubGraphQlQuery = ` + query($org: String!, $number: Int!) { + organization(login: $org) { + projectV2(number: $number) { + items(first: 100) { + nodes { + id + databaseId + fieldValues(first: 50) { + nodes { + ... on ProjectV2ItemFieldSingleSelectValue { + name + field { ... on ProjectV2FieldCommon { name } } + } + } + } + content { + ... on Issue { + title + url + number + issueType { name } + subIssues(first: 100) { + totalCount + nodes { + state + number + } + } + } + } + } + } + } + } + } + `; + + try { + const response = await fetch("https://api.github.com/graphql", { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${githubRoadmapToken}`, + }, + body: JSON.stringify({ + query: githubGraphQlQuery, + variables: { + org: GITHUB_ORG_NAME, + number: OPEN_SAAS_GITHUB_PROJECT_NUMBER, + }, + }), + }); + + const json = await response.json(); + + const parseResult = GithubGraphQLResponseSchema.safeParse(json); + if (!parseResult.success) { + console.error( + "GitHub API response validation failed:", + parseResult.error, + ); + return cachedGithubEpicsData || []; + } + + const validatedResponse = parseResult.data; + + if (validatedResponse.errors) { + console.error("GitHub API Errors:", validatedResponse.errors); + throw new Error("GitHub API returned errors"); + } + + const nodes = + validatedResponse.data?.organization?.projectV2?.items?.nodes || []; + const statusFieldName = "Status"; + + const BASE_PROJECT_URL = `https://github.com/orgs/${GITHUB_ORG_NAME}/projects/${OPEN_SAAS_GITHUB_PROJECT_NUMBER}/views/1?pane=issue&itemId=`; + + const epics: GithubEpic[] = nodes + .filter((node) => node.content?.issueType?.name === "Epic") + .map((node) => { + const issue = node.content; + + const statusField = node.fieldValues.nodes.find( + (f) => f.field?.name === statusFieldName, + ); + const statusName = statusField?.name || "Ideas"; + + let status: GithubEpicStatus = GithubEpicStatus.Ideas; + if ( + Object.values(GithubEpicStatus).includes( + statusName as GithubEpicStatus, + ) + ) { + status = statusName as GithubEpicStatus; + } else { + console.warn(`Unknown GitHub Epic status: ${statusName}`); + } + + const subIssues = issue.subIssues?.nodes || []; + const totalIssues = issue.subIssues?.totalCount ?? subIssues.length; + const doneIssues = subIssues.filter( + (sub) => sub.state === "CLOSED", + ).length; + + return { + name: issue.title, + totalIssues, + doneIssues, + status, + url: `${BASE_PROJECT_URL}${node.databaseId}`, + }; + }); + + epics.sort((a, b) => getStatusOrder(a.status) - getStatusOrder(b.status)); + + cachedGithubEpicsData = epics; + return epics; + } catch (error) { + console.error("Failed to fetch GitHub roadmap:", error); + return cachedGithubEpicsData || []; + } +}; + +const getStatusOrder = (status: GithubEpicStatus): number => { + switch (status) { + case GithubEpicStatus.InProgress: + return 0; + case GithubEpicStatus.Planned: + return 1; + case GithubEpicStatus.Ideas: + return 2; + case GithubEpicStatus.Done: + return 3; + default: + return assertUnreachable(status); + } +}; + +const GithubIssueSchema = z.object({ + title: z.string(), + url: z.string(), + number: z.number(), + issueType: z + .object({ + name: z.string(), + }) + .nullish(), + subIssues: z + .object({ + totalCount: z.number(), + nodes: z.array( + z.object({ + state: z.string(), + number: z.number(), + }), + ), + }) + .nullish(), +}); + +const ProjectV2ItemFieldValueSchema = z.object({ + name: z.string().nullish(), + field: z + .object({ + name: z.string(), + }) + .nullish(), +}); + +const ProjectV2ItemSchema = z.object({ + id: z.string(), + databaseId: z.number().nullish(), + fieldValues: z.object({ + nodes: z.array(ProjectV2ItemFieldValueSchema), + }), + content: GithubIssueSchema, +}); + +const GithubGraphQLResponseSchema = z.object({ + data: z + .object({ + organization: z + .object({ + projectV2: z + .object({ + items: z + .object({ + nodes: z.array(ProjectV2ItemSchema), + }) + .nullish(), + }) + .nullish(), + }) + .nullish(), + }) + .nullish(), + errors: z.array(z.any()).nullish(), +});