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 (
+ <>
+
+ {pipelines.items.map((pipeline) => (
+ -
+
+
+ ))}
+
+ {hasMore && (
+
+ void loadMore()}
+ />
+
+ )}
+ {error && (
+
+ Could not load more pipelines: {error.message}
+
+ )}
+ >
+ )
+}
+
+function PipelineCard({ pipeline }: { pipeline: Pipeline }) {
+ return (
+
+
+
+ {pipeline.workflows.map((workflow) => (
+ -
+ {workflow.name}: {workflow.status}
+
+ ))}
+
+
+ )
+}
+
+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('/')}`
+}