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"` +}