diff --git a/src/components/repo/RepoPipelines.tsx b/src/components/repo/RepoPipelines.tsx new file mode 100644 index 0000000..c0479a2 --- /dev/null +++ b/src/components/repo/RepoPipelines.tsx @@ -0,0 +1,100 @@ +import type { Did } from '@atcute/lexicons' +import { IconRoute } from '@tabler/icons-react' +import { useRepoPipelines } from '../../hooks/useRepoPipelines' +import type { Main as Pipeline } from '@atcute/tangled/types/ci/pipeline' +import { LoadMoreButton } from '../shared/LoadMoreButton' +import { SurfaceCard } from '../shared/SurfaceCard' + +export function RepoPipelines({ repoDid, spindle }: { repoDid: Did; spindle?: string }) { + if (spindle === undefined) + return ( +

+ Pipelines are not configured for this repository. +

+ ) + + return +} + +function ConfiguredRepoPipelines({ repoDid, spindle }: { repoDid: Did; spindle: string }) { + const { pipelines, error, hasMore, isLoadingMore, loadMore } = useRepoPipelines(repoDid, spindle) + + if (error && pipelines === null) + return

Could not load pipelines: {error.message}

+ if (pipelines === null) return

Loading pipelines...

+ if (pipelines.items.length === 0) + return

No pipelines found.

+ + return ( + <> + + {hasMore && ( +
+ void loadMore()} + /> +
+ )} + {error && ( +

+ Could not load more pipelines: {error.message} +

+ )} + + ) +} + +function PipelineCard({ pipeline }: { pipeline: Pipeline }) { + return ( + +
+
+
+ + {getTriggerReference(pipeline)} + +
+ +
+ ) +} + +function getTriggerName(pipeline: Pipeline) { + const type = pipeline.trigger.$type + if (type === 'sh.tangled.ci.trigger#pullRequest') return 'pull request' + if (type === 'sh.tangled.ci.trigger#push') return 'push' + return 'manual' +} + +function getTriggerReference(pipeline: Pipeline) { + const { trigger } = pipeline + if (trigger.$type === 'sh.tangled.ci.trigger#push') return trigger.ref + if (trigger.$type === 'sh.tangled.ci.trigger#pullRequest') return trigger.targetBranch + return trigger.ref ?? trigger.sha.slice(0, 7) +} + +function getStatusClass(status: string | undefined) { + if (status === 'success') return 'bg-ctp-green/20 text-ctp-green' + if (status === 'failed' || status === 'timeout') return 'bg-ctp-red/20 text-ctp-red' + if (status === 'running') return 'bg-ctp-blue/20 text-ctp-blue' + return 'bg-ctp-surface-1 text-ctp-subtext-0' +} diff --git a/src/hooks/useRepoPipelines.ts b/src/hooks/useRepoPipelines.ts new file mode 100644 index 0000000..ac63fcd --- /dev/null +++ b/src/hooks/useRepoPipelines.ts @@ -0,0 +1,20 @@ +import type { Did } from '@atcute/lexicons' +import { queryPipelines } from '../lib/tangled/pipeline' +import { useCursorList } from './useCursorList' + +const INITIAL_PIPELINE_LIMIT = 20 + +export function useRepoPipelines(repoDid: Did, spindle: string) { + const result = useCursorList( + `${spindle}:${repoDid}`, + (options) => queryPipelines(spindle, repoDid, options), + INITIAL_PIPELINE_LIMIT, + ) + return { + pipelines: result.data, + error: result.error, + hasMore: result.hasMore, + isLoadingMore: result.isLoadingMore, + loadMore: result.loadMore, + } +} diff --git a/src/lib/tangled/pipeline/index.ts b/src/lib/tangled/pipeline/index.ts new file mode 100644 index 0000000..79b73bc --- /dev/null +++ b/src/lib/tangled/pipeline/index.ts @@ -0,0 +1,2 @@ +export { queryPipelines } from './queryPipelines' +export type { PipelineList } from './types' diff --git a/src/lib/tangled/pipeline/queryPipelines.ts b/src/lib/tangled/pipeline/queryPipelines.ts new file mode 100644 index 0000000..ac7e99f --- /dev/null +++ b/src/lib/tangled/pipeline/queryPipelines.ts @@ -0,0 +1,32 @@ +import { Client, ok, simpleFetchHandler, type FetchHandler } from '@atcute/client' +import type { Did } from '@atcute/lexicons' +import { safeParse } from '@atcute/lexicons' +import { mainSchema as queryPipelinesSchema } from '@atcute/tangled/types/ci/queryPipelines' +import { removeNullCursor } from '../utils' +import type { PipelineList, QueryPipelinesOptions } from './types' + +export async function queryPipelines( + spindle: string, + repo: Did, + options: QueryPipelinesOptions = {}, +): Promise { + const rpc = new Client({ handler: getSpindleFetchHandler(spindle) }) + const response = await ok( + rpc.get('sh.tangled.ci.queryPipelines', { params: { repo, ...options } }), + ) + const validation = safeParse(queryPipelinesSchema.output.schema, removeNullCursor(response)) + if (!validation.ok) throw new Error(`Spindle returned invalid pipelines: ${validation.message}`) + + return { items: validation.value.pipelines, cursor: validation.value.cursor } +} + +function getSpindleFetchHandler(spindle: string): FetchHandler { + const spindleUrl = + spindle.startsWith('http://') || spindle.startsWith('https://') ? spindle : `https://${spindle}` + + if (!import.meta.env.DEV) return simpleFetchHandler({ service: spindleUrl }) + + const hostname = new URL(spindleUrl).hostname + const proxyUrl = `${window.location.origin}/spindle/${encodeURIComponent(hostname)}` + return (pathname, init) => fetch(`${proxyUrl}${pathname}`, init) +} diff --git a/src/lib/tangled/pipeline/types.ts b/src/lib/tangled/pipeline/types.ts new file mode 100644 index 0000000..6f230b7 --- /dev/null +++ b/src/lib/tangled/pipeline/types.ts @@ -0,0 +1,4 @@ +import type { Main as CiPipeline } from '@atcute/tangled/types/ci/pipeline' + +export type PipelineList = { items: CiPipeline[]; cursor?: string } +export type QueryPipelinesOptions = { cursor?: string; limit?: number } diff --git a/src/pages/RepoPage.tsx b/src/pages/RepoPage.tsx index 9c655d6..2aabcda 100644 --- a/src/pages/RepoPage.tsx +++ b/src/pages/RepoPage.tsx @@ -3,6 +3,7 @@ import { PageContainer } from '../components/layout/PageContainer' import { ProfileByline } from '../components/profile/ProfileByline' import { RepoIssues } from '../components/repo/RepoIssues' import { RepoPulls } from '../components/repo/RepoPulls' +import { RepoPipelines } from '../components/repo/RepoPipelines' import { RepoReadme } from '../components/repo/RepoReadme' import { parseRepoSection } from '../components/repo/repoSections' import { RepoTabs } from '../components/repo/RepoTabs' @@ -11,7 +12,6 @@ import { RepoWorkspace } from '../components/repo/RepoWorkspace' import { ErrorPage } from '../components/shared/ErrorPage' import { LoadingPanel } from '../components/shared/LoadingPanel' import { RepoPageSkeleton } from '../components/shared/PageSkeletons' -import { SurfaceCard } from '../components/shared/SurfaceCard' import { useRepoPage } from '../hooks/useRepoPage' import { useRepoRootTree } from '../hooks/useRepoRootTree' import { parseHandle } from '../lib/routes' @@ -107,22 +107,11 @@ export function RepoPage() { repoKey={getRepoRkey(repo)} /> )} - {activeSection === 'pipelines' && } + {activeSection === 'pipelines' && repo.value.repoDid !== undefined && ( + + )} ) } - -type RepoPlaceholderProps = { - title: string -} - -function RepoPlaceholder({ title }: RepoPlaceholderProps) { - return ( - -

{title}

-

{title} will be available here soon.

-
- ) -} diff --git a/vite.config.ts b/vite.config.ts index c252982..70d0cba 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,12 +1,44 @@ -import { defineConfig } from 'vite' +import { defineConfig, type Plugin } from 'vite' import react, { reactCompilerPreset } from '@vitejs/plugin-react' import babel from '@rolldown/plugin-babel' import tailwindcss from '@tailwindcss/vite' // https://vite.dev/config/ export default defineConfig({ - plugins: [react(), babel({ presets: [reactCompilerPreset()] }), tailwindcss()], + plugins: [spindleProxy(), react(), babel({ presets: [reactCompilerPreset()] }), tailwindcss()], server: { allowedHosts: ['petalburg'], }, }) + +function spindleProxy(): Plugin { + return { + name: 'spindle-proxy', + configureServer(server) { + server.middlewares.use('/spindle', async (request, response, next) => { + if (request.method !== 'GET') return next() + + try { + const upstreamUrl = getUpstreamUrl(request.url) + const upstreamResponse = await fetch(upstreamUrl) + response.statusCode = upstreamResponse.status + const contentType = upstreamResponse.headers.get('content-type') + if (contentType !== null) response.setHeader('content-type', contentType) + response.end(Buffer.from(await upstreamResponse.arrayBuffer())) + } catch (error) { + next(error) + } + }) + }, + } +} + +function getUpstreamUrl(requestUrl: string | undefined): string { + const [encodedHostname, ...pathParts] = (requestUrl ?? '/').slice(1).split('/') + if (encodedHostname === undefined || encodedHostname.length === 0) { + throw new Error('Missing Spindle hostname') + } + + const hostname = decodeURIComponent(encodedHostname) + return `https://${hostname}/${pathParts.join('/')}` +}