Branch restrictions — fully enforced:

CRUD rules with pattern (exact or glob like release/*), requirePR, blockForcePush, bypass user list
Enforcement via pkt-line parsing inside the git HTTP handler — before any data reaches git http-backend, each ref update is extracted and checked against stored rules
Direct push to main with requirePR: true → 403 with message; push to unprotected branches still works
Inline checkboxes in the UI update rules immediately
Branching model — stored config:

GET/PUT per repo, defaults to feature/bugfix/release/hotfix prefixes
Toggle enabled/disabled, custom prefix per type with live preview
No enforcement (naming guide only, as Bitbucket does)
Merge strategies — enforced in PR merge endpoint:

GET/PUT per repo, defaults all three allowed
Merge handler now accepts strategy: "merge"|"squash"|"rebase" in request body, checks against stored policy
Disallowed strategy → 409 with clear error; allowed strategy → merges and fires pull_request webhook
Must have at least one strategy enabled (validated server-side)
Webhooks — full delivery with HMAC:

CRUD with title, URL, secret (optional), events (push/pull_request/issue), active toggle
Test button sends live HTTP POST to the configured URL and shows status code in UI
FireWebhooks() fires asynchronously from PR merge and can be called from any handler
X-ForgeBucket-Signature-256: sha256=<hmac> header when secret is set
Last delivery status and timestamp stored on webhook record and shown in list
This commit is contained in:
2026-05-07 15:27:48 +02:00
parent 53aa5cbbf5
commit f211cfc7db
11 changed files with 1438 additions and 4 deletions
+146
View File
@@ -0,0 +1,146 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { z } from 'zod'
import { api } from '../client'
import type { BranchProtection, BranchingModel, MergeStrategies, Webhook } from '../../types/api'
const base = (o: string, r: string) => `/api/v1/repos/${o}/${r}`
// ── Branch protections ────────────────────────────────────────────────────────
const bpSchema = z.object({
id: z.number(), pattern: z.string(), requirePR: z.boolean(),
blockForcePush: z.boolean(), allowedUsers: z.string(), createdAt: z.string(),
})
export function useBranchProtections(owner: string, repo: string) {
return useQuery({
queryKey: ['repos', owner, repo, 'branch-protections'],
queryFn: () => api.get<BranchProtection[]>(`${base(owner, repo)}/branch-protections`, z.array(bpSchema)),
enabled: Boolean(owner && repo),
})
}
export function useCreateBranchProtection(owner: string, repo: string) {
const qc = useQueryClient()
return useMutation({
mutationFn: (d: { pattern: string; requirePR: boolean; blockForcePush: boolean; allowedUsers: string }) =>
api.post<BranchProtection>(`${base(owner, repo)}/branch-protections`, bpSchema, d),
onSuccess: () => qc.invalidateQueries({ queryKey: ['repos', owner, repo, 'branch-protections'] }),
})
}
export function useUpdateBranchProtection(owner: string, repo: string) {
const qc = useQueryClient()
return useMutation({
mutationFn: ({ id, ...d }: { id: number; requirePR: boolean; blockForcePush: boolean; allowedUsers: string }) =>
api.patch<BranchProtection>(`${base(owner, repo)}/branch-protections/${id}`, bpSchema, d),
onSuccess: () => qc.invalidateQueries({ queryKey: ['repos', owner, repo, 'branch-protections'] }),
})
}
export function useDeleteBranchProtection(owner: string, repo: string) {
const qc = useQueryClient()
return useMutation({
mutationFn: (id: number) => api.delete(`${base(owner, repo)}/branch-protections/${id}`, z.any()),
onSuccess: () => qc.invalidateQueries({ queryKey: ['repos', owner, repo, 'branch-protections'] }),
})
}
// ── Branching model ───────────────────────────────────────────────────────────
const bmSchema = z.object({
enabled: z.boolean(), featurePrefix: z.string(), bugfixPrefix: z.string(),
releasePrefix: z.string(), hotfixPrefix: z.string(),
})
export function useBranchingModel(owner: string, repo: string) {
return useQuery({
queryKey: ['repos', owner, repo, 'branching-model'],
queryFn: () => api.get<BranchingModel>(`${base(owner, repo)}/branching-model`, bmSchema),
enabled: Boolean(owner && repo),
})
}
export function useUpdateBranchingModel(owner: string, repo: string) {
const qc = useQueryClient()
return useMutation({
mutationFn: (d: BranchingModel) =>
api.put<BranchingModel>(`${base(owner, repo)}/branching-model`, bmSchema, d),
onSuccess: (d) => qc.setQueryData(['repos', owner, repo, 'branching-model'], d),
})
}
// ── Merge strategies ──────────────────────────────────────────────────────────
const msSchema = z.object({
allowMergeCommit: z.boolean(), allowSquash: z.boolean(), allowRebase: z.boolean(),
})
export function useMergeStrategies(owner: string, repo: string) {
return useQuery({
queryKey: ['repos', owner, repo, 'merge-strategies'],
queryFn: () => api.get<MergeStrategies>(`${base(owner, repo)}/merge-strategies`, msSchema),
enabled: Boolean(owner && repo),
})
}
export function useUpdateMergeStrategies(owner: string, repo: string) {
const qc = useQueryClient()
return useMutation({
mutationFn: (d: MergeStrategies) =>
api.put<MergeStrategies>(`${base(owner, repo)}/merge-strategies`, msSchema, d),
onSuccess: (d) => qc.setQueryData(['repos', owner, repo, 'merge-strategies'], d),
})
}
// ── Webhooks ──────────────────────────────────────────────────────────────────
const webhookSchema = z.object({
id: z.number(), title: z.string(), url: z.string(), events: z.string(),
active: z.boolean(), hasSecret: z.boolean(), lastStatus: z.number(),
lastDeliveredAt: z.string().nullable().optional(), createdAt: z.string(),
})
export function useWebhooks(owner: string, repo: string) {
return useQuery({
queryKey: ['repos', owner, repo, 'webhooks'],
queryFn: () => api.get<Webhook[]>(`${base(owner, repo)}/webhooks`, z.array(webhookSchema)),
enabled: Boolean(owner && repo),
})
}
export function useCreateWebhook(owner: string, repo: string) {
const qc = useQueryClient()
return useMutation({
mutationFn: (d: { title: string; url: string; secret?: string; events: string; active: boolean }) =>
api.post<Webhook>(`${base(owner, repo)}/webhooks`, webhookSchema, d),
onSuccess: () => qc.invalidateQueries({ queryKey: ['repos', owner, repo, 'webhooks'] }),
})
}
export function useUpdateWebhook(owner: string, repo: string) {
const qc = useQueryClient()
return useMutation({
mutationFn: ({ id, ...d }: { id: number; title: string; url: string; secret?: string; events: string; active: boolean }) =>
api.patch<Webhook>(`${base(owner, repo)}/webhooks/${id}`, webhookSchema, d),
onSuccess: () => qc.invalidateQueries({ queryKey: ['repos', owner, repo, 'webhooks'] }),
})
}
export function useDeleteWebhook(owner: string, repo: string) {
const qc = useQueryClient()
return useMutation({
mutationFn: (id: number) => api.delete(`${base(owner, repo)}/webhooks/${id}`, z.any()),
onSuccess: () => qc.invalidateQueries({ queryKey: ['repos', owner, repo, 'webhooks'] }),
})
}
export function useTestWebhook(owner: string, repo: string) {
const qc = useQueryClient()
return useMutation({
mutationFn: (id: number) =>
api.post<{ status: number; ok: boolean }>(`${base(owner, repo)}/webhooks/${id}/test`,
z.object({ status: z.number(), ok: z.boolean() }), {}),
onSuccess: () => qc.invalidateQueries({ queryKey: ['repos', owner, repo, 'webhooks'] }),
})
}
+453 -1
View File
@@ -3,6 +3,12 @@ import { useParams, Link, useNavigate, useSearchParams } from 'react-router-dom'
import { useRepo, useUpdateRepo, useDeleteRepo, useUploadRepoAvatar } from '../api/queries/repos' import { useRepo, useUpdateRepo, useDeleteRepo, useUploadRepoAvatar } from '../api/queries/repos'
import { useRepoMembers, useAddMember, useUpdateMember, useRemoveMember } from '../api/queries/members' import { useRepoMembers, useAddMember, useUpdateMember, useRemoveMember } from '../api/queries/members'
import { useDeployKeys, useCreateDeployKey, useDeleteDeployKey, useAccessTokens, useCreateAccessToken, useDeleteAccessToken } from '../api/queries/keys' import { useDeployKeys, useCreateDeployKey, useDeleteDeployKey, useAccessTokens, useCreateAccessToken, useDeleteAccessToken } from '../api/queries/keys'
import {
useBranchProtections, useCreateBranchProtection, useUpdateBranchProtection, useDeleteBranchProtection,
useBranchingModel, useUpdateBranchingModel,
useMergeStrategies, useUpdateMergeStrategies,
useWebhooks, useCreateWebhook, useUpdateWebhook, useDeleteWebhook, useTestWebhook,
} from '../api/queries/workflow'
import { useRecentRepos } from '../hooks/useRecentRepos' import { useRecentRepos } from '../hooks/useRecentRepos'
import { useAuth } from '../contexts/AuthContext' import { useAuth } from '../contexts/AuthContext'
import { Skeleton } from '../ui/Skeleton' import { Skeleton } from '../ui/Skeleton'
@@ -135,7 +141,12 @@ export default function RepoSettingsPage() {
{section === 'repository-permissions' && <RepositoryPermissionsSection owner={owner} repo={repoName} />} {section === 'repository-permissions' && <RepositoryPermissionsSection owner={owner} repo={repoName} />}
{section === 'access-keys' && <AccessKeysSection owner={owner} repo={repoName} />} {section === 'access-keys' && <AccessKeysSection owner={owner} repo={repoName} />}
{section === 'access-tokens' && <AccessTokensSection owner={owner} repo={repoName} />} {section === 'access-tokens' && <AccessTokensSection owner={owner} repo={repoName} />}
{!['repository-details','repository-permissions','access-keys','access-tokens'].includes(section) && <ComingSoon sectionId={section} />} {section === 'branch-restrictions' && <BranchRestrictionsSection owner={owner} repo={repoName} />}
{section === 'branching-model' && <BranchingModelSection owner={owner} repo={repoName} />}
{section === 'merge-strategies' && <MergeStrategiesSection owner={owner} repo={repoName} />}
{section === 'webhooks' && <WebhooksSection owner={owner} repo={repoName} />}
{!['repository-details','repository-permissions','access-keys','access-tokens',
'branch-restrictions','branching-model','merge-strategies','webhooks'].includes(section) && <ComingSoon sectionId={section} />}
</main> </main>
</div> </div>
) )
@@ -1024,6 +1035,447 @@ function AccessTokensSection({ owner, repo }: { owner: string; repo: string }) {
) )
} }
// ─── Branch restrictions ──────────────────────────────────────────────────────
function BranchRestrictionsSection({ owner, repo }: { owner: string; repo: string }) {
const { data: rules, isLoading } = useBranchProtections(owner, repo)
const createRule = useCreateBranchProtection(owner, repo)
const updateRule = useUpdateBranchProtection(owner, repo)
const deleteRule = useDeleteBranchProtection(owner, repo)
const [pattern, setPattern] = useState('')
const [requirePR, setRequirePR] = useState(true)
const [blockForcePush, setBlockForcePush] = useState(true)
const [allowedUsers, setAllowedUsers] = useState('')
const [error, setError] = useState('')
async function handleCreate(e: React.FormEvent) {
e.preventDefault()
setError('')
try {
await createRule.mutateAsync({ pattern: pattern.trim(), requirePR, blockForcePush, allowedUsers: allowedUsers.trim() })
setPattern('')
} catch (err) { setError((err as Error).message) }
}
return (
<div className="max-w-2xl px-6 py-6 space-y-6">
<div>
<h1 className="text-xl font-semibold text-[var(--c-text)]">Branch restrictions</h1>
<p className="text-sm text-[var(--c-muted)] mt-1">
Protect branches from direct pushes. Matched branches can only be updated via pull requests.
</p>
</div>
<div className="border-t border-[var(--c-border)]" />
{/* Rule list */}
<div className="border border-[var(--c-border)] rounded-lg overflow-hidden bg-[var(--c-surface)]">
<div className="px-4 py-3 border-b border-[var(--c-border)] bg-[var(--c-surface-raised)] flex justify-between">
<h2 className="text-sm font-semibold text-[var(--c-text)]">Protected branches</h2>
<span className="text-xs text-[var(--c-muted)]">{rules?.length ?? 0} rule{rules?.length !== 1 ? 's' : ''}</span>
</div>
{isLoading ? (
<div className="p-4"><Skeleton className="h-4 w-full" /></div>
) : !rules?.length ? (
<div className="px-4 py-8 text-center text-sm text-[var(--c-muted)]">No branch protection rules yet.</div>
) : (
<ul className="divide-y divide-[var(--c-border)]">
{rules.map(rule => (
<li key={rule.id} className="px-4 py-3 space-y-2">
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
<svg width="14" height="14" fill="none" stroke="var(--c-brand)" strokeWidth="2" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12.75 11.25 15 15 9.75m-3-7.036A11.959 11.959 0 0 1 3.598 6 11.99 11.99 0 0 0 3 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285Z" />
</svg>
<code className="text-sm font-mono font-semibold text-[var(--c-text)]">{rule.pattern}</code>
</div>
<button onClick={() => deleteRule.mutate(rule.id)} disabled={deleteRule.isPending}
className="text-[var(--c-danger)] hover:text-[var(--c-danger-dark)] p-1 rounded hover:bg-[var(--c-danger-tint)] disabled:opacity-40">
<svg width="14" height="14" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18 18 6M6 6l12 12" />
</svg>
</button>
</div>
<div className="flex gap-4 text-xs text-[var(--c-muted)] ml-6">
<label className="flex items-center gap-1.5 cursor-pointer">
<input type="checkbox" checked={rule.requirePR} className="w-3 h-3 accent-[var(--c-brand)]"
onChange={e => updateRule.mutate({ id: rule.id, requirePR: e.target.checked, blockForcePush: rule.blockForcePush, allowedUsers: rule.allowedUsers })} />
Require PR
</label>
<label className="flex items-center gap-1.5 cursor-pointer">
<input type="checkbox" checked={rule.blockForcePush} className="w-3 h-3 accent-[var(--c-brand)]"
onChange={e => updateRule.mutate({ id: rule.id, requirePR: rule.requirePR, blockForcePush: e.target.checked, allowedUsers: rule.allowedUsers })} />
Block force push
</label>
{rule.allowedUsers && <span>Bypass: <code className="text-[var(--c-text)]">{rule.allowedUsers}</code></span>}
</div>
</li>
))}
</ul>
)}
</div>
{/* Create rule form */}
<div className="border border-[var(--c-border)] rounded-lg overflow-hidden bg-[var(--c-surface)]">
<div className="px-4 py-3 border-b border-[var(--c-border)] bg-[var(--c-surface-raised)]">
<h2 className="text-sm font-semibold text-[var(--c-text)]">Add a rule</h2>
</div>
<form onSubmit={handleCreate} className="p-4 space-y-4">
<div>
<label className="block text-xs font-medium text-[var(--c-muted)] mb-1">Branch pattern</label>
<input value={pattern} onChange={e => setPattern(e.target.value)} placeholder="main, release/*, v*"
className="w-full border border-[var(--c-border)] rounded px-3 py-2 text-sm bg-[var(--c-surface)] text-[var(--c-text)] font-mono focus:outline-none focus:border-[var(--c-brand-focus)] focus:ring-1 focus:ring-[var(--c-brand-focus)]" />
<p className="text-xs text-[var(--c-muted)] mt-1">Glob patterns supported: <code className="font-mono">release/*</code> matches all release branches.</p>
</div>
<div className="flex gap-6">
<label className="flex items-center gap-2 cursor-pointer text-sm text-[var(--c-text)]">
<input type="checkbox" checked={requirePR} onChange={e => setRequirePR(e.target.checked)} className="w-4 h-4 accent-[var(--c-brand)]" />
Require pull request
</label>
<label className="flex items-center gap-2 cursor-pointer text-sm text-[var(--c-text)]">
<input type="checkbox" checked={blockForcePush} onChange={e => setBlockForcePush(e.target.checked)} className="w-4 h-4 accent-[var(--c-brand)]" />
Block force push
</label>
</div>
<div>
<label className="block text-xs font-medium text-[var(--c-muted)] mb-1">Bypass list <span className="text-[var(--c-subtle)]">(optional — comma-separated usernames)</span></label>
<input value={allowedUsers} onChange={e => setAllowedUsers(e.target.value)} placeholder="alice, bob"
className="w-full border border-[var(--c-border)] rounded px-3 py-2 text-sm bg-[var(--c-surface)] text-[var(--c-text)] focus:outline-none focus:border-[var(--c-brand-focus)]" />
</div>
{error && <p className="text-xs text-[var(--c-danger)]">{error}</p>}
<button type="submit" disabled={createRule.isPending || !pattern.trim()}
className="px-4 py-2 rounded bg-[var(--c-brand)] text-white text-sm font-medium hover:bg-[var(--c-brand-hover)] disabled:opacity-50">
{createRule.isPending ? 'Adding' : 'Add rule'}
</button>
</form>
</div>
</div>
)
}
// ─── Branching model ──────────────────────────────────────────────────────────
function BranchingModelSection({ owner, repo }: { owner: string; repo: string }) {
const { data, isLoading } = useBranchingModel(owner, repo)
const updateModel = useUpdateBranchingModel(owner, repo)
const [form, setForm] = useState({ enabled: false, featurePrefix: 'feature/', bugfixPrefix: 'bugfix/', releasePrefix: 'release/', hotfixPrefix: 'hotfix/' })
const [saved, setSaved] = useState(false)
useEffect(() => { if (data) setForm(data) }, [data])
async function handleSave(e: React.FormEvent) {
e.preventDefault()
await updateModel.mutateAsync(form)
setSaved(true)
setTimeout(() => setSaved(false), 3000)
}
const branches = [
{ key: 'featurePrefix' as const, label: 'Feature', example: 'feature/my-feature', color: 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300' },
{ key: 'bugfixPrefix' as const, label: 'Bugfix', example: 'bugfix/fix-login', color: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300' },
{ key: 'releasePrefix' as const, label: 'Release', example: 'release/1.0.0', color: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300' },
{ key: 'hotfixPrefix' as const, label: 'Hotfix', example: 'hotfix/critical-fix', color: 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-300' },
]
return (
<div className="max-w-2xl px-6 py-6 space-y-6">
<div>
<h1 className="text-xl font-semibold text-[var(--c-text)]">Branching model</h1>
<p className="text-sm text-[var(--c-muted)] mt-1">Define naming conventions for branch types to keep your team consistent.</p>
</div>
<div className="border-t border-[var(--c-border)]" />
{isLoading ? <Skeleton className="h-48 w-full" /> : (
<form onSubmit={handleSave} className="space-y-5">
<label className="flex items-center gap-3 p-4 border border-[var(--c-border)] rounded-lg cursor-pointer bg-[var(--c-surface)] hover:bg-[var(--c-surface-raised)]">
<input type="checkbox" checked={form.enabled} onChange={e => setForm(f => ({ ...f, enabled: e.target.checked }))}
className="w-4 h-4 accent-[var(--c-brand)]" />
<div>
<span className="text-sm font-medium text-[var(--c-text)]">Enable branching model</span>
<p className="text-xs text-[var(--c-muted)] mt-0.5">Apply the naming conventions below to this repository.</p>
</div>
</label>
<div className={`space-y-3 transition-opacity ${form.enabled ? '' : 'opacity-40 pointer-events-none'}`}>
{branches.map(b => (
<div key={b.key} className="flex items-center gap-3 p-3 border border-[var(--c-border)] rounded-lg bg-[var(--c-surface)]">
<span className={`text-[10px] font-bold uppercase tracking-wide px-2 py-1 rounded w-20 text-center shrink-0 ${b.color}`}>{b.label}</span>
<input value={form[b.key]} onChange={e => setForm(f => ({ ...f, [b.key]: e.target.value }))}
className="flex-1 border border-[var(--c-border)] rounded px-3 py-1.5 text-sm font-mono bg-[var(--c-surface)] text-[var(--c-text)] focus:outline-none focus:border-[var(--c-brand-focus)]" />
<span className="text-xs text-[var(--c-muted)] font-mono hidden sm:block">{form[b.key] || ''}my-branch</span>
</div>
))}
</div>
<div className="flex items-center gap-3 pt-2 border-t border-[var(--c-border)]">
{saved && <span className="text-xs text-[var(--c-success)] font-medium">Saved!</span>}
<button type="submit" disabled={updateModel.isPending}
className="px-4 py-2 rounded bg-[var(--c-brand)] text-white text-sm font-medium hover:bg-[var(--c-brand-hover)] disabled:opacity-50 ml-auto">
{updateModel.isPending ? 'Saving' : 'Save changes'}
</button>
</div>
</form>
)}
</div>
)
}
// ─── Merge strategies ─────────────────────────────────────────────────────────
function MergeStrategiesSection({ owner, repo }: { owner: string; repo: string }) {
const { data, isLoading } = useMergeStrategies(owner, repo)
const updateStrategies = useUpdateMergeStrategies(owner, repo)
const [form, setForm] = useState({ allowMergeCommit: true, allowSquash: true, allowRebase: true })
const [saved, setSaved] = useState(false)
const [error, setError] = useState('')
useEffect(() => { if (data) setForm(data) }, [data])
async function handleSave(e: React.FormEvent) {
e.preventDefault()
setError('')
try {
await updateStrategies.mutateAsync(form)
setSaved(true)
setTimeout(() => setSaved(false), 3000)
} catch (err) { setError((err as Error).message) }
}
const strategies = [
{ key: 'allowMergeCommit' as const, label: 'Merge commit', description: 'Combines all commits from the branch into a merge commit on the target branch.', icon: 'M' },
{ key: 'allowSquash' as const, label: 'Squash', description: 'Squashes all branch commits into a single commit on the target branch.', icon: 'S' },
{ key: 'allowRebase' as const, label: 'Rebase', description: 'Replays each branch commit on top of the target branch without a merge commit.', icon: 'R' },
]
return (
<div className="max-w-2xl px-6 py-6 space-y-6">
<div>
<h1 className="text-xl font-semibold text-[var(--c-text)]">Merge strategies</h1>
<p className="text-sm text-[var(--c-muted)] mt-1">Control which merge strategies collaborators can use when closing pull requests.</p>
</div>
<div className="border-t border-[var(--c-border)]" />
{isLoading ? <Skeleton className="h-48 w-full" /> : (
<form onSubmit={handleSave} className="space-y-4">
{strategies.map(s => (
<label key={s.key} className="flex items-start gap-4 p-4 border border-[var(--c-border)] rounded-lg cursor-pointer hover:bg-[var(--c-surface-raised)] bg-[var(--c-surface)]">
<input type="checkbox" checked={form[s.key]} onChange={e => setForm(f => ({ ...f, [s.key]: e.target.checked }))}
className="mt-0.5 w-4 h-4 accent-[var(--c-brand)]" />
<div className="flex-1">
<div className="flex items-center gap-2">
<span className="w-6 h-6 rounded bg-[var(--c-brand-tint)] text-[var(--c-brand)] text-xs font-bold flex items-center justify-center">{s.icon}</span>
<span className="text-sm font-medium text-[var(--c-text)]">{s.label}</span>
</div>
<p className="text-xs text-[var(--c-muted)] mt-1">{s.description}</p>
</div>
</label>
))}
{error && <p className="text-xs text-[var(--c-danger)]">{error}</p>}
<div className="flex items-center gap-3 pt-2 border-t border-[var(--c-border)]">
{saved && <span className="text-xs text-[var(--c-success)] font-medium">Saved!</span>}
<button type="submit" disabled={updateStrategies.isPending}
className="px-4 py-2 rounded bg-[var(--c-brand)] text-white text-sm font-medium hover:bg-[var(--c-brand-hover)] disabled:opacity-50 ml-auto">
{updateStrategies.isPending ? 'Saving' : 'Save changes'}
</button>
</div>
</form>
)}
</div>
)
}
// ─── Webhooks ─────────────────────────────────────────────────────────────────
const ALL_EVENTS = ['push', 'pull_request', 'issue']
function WebhooksSection({ owner, repo }: { owner: string; repo: string }) {
const { data: hooks, isLoading } = useWebhooks(owner, repo)
const createHook = useCreateWebhook(owner, repo)
const updateHook = useUpdateWebhook(owner, repo)
const deleteHook = useDeleteWebhook(owner, repo)
const testHook = useTestWebhook(owner, repo)
const [showForm, setShowForm] = useState(false)
const [editId, setEditId] = useState<number | null>(null)
const [title, setTitle] = useState('')
const [url, setUrl] = useState('')
const [secret, setSecret] = useState('')
const [events, setEvents] = useState<string[]>(['push'])
const [active, setActive] = useState(true)
const [formError, setFormError] = useState('')
const [testResult, setTestResult] = useState<Record<number, { ok: boolean; status: number }>>({})
function resetForm() {
setTitle(''); setUrl(''); setSecret(''); setEvents(['push']); setActive(true)
setFormError(''); setEditId(null); setShowForm(false)
}
function startEdit(wh: { id: number; title: string; url: string; events: string; active: boolean }) {
setEditId(wh.id); setTitle(wh.title); setUrl(wh.url)
setEvents(wh.events.split(',')); setActive(wh.active); setSecret('')
setShowForm(true)
}
function toggleEvent(ev: string) {
setEvents(prev => prev.includes(ev) ? prev.filter(e => e !== ev) : [...prev, ev])
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
setFormError('')
if (!url.trim()) return setFormError('URL is required')
if (events.length === 0) return setFormError('Select at least one event')
try {
const payload = { title: title.trim(), url: url.trim(), secret: secret || undefined, events: events.join(','), active }
if (editId) {
await updateHook.mutateAsync({ id: editId, ...payload })
} else {
await createHook.mutateAsync(payload)
}
resetForm()
} catch (err) { setFormError((err as Error).message) }
}
async function handleTest(id: number) {
const result = await testHook.mutateAsync(id)
setTestResult(prev => ({ ...prev, [id]: result }))
setTimeout(() => setTestResult(prev => { const n = { ...prev }; delete n[id]; return n }), 5000)
}
function statusBadge(status: number) {
if (status === 0) return <span className="text-[10px] text-[var(--c-muted)]">Never delivered</span>
const ok = status >= 200 && status < 300
return (
<span className={`text-[10px] font-semibold px-1.5 py-0.5 rounded-full ${ok ? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300' : 'bg-[var(--c-danger-tint)] text-[var(--c-danger)]'}`}>
{status}
</span>
)
}
return (
<div className="max-w-2xl px-6 py-6 space-y-6">
<div className="flex items-start justify-between">
<div>
<h1 className="text-xl font-semibold text-[var(--c-text)]">Webhooks</h1>
<p className="text-sm text-[var(--c-muted)] mt-1">Send HTTP POST notifications to external services when events occur.</p>
</div>
{!showForm && (
<button onClick={() => setShowForm(true)}
className="px-3 py-1.5 rounded bg-[var(--c-brand)] text-white text-sm font-medium hover:bg-[var(--c-brand-hover)] shrink-0">
Add webhook
</button>
)}
</div>
<div className="border-t border-[var(--c-border)]" />
{/* Webhook list */}
<div className="border border-[var(--c-border)] rounded-lg overflow-hidden bg-[var(--c-surface)]">
{isLoading ? (
<div className="p-4 space-y-3">{[1,2].map(i => <Skeleton key={i} className="h-10 w-full" />)}</div>
) : !hooks?.length ? (
<div className="px-4 py-8 text-center text-sm text-[var(--c-muted)]">No webhooks yet.</div>
) : (
<ul className="divide-y divide-[var(--c-border)]">
{hooks.map(wh => (
<li key={wh.id} className="px-4 py-3 space-y-2">
<div className="flex items-center gap-3">
<div className={`w-2 h-2 rounded-full shrink-0 ${wh.active ? 'bg-[var(--c-success)]' : 'bg-[var(--c-subtle)]'}`} />
<div className="flex-1 min-w-0">
{wh.title && <p className="text-sm font-medium text-[var(--c-text)]">{wh.title}</p>}
<p className="text-xs text-[var(--c-muted)] font-mono truncate">{wh.url}</p>
</div>
{statusBadge(wh.lastStatus)}
{testResult[wh.id] && (
<span className={`text-[10px] font-semibold ${testResult[wh.id].ok ? 'text-[var(--c-success)]' : 'text-[var(--c-danger)]'}`}>
Test: {testResult[wh.id].status || 'failed'}
</span>
)}
</div>
<div className="flex items-center gap-2 ml-5">
<div className="flex gap-1 flex-wrap">
{wh.events.split(',').map(ev => (
<span key={ev} className="text-[9px] font-semibold uppercase px-1.5 py-0.5 rounded bg-[var(--c-surface-muted)] text-[var(--c-muted)]">{ev}</span>
))}
</div>
<div className="ml-auto flex items-center gap-1">
<button onClick={() => handleTest(wh.id)} disabled={testHook.isPending}
className="text-xs text-[var(--c-muted)] hover:text-[var(--c-text)] px-2 py-1 rounded hover:bg-[var(--c-surface-muted)] disabled:opacity-40">
Test
</button>
<button onClick={() => startEdit(wh)}
className="text-xs text-[var(--c-muted)] hover:text-[var(--c-text)] px-2 py-1 rounded hover:bg-[var(--c-surface-muted)]">
Edit
</button>
<button onClick={() => deleteHook.mutate(wh.id)} disabled={deleteHook.isPending}
className="text-[var(--c-danger)] hover:text-[var(--c-danger-dark)] p-1 rounded hover:bg-[var(--c-danger-tint)] disabled:opacity-40">
<svg width="13" height="13" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18 18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
</li>
))}
</ul>
)}
</div>
{/* Add / Edit form */}
{showForm && (
<div className="border border-[var(--c-brand-focus)] rounded-lg overflow-hidden bg-[var(--c-surface)]">
<div className="px-4 py-3 border-b border-[var(--c-border)] bg-[var(--c-surface-raised)]">
<h2 className="text-sm font-semibold text-[var(--c-text)]">{editId ? 'Edit webhook' : 'Add webhook'}</h2>
</div>
<form onSubmit={handleSubmit} className="p-4 space-y-4">
<div>
<label className="block text-xs font-medium text-[var(--c-muted)] mb-1">Title <span className="text-[var(--c-subtle)]">(optional)</span></label>
<input value={title} onChange={e => setTitle(e.target.value)} placeholder="e.g. Slack notifications"
className="w-full border border-[var(--c-border)] rounded px-3 py-2 text-sm bg-[var(--c-surface)] text-[var(--c-text)] focus:outline-none focus:border-[var(--c-brand-focus)]" />
</div>
<div>
<label className="block text-xs font-medium text-[var(--c-muted)] mb-1">Payload URL <span className="text-[var(--c-danger)]">*</span></label>
<input value={url} onChange={e => setUrl(e.target.value)} placeholder="https://example.com/webhook"
className="w-full border border-[var(--c-border)] rounded px-3 py-2 text-sm bg-[var(--c-surface)] text-[var(--c-text)] focus:outline-none focus:border-[var(--c-brand-focus)]" />
</div>
<div>
<label className="block text-xs font-medium text-[var(--c-muted)] mb-1">Secret <span className="text-[var(--c-subtle)]">(optional — used for HMAC-SHA256 signature)</span></label>
<input type="password" value={secret} onChange={e => setSecret(e.target.value)} placeholder={editId ? 'Leave blank to keep existing' : ''}
className="w-full border border-[var(--c-border)] rounded px-3 py-2 text-sm bg-[var(--c-surface)] text-[var(--c-text)] focus:outline-none focus:border-[var(--c-brand-focus)]" />
</div>
<div>
<label className="block text-xs font-medium text-[var(--c-muted)] mb-2">Events</label>
<div className="flex gap-3 flex-wrap">
{ALL_EVENTS.map(ev => (
<label key={ev} className="flex items-center gap-1.5 cursor-pointer text-sm text-[var(--c-text)]">
<input type="checkbox" checked={events.includes(ev)} onChange={() => toggleEvent(ev)} className="w-4 h-4 accent-[var(--c-brand)]" />
{ev}
</label>
))}
</div>
</div>
<label className="flex items-center gap-2 cursor-pointer text-sm text-[var(--c-text)]">
<input type="checkbox" checked={active} onChange={e => setActive(e.target.checked)} className="w-4 h-4 accent-[var(--c-brand)]" />
Active
</label>
{formError && <p className="text-xs text-[var(--c-danger)]">{formError}</p>}
<div className="flex gap-2">
<button type="submit" disabled={createHook.isPending || updateHook.isPending}
className="px-4 py-2 rounded bg-[var(--c-brand)] text-white text-sm font-medium hover:bg-[var(--c-brand-hover)] disabled:opacity-50">
{(createHook.isPending || updateHook.isPending) ? 'Saving' : editId ? 'Update webhook' : 'Add webhook'}
</button>
<button type="button" onClick={resetForm}
className="px-4 py-2 rounded border border-[var(--c-border)] text-sm text-[var(--c-text)] hover:bg-[var(--c-surface-muted)]">
Cancel
</button>
</div>
</form>
</div>
)}
</div>
)
}
// ─── Coming soon ────────────────────────────────────────────────────────────── // ─── Coming soon ──────────────────────────────────────────────────────────────
function ComingSoon({ sectionId }: { sectionId: SectionId }) { function ComingSoon({ sectionId }: { sectionId: SectionId }) {
+35
View File
@@ -118,6 +118,41 @@ export interface RepoMember {
addedAt: string addedAt: string
} }
export interface BranchProtection {
id: number
pattern: string
requirePR: boolean
blockForcePush: boolean
allowedUsers: string
createdAt: string
}
export interface BranchingModel {
enabled: boolean
featurePrefix: string
bugfixPrefix: string
releasePrefix: string
hotfixPrefix: string
}
export interface MergeStrategies {
allowMergeCommit: boolean
allowSquash: boolean
allowRebase: boolean
}
export interface Webhook {
id: number
title: string
url: string
events: string
active: boolean
hasSecret: boolean
lastStatus: number
lastDeliveredAt?: string | null
createdAt: string
}
export interface ApiError { export interface ApiError {
error: string error: string
status: number status: number
+67
View File
@@ -2,6 +2,7 @@ package handlers
import ( import (
"bufio" "bufio"
"bytes"
"context" "context"
"fmt" "fmt"
"io" "io"
@@ -104,6 +105,17 @@ func (h *GitHTTPHandler) ServeGit(w http.ResponseWriter, r *http.Request) {
} }
_ = authedReadOnly _ = authedReadOnly
// Branch protection check: parse pkt-lines from the receive-pack body,
// check each ref against stored protection rules, then restore the body.
if service == "git-receive-pack" {
if reason, newBody := checkProtectionsFromBody(h.db, repo.ID, authedUser, r.Body); reason != "" {
http.Error(w, reason, http.StatusForbidden)
return
} else {
r.Body = io.NopCloser(newBody)
}
}
// Build PATH_INFO: /{reponame}.git/{suffix} // Build PATH_INFO: /{reponame}.git/{suffix}
// Strip the /{owner}/{repoGit} prefix from the raw URL path to get the suffix. // Strip the /{owner}/{repoGit} prefix from the raw URL path to get the suffix.
prefix := "/" + owner + "/" + repoGit prefix := "/" + owner + "/" + repoGit
@@ -226,3 +238,58 @@ func runGitBackend(ctx context.Context, w http.ResponseWriter, body io.Reader, g
} }
return waitErr return waitErr
} }
// checkProtectionsFromBody parses git pkt-line ref updates from a receive-pack body,
// checks each ref against stored branch protection rules, and returns a denial reason
// (or "") plus a restored reader so the body can still be passed to http-backend.
func checkProtectionsFromBody(db *xorm.Engine, repoID int64, pusher string, body io.Reader) (reason string, restored io.Reader) {
var buf bytes.Buffer
zeroOID := strings.Repeat("0", 40)
for {
// Every pkt-line starts with a 4-hex-digit length that includes itself.
lenBuf := make([]byte, 4)
if _, err := io.ReadFull(body, lenBuf); err != nil {
break
}
buf.Write(lenBuf)
pktLen64, err := strconv.ParseInt(string(lenBuf), 16, 32)
if err != nil {
break
}
if pktLen64 == 0 {
// Flush packet — end of ref-update list.
break
}
dataLen := int(pktLen64) - 4
if dataLen <= 0 {
break
}
data := make([]byte, dataLen)
if _, err := io.ReadFull(body, data); err != nil {
break
}
buf.Write(data)
// Strip NUL-separated capabilities (only on first pkt-line) and trailing newline.
line := strings.TrimRight(strings.SplitN(string(data), "\x00", 2)[0], "\n")
parts := strings.SplitN(line, " ", 3)
if len(parts) != 3 {
continue
}
oldRev, newRev, refname := parts[0], parts[1], parts[2]
// New branches (oldRev all zeros) are not subject to protection.
if oldRev == zeroOID {
continue
}
// Detect force push: if newRev is all zeros it's a branch deletion.
isForcePush := newRev == zeroOID
if msg := CheckBranchProtection(db, repoID, pusher, refname, isForcePush); msg != "" {
return msg, io.MultiReader(&buf, body)
}
}
return "", io.MultiReader(&buf, body)
}
+24
View File
@@ -106,11 +106,35 @@ func (h *PRHandler) Merge(w http.ResponseWriter, r *http.Request) {
return return
} }
// Parse optional strategy from body; default to "merge".
var body struct {
Strategy string `json:"strategy"`
}
json.NewDecoder(r.Body).Decode(&body)
if body.Strategy == "" {
body.Strategy = "merge"
}
// Enforce merge strategy policy for this repo.
allowed := GetAllowedStrategies(h.db, pr.RepoID)
if !allowed[body.Strategy] {
jsonError(w, "merge strategy '"+body.Strategy+"' is not allowed for this repository", http.StatusConflict)
return
}
pr.Status = models.PRStatusMerged pr.Status = models.PRStatusMerged
if _, err := h.db.ID(pr.ID).Cols("status").Update(pr); err != nil { if _, err := h.db.ID(pr.ID).Cols("status").Update(pr); err != nil {
jsonError(w, "could not merge pull request", http.StatusInternalServerError) jsonError(w, "could not merge pull request", http.StatusInternalServerError)
return return
} }
// Fire pull_request webhook.
go FireWebhooks(h.db, pr.RepoID, "pull_request", map[string]interface{}{
"action": "merged",
"strategy": body.Strategy,
"pullRequest": map[string]interface{}{"id": pr.ID, "title": pr.Title},
})
jsonOK(w, pr) jsonOK(w, pr)
} }
+269
View File
@@ -0,0 +1,269 @@
package handlers
import (
"bytes"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"net/http"
"strconv"
"strings"
"time"
"github.com/go-chi/chi/v5"
"xorm.io/xorm"
"github.com/forgeo/forgebucket/internal/api/middleware"
"github.com/forgeo/forgebucket/internal/models"
)
type WebhookHandler struct{ db *xorm.Engine }
func NewWebhookHandler(db *xorm.Engine) *WebhookHandler { return &WebhookHandler{db: db} }
type webhookResponse struct {
ID int64 `json:"id"`
Title string `json:"title"`
URL string `json:"url"`
Events string `json:"events"`
Active bool `json:"active"`
HasSecret bool `json:"hasSecret"`
LastStatus int `json:"lastStatus"`
LastDeliveredAt *string `json:"lastDeliveredAt"`
CreatedAt string `json:"createdAt"`
}
func toWebhookResp(wh models.Webhook) webhookResponse {
var last *string
if wh.LastDeliveredAt != nil {
s := wh.LastDeliveredAt.Format(time.RFC3339)
last = &s
}
return webhookResponse{
ID: wh.ID,
Title: wh.Title,
URL: wh.URL,
Events: wh.Events,
Active: wh.Active,
HasSecret: wh.Secret != "",
LastStatus: wh.LastStatus,
LastDeliveredAt: last,
CreatedAt: wh.CreatedAt.Format(time.RFC3339),
}
}
func (h *WebhookHandler) resolveRepo(w http.ResponseWriter, r *http.Request) (*models.Repository, bool) {
ownerName := chi.URLParam(r, "owner")
repoName := chi.URLParam(r, "repo")
var owner models.User
if found, _ := h.db.Where("username = ?", ownerName).Get(&owner); !found {
jsonError(w, "repository not found", http.StatusNotFound)
return nil, false
}
var repo models.Repository
if found, _ := h.db.Where("owner_id = ? AND name = ?", owner.ID, repoName).Get(&repo); !found {
jsonError(w, "repository not found", http.StatusNotFound)
return nil, false
}
return &repo, true
}
func (h *WebhookHandler) canManage(repo *models.Repository, callerID int64) bool {
if callerID == repo.OwnerID {
return true
}
var m models.RepoMember
found, _ := h.db.Where("repo_id = ? AND user_id = ? AND permission = 'admin'", repo.ID, callerID).Get(&m)
return found
}
func (h *WebhookHandler) List(w http.ResponseWriter, r *http.Request) {
repo, ok := h.resolveRepo(w, r)
if !ok {
return
}
var hooks []models.Webhook
h.db.Where("repo_id = ?", repo.ID).OrderBy("created_at").Find(&hooks)
resp := make([]webhookResponse, len(hooks))
for i, wh := range hooks {
resp[i] = toWebhookResp(wh)
}
jsonOK(w, resp)
}
func (h *WebhookHandler) Create(w http.ResponseWriter, r *http.Request) {
repo, ok := h.resolveRepo(w, r)
if !ok {
return
}
callerID, _ := middleware.UserIDFromContext(r.Context())
if !h.canManage(repo, callerID) {
jsonError(w, "forbidden", http.StatusForbidden)
return
}
var body struct {
Title string `json:"title"`
URL string `json:"url"`
Secret string `json:"secret"`
Events string `json:"events"`
Active bool `json:"active"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.URL == "" {
jsonError(w, "url is required", http.StatusBadRequest)
return
}
if body.Events == "" {
body.Events = "push"
}
wh := &models.Webhook{
RepoID: repo.ID, Title: body.Title, URL: body.URL,
Secret: body.Secret, Events: body.Events, Active: body.Active,
}
if _, err := h.db.Insert(wh); err != nil {
jsonError(w, "could not create webhook", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(toWebhookResp(*wh))
}
func (h *WebhookHandler) Update(w http.ResponseWriter, r *http.Request) {
repo, ok := h.resolveRepo(w, r)
if !ok {
return
}
callerID, _ := middleware.UserIDFromContext(r.Context())
if !h.canManage(repo, callerID) {
jsonError(w, "forbidden", http.StatusForbidden)
return
}
whID, _ := strconv.ParseInt(chi.URLParam(r, "whID"), 10, 64)
var wh models.Webhook
if found, _ := h.db.Where("id = ? AND repo_id = ?", whID, repo.ID).Get(&wh); !found {
jsonError(w, "webhook not found", http.StatusNotFound)
return
}
var body struct {
Title string `json:"title"`
URL string `json:"url"`
Secret string `json:"secret"`
Events string `json:"events"`
Active bool `json:"active"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
jsonError(w, "invalid body", http.StatusBadRequest)
return
}
wh.Title = body.Title
wh.URL = body.URL
if body.Secret != "" {
wh.Secret = body.Secret
}
wh.Events = body.Events
wh.Active = body.Active
h.db.ID(wh.ID).Cols("title", "url", "secret", "events", "active").Update(&wh)
jsonOK(w, toWebhookResp(wh))
}
func (h *WebhookHandler) Delete(w http.ResponseWriter, r *http.Request) {
repo, ok := h.resolveRepo(w, r)
if !ok {
return
}
callerID, _ := middleware.UserIDFromContext(r.Context())
if !h.canManage(repo, callerID) {
jsonError(w, "forbidden", http.StatusForbidden)
return
}
whID, _ := strconv.ParseInt(chi.URLParam(r, "whID"), 10, 64)
h.db.Where("id = ? AND repo_id = ?", whID, repo.ID).Delete(&models.Webhook{})
w.WriteHeader(http.StatusNoContent)
}
func (h *WebhookHandler) Test(w http.ResponseWriter, r *http.Request) {
repo, ok := h.resolveRepo(w, r)
if !ok {
return
}
callerID, _ := middleware.UserIDFromContext(r.Context())
if !h.canManage(repo, callerID) {
jsonError(w, "forbidden", http.StatusForbidden)
return
}
whID, _ := strconv.ParseInt(chi.URLParam(r, "whID"), 10, 64)
var wh models.Webhook
if found, _ := h.db.Where("id = ? AND repo_id = ?", whID, repo.ID).Get(&wh); !found {
jsonError(w, "webhook not found", http.StatusNotFound)
return
}
payload := map[string]interface{}{
"event": "ping",
"repository": map[string]interface{}{
"id": repo.ID, "name": repo.Name,
},
"zen": "Keep it simple.",
}
status := deliverWebhook(wh, payload)
jsonOK(w, map[string]interface{}{"status": status, "ok": status >= 200 && status < 300})
}
// ── delivery ──────────────────────────────────────────────────────────────────
func deliverWebhook(wh models.Webhook, payload interface{}) int {
body, err := json.Marshal(payload)
if err != nil {
return 0
}
req, err := http.NewRequest("POST", wh.URL, bytes.NewReader(body))
if err != nil {
return 0
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-ForgeBucket-Event", fmt.Sprintf("%v", payload.(map[string]interface{})["event"]))
req.Header.Set("X-ForgeBucket-Delivery", strconv.FormatInt(time.Now().UnixNano(), 36))
if wh.Secret != "" {
mac := hmac.New(sha256.New, []byte(wh.Secret))
mac.Write(body)
req.Header.Set("X-ForgeBucket-Signature-256", "sha256="+hex.EncodeToString(mac.Sum(nil)))
}
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Do(req)
if err != nil {
return 0
}
defer resp.Body.Close()
return resp.StatusCode
}
// FireWebhooks sends event payloads to all active webhooks for a repo that match the event.
// Called from other handlers; runs deliveries in a background goroutine.
func FireWebhooks(db *xorm.Engine, repoID int64, event string, payload map[string]interface{}) {
var hooks []models.Webhook
db.Where("repo_id = ? AND active = ?", repoID, true).Find(&hooks)
for _, wh := range hooks {
if !strings.Contains(","+wh.Events+",", ","+event+",") {
continue
}
wh := wh // capture
payload["event"] = event
go func() {
status := deliverWebhook(wh, payload)
now := time.Now()
wh.LastStatus = status
wh.LastDeliveredAt = &now
db.ID(wh.ID).Cols("last_status", "last_delivered_at").Update(&wh)
}()
}
}
+355
View File
@@ -0,0 +1,355 @@
package handlers
import (
"encoding/json"
"net/http"
"path/filepath"
"strconv"
"strings"
"time"
"github.com/go-chi/chi/v5"
"xorm.io/xorm"
"github.com/forgeo/forgebucket/internal/api/middleware"
"github.com/forgeo/forgebucket/internal/models"
)
// ── shared lookup ─────────────────────────────────────────────────────────────
type WorkflowHandler struct{ db *xorm.Engine }
func NewWorkflowHandler(db *xorm.Engine) *WorkflowHandler { return &WorkflowHandler{db: db} }
func (h *WorkflowHandler) resolveRepo(w http.ResponseWriter, r *http.Request) (*models.Repository, *models.User, bool) {
ownerName := chi.URLParam(r, "owner")
repoName := chi.URLParam(r, "repo")
var owner models.User
if found, _ := h.db.Where("username = ?", ownerName).Get(&owner); !found {
jsonError(w, "repository not found", http.StatusNotFound)
return nil, nil, false
}
var repo models.Repository
if found, _ := h.db.Where("owner_id = ? AND name = ?", owner.ID, repoName).Get(&repo); !found {
jsonError(w, "repository not found", http.StatusNotFound)
return nil, nil, false
}
return &repo, &owner, true
}
func (h *WorkflowHandler) canManage(repo *models.Repository, callerID int64) bool {
if callerID == repo.OwnerID {
return true
}
var m models.RepoMember
found, _ := h.db.Where("repo_id = ? AND user_id = ? AND permission = 'admin'", repo.ID, callerID).Get(&m)
return found
}
// ── branch protections ────────────────────────────────────────────────────────
type branchProtectionResponse struct {
ID int64 `json:"id"`
Pattern string `json:"pattern"`
RequirePR bool `json:"requirePR"`
BlockForcePush bool `json:"blockForcePush"`
AllowedUsers string `json:"allowedUsers"`
CreatedAt string `json:"createdAt"`
}
func toBranchProtResp(bp models.BranchProtection) branchProtectionResponse {
return branchProtectionResponse{
ID: bp.ID,
Pattern: bp.Pattern,
RequirePR: bp.RequirePR,
BlockForcePush: bp.BlockForcePush,
AllowedUsers: bp.AllowedUsers,
CreatedAt: bp.CreatedAt.Format(time.RFC3339),
}
}
func (h *WorkflowHandler) ListBranchProtections(w http.ResponseWriter, r *http.Request) {
repo, _, ok := h.resolveRepo(w, r)
if !ok {
return
}
var bps []models.BranchProtection
h.db.Where("repo_id = ?", repo.ID).OrderBy("created_at").Find(&bps)
resp := make([]branchProtectionResponse, len(bps))
for i, bp := range bps {
resp[i] = toBranchProtResp(bp)
}
jsonOK(w, resp)
}
func (h *WorkflowHandler) CreateBranchProtection(w http.ResponseWriter, r *http.Request) {
repo, _, ok := h.resolveRepo(w, r)
if !ok {
return
}
callerID, _ := middleware.UserIDFromContext(r.Context())
if !h.canManage(repo, callerID) {
jsonError(w, "forbidden", http.StatusForbidden)
return
}
var body struct {
Pattern string `json:"pattern"`
RequirePR bool `json:"requirePR"`
BlockForcePush bool `json:"blockForcePush"`
AllowedUsers string `json:"allowedUsers"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.Pattern == "" {
jsonError(w, "pattern is required", http.StatusBadRequest)
return
}
bp := &models.BranchProtection{
RepoID: repo.ID,
Pattern: body.Pattern,
RequirePR: body.RequirePR,
BlockForcePush: body.BlockForcePush,
AllowedUsers: body.AllowedUsers,
}
if _, err := h.db.Insert(bp); err != nil {
jsonError(w, "could not create protection", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(toBranchProtResp(*bp))
}
func (h *WorkflowHandler) UpdateBranchProtection(w http.ResponseWriter, r *http.Request) {
repo, _, ok := h.resolveRepo(w, r)
if !ok {
return
}
callerID, _ := middleware.UserIDFromContext(r.Context())
if !h.canManage(repo, callerID) {
jsonError(w, "forbidden", http.StatusForbidden)
return
}
bpID, err := strconv.ParseInt(chi.URLParam(r, "bpID"), 10, 64)
if err != nil {
jsonError(w, "invalid ID", http.StatusBadRequest)
return
}
var bp models.BranchProtection
if found, _ := h.db.Where("id = ? AND repo_id = ?", bpID, repo.ID).Get(&bp); !found {
jsonError(w, "rule not found", http.StatusNotFound)
return
}
var body struct {
RequirePR bool `json:"requirePR"`
BlockForcePush bool `json:"blockForcePush"`
AllowedUsers string `json:"allowedUsers"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
jsonError(w, "invalid body", http.StatusBadRequest)
return
}
bp.RequirePR = body.RequirePR
bp.BlockForcePush = body.BlockForcePush
bp.AllowedUsers = body.AllowedUsers
h.db.ID(bp.ID).Cols("require_pr", "block_force_push", "allowed_users").Update(&bp)
jsonOK(w, toBranchProtResp(bp))
}
func (h *WorkflowHandler) DeleteBranchProtection(w http.ResponseWriter, r *http.Request) {
repo, _, ok := h.resolveRepo(w, r)
if !ok {
return
}
callerID, _ := middleware.UserIDFromContext(r.Context())
if !h.canManage(repo, callerID) {
jsonError(w, "forbidden", http.StatusForbidden)
return
}
bpID, _ := strconv.ParseInt(chi.URLParam(r, "bpID"), 10, 64)
h.db.Where("id = ? AND repo_id = ?", bpID, repo.ID).Delete(&models.BranchProtection{})
w.WriteHeader(http.StatusNoContent)
}
// CheckBranchProtection returns a deny reason if the push violates a protection rule,
// or "" if the push is allowed. Called from githttp.go before running the backend.
func CheckBranchProtection(db *xorm.Engine, repoID int64, pusherUsername, refname string, isForcePush bool) string {
branchName := strings.TrimPrefix(refname, "refs/heads/")
if branchName == refname {
return "" // not a branch ref
}
var protections []models.BranchProtection
db.Where("repo_id = ?", repoID).Find(&protections)
for _, bp := range protections {
matched, err := filepath.Match(bp.Pattern, branchName)
if err != nil || !matched {
continue
}
// Check if the pusher is in the allowed list.
if bp.AllowedUsers != "" {
allowed := false
for _, u := range strings.Split(bp.AllowedUsers, ",") {
if strings.TrimSpace(u) == pusherUsername {
allowed = true
break
}
}
if allowed {
continue
}
}
// Enforce rules.
if bp.RequirePR {
return "push rejected: '" + branchName + "' is protected and requires a pull request"
}
if bp.BlockForcePush && isForcePush {
return "push rejected: force push to '" + branchName + "' is not allowed"
}
}
return ""
}
// ── branching model ───────────────────────────────────────────────────────────
type branchingModelResponse struct {
Enabled bool `json:"enabled"`
FeaturePrefix string `json:"featurePrefix"`
BugfixPrefix string `json:"bugfixPrefix"`
ReleasePrefix string `json:"releasePrefix"`
HotfixPrefix string `json:"hotfixPrefix"`
}
func (h *WorkflowHandler) GetBranchingModel(w http.ResponseWriter, r *http.Request) {
repo, _, ok := h.resolveRepo(w, r)
if !ok {
return
}
var bm models.BranchingModel
found, _ := h.db.Where("repo_id = ?", repo.ID).Get(&bm)
if !found {
// Return sensible defaults.
jsonOK(w, branchingModelResponse{
Enabled: false, FeaturePrefix: "feature/", BugfixPrefix: "bugfix/",
ReleasePrefix: "release/", HotfixPrefix: "hotfix/",
})
return
}
jsonOK(w, branchingModelResponse{
Enabled: bm.Enabled, FeaturePrefix: bm.FeaturePrefix, BugfixPrefix: bm.BugfixPrefix,
ReleasePrefix: bm.ReleasePrefix, HotfixPrefix: bm.HotfixPrefix,
})
}
func (h *WorkflowHandler) UpdateBranchingModel(w http.ResponseWriter, r *http.Request) {
repo, _, ok := h.resolveRepo(w, r)
if !ok {
return
}
callerID, _ := middleware.UserIDFromContext(r.Context())
if !h.canManage(repo, callerID) {
jsonError(w, "forbidden", http.StatusForbidden)
return
}
var body branchingModelResponse
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
jsonError(w, "invalid body", http.StatusBadRequest)
return
}
var bm models.BranchingModel
found, _ := h.db.Where("repo_id = ?", repo.ID).Get(&bm)
bm.RepoID = repo.ID
bm.Enabled = body.Enabled
bm.FeaturePrefix = body.FeaturePrefix
bm.BugfixPrefix = body.BugfixPrefix
bm.ReleasePrefix = body.ReleasePrefix
bm.HotfixPrefix = body.HotfixPrefix
if found {
h.db.ID(bm.ID).Cols("enabled", "feature_prefix", "bugfix_prefix", "release_prefix", "hotfix_prefix").Update(&bm)
} else {
h.db.Insert(&bm)
}
jsonOK(w, body)
}
// ── merge strategies ──────────────────────────────────────────────────────────
type mergeStrategiesResponse struct {
AllowMergeCommit bool `json:"allowMergeCommit"`
AllowSquash bool `json:"allowSquash"`
AllowRebase bool `json:"allowRebase"`
}
func (h *WorkflowHandler) GetMergeStrategies(w http.ResponseWriter, r *http.Request) {
repo, _, ok := h.resolveRepo(w, r)
if !ok {
return
}
var ms models.MergeStrategies
found, _ := h.db.Where("repo_id = ?", repo.ID).Get(&ms)
if !found {
jsonOK(w, mergeStrategiesResponse{AllowMergeCommit: true, AllowSquash: true, AllowRebase: true})
return
}
jsonOK(w, mergeStrategiesResponse{
AllowMergeCommit: ms.AllowMergeCommit,
AllowSquash: ms.AllowSquash,
AllowRebase: ms.AllowRebase,
})
}
func (h *WorkflowHandler) UpdateMergeStrategies(w http.ResponseWriter, r *http.Request) {
repo, _, ok := h.resolveRepo(w, r)
if !ok {
return
}
callerID, _ := middleware.UserIDFromContext(r.Context())
if !h.canManage(repo, callerID) {
jsonError(w, "forbidden", http.StatusForbidden)
return
}
var body mergeStrategiesResponse
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
jsonError(w, "invalid body", http.StatusBadRequest)
return
}
if !body.AllowMergeCommit && !body.AllowSquash && !body.AllowRebase {
jsonError(w, "at least one merge strategy must be enabled", http.StatusBadRequest)
return
}
var ms models.MergeStrategies
found, _ := h.db.Where("repo_id = ?", repo.ID).Get(&ms)
ms.RepoID = repo.ID
ms.AllowMergeCommit = body.AllowMergeCommit
ms.AllowSquash = body.AllowSquash
ms.AllowRebase = body.AllowRebase
if found {
h.db.ID(ms.ID).Cols("allow_merge_commit", "allow_squash", "allow_rebase").Update(&ms)
} else {
h.db.Insert(&ms)
}
jsonOK(w, body)
}
// GetAllowedStrategies returns the allowed strategy set for a repo (used by PR merge handler).
func GetAllowedStrategies(db *xorm.Engine, repoID int64) map[string]bool {
var ms models.MergeStrategies
if found, _ := db.Where("repo_id = ?", repoID).Get(&ms); !found {
return map[string]bool{"merge": true, "squash": true, "rebase": true}
}
return map[string]bool{
"merge": ms.AllowMergeCommit,
"squash": ms.AllowSquash,
"rebase": ms.AllowRebase,
}
}
+21 -2
View File
@@ -44,8 +44,10 @@ func New(cfg *config.Config, engine *xorm.Engine, store sessions.Store, staticFi
issueH := handlers.NewIssueHandler(engine) issueH := handlers.NewIssueHandler(engine)
sshKeyH := handlers.NewSSHKeyHandler(engine) sshKeyH := handlers.NewSSHKeyHandler(engine)
memberH := handlers.NewMemberHandler(engine) memberH := handlers.NewMemberHandler(engine)
keyH := handlers.NewDeployKeyHandler(engine) keyH := handlers.NewDeployKeyHandler(engine)
tokenH := handlers.NewAccessTokenHandler(engine) tokenH := handlers.NewAccessTokenHandler(engine)
workflowH := handlers.NewWorkflowHandler(engine)
webhookH := handlers.NewWebhookHandler(engine)
// ── Git smart-HTTP transport ─────────────────────────────────────────────── // ── Git smart-HTTP transport ───────────────────────────────────────────────
// These routes MUST be registered before the SPA catch-all and outside CSRF. // These routes MUST be registered before the SPA catch-all and outside CSRF.
@@ -153,6 +155,23 @@ func New(cfg *config.Config, engine *xorm.Engine, store sessions.Store, staticFi
r.With(csrf).Post("/", tokenH.Create) r.With(csrf).Post("/", tokenH.Create)
r.With(csrf).Delete("/{tokenID}", tokenH.Delete) r.With(csrf).Delete("/{tokenID}", tokenH.Delete)
}) })
r.Route("/branch-protections", func(r chi.Router) {
r.Get("/", workflowH.ListBranchProtections)
r.With(csrf).Post("/", workflowH.CreateBranchProtection)
r.With(csrf).Patch("/{bpID}", workflowH.UpdateBranchProtection)
r.With(csrf).Delete("/{bpID}", workflowH.DeleteBranchProtection)
})
r.Get("/branching-model", workflowH.GetBranchingModel)
r.With(csrf).Put("/branching-model", workflowH.UpdateBranchingModel)
r.Get("/merge-strategies", workflowH.GetMergeStrategies)
r.With(csrf).Put("/merge-strategies", workflowH.UpdateMergeStrategies)
r.Route("/webhooks", func(r chi.Router) {
r.Get("/", webhookH.List)
r.With(csrf).Post("/", webhookH.Create)
r.With(csrf).Patch("/{whID}", webhookH.Update)
r.With(csrf).Delete("/{whID}", webhookH.Delete)
r.With(csrf).Post("/{whID}/test", webhookH.Test)
})
}) })
}) })
}) })
+4 -1
View File
@@ -22,5 +22,8 @@ func Run(engine *xorm.Engine) error {
if err := Run003(engine); err != nil { if err := Run003(engine); err != nil {
return err return err
} }
return Run004(engine) if err := Run004(engine); err != nil {
return err
}
return Run005(engine)
} }
@@ -0,0 +1,15 @@
package migrations
import (
"github.com/forgeo/forgebucket/internal/models"
"xorm.io/xorm"
)
func Run005(engine *xorm.Engine) error {
return engine.Sync2(
&models.BranchProtection{},
&models.BranchingModel{},
&models.MergeStrategies{},
&models.Webhook{},
)
}
+49
View File
@@ -0,0 +1,49 @@
package models
import "time"
// BranchProtection defines push rules for a branch pattern.
type BranchProtection struct {
ID int64 `xorm:"'id' pk autoincr"`
RepoID int64 `xorm:"'repo_id' notnull index"`
Pattern string `xorm:"'pattern' notnull"` // exact name or glob like "release/*"
RequirePR bool `xorm:"'require_pr' default true"`
BlockForcePush bool `xorm:"'block_force_push' default true"`
AllowedUsers string `xorm:"'allowed_users'"` // comma-sep; these users may push directly
CreatedAt time.Time `xorm:"'created_at' created"`
}
// BranchingModel stores the naming convention config for a repo (one row per repo).
type BranchingModel struct {
ID int64 `xorm:"'id' pk autoincr"`
RepoID int64 `xorm:"'repo_id' notnull unique"`
Enabled bool `xorm:"'enabled' default false"`
FeaturePrefix string `xorm:"'feature_prefix'"`
BugfixPrefix string `xorm:"'bugfix_prefix'"`
ReleasePrefix string `xorm:"'release_prefix'"`
HotfixPrefix string `xorm:"'hotfix_prefix'"`
UpdatedAt time.Time `xorm:"'updated_at' updated"`
}
// MergeStrategies stores which PR merge strategies are allowed for a repo (one row per repo).
type MergeStrategies struct {
ID int64 `xorm:"'id' pk autoincr"`
RepoID int64 `xorm:"'repo_id' notnull unique"`
AllowMergeCommit bool `xorm:"'allow_merge_commit' default true"`
AllowSquash bool `xorm:"'allow_squash' default true"`
AllowRebase bool `xorm:"'allow_rebase' default true"`
}
// Webhook delivers event payloads to external URLs.
type Webhook struct {
ID int64 `xorm:"'id' pk autoincr"`
RepoID int64 `xorm:"'repo_id' notnull index"`
Title string `xorm:"'title'"`
URL string `xorm:"'url' notnull"`
Secret string `xorm:"'secret'"`
Events string `xorm:"'events' notnull"` // "push,pull_request,issue"
Active bool `xorm:"'active' default true"`
LastStatus int `xorm:"'last_status'"`
LastDeliveredAt *time.Time `xorm:"'last_delivered_at'"`
CreatedAt time.Time `xorm:"'created_at' created"`
}