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:
91
src/app/(dashboard)/projects/new/actions.ts
Normal file
91
src/app/(dashboard)/projects/new/actions.ts
Normal 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}`
|
||||
}
|
||||
@@ -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
197
src/lib/gitea/client.ts
Normal 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
82
src/lib/gitea/types.ts
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user