From f211cfc7dbb9f5cc1dc0f57cbb53dbdc6be58687 Mon Sep 17 00:00:00 2001 From: erangel1 Date: Thu, 7 May 2026 15:27:48 +0200 Subject: [PATCH] =?UTF-8?q?Branch=20restrictions=20=E2=80=94=20fully=20enf?= =?UTF-8?q?orced:?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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= header when secret is set Last delivery status and timestamp stored on webhook record and shown in list --- frontend/src/api/queries/workflow.ts | 146 +++++++ frontend/src/pages/RepoSettingsPage.tsx | 454 ++++++++++++++++++++- frontend/src/types/api.ts | 35 ++ internal/api/handlers/githttp.go | 67 +++ internal/api/handlers/prs.go | 24 ++ internal/api/handlers/webhooks.go | 269 ++++++++++++ internal/api/handlers/workflow.go | 355 ++++++++++++++++ internal/api/router.go | 23 +- internal/models/migrations/001_init.go | 5 +- internal/models/migrations/005_workflow.go | 15 + internal/models/workflow.go | 49 +++ 11 files changed, 1438 insertions(+), 4 deletions(-) create mode 100644 frontend/src/api/queries/workflow.ts create mode 100644 internal/api/handlers/webhooks.go create mode 100644 internal/api/handlers/workflow.go create mode 100644 internal/models/migrations/005_workflow.go create mode 100644 internal/models/workflow.go diff --git a/frontend/src/api/queries/workflow.ts b/frontend/src/api/queries/workflow.ts new file mode 100644 index 0000000..c2018db --- /dev/null +++ b/frontend/src/api/queries/workflow.ts @@ -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(`${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(`${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(`${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(`${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(`${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(`${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(`${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(`${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(`${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(`${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'] }), + }) +} diff --git a/frontend/src/pages/RepoSettingsPage.tsx b/frontend/src/pages/RepoSettingsPage.tsx index f3927e0..f8e3ff2 100644 --- a/frontend/src/pages/RepoSettingsPage.tsx +++ b/frontend/src/pages/RepoSettingsPage.tsx @@ -3,6 +3,12 @@ import { useParams, Link, useNavigate, useSearchParams } from 'react-router-dom' import { useRepo, useUpdateRepo, useDeleteRepo, useUploadRepoAvatar } from '../api/queries/repos' import { useRepoMembers, useAddMember, useUpdateMember, useRemoveMember } from '../api/queries/members' 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 { useAuth } from '../contexts/AuthContext' import { Skeleton } from '../ui/Skeleton' @@ -135,7 +141,12 @@ export default function RepoSettingsPage() { {section === 'repository-permissions' && } {section === 'access-keys' && } {section === 'access-tokens' && } - {!['repository-details','repository-permissions','access-keys','access-tokens'].includes(section) && } + {section === 'branch-restrictions' && } + {section === 'branching-model' && } + {section === 'merge-strategies' && } + {section === 'webhooks' && } + {!['repository-details','repository-permissions','access-keys','access-tokens', + 'branch-restrictions','branching-model','merge-strategies','webhooks'].includes(section) && } ) @@ -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 ( +
+
+

Branch restrictions

+

+ Protect branches from direct pushes. Matched branches can only be updated via pull requests. +

+
+
+ + {/* Rule list */} +
+
+

Protected branches

+ {rules?.length ?? 0} rule{rules?.length !== 1 ? 's' : ''} +
+ {isLoading ? ( +
+ ) : !rules?.length ? ( +
No branch protection rules yet.
+ ) : ( +
    + {rules.map(rule => ( +
  • +
    +
    + + + + {rule.pattern} +
    + +
    +
    + + + {rule.allowedUsers && Bypass: {rule.allowedUsers}} +
    +
  • + ))} +
+ )} +
+ + {/* Create rule form */} +
+
+

Add a rule

+
+
+
+ + 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)]" /> +

Glob patterns supported: release/* matches all release branches.

+
+
+ + +
+
+ + 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)]" /> +
+ {error &&

{error}

} + +
+
+
+ ) +} + +// ─── 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 ( +
+
+

Branching model

+

Define naming conventions for branch types to keep your team consistent.

+
+
+ + {isLoading ? : ( +
+ + +
+ {branches.map(b => ( +
+ {b.label} + 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)]" /> + {form[b.key] || '…'}my-branch +
+ ))} +
+ +
+ {saved && Saved!} + +
+
+ )} +
+ ) +} + +// ─── 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 ( +
+
+

Merge strategies

+

Control which merge strategies collaborators can use when closing pull requests.

+
+
+ + {isLoading ? : ( +
+ {strategies.map(s => ( + + ))} + + {error &&

{error}

} +
+ {saved && Saved!} + +
+
+ )} +
+ ) +} + +// ─── 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(null) + const [title, setTitle] = useState('') + const [url, setUrl] = useState('') + const [secret, setSecret] = useState('') + const [events, setEvents] = useState(['push']) + const [active, setActive] = useState(true) + const [formError, setFormError] = useState('') + const [testResult, setTestResult] = useState>({}) + + 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 Never delivered + const ok = status >= 200 && status < 300 + return ( + + {status} + + ) + } + + return ( +
+
+
+

Webhooks

+

Send HTTP POST notifications to external services when events occur.

+
+ {!showForm && ( + + )} +
+
+ + {/* Webhook list */} +
+ {isLoading ? ( +
{[1,2].map(i => )}
+ ) : !hooks?.length ? ( +
No webhooks yet.
+ ) : ( +
    + {hooks.map(wh => ( +
  • +
    +
    +
    + {wh.title &&

    {wh.title}

    } +

    {wh.url}

    +
    + {statusBadge(wh.lastStatus)} + {testResult[wh.id] && ( + + Test: {testResult[wh.id].status || 'failed'} + + )} +
    +
    +
    + {wh.events.split(',').map(ev => ( + {ev} + ))} +
    +
    + + + +
    +
    +
  • + ))} +
+ )} +
+ + {/* Add / Edit form */} + {showForm && ( +
+
+

{editId ? 'Edit webhook' : 'Add webhook'}

+
+
+
+ + 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)]" /> +
+
+ + 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)]" /> +
+
+ + 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)]" /> +
+
+ +
+ {ALL_EVENTS.map(ev => ( + + ))} +
+
+ + {formError &&

{formError}

} +
+ + +
+
+
+ )} +
+ ) +} + // ─── Coming soon ────────────────────────────────────────────────────────────── function ComingSoon({ sectionId }: { sectionId: SectionId }) { diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts index c929717..961f877 100644 --- a/frontend/src/types/api.ts +++ b/frontend/src/types/api.ts @@ -118,6 +118,41 @@ export interface RepoMember { 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 { error: string status: number diff --git a/internal/api/handlers/githttp.go b/internal/api/handlers/githttp.go index 0760446..7c671e1 100644 --- a/internal/api/handlers/githttp.go +++ b/internal/api/handlers/githttp.go @@ -2,6 +2,7 @@ package handlers import ( "bufio" + "bytes" "context" "fmt" "io" @@ -104,6 +105,17 @@ func (h *GitHTTPHandler) ServeGit(w http.ResponseWriter, r *http.Request) { } _ = 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} // Strip the /{owner}/{repoGit} prefix from the raw URL path to get the suffix. prefix := "/" + owner + "/" + repoGit @@ -226,3 +238,58 @@ func runGitBackend(ctx context.Context, w http.ResponseWriter, body io.Reader, g } 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) +} diff --git a/internal/api/handlers/prs.go b/internal/api/handlers/prs.go index f05e29e..2494d5e 100644 --- a/internal/api/handlers/prs.go +++ b/internal/api/handlers/prs.go @@ -106,11 +106,35 @@ func (h *PRHandler) Merge(w http.ResponseWriter, r *http.Request) { 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 if _, err := h.db.ID(pr.ID).Cols("status").Update(pr); err != nil { jsonError(w, "could not merge pull request", http.StatusInternalServerError) 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) } diff --git a/internal/api/handlers/webhooks.go b/internal/api/handlers/webhooks.go new file mode 100644 index 0000000..d4ee223 --- /dev/null +++ b/internal/api/handlers/webhooks.go @@ -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) + }() + } +} diff --git a/internal/api/handlers/workflow.go b/internal/api/handlers/workflow.go new file mode 100644 index 0000000..360cb84 --- /dev/null +++ b/internal/api/handlers/workflow.go @@ -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, + } +} diff --git a/internal/api/router.go b/internal/api/router.go index 422fb7d..356f9c2 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -44,8 +44,10 @@ func New(cfg *config.Config, engine *xorm.Engine, store sessions.Store, staticFi issueH := handlers.NewIssueHandler(engine) sshKeyH := handlers.NewSSHKeyHandler(engine) memberH := handlers.NewMemberHandler(engine) - keyH := handlers.NewDeployKeyHandler(engine) - tokenH := handlers.NewAccessTokenHandler(engine) + keyH := handlers.NewDeployKeyHandler(engine) + tokenH := handlers.NewAccessTokenHandler(engine) + workflowH := handlers.NewWorkflowHandler(engine) + webhookH := handlers.NewWebhookHandler(engine) // ── Git smart-HTTP transport ─────────────────────────────────────────────── // 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).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) + }) }) }) }) diff --git a/internal/models/migrations/001_init.go b/internal/models/migrations/001_init.go index 6dd418d..c0c69f2 100644 --- a/internal/models/migrations/001_init.go +++ b/internal/models/migrations/001_init.go @@ -22,5 +22,8 @@ func Run(engine *xorm.Engine) error { if err := Run003(engine); err != nil { return err } - return Run004(engine) + if err := Run004(engine); err != nil { + return err + } + return Run005(engine) } diff --git a/internal/models/migrations/005_workflow.go b/internal/models/migrations/005_workflow.go new file mode 100644 index 0000000..9665444 --- /dev/null +++ b/internal/models/migrations/005_workflow.go @@ -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{}, + ) +} diff --git a/internal/models/workflow.go b/internal/models/workflow.go new file mode 100644 index 0000000..25c54b7 --- /dev/null +++ b/internal/models/workflow.go @@ -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"` +}