Add Gitea integration for auto-repo creation on project creation

- Add Gitea API client with repo, webhook, commit, and PR methods
- Add TypeScript types for Gitea API responses
- Update project creation to auto-create Gitea repo with webhook
- Add step-by-step progress UI during project creation
- Update database types for agent_runs approval workflow
- Update database types for project automation config

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2025-12-15 13:29:13 +01:00
parent c5557ce9d6
commit c16c9b2d25
5 changed files with 494 additions and 3 deletions

View File

@@ -0,0 +1,91 @@
'use server'
import type { GiteaRepository, GiteaWebhook } from '@/lib/gitea/types'
const GITEA_URL = process.env.GITEA_URL || 'https://gitea.mylder.io'
const GITEA_TOKEN = process.env.GITEA_TOKEN || ''
const GITEA_OWNER = 'admin'
const WWS_WEBHOOK_URL = 'https://wws.mylder.io/webhook/gitea'
interface CreateRepoResult {
success: boolean
repo?: GiteaRepository
webhook?: GiteaWebhook
error?: string
}
async function giteaRequest<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
const url = `${GITEA_URL}/api/v1${endpoint}`
const response = await fetch(url, {
...options,
headers: {
'Authorization': `token ${GITEA_TOKEN}`,
'Content-Type': 'application/json',
...options.headers,
},
})
if (!response.ok) {
const error = await response.json().catch(() => ({
message: `HTTP ${response.status}: ${response.statusText}`,
}))
throw new Error(error.message || `Gitea API error: ${response.status}`)
}
if (response.status === 204) {
return {} as T
}
return response.json()
}
export async function createGiteaRepo(slug: string, description?: string): Promise<CreateRepoResult> {
try {
// Check if repo already exists
const existingCheck = await fetch(`${GITEA_URL}/api/v1/repos/${GITEA_OWNER}/${slug}`, {
headers: { 'Authorization': `token ${GITEA_TOKEN}` },
})
if (existingCheck.ok) {
return { success: false, error: 'Repository already exists' }
}
// Create the repository
const repo = await giteaRequest<GiteaRepository>('/user/repos', {
method: 'POST',
body: JSON.stringify({
name: slug,
description: description || `Mylder project: ${slug}`,
private: false,
auto_init: true,
default_branch: 'main',
}),
})
// Create webhook pointing to WWS (which routes to n8n)
const webhook = await giteaRequest<GiteaWebhook>(`/repos/${GITEA_OWNER}/${slug}/hooks`, {
method: 'POST',
body: JSON.stringify({
type: 'gitea',
config: {
url: WWS_WEBHOOK_URL,
content_type: 'json',
secret: '',
},
events: ['push', 'pull_request', 'issues', 'create', 'delete'],
active: true,
}),
})
return { success: true, repo, webhook }
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to create repository',
}
}
}
export async function getGiteaRepoUrl(slug: string): Promise<string> {
return `${GITEA_URL}/${GITEA_OWNER}/${slug}`
}

View File

@@ -8,13 +8,17 @@ import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { ArrowLeft, Loader2 } from 'lucide-react'
import { ArrowLeft, Loader2, GitBranch, Check } from 'lucide-react'
import { createGiteaRepo } from './actions'
type CreationStep = 'idle' | 'creating_project' | 'creating_repo' | 'complete'
export default function NewProjectPage() {
const router = useRouter()
const [name, setName] = useState('')
const [description, setDescription] = useState('')
const [isLoading, setIsLoading] = useState(false)
const [step, setStep] = useState<CreationStep>('idle')
const [error, setError] = useState<string | null>(null)
const handleSubmit = async (e: React.FormEvent) => {
@@ -37,6 +41,9 @@ export default function NewProjectPage() {
.replace(/[^a-z0-9]+/g, '-')
.replace(/(^-|-$)/g, '')
// Step 1: Create Supabase project
setStep('creating_project')
// First, get or create the user's default team
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let { data: team } = await (supabase as any)
@@ -60,12 +67,13 @@ export default function NewProjectPage() {
if (teamError) {
setError('Failed to create team')
setIsLoading(false)
setStep('idle')
return
}
team = newTeam
}
// Create the project
// Create the project (without gitea_repo initially)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const { data: project, error: projectError } = await (supabase as any)
.from('projects')
@@ -81,9 +89,34 @@ export default function NewProjectPage() {
if (projectError) {
setError(projectError.message)
setIsLoading(false)
setStep('idle')
return
}
// Step 2: Create Gitea repository
setStep('creating_repo')
const repoResult = await createGiteaRepo(slug, description || name)
if (repoResult.success && repoResult.repo) {
// Update project with gitea_repo URL
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await (supabase as any)
.from('projects')
.update({
gitea_repo: repoResult.repo.html_url,
gitea_webhook_id: repoResult.webhook?.id,
})
.eq('id', project.id)
} else {
// Log warning but don't fail - project still created
console.warn('Gitea repo creation failed:', repoResult.error)
}
setStep('complete')
// Small delay to show completion state
await new Promise(resolve => setTimeout(resolve, 500))
router.push(`/projects/${project.id}`)
}
@@ -127,11 +160,42 @@ export default function NewProjectPage() {
{error && (
<p className="text-sm text-destructive">{error}</p>
)}
{isLoading && (
<div className="space-y-2 py-2">
<div className="flex items-center gap-2 text-sm">
{step === 'creating_project' ? (
<Loader2 className="h-4 w-4 animate-spin text-brand" />
) : step === 'creating_repo' || step === 'complete' ? (
<Check className="h-4 w-4 text-emerald-500" />
) : (
<div className="h-4 w-4 rounded-full border-2 border-muted" />
)}
<span className={step === 'creating_project' ? 'text-foreground' : 'text-muted-foreground'}>
Creating project
</span>
</div>
<div className="flex items-center gap-2 text-sm">
{step === 'creating_repo' ? (
<Loader2 className="h-4 w-4 animate-spin text-brand" />
) : step === 'complete' ? (
<Check className="h-4 w-4 text-emerald-500" />
) : (
<div className="h-4 w-4 rounded-full border-2 border-muted" />
)}
<span className={step === 'creating_repo' ? 'text-foreground' : 'text-muted-foreground'}>
<GitBranch className="inline h-3 w-3 mr-1" />
Creating Gitea repository
</span>
</div>
</div>
)}
<Button type="submit" variant="brand" className="w-full" disabled={isLoading || !name}>
{isLoading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Creating...
{step === 'creating_project' && 'Creating project...'}
{step === 'creating_repo' && 'Setting up repository...'}
{step === 'complete' && 'Done!'}
</>
) : (
'Create Project'

197
src/lib/gitea/client.ts Normal file
View File

@@ -0,0 +1,197 @@
import type {
GiteaRepository,
GiteaWebhook,
GiteaPullRequest,
GiteaBranch,
CreateRepoOptions,
CreateWebhookOptions,
CreateCommitOptions,
CreatePROptions,
GiteaError,
} from './types'
const GITEA_URL = process.env.NEXT_PUBLIC_GITEA_URL || 'https://gitea.mylder.io'
const GITEA_TOKEN = process.env.GITEA_TOKEN || ''
const GITEA_OWNER = 'admin'
class GiteaClient {
private baseUrl: string
private token: string
private owner: string
constructor(baseUrl: string = GITEA_URL, token: string = GITEA_TOKEN, owner: string = GITEA_OWNER) {
this.baseUrl = baseUrl
this.token = token
this.owner = owner
}
private async request<T>(
endpoint: string,
options: RequestInit = {}
): Promise<T> {
const url = `${this.baseUrl}/api/v1${endpoint}`
const response = await fetch(url, {
...options,
headers: {
'Authorization': `token ${this.token}`,
'Content-Type': 'application/json',
...options.headers,
},
})
if (!response.ok) {
const error: GiteaError = await response.json().catch(() => ({
message: `HTTP ${response.status}: ${response.statusText}`,
}))
throw new Error(error.message || `Gitea API error: ${response.status}`)
}
if (response.status === 204) {
return {} as T
}
return response.json()
}
async createRepo(options: CreateRepoOptions): Promise<GiteaRepository> {
return this.request<GiteaRepository>('/user/repos', {
method: 'POST',
body: JSON.stringify({
name: options.name,
description: options.description || '',
private: options.private ?? false,
auto_init: options.auto_init ?? true,
default_branch: options.default_branch || 'main',
}),
})
}
async getRepo(name: string): Promise<GiteaRepository | null> {
try {
return await this.request<GiteaRepository>(`/repos/${this.owner}/${name}`)
} catch {
return null
}
}
async deleteRepo(name: string): Promise<void> {
await this.request(`/repos/${this.owner}/${name}`, { method: 'DELETE' })
}
async createWebhook(options: CreateWebhookOptions): Promise<GiteaWebhook> {
return this.request<GiteaWebhook>(`/repos/${this.owner}/${options.repo}/hooks`, {
method: 'POST',
body: JSON.stringify({
type: 'gitea',
config: {
url: options.url,
content_type: 'json',
secret: options.secret || '',
},
events: options.events,
active: options.active ?? true,
}),
})
}
async listWebhooks(repo: string): Promise<GiteaWebhook[]> {
return this.request<GiteaWebhook[]>(`/repos/${this.owner}/${repo}/hooks`)
}
async deleteWebhook(repo: string, hookId: number): Promise<void> {
await this.request(`/repos/${this.owner}/${repo}/hooks/${hookId}`, { method: 'DELETE' })
}
async getBranch(repo: string, branch: string): Promise<GiteaBranch | null> {
try {
return await this.request<GiteaBranch>(`/repos/${this.owner}/${repo}/branches/${branch}`)
} catch {
return null
}
}
async createBranch(repo: string, branchName: string, baseBranch: string = 'main'): Promise<GiteaBranch> {
const base = await this.getBranch(repo, baseBranch)
if (!base) {
throw new Error(`Base branch ${baseBranch} not found`)
}
return this.request<GiteaBranch>(`/repos/${this.owner}/${repo}/branches`, {
method: 'POST',
body: JSON.stringify({
new_branch_name: branchName,
old_ref_name: baseBranch,
}),
})
}
async createCommit(options: CreateCommitOptions): Promise<{ commit: { sha: string } }> {
const results = await Promise.all(
options.files.map(async (file) => {
const existing = await this.getFileContent(options.repo, file.path, options.branch)
const method = existing ? 'PUT' : 'POST'
const endpoint = `/repos/${this.owner}/${options.repo}/contents/${file.path}`
return this.request(endpoint, {
method,
body: JSON.stringify({
message: options.message,
content: Buffer.from(file.content).toString('base64'),
branch: options.branch,
sha: existing?.sha,
author: options.author,
}),
})
})
)
return results[0] as { commit: { sha: string } }
}
async getFileContent(repo: string, path: string, ref?: string): Promise<{ sha: string; content: string } | null> {
try {
const endpoint = `/repos/${this.owner}/${repo}/contents/${path}${ref ? `?ref=${ref}` : ''}`
return await this.request(endpoint)
} catch {
return null
}
}
async createPullRequest(options: CreatePROptions): Promise<GiteaPullRequest> {
return this.request<GiteaPullRequest>(`/repos/${this.owner}/${options.repo}/pulls`, {
method: 'POST',
body: JSON.stringify({
title: options.title,
body: options.body || '',
head: options.head,
base: options.base || 'main',
}),
})
}
async getPullRequest(repo: string, number: number): Promise<GiteaPullRequest> {
return this.request<GiteaPullRequest>(`/repos/${this.owner}/${repo}/pulls/${number}`)
}
async mergePullRequest(repo: string, number: number, strategy: 'merge' | 'rebase' | 'squash' = 'squash'): Promise<void> {
await this.request(`/repos/${this.owner}/${repo}/pulls/${number}/merge`, {
method: 'POST',
body: JSON.stringify({ do: strategy }),
})
}
async listPullRequests(repo: string, state: 'open' | 'closed' | 'all' = 'open'): Promise<GiteaPullRequest[]> {
return this.request<GiteaPullRequest[]>(`/repos/${this.owner}/${repo}/pulls?state=${state}`)
}
getRepoUrl(name: string): string {
return `${this.baseUrl}/${this.owner}/${name}`
}
getCloneUrl(name: string): string {
return `${this.baseUrl}/${this.owner}/${name}.git`
}
}
export const gitea = new GiteaClient()
export { GiteaClient }

82
src/lib/gitea/types.ts Normal file
View File

@@ -0,0 +1,82 @@
export interface GiteaRepository {
id: number
name: string
full_name: string
description: string
html_url: string
clone_url: string
ssh_url: string
default_branch: string
empty: boolean
created_at: string
updated_at: string
}
export interface GiteaWebhook {
id: number
type: string
url: string
active: boolean
events: string[]
created_at: string
}
export interface GiteaCommitFile {
path: string
content: string
operation?: 'create' | 'update' | 'delete'
}
export interface GiteaPullRequest {
id: number
number: number
title: string
body: string
html_url: string
state: 'open' | 'closed' | 'merged'
head: { ref: string }
base: { ref: string }
created_at: string
}
export interface GiteaBranch {
name: string
commit: { id: string; message: string }
}
export interface CreateRepoOptions {
name: string
description?: string
private?: boolean
auto_init?: boolean
default_branch?: string
}
export interface CreateWebhookOptions {
repo: string
url: string
events: string[]
secret?: string
active?: boolean
}
export interface CreateCommitOptions {
repo: string
branch: string
message: string
files: GiteaCommitFile[]
author?: { name: string; email: string }
}
export interface CreatePROptions {
repo: string
title: string
body?: string
head: string
base?: string
}
export type GiteaError = {
message: string
url?: string
}

View File

@@ -95,6 +95,13 @@ export interface Database {
slug: string
description: string | null
gitea_repo: string | null
gitea_webhook_id: number | null
automation_config: {
auto_review: boolean
auto_deploy: boolean
deployment_target: 'wws' | 'coolify' | 'both' | null
coolify_app_uuid: string | null
} | null
tech_stack: string[]
platform: string | null
status: 'active' | 'archived' | 'paused'
@@ -109,6 +116,13 @@ export interface Database {
slug: string
description?: string | null
gitea_repo?: string | null
gitea_webhook_id?: number | null
automation_config?: {
auto_review?: boolean
auto_deploy?: boolean
deployment_target?: 'wws' | 'coolify' | 'both' | null
coolify_app_uuid?: string | null
} | null
tech_stack?: string[]
platform?: string | null
status?: 'active' | 'archived' | 'paused'
@@ -123,6 +137,13 @@ export interface Database {
slug?: string
description?: string | null
gitea_repo?: string | null
gitea_webhook_id?: number | null
automation_config?: {
auto_review?: boolean
auto_deploy?: boolean
deployment_target?: 'wws' | 'coolify' | 'both' | null
coolify_app_uuid?: string | null
} | null
tech_stack?: string[]
platform?: string | null
status?: 'active' | 'archived' | 'paused'
@@ -168,6 +189,18 @@ export interface Database {
command: string
status: 'pending' | 'running' | 'completed' | 'failed' | 'cancelled'
result: Json | null
proposed_changes: {
description: string
files: Array<{
path: string
content: string
operation: 'create' | 'update' | 'delete'
}>
estimated_impact?: string
} | null
approval_status: 'pending' | 'approved' | 'rejected' | 'auto_approved'
approved_by: string | null
approved_at: string | null
started_at: string | null
completed_at: string | null
created_at: string
@@ -179,6 +212,18 @@ export interface Database {
command: string
status?: 'pending' | 'running' | 'completed' | 'failed' | 'cancelled'
result?: Json | null
proposed_changes?: {
description: string
files: Array<{
path: string
content: string
operation: 'create' | 'update' | 'delete'
}>
estimated_impact?: string
} | null
approval_status?: 'pending' | 'approved' | 'rejected' | 'auto_approved'
approved_by?: string | null
approved_at?: string | null
started_at?: string | null
completed_at?: string | null
created_at?: string
@@ -190,6 +235,18 @@ export interface Database {
command?: string
status?: 'pending' | 'running' | 'completed' | 'failed' | 'cancelled'
result?: Json | null
proposed_changes?: {
description: string
files: Array<{
path: string
content: string
operation: 'create' | 'update' | 'delete'
}>
estimated_impact?: string
} | null
approval_status?: 'pending' | 'approved' | 'rejected' | 'auto_approved'
approved_by?: string | null
approved_at?: string | null
started_at?: string | null
completed_at?: string | null
created_at?: string