git-trace

romele / git-trace public

62 commits · 1 branch · 1 tag · 1 contributor (top: Romele) · first commit 3d ago · last push 11m ago

src/collab.c

#include "collab.h"

#include "auth.h"
#include "gitx.h"
#include "mirror.h"
#include "repo.h"
#include "util.h"
#include "webhook.h"

#include <errno.h>
#include <ftw.h>
#include <limits.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/stat.h>

/* recursive remove for repo deletion (depth-first, no symlinks) */
static int rm_cb(const char *p, const struct stat *st,
                 int flag, struct FTW *f)
{
    (void)st; (void)flag; (void)f;
    return remove(p);
}

/* ---------------- shared helpers ---------------- */

int gt_repo_rowid(sqlite3 *db, const char *owner, const char *name)
{
    sqlite3_stmt *st;
    if (sqlite3_prepare_v2(db,
            "SELECT r.id FROM repos r LEFT JOIN users u ON u.id=r.owner_id"
            " WHERE r.name=? AND (u.name=? OR (r.owner_id IS NULL AND ?='_'))",
            -1, &st, NULL) != SQLITE_OK)
        return -1;
    sqlite3_bind_text(st, 1, name, -1, SQLITE_TRANSIENT);
    sqlite3_bind_text(st, 2, owner, -1, SQLITE_TRANSIENT);
    sqlite3_bind_text(st, 3, owner, -1, SQLITE_TRANSIENT);
    int id = (sqlite3_step(st) == SQLITE_ROW) ? sqlite3_column_int(st, 0) : -1;
    sqlite3_finalize(st);
    return id;
}

char *gt_read_form(struct request *r, size_t max, size_t *outlen)
{
    if (r->content_length <= 0 || (size_t)r->content_length > max)
        return NULL;
    size_t cap = (size_t)r->content_length;
    char *body = xmalloc(cap + 1);
    size_t got = 0;
    while (got < cap) {
        long n = body_read(r, body + got, cap - got);
        if (n <= 0)
            break;
        got += (size_t)n;
    }
    body[got] = '\0';
    *outlen = got;
    return body;
}

/* Cookie-authenticated POSTs must carry the session token as a hidden
 * form field — binds the request to the browser session and defeats
 * CSRF.  Requests without a cookie (Basic-auth API clients, e.g.
 * `curl -u user:token`) are not ambient and need no token. */
int gt_csrf_ok(struct request *r, const char *body, size_t n)
{
    if (!r->session_tok[0])
        return r->user_id > 0;
    char f[80];
    if (form_get(body, n, "csrf", f, sizeof f) != 0)
        return 0;
    return strcmp(f, r->session_tok) == 0;
}

void gt_csrf_input(struct buf *b, struct request *r)
{
    buf_puts(b, "<input type=hidden name=csrf value='");
    buf_put_escaped(b, r->session_tok, strlen(r->session_tok));
    buf_puts(b, "'>");
}

int gt_require_post(struct request *r)
{
    if (r->user_id == 0) {
        http_redirect(r, "/login");
        return 0;
    }
    if (strcmp(r->method, "POST") != 0) {
        http_error(r, 405, "method not allowed");
        return 0;
    }
    return 1;
}

/* Read a form POST body and enforce CSRF in one step — emits the
 * right error on failure.  Success: caller owns the malloc'd body. */
char *gt_read_post(struct request *r, size_t max, size_t *outlen)
{
    if (!gt_require_post(r))
        return NULL;
    char *body = gt_read_form(r, max, outlen);
    if (!body) {
        http_error(r, 400, "bad form body");
        return NULL;
    }
    if (!gt_csrf_ok(r, body, *outlen)) {
        free(body);
        http_error(r, 403, "bad csrf token");
        return NULL;
    }
    return body;
}

void gt_esc(struct buf *b, const char *s)
{
    if (s)
        buf_put_escaped(b, s, strlen(s));
}

void gt_comment_list(struct buf *b, sqlite3 *db,
                     const char *ptype, long parent)
{
    sqlite3_stmt *st;
    if (sqlite3_prepare_v2(db,
            "SELECT c.body, COALESCE(u.name,'(deleted)'),"
            " strftime('%Y-%m-%d %H:%M', c.created_at, 'unixepoch')"
            " FROM comments c LEFT JOIN users u ON u.id=c.author_id"
            " WHERE c.parent_type=? AND c.parent_id=? ORDER BY c.id",
            -1, &st, NULL) != SQLITE_OK)
        return;
    sqlite3_bind_text(st, 1, ptype, -1, SQLITE_TRANSIENT);
    sqlite3_bind_int64(st, 2, parent);
    while (sqlite3_step(st) == SQLITE_ROW) {
        buf_puts(b, "<tr><td class=muted style='width:14em'>");
        gt_esc(b, (const char *)sqlite3_column_text(st, 1));
        buf_puts(b, "<br>");
        gt_esc(b, (const char *)sqlite3_column_text(st, 2));
        buf_puts(b, "</td><td><pre style='border:0;background:none'>");
        gt_esc(b, (const char *)sqlite3_column_text(st, 0));
        buf_puts(b, "</pre></td></tr>");
    }
    sqlite3_finalize(st);
}

void gt_comment_form(struct buf *b, struct request *r,
                     const char *action)
{
    buf_puts(b, "<form method=post action='");
    buf_put_escaped(b, action, strlen(action));
    buf_puts(b, "'>");
    gt_csrf_input(b, r);
    buf_puts(b, "<input type=hidden name=action value=comment>"
                "<p><textarea name=body rows=5 cols=80 required></textarea></p>"
                "<p><button type=submit>comment</button></p></form>");
}

int gt_add_comment(sqlite3 *db, const char *ptype, long parent,
                   int uid, const char *body)
{
    sqlite3_stmt *st;
    if (sqlite3_prepare_v2(db,
            "INSERT INTO comments(parent_type,parent_id,author_id,body)"
            " VALUES(?,?,?,?)", -1, &st, NULL) != SQLITE_OK)
        return -1;
    sqlite3_bind_text(st, 1, ptype, -1, SQLITE_TRANSIENT);
    sqlite3_bind_int64(st, 2, parent);
    sqlite3_bind_int(st, 3, uid);
    sqlite3_bind_text(st, 4, body, -1, SQLITE_TRANSIENT);
    int rc = sqlite3_step(st);
    sqlite3_finalize(st);
    return rc == SQLITE_DONE ? 0 : -1;
}

/* ---------------- issues ---------------- */

static void issue_list(struct app_ctx *app, struct request *r,
                       const char *o, const char *n, int rk, int repo_id)
{
    struct buf b;
    buf_init(&b);
    gt_page_top(&b, n, r);
    gt_repo_tabs(&b, app, o, n, rk, "issues");
    if (r->user_id > 0)
        buf_printf(&b, "<p><a class=btn2 href='/%s/%s/issues/new'>"
                       I_PLUS "new issue</a></p>",
                   o, n);

    sqlite3_stmt *st;
    if (sqlite3_prepare_v2(app->db,
            "SELECT i.id,i.title,i.state,COALESCE(u.name,'(deleted)'),"
            " strftime('%Y-%m-%d', i.created_at, 'unixepoch')"
            " FROM issues i LEFT JOIN users u ON u.id=i.author_id"
            " WHERE i.repo_id=?"
            " ORDER BY (i.state='open') DESC, i.id DESC LIMIT 200",
            -1, &st, NULL) != SQLITE_OK) {
        buf_free(&b);
        http_error(r, 500, "db error");
        return;
    }
    sqlite3_bind_int(st, 1, repo_id);
    buf_puts(&b, "<table><tr><th>#</th><th>title</th><th>state</th>"
                "<th>author</th><th>opened</th></tr>");
    int any = 0;
    while (sqlite3_step(st) == SQLITE_ROW) {
        any = 1;
        buf_printf(&b, "<tr><td>%lld</td><td><a href='/%s/%s/issues/%lld'>",
                   sqlite3_column_int64(st, 0), o, n,
                   sqlite3_column_int64(st, 0));
        gt_esc(&b, (const char *)sqlite3_column_text(st, 1));
        buf_puts(&b, "</a></td><td class=muted>");
        gt_esc(&b, (const char *)sqlite3_column_text(st, 2));
        buf_puts(&b, "</td><td class=muted>");
        gt_esc(&b, (const char *)sqlite3_column_text(st, 3));
        buf_puts(&b, "</td><td class=muted>");
        gt_esc(&b, (const char *)sqlite3_column_text(st, 4));
        buf_puts(&b, "</td></tr>");
    }
    sqlite3_finalize(st);
    if (!any)
        buf_puts(&b, "<tr><td class=muted colspan=5>no issues</td></tr>");
    buf_puts(&b, "</table>");
    gt_repo_body_end(&b);
    gt_page_bottom(&b);
    gt_send_page(r, &b);
    buf_free(&b);
}

static void issue_new_form(struct app_ctx *app, struct request *r,
                           const char *o, const char *n, int rk)
{
    struct buf b;
    buf_init(&b);
    gt_page_top(&b, n, r);
    gt_repo_tabs(&b, app, o, n, rk, "issues");
    buf_printf(&b, "<h3>new issue</h3>"
        "<form method=post action='/%s/%s/issues/new'>", o, n);
    gt_csrf_input(&b, r);
    buf_puts(&b, "<p><input name=title required maxlength=200 size=60 "
                 "placeholder='title'></p>"
                 "<p><textarea name=body rows=8 cols=80></textarea></p>"
                 "<p><button type=submit>create issue</button></p></form>");
    gt_repo_body_end(&b);
    gt_page_bottom(&b);
    gt_send_page(r, &b);
    buf_free(&b);
}

static void issue_view(struct app_ctx *app, struct request *r,
                       const char *o, const char *n, int rk,
                       int repo_id, long id)
{
    sqlite3_stmt *st;
    if (sqlite3_prepare_v2(app->db,
            "SELECT i.title,i.state,COALESCE(u.name,'(deleted)'),i.body,"
            " i.author_id,"
            " strftime('%Y-%m-%d %H:%M', i.created_at, 'unixepoch')"
            " FROM issues i LEFT JOIN users u ON u.id=i.author_id"
            " WHERE i.id=? AND i.repo_id=?",
            -1, &st, NULL) != SQLITE_OK) {
        http_error(r, 500, "db error");
        return;
    }
    sqlite3_bind_int64(st, 1, id);
    sqlite3_bind_int(st, 2, repo_id);
    if (sqlite3_step(st) != SQLITE_ROW) {
        sqlite3_finalize(st);
        http_error(r, 404, "no such issue");
        return;
    }

    struct buf b;
    buf_init(&b);
    gt_page_top(&b, n, r);
    gt_repo_tabs(&b, app, o, n, rk, "issues");
    buf_printf(&b, "<h3>issue #%ld <span class=muted>(%s)</span></h3>",
               id, (const char *)sqlite3_column_text(st, 1));
    buf_puts(&b, "<p><b>");
    gt_esc(&b, (const char *)sqlite3_column_text(st, 0));
    buf_puts(&b, "</b></p><p class=muted>opened by ");
    gt_esc(&b, (const char *)sqlite3_column_text(st, 2));
    buf_puts(&b, " on ");
    gt_esc(&b, (const char *)sqlite3_column_text(st, 5));
    buf_puts(&b, "</p><pre style='border:0;background:none'>");
    gt_esc(&b, (const char *)sqlite3_column_text(st, 3));
    buf_puts(&b, "</pre>");

    int author = sqlite3_column_int(st, 4);
    int is_open = strcmp((const char *)sqlite3_column_text(st, 1),
                         "open") == 0;
    sqlite3_finalize(st);

    buf_puts(&b, "<h4>comments</h4><table>");
    gt_comment_list(&b, app->db, "issue", id);
    buf_puts(&b, "</table>");

    if (r->user_id > 0) {
        gt_comment_form(&b, r, "");
        if (r->user_id == author || rk >= 2) {
            buf_printf(&b, "<form method=post style='display:inline'>");
            gt_csrf_input(&b, r);
            buf_printf(&b, "<input type=hidden name=action value=%s>"
                           "<button>%s issue</button></form>",
                       is_open ? "close" : "reopen",
                       is_open ? "close" : "reopen");
        }
    }
    gt_repo_body_end(&b);
    gt_page_bottom(&b);
    gt_send_page(r, &b);
    buf_free(&b);
}

static void issue_post(struct app_ctx *app, struct request *r,
                       const char *o, const char *n,
                       int repo_id, long id, int creating)
{
    size_t blen;
    char *body = gt_read_post(r, 65536, &blen);
    if (!body)
        return;

    if (creating) {
        char title[210], ibody[8192];
        if (form_get(body, blen, "title", title, sizeof title) != 0 ||
            !title[0]) {
            free(body);
            http_error(r, 400, "title required");
            return;
        }
        form_get(body, blen, "body", ibody, sizeof ibody);
        sqlite3_stmt *st;
        sqlite3_prepare_v2(app->db,
            "INSERT INTO issues(repo_id,author_id,title,body)"
            " VALUES(?,?,?,?)", -1, &st, NULL);
        sqlite3_bind_int(st, 1, repo_id);
        sqlite3_bind_int(st, 2, r->user_id);
        sqlite3_bind_text(st, 3, title, -1, SQLITE_TRANSIENT);
        sqlite3_bind_text(st, 4, ibody, -1, SQLITE_TRANSIENT);
        sqlite3_step(st);
        long nid = sqlite3_last_insert_rowid(app->db);
        sqlite3_finalize(st);
        free(body);
        db_audit(app->db, r->user_id, r->user_name, r->remote_ip,
                 repo_id, "issue_create", "#%ld %.80s", nid, title);
        char loc[256];
        snprintf(loc, sizeof loc, "/%s/%s/issues/%ld", o, n, nid);
        http_redirect(r, loc);
        return;
    }

    char act[16] = "", cbody[8192];
    form_get(body, blen, "action", act, sizeof act);
    if (strcmp(act, "comment") == 0) {
        if (form_get(body, blen, "body", cbody, sizeof cbody) == 0 &&
            cbody[0])
            gt_add_comment(app->db, "issue", id, r->user_id, cbody);
    } else if (strcmp(act, "close") == 0 || strcmp(act, "reopen") == 0) {
        /* author or write perm; verified again here */
        sqlite3_stmt *st;
        sqlite3_prepare_v2(app->db,
            "SELECT author_id FROM issues WHERE id=? AND repo_id=?",
            -1, &st, NULL);
        sqlite3_bind_int64(st, 1, id);
        sqlite3_bind_int(st, 2, repo_id);
        int author = (sqlite3_step(st) == SQLITE_ROW)
                     ? sqlite3_column_int(st, 0) : -1;
        sqlite3_finalize(st);
        if (author == r->user_id || perm_rank(repo_perm(app->db, o, n,
                                          r->user_id)) >= 2) {
            sqlite3_prepare_v2(app->db,
                "UPDATE issues SET state=?,"
                " updated_at=strftime('%s','now') WHERE id=? AND repo_id=?",
                -1, &st, NULL);
            sqlite3_bind_text(st, 1,
                strcmp(act, "close") == 0 ? "closed" : "open",
                -1, SQLITE_STATIC);
            sqlite3_bind_int64(st, 2, id);
            sqlite3_bind_int(st, 3, repo_id);
            sqlite3_step(st);
            sqlite3_finalize(st);
        }
    }
    free(body);
    char loc[256];
    snprintf(loc, sizeof loc, "/%s/%s/issues/%ld", o, n, id);
    http_redirect(r, loc);
}

void collab_issues(struct app_ctx *app, struct request *r,
                   const char *repo_dir, const char *o, const char *n,
                   int rk, const char *tail)
{
    (void)repo_dir;
    int repo_id = gt_repo_rowid(app->db, o, n);
    if (repo_id < 0) {
        http_error(r, 404, "repository not registered");
        return;
    }

    if (strcmp(tail, "") == 0) {
        issue_list(app, r, o, n, rk, repo_id);
        return;
    }
    if (strcmp(tail, "/new") == 0) {
        if (strcmp(r->method, "GET") == 0) {
            if (r->user_id == 0) { http_redirect(r, "/login"); return; }
            issue_new_form(app, r, o, n, rk);
        } else {
            issue_post(app, r, o, n, repo_id, 0, 1);
        }
        return;
    }
    long id = strtol(tail + 1, NULL, 10);
    if (id <= 0) {
        http_error(r, 404, "no such issue");
        return;
    }
    if (strcmp(r->method, "POST") == 0) {
        issue_post(app, r, o, n, repo_id, id, 0);
        return;
    }
    issue_view(app, r, o, n, rk, repo_id, id);
}

/* ---------------- merge requests ---------------- */

static int branch_sha(const char *repo_dir, const char *branch, char out[41])
{
    char ref[300];
    snprintf(ref, sizeof ref, "refs/heads/%s", branch);
    struct buf b;
    buf_init(&b);
    const char *argv[] = { "git", "rev-parse", "--verify", "-q", ref, NULL };
    int rc = git_capture(repo_dir, argv, &b);
    if (rc != 0 || b.len < 40) {
        buf_free(&b);
        return -1;
    }
    memcpy(out, b.data, 40);
    out[40] = '\0';
    buf_free(&b);
    return 0;
}

static void mr_list(struct app_ctx *app, struct request *r,
                    const char *o, const char *n, int rk, int repo_id)
{
    struct buf b;
    buf_init(&b);
    gt_page_top(&b, n, r);
    gt_repo_tabs(&b, app, o, n, rk, "changes");
    if (r->user_id > 0)
        buf_printf(&b, "<p><a class=btn2 href='/%s/%s/mr/new'>"
                       I_PLUS "new merge request</a></p>", o, n);
    sqlite3_stmt *st;
    if (sqlite3_prepare_v2(app->db,
            "SELECT m.id,m.title,m.src_branch,m.dst_branch,m.state,"
            " COALESCE(u.name,'(deleted)'),"
            " strftime('%Y-%m-%d', m.created_at, 'unixepoch')"
            " FROM merge_requests m LEFT JOIN users u ON u.id=m.author_id"
            " WHERE m.repo_id=?"
            " ORDER BY (m.state='open') DESC, m.id DESC LIMIT 200",
            -1, &st, NULL) != SQLITE_OK) {
        buf_free(&b);
        http_error(r, 500, "db error");
        return;
    }
    sqlite3_bind_int(st, 1, repo_id);
    buf_puts(&b, "<table><tr><th>#</th><th>title</th><th>branches</th>"
                "<th>state</th><th>author</th></tr>");
    int any = 0;
    while (sqlite3_step(st) == SQLITE_ROW) {
        any = 1;
        buf_printf(&b, "<tr><td>%lld</td><td><a href='/%s/%s/mr/%lld'>",
                   sqlite3_column_int64(st, 0), o, n,
                   sqlite3_column_int64(st, 0));
        gt_esc(&b, (const char *)sqlite3_column_text(st, 1));
        buf_puts(&b, "</a></td><td class=sha>");
        gt_esc(&b, (const char *)sqlite3_column_text(st, 2));
        buf_puts(&b, " &rarr; ");
        gt_esc(&b, (const char *)sqlite3_column_text(st, 3));
        buf_puts(&b, "</td><td class=muted>");
        gt_esc(&b, (const char *)sqlite3_column_text(st, 4));
        buf_puts(&b, "</td><td class=muted>");
        gt_esc(&b, (const char *)sqlite3_column_text(st, 5));
        buf_puts(&b, "</td></tr>");
    }
    sqlite3_finalize(st);
    if (!any)
        buf_puts(&b, "<tr><td class=muted colspan=5>no merge requests"
                     "</td></tr>");
    buf_puts(&b, "</table>");
    gt_repo_body_end(&b);
    gt_page_bottom(&b);
    gt_send_page(r, &b);
    buf_free(&b);
}

static void mr_new_form(struct app_ctx *app, struct request *r,
                        const char *repo_dir,
                        const char *o, const char *n, int rk)
{
    (void)app;
    /* branch list for the <select>s — built once, emitted twice */
    struct buf br;
    buf_init(&br);
    const char *argv[] = { "git", "for-each-ref",
                           "--format=%(refname:short)", "refs/heads", NULL };
    git_capture(repo_dir, argv, &br);
    struct buf opts;
    buf_init(&opts);
    if (br.data) {
        char *save = NULL;
        for (char *s = strtok_r(br.data, "\n", &save); s;
             s = strtok_r(NULL, "\n", &save)) {
            buf_puts(&opts, "<option>");
            gt_esc(&opts, s);
            buf_puts(&opts, "</option>");
        }
    }

    struct buf b;
    buf_init(&b);
    gt_page_top(&b, n, r);
    gt_repo_tabs(&b, app, o, n, rk, "changes");
    buf_printf(&b, "<h3>new merge request</h3>"
        "<form method=post action='/%s/%s/mr/new'>", o, n);
    gt_csrf_input(&b, r);
    buf_puts(&b, "<p><input name=title required maxlength=200 size=60 "
                 "placeholder='title'></p>"
                 "<p><textarea name=body rows=6 cols=80></textarea></p>"
                 "<p>merge ");
    buf_puts(&b, "<select name=src>");
    if (opts.data)
        buf_puts(&b, opts.data);
    buf_puts(&b, "</select> into <select name=dst>");
    if (opts.data)
        buf_puts(&b, opts.data);
    buf_puts(&b, "</select></p>"
                 "<p><button type=submit>create merge request</button></p>"
                 "</form><p class=muted>push the source branch first"
                 " (same-repo merge requests for now; forks later).</p>");
    gt_repo_body_end(&b);
    gt_page_bottom(&b);
    gt_send_page(r, &b);
    buf_free(&b);
    buf_free(&br);
    buf_free(&opts);
}

static void mr_view(struct app_ctx *app, struct request *r,
                    const char *repo_dir,
                    const char *o, const char *n, int rk,
                    int repo_id, long id)
{
    sqlite3_stmt *st;
    if (sqlite3_prepare_v2(app->db,
            "SELECT m.title,m.state,m.src_branch,m.dst_branch,m.body,"
            " COALESCE(u.name,'(deleted)'),m.author_id,"
            " strftime('%Y-%m-%d %H:%M', m.created_at, 'unixepoch')"
            " FROM merge_requests m LEFT JOIN users u ON u.id=m.author_id"
            " WHERE m.id=? AND m.repo_id=?",
            -1, &st, NULL) != SQLITE_OK) {
        http_error(r, 500, "db error");
        return;
    }
    sqlite3_bind_int64(st, 1, id);
    sqlite3_bind_int(st, 2, repo_id);
    if (sqlite3_step(st) != SQLITE_ROW) {
        sqlite3_finalize(st);
        http_error(r, 404, "no such merge request");
        return;
    }

    char src[256], dst[256], state[16];
    snprintf(src, sizeof src, "%s",
             (const char *)sqlite3_column_text(st, 2));
    snprintf(dst, sizeof dst, "%s",
             (const char *)sqlite3_column_text(st, 3));
    snprintf(state, sizeof state, "%s",
             (const char *)sqlite3_column_text(st, 1));
    int author = sqlite3_column_int(st, 6);

    struct buf b;
    buf_init(&b);
    gt_page_top(&b, n, r);
    gt_repo_tabs(&b, app, o, n, rk, "changes");
    buf_printf(&b, "<h3>merge request #%ld <span class=muted>(%s)</span>"
                   "</h3>", id, state);
    buf_puts(&b, "<p><b>");
    gt_esc(&b, (const char *)sqlite3_column_text(st, 0));
    buf_puts(&b, "</b></p><p class=muted>");
    gt_esc(&b, (const char *)sqlite3_column_text(st, 2));
    buf_puts(&b, " &rarr; ");
    gt_esc(&b, (const char *)sqlite3_column_text(st, 3));
    buf_puts(&b, " &middot; opened by ");
    gt_esc(&b, (const char *)sqlite3_column_text(st, 5));
    buf_puts(&b, " on ");
    gt_esc(&b, (const char *)sqlite3_column_text(st, 7));
    buf_puts(&b, "</p><pre style='border:0;background:none'>");
    gt_esc(&b, (const char *)sqlite3_column_text(st, 4));
    buf_puts(&b, "</pre>");
    sqlite3_finalize(st);

    /* merge-base diff */
    struct buf diff;
    buf_init(&diff);
    char spec[600];
    snprintf(spec, sizeof spec, "refs/heads/%s...refs/heads/%s", dst, src);
    const char *dargv[] = { "git", "diff", "--no-color", spec, NULL };
    if (git_capture(repo_dir, dargv, &diff) == 0 && diff.len) {
        buf_puts(&b, "<h4>changes</h4><pre>");
        gt_render_diff(&b, diff.data, diff.len);
        buf_puts(&b, "</pre>");
    }
    buf_free(&diff);

    /* GNU-readable export + back-link when this MR was born as a
     * mailing-list patch set */
    buf_printf(&b, "<p class=muted>terminal workflow: "
        "<code>curl -o mr%ld.patch '&lt;base-url&gt;/%s/%s/mr/%ld.patch' "
        "&amp;&amp; git am mr%ld.patch</code></p>",
        id, o, n, id, id);
    buf_printf(&b, "<p class=muted><a href='/%s/%s/mr/%ld.patch'>"
        "download as mbox</a> &middot; <a href='/%s/%s/mr/%ld.diff'>"
        "as diff</a></p>", o, n, id, o, n, id);
    if (starts_with(src, "psets/")) {
        long psid = strtol(src + 6, NULL, 10);
        if (psid > 0)
            buf_printf(&b, "<p class=muted>&#8596; patch set "
                "<a href='/%s/%s/patches/%ld'>#%ld</a></p>",
                o, n, psid, psid);
    }

    buf_puts(&b, "<h4>comments</h4><table>");
    gt_comment_list(&b, app->db, "mr", id);
    buf_puts(&b, "</table>");

    if (r->user_id > 0 && strcmp(state, "open") == 0) {
        gt_comment_form(&b, r, "");
        if (rk >= 2) {
            buf_puts(&b, "<form method=post style='display:inline'>");
            gt_csrf_input(&b, r);
            buf_puts(&b, "<input type=hidden name=action value=merge>"
                         "<button>merge</button></form> ");
        }
        if (r->user_id == author || rk >= 2) {
            buf_puts(&b, "<form method=post style='display:inline'>");
            gt_csrf_input(&b, r);
            buf_puts(&b, "<input type=hidden name=action value=close>"
                         "<button>close</button></form>");
        }
    }
    gt_repo_body_end(&b);
    gt_page_bottom(&b);
    gt_send_page(r, &b);
    buf_free(&b);
}

/* merge src into dst inside the bare repo via plumbing:
 *   merge-tree --write-tree  → tree oid (or conflicts)
 *   commit-tree              → merge commit (2 parents)
 *   update-ref <ref> new old → compare-and-swap
 * returns 0 merged, 1 conflicts, -1 error */
static int try_merge(const char *repo_dir, const char *src,
                     const char *dst, const char *msg,
                     struct buf *details)
{
    char dstref[300], srcref[300];
    snprintf(dstref, sizeof dstref, "refs/heads/%s", dst);
    snprintf(srcref, sizeof srcref, "refs/heads/%s", src);

    const char *margv[] = { "git", "merge-tree", "--write-tree",
                            dstref, srcref, NULL };
    struct buf mo;
    buf_init(&mo);
    int rc = git_capture(repo_dir, margv, &mo);
    if (rc != 0) {
        if (mo.data && mo.len)
            buf_put(details, mo.data, mo.len);
        buf_free(&mo);
        return 1;                       /* conflicts */
    }
    char tree[64];
    if (!mo.data || mo.len < 40) {
        buf_free(&mo);
        return -1;
    }
    memcpy(tree, mo.data, 40);
    tree[40] = '\0';
    buf_free(&mo);

    char old[41];
    if (branch_sha(repo_dir, dst, old) != 0)
        return -1;
    char srcsha[41];
    if (branch_sha(repo_dir, src, srcsha) != 0)
        return -1;

    const char *cargv[] = {
        "git", "-c", "user.name=git-trace",
        "-c", "user.email=git-trace@localhost",
        "commit-tree", "-m", msg,
        "-p", old, "-p", srcsha, tree, NULL
    };
    struct buf co;
    buf_init(&co);
    if (git_capture(repo_dir, cargv, &co) != 0 || co.len < 40) {
        buf_free(&co);
        return -1;
    }
    char commit[64];
    memcpy(commit, co.data, 40);
    commit[40] = '\0';
    buf_free(&co);

    const char *uargv[] = { "git", "update-ref", dstref, commit, old,
                            NULL };
    if (git_capture(repo_dir, uargv, details) != 0)
        return -1;                      /* lost the CAS race */
    return 0;
}

static void mr_post(struct app_ctx *app, struct request *r,
                    const char *repo_dir, const char *o, const char *n,
                    int rk, int repo_id, long id, int creating)
{
    size_t blen;
    char *body = gt_read_post(r, 65536, &blen);
    if (!body)
        return;

    if (creating) {
        char title[210], mbody[8192], src[256], dst[256];
        if (form_get(body, blen, "title", title, sizeof title) != 0 ||
            !title[0] ||
            form_get(body, blen, "src", src, sizeof src) != 0 ||
            form_get(body, blen, "dst", dst, sizeof dst) != 0 ||
            !git_ref_ok(src) || !git_ref_ok(dst) ||
            strcmp(src, dst) == 0) {
            free(body);
            http_error(r, 400, "bad merge request fields");
            return;
        }
        char srcsha[41], dstsha[41];
        if (branch_sha(repo_dir, src, srcsha) != 0 ||
            branch_sha(repo_dir, dst, dstsha) != 0) {
            free(body);
            http_error(r, 400, "unknown source or destination branch");
            return;
        }
        form_get(body, blen, "body", mbody, sizeof mbody);
        sqlite3_stmt *st;
        sqlite3_prepare_v2(app->db,
            "INSERT INTO merge_requests(repo_id,author_id,title,body,"
            " src_branch,dst_branch,src_commit) VALUES(?,?,?,?,?,?,?)",
            -1, &st, NULL);
        sqlite3_bind_int(st, 1, repo_id);
        sqlite3_bind_int(st, 2, r->user_id);
        sqlite3_bind_text(st, 3, title, -1, SQLITE_TRANSIENT);
        sqlite3_bind_text(st, 4, mbody, -1, SQLITE_TRANSIENT);
        sqlite3_bind_text(st, 5, src, -1, SQLITE_TRANSIENT);
        sqlite3_bind_text(st, 6, dst, -1, SQLITE_TRANSIENT);
        sqlite3_bind_text(st, 7, srcsha, -1, SQLITE_TRANSIENT);
        sqlite3_step(st);
        long nid = sqlite3_last_insert_rowid(app->db);
        sqlite3_finalize(st);
        free(body);
        db_audit(app->db, r->user_id, r->user_name, r->remote_ip,
                 repo_id, "mr_create", "#%ld %s -> %s", nid, src, dst);
        {
            struct buf wp;
            buf_init(&wp);
            char on2[200];
            snprintf(on2, sizeof on2, "%s/%s", o, n);
            buf_puts(&wp, "{\"event\":\"mr.open\",\"repo\":\"");
            json_put_esc(&wp, on2, strlen(on2));
            buf_printf(&wp, "\",\"mr\":%ld,\"title\":\"", nid);
            json_put_esc(&wp, title, strlen(title));
            buf_puts(&wp, "\",\"author\":\"");
            json_put_esc(&wp, r->user_name, strlen(r->user_name));
            buf_puts(&wp, "\",\"src\":\"");
            json_put_esc(&wp, src, strlen(src));
            buf_puts(&wp, "\",\"dst\":\"");
            json_put_esc(&wp, dst, strlen(dst));
            buf_puts(&wp, "\"}");
            wh_notify(app, repo_id, "mr", wp.data ? wp.data : "{}");
            buf_free(&wp);
        }
        char loc[256];
        snprintf(loc, sizeof loc, "/%s/%s/mr/%ld", o, n, nid);
        http_redirect(r, loc);
        return;
    }

    char act[16] = "", cbody[8192];
    form_get(body, blen, "action", act, sizeof act);

    if (strcmp(act, "comment") == 0) {
        if (form_get(body, blen, "body", cbody, sizeof cbody) == 0 &&
            cbody[0])
            gt_add_comment(app->db, "mr", id, r->user_id, cbody);
    } else if (strcmp(act, "merge") == 0 && rk >= 2) {
        /* load MR */
        sqlite3_stmt *st;
        sqlite3_prepare_v2(app->db,
            "SELECT src_branch,dst_branch,title FROM merge_requests"
            " WHERE id=? AND repo_id=? AND state='open'",
            -1, &st, NULL);
        sqlite3_bind_int64(st, 1, id);
        sqlite3_bind_int(st, 2, repo_id);
        if (sqlite3_step(st) != SQLITE_ROW) {
            sqlite3_finalize(st);
            free(body);
            http_error(r, 409, "merge request not open");
            return;
        }
        char src[256], dst[256];
        snprintf(src, sizeof src, "%s",
                 (const char *)sqlite3_column_text(st, 0));
        snprintf(dst, sizeof dst, "%s",
                 (const char *)sqlite3_column_text(st, 1));
        char msg[512];
        snprintf(msg, sizeof msg, "merge request #%ld: %.400s",
                 id, (const char *)sqlite3_column_text(st, 2));
        sqlite3_finalize(st);

        struct buf det;
        buf_init(&det);
        int rc = try_merge(repo_dir, src, dst, msg, &det);
        free(body);
        if (rc == 0) {
            sqlite3_prepare_v2(app->db,
                "UPDATE merge_requests SET state='merged',"
                " updated_at=strftime('%s','now') WHERE id=?", -1, &st, NULL);
            sqlite3_bind_int64(st, 1, id);
            sqlite3_step(st);
            sqlite3_finalize(st);
            buf_free(&det);
            db_audit(app->db, r->user_id, r->user_name, r->remote_ip,
                     repo_id, "mr_merge", "#%ld %s -> %s", id, src, dst);
            char loc[256];
            snprintf(loc, sizeof loc, "/%s/%s/mr/%ld", o, n, id);
            http_redirect(r, loc);
            return;
        }
        /* conflicts or error */
        struct buf b;
        buf_init(&b);
        gt_page_top(&b, n, r);
        gt_repo_tabs(&b, app, o, n, rk, "changes");
        buf_puts(&b, rc == 1 ? "<h3>merge conflicts</h3>"
                             : "<h3>merge failed</h3><pre>");
        if (det.data)
            gt_esc(&b, det.data);
        buf_puts(&b, "</pre><p class=muted>resolve conflicts on the "
                     "source branch, or merge manually.</p>");
        gt_repo_body_end(&b);
        gt_page_bottom(&b);
        gt_send_page(r, &b);
        buf_free(&b);
        buf_free(&det);
        return;
    } else if (strcmp(act, "close") == 0) {
        sqlite3_stmt *st;
        sqlite3_prepare_v2(app->db,
            "SELECT author_id FROM merge_requests WHERE id=? AND repo_id=?",
            -1, &st, NULL);
        sqlite3_bind_int64(st, 1, id);
        sqlite3_bind_int(st, 2, repo_id);
        int author = (sqlite3_step(st) == SQLITE_ROW)
                     ? sqlite3_column_int(st, 0) : -1;
        sqlite3_finalize(st);
        if (author == r->user_id || rk >= 2) {
            sqlite3_prepare_v2(app->db,
                "UPDATE merge_requests SET state='closed',"
                " updated_at=strftime('%s','now') WHERE id=? AND repo_id=?",
                -1, &st, NULL);
            sqlite3_bind_int64(st, 1, id);
            sqlite3_bind_int(st, 2, repo_id);
            sqlite3_step(st);
            sqlite3_finalize(st);
        }
    }
    free(body);
    char loc[256];
    snprintf(loc, sizeof loc, "/%s/%s/mr/%ld", o, n, id);
    http_redirect(r, loc);
}

/* /mr/N.patch — the web request becomes a GNU-readable mbox;
 * /mr/N.diff — plain unified diff for review plumbing */
static void mr_export(struct app_ctx *app, struct request *r,
                      const char *repo_dir, int repo_id, long id,
                      const char *ext)
{
    sqlite3_stmt *st;
    sqlite3_prepare_v2(app->db,
        "SELECT src_branch,dst_branch FROM merge_requests"
        " WHERE id=? AND repo_id=?", -1, &st, NULL);
    sqlite3_bind_int64(st, 1, id);
    sqlite3_bind_int(st, 2, repo_id);
    if (sqlite3_step(st) != SQLITE_ROW) {
        sqlite3_finalize(st);
        http_error(r, 404, "no such merge request");
        return;
    }
    char sref[300], dref[300];
    snprintf(sref, sizeof sref, "refs/heads/%s",
             (const char *)sqlite3_column_text(st, 0));
    snprintf(dref, sizeof dref, "refs/heads/%s",
             (const char *)sqlite3_column_text(st, 1));
    sqlite3_finalize(st);

    struct buf out;
    buf_init(&out);
    if (strcmp(ext, ".diff") == 0) {
        char spec[700];
        snprintf(spec, sizeof spec, "%s...%s", dref, sref);
        const char *av[] = { "git", "diff", "--no-color", spec, NULL };
        git_capture(repo_dir, av, &out);
    } else {
        /* merge-base so the mbox applies cleanly with `git am` */
        struct buf mb;
        buf_init(&mb);
        const char *mv[] = { "git", "merge-base", dref, sref, NULL };
        if (git_capture(repo_dir, mv, &mb) != 0 || mb.len < 40) {
            buf_free(&mb);
            buf_free(&out);
            http_error(r, 500, "no merge base");
            return;
        }
        mb.data[40] = '\0';
        char range[128];
        int rl = snprintf(range, sizeof range, "%.40s..%s",
                          mb.data, sref);
        buf_free(&mb);
        if (rl < 0 || rl >= (int)sizeof range) {
            buf_free(&out);
            http_error(r, 400, "ref too long");
            return;
        }
        const char *av[] = { "git", "format-patch", "--stdout",
                             range, NULL };
        git_capture(repo_dir, av, &out);
    }
    if (!out.data || out.len == 0) {
        buf_free(&out);
        http_error(r, 404, "nothing to export");
        return;
    }
    http_respond(r, 200, "text/plain; charset=utf-8",
                 out.data, out.len);
    buf_free(&out);
}

void collab_mrs(struct app_ctx *app, struct request *r,
                const char *repo_dir, const char *o, const char *n,
                int rk, const char *tail)
{
    int repo_id = gt_repo_rowid(app->db, o, n);
    if (repo_id < 0) {
        http_error(r, 404, "repository not registered");
        return;
    }
    if (strcmp(tail, "") == 0) {
        mr_list(app, r, o, n, rk, repo_id);
        return;
    }
    if (strcmp(tail, "/new") == 0) {
        if (strcmp(r->method, "GET") == 0) {
            if (r->user_id == 0) { http_redirect(r, "/login"); return; }
            mr_new_form(app, r, repo_dir, o, n, rk);
        } else {
            mr_post(app, r, repo_dir, o, n, rk, repo_id, 0, 1);
        }
        return;
    }
    char *endp = NULL;
    long id = strtol(tail + 1, &endp, 10);
    if (id <= 0) {
        http_error(r, 404, "no such merge request");
        return;
    }
    /* export formats: /mr/N.patch (mbox, `git am`-ready),
     * /mr/N.diff (plain unified diff) */
    if (strcmp(endp, ".patch") == 0 || strcmp(endp, ".mbox") == 0 ||
        strcmp(endp, ".diff") == 0) {
        mr_export(app, r, repo_dir, repo_id, id, endp);
        return;
    }
    if (endp[0] != '\0') {
        http_error(r, 404, "unknown format");
        return;
    }
    if (strcmp(r->method, "POST") == 0) {
        mr_post(app, r, repo_dir, o, n, rk, repo_id, id, 0);
        return;
    }
    mr_view(app, r, repo_dir, o, n, rk, repo_id, id);
}

/* ---------------- settings (ACL + meta) ---------------- */

void collab_settings(struct app_ctx *app, struct request *r,
                     const char *o, const char *n, int rk)
{
    if (rk < 3) {
        http_error(r, 403, "admin only");
        return;
    }
    int repo_id = gt_repo_rowid(app->db, o, n);
    if (repo_id < 0) {
        http_error(r, 404, "repository not registered");
        return;
    }

    if (strcmp(r->method, "POST") == 0) {
        size_t blen;
        char *body = gt_read_post(r, 65536, &blen);
        if (!body)
            return;
        char act[16] = "";
        form_get(body, blen, "action", act, sizeof act);
        sqlite3_stmt *st;

        if (strcmp(act, "meta") == 0) {
            char desc[500], pub[8] = "", ci[8] = "";
            form_get(body, blen, "desc", desc, sizeof desc);
            form_get(body, blen, "public", pub, sizeof pub);
            form_get(body, blen, "ci", ci, sizeof ci);
            sqlite3_prepare_v2(app->db,
                "UPDATE repos SET description=?, is_public=?,"
                " ci_enabled=? WHERE id=?",
                -1, &st, NULL);
            sqlite3_bind_text(st, 1, desc, -1, SQLITE_TRANSIENT);
            sqlite3_bind_int(st, 2, strcmp(pub, "on") == 0);
            sqlite3_bind_int(st, 3, strcmp(ci, "on") == 0);
            sqlite3_bind_int(st, 4, repo_id);
            sqlite3_step(st);
            sqlite3_finalize(st);
        } else if (strcmp(act, "acl_add") == 0) {
            char uname[64], role[4];
            if (form_get(body, blen, "user", uname, sizeof uname) == 0 &&
                form_get(body, blen, "role", role, sizeof role) == 0 &&
                name_ok(uname) &&
                (role[0] == 'r' || role[0] == 'w' || role[0] == 'a')) {
                int uid = auth_user_id(app->db, uname);
                if (uid > 0) {
                    sqlite3_prepare_v2(app->db,
                        "INSERT INTO repo_acl(repo_id,user_id,role)"
                        " VALUES(?,?,?)"
                        " ON CONFLICT(repo_id,user_id)"
                        " DO UPDATE SET role=excluded.role",
                        -1, &st, NULL);
                    sqlite3_bind_int(st, 1, repo_id);
                    sqlite3_bind_int(st, 2, uid);
                    sqlite3_bind_text(st, 3, role, -1, SQLITE_TRANSIENT);
                    sqlite3_step(st);
                    sqlite3_finalize(st);
                    db_audit(app->db, r->user_id, r->user_name,
                             r->remote_ip, repo_id, "acl_add",
                             "%s -> %s", uname, role);
                }
            }
        } else if (strcmp(act, "acl_rm") == 0) {
            char uname[64];
            if (form_get(body, blen, "user", uname, sizeof uname) == 0) {
                int uid = auth_user_id(app->db, uname);
                if (uid > 0) {
                    sqlite3_prepare_v2(app->db,
                        "DELETE FROM repo_acl WHERE repo_id=? AND user_id=?",
                        -1, &st, NULL);
                    sqlite3_bind_int(st, 1, repo_id);
                    sqlite3_bind_int(st, 2, uid);
                    sqlite3_step(st);
                    sqlite3_finalize(st);
                    db_audit(app->db, r->user_id, r->user_name,
                             r->remote_ip, repo_id, "acl_rm",
                             "%s", uname);
                }
            }
        } else if (strcmp(act, "rule_add") == 0) {
            char pat[260], role[4] = "", ff[8] = "", del[8] = "";
            if (form_get(body, blen, "pattern", pat, sizeof pat) == 0 &&
                form_get(body, blen, "role", role, sizeof role) == 0 &&
                git_pat_ok(pat) &&
                (role[0] == 'w' || role[0] == 'a') && role[1] == '\0') {
                form_get(body, blen, "ff", ff, sizeof ff);
                form_get(body, blen, "del", del, sizeof del);
                sqlite3_prepare_v2(app->db,
                    "INSERT INTO branch_rules"
                    " (repo_id,pattern,min_role,ff_only,no_delete)"
                    " VALUES(?,?,?,?,?)"
                    " ON CONFLICT(repo_id,pattern) DO UPDATE SET"
                    " min_role=excluded.min_role,"
                    " ff_only=excluded.ff_only,"
                    " no_delete=excluded.no_delete",
                    -1, &st, NULL);
                sqlite3_bind_int(st, 1, repo_id);
                sqlite3_bind_text(st, 2, pat, -1, SQLITE_TRANSIENT);
                sqlite3_bind_text(st, 3, role, -1, SQLITE_TRANSIENT);
                sqlite3_bind_int(st, 4, strcmp(ff, "on") == 0);
                sqlite3_bind_int(st, 5, strcmp(del, "on") == 0);
                sqlite3_step(st);
                sqlite3_finalize(st);
                db_audit(app->db, r->user_id, r->user_name,
                         r->remote_ip, repo_id, "rule_add",
                         "%s -> %s", pat, role);
            }
        } else if (strcmp(act, "delete") == 0) {
            /* destructive: requires typing the repo name to confirm.
             * Order matters: drop the on-disk tree first, then the DB
             * row — if rm fails the repo stays registered (and thus
             * still protected by its ACL), never an orphaned
             * world-readable directory. */
            char conf[64] = "";
            form_get(body, blen, "confirm", conf, sizeof conf);
            if (strcmp(conf, n) == 0) {
                char rdir[PATH_MAX];
                struct stat sb;
                int fsok = repo_path(rdir, sizeof rdir, app->repos_root,
                                     o, n) == 0;
                if (fsok && lstat(rdir, &sb) == 0)
                    fsok = nftw(rdir, rm_cb, 16,
                                FTW_DEPTH | FTW_PHYS) == 0;
                /* ENOENT: tree already gone -> row cleanup is still ok */
                else if (fsok && errno != ENOENT)
                    fsok = 0;
                if (!fsok) {
                    free(body);
                    http_error(r, 500, "could not remove repo tree");
                    return;
                }
                sqlite3_prepare_v2(app->db,
                    "DELETE FROM repos WHERE id=?", -1, &st, NULL);
                sqlite3_bind_int(st, 1, repo_id);
                sqlite3_step(st);
                sqlite3_finalize(st);
                db_audit(app->db, r->user_id, r->user_name,
                         r->remote_ip, repo_id, "repo_delete",
                         "%s/%s", o, n);
                free(body);
                http_redirect(r, "/");
                return;
            }
        } else if (strcmp(act, "rule_rm") == 0) {
            char rid[16];
            if (form_get(body, blen, "id", rid, sizeof rid) == 0) {
                sqlite3_prepare_v2(app->db,
                    "DELETE FROM branch_rules WHERE repo_id=? AND id=?",
                    -1, &st, NULL);
                sqlite3_bind_int(st, 1, repo_id);
                sqlite3_bind_int(st, 2, atoi(rid));
                sqlite3_step(st);
                sqlite3_finalize(st);
                db_audit(app->db, r->user_id, r->user_name,
                         r->remote_ip, repo_id, "rule_rm",
                         "id=%s", rid);
            }
        } else if (strcmp(act, "wh_add") == 0) {
            char url[500], sec[200] = "", evs[40] = "", ep[8];
            if (form_get(body, blen, "url", url, sizeof url) == 0 &&
                (strncmp(url, "http://", 7) == 0 ||
                 strncmp(url, "https://", 8) == 0) &&
                strcspn(url, " \t\r\n") == strlen(url)) {
                form_get(body, blen, "secret", sec, sizeof sec);
                const char *al[] = { "push", "mr", "patch" };
                for (size_t i = 0; i < 3; i++) {
                    ep[0] = '\0';
                    char key[16];
                    snprintf(key, sizeof key, "ev_%s", al[i]);
                    form_get(body, blen, key, ep, sizeof ep);
                    if (strcmp(ep, "on") == 0) {
                        if (evs[0])
                            strncat(evs, ",",
                                    sizeof evs - strlen(evs) - 1);
                        strncat(evs, al[i],
                                sizeof evs - strlen(evs) - 1);
                    }
                }
                if (!evs[0])
                    snprintf(evs, sizeof evs, "push");
                sqlite3_prepare_v2(app->db,
                    "INSERT INTO repo_webhooks(repo_id,url,secret,events)"
                    " VALUES(?,?,?,?)", -1, &st, NULL);
                sqlite3_bind_int(st, 1, repo_id);
                sqlite3_bind_text(st, 2, url, -1, SQLITE_TRANSIENT);
                sqlite3_bind_text(st, 3, sec, -1, SQLITE_TRANSIENT);
                sqlite3_bind_text(st, 4, evs, -1, SQLITE_TRANSIENT);
                sqlite3_step(st);
                sqlite3_finalize(st);
                db_audit(app->db, r->user_id, r->user_name,
                         r->remote_ip, repo_id, "webhook_add",
                         "%s [%s]", url, evs);
            }
        } else if (strcmp(act, "wh_del") == 0) {
            char hid[16];
            if (form_get(body, blen, "id", hid, sizeof hid) == 0) {
                sqlite3_prepare_v2(app->db,
                    "DELETE FROM repo_webhooks"
                    " WHERE repo_id=? AND id=?", -1, &st, NULL);
                sqlite3_bind_int(st, 1, repo_id);
                sqlite3_bind_int(st, 2, atoi(hid));
                sqlite3_step(st);
                sqlite3_finalize(st);
                db_audit(app->db, r->user_id, r->user_name,
                         r->remote_ip, repo_id, "webhook_del",
                         "id=%s", hid);
            }
        } else if (strcmp(act, "mir_add") == 0) {
            char url[500], spec[256], sec[200] = "", dir[8] = "pull";
            if (form_get(body, blen, "url", url, sizeof url) == 0 &&
                mirror_url_ok(url)) {
                form_get(body, blen, "dir", dir, sizeof dir);
                int push = strcmp(dir, "push") == 0;
                if (form_get(body, blen, "refspec", spec, sizeof spec) != 0 ||
                    !*spec)
                    snprintf(spec, sizeof spec, "%s", push
                             ? "+refs/heads/*:refs/heads/*"
                             : "+refs/heads/*:refs/mirror/*");
                form_get(body, blen, "secret", sec, sizeof sec);
                int ok = push ? mirror_refspec_push_ok(spec)
                              : mirror_refspec_ok(spec);
                if (ok) {
                    sqlite3_prepare_v2(app->db,
                        "INSERT INTO repo_mirrors(repo_id,url,direction,"
                        "refspec,secret) VALUES(?,?,?,?,?)", -1, &st, NULL);
                    sqlite3_bind_int(st, 1, repo_id);
                    sqlite3_bind_text(st, 2, url, -1, SQLITE_TRANSIENT);
                    sqlite3_bind_text(st, 3, push ? "push" : "pull",
                                      -1, SQLITE_TRANSIENT);
                    sqlite3_bind_text(st, 4, spec, -1, SQLITE_TRANSIENT);
                    sqlite3_bind_text(st, 5, push ? "" : sec,
                                      -1, SQLITE_TRANSIENT);
                    sqlite3_step(st);
                    sqlite3_finalize(st);
                    char murl[512];
                    mirror_url_mask(url, murl, sizeof murl);
                    db_audit(app->db, r->user_id, r->user_name,
                             r->remote_ip, repo_id, "mirror_add",
                             "%s %s %s", push ? "push" : "pull",
                             murl, spec);
                }
            }
        } else if (strcmp(act, "mir_del") == 0) {
            char hid[16];
            if (form_get(body, blen, "id", hid, sizeof hid) == 0) {
                sqlite3_prepare_v2(app->db,
                    "DELETE FROM repo_mirrors"
                    " WHERE repo_id=? AND id=?", -1, &st, NULL);
                sqlite3_bind_int(st, 1, repo_id);
                sqlite3_bind_int(st, 2, atoi(hid));
                sqlite3_step(st);
                sqlite3_finalize(st);
                db_audit(app->db, r->user_id, r->user_name,
                         r->remote_ip, repo_id, "mirror_del",
                         "id=%s", hid);
            }
        } else if (strcmp(act, "mir_sync") == 0) {
            char arg[300];
            snprintf(arg, sizeof arg, "%s/%s", o, n);
            gt_spawn_task_arg("mirror-sync", arg);
        } else if (strcmp(act, "mir_push") == 0) {
            char arg[300];
            snprintf(arg, sizeof arg, "%s/%s", o, n);
            gt_spawn_task_arg("mirror-push", arg);
        }
        free(body);
        char loc[300];
        snprintf(loc, sizeof loc, "/%s/%s/settings", o, n);
        http_redirect(r, loc);
        return;
    }

    /* GET: settings page */
    struct buf b;
    buf_init(&b);
    gt_page_top(&b, n, r);
    gt_repo_tabs(&b, app, o, n, rk, "settings");

    sqlite3_stmt *st;
    sqlite3_prepare_v2(app->db,
        "SELECT description,is_public,ci_enabled FROM repos WHERE id=?",
        -1, &st, NULL);
    sqlite3_bind_int(st, 1, repo_id);
    int is_pub = 1, ci_on = 0;
    const char *desc = "";
    if (sqlite3_step(st) == SQLITE_ROW) {
        desc = (const char *)sqlite3_column_text(st, 0);
        is_pub = sqlite3_column_int(st, 1);
        ci_on  = sqlite3_column_int(st, 2);
    }
    char descbuf[512];
    snprintf(descbuf, sizeof descbuf, "%s", desc ? desc : "");
    sqlite3_finalize(st);

    buf_puts(&b, "<h3>settings</h3><form method=post>");
    gt_csrf_input(&b, r);
    buf_puts(&b, "<input type=hidden name=action value=meta>"
                 "<p>description <input name=desc size=60 value='");
    gt_esc(&b, descbuf);
    buf_puts(&b, "'></p><p><label><input type=checkbox name=public");
    buf_puts(&b, is_pub ? " checked" : "");
    buf_puts(&b, "> public (anonymous read)</label></p>"
                 "<p><label><input type=checkbox name=ci");
    buf_puts(&b, ci_on ? " checked" : "");
    buf_puts(&b, "> ci — run <code>.git-trace-ci</code> from the"
                 " default branch on every push</label></p>"
                 "<p><button>save</button></p></form>");
    if (ci_on) {
        buf_puts(&b, "<table><tr><th>recent builds</th><th>status</th>"
                     "<th>branch</th></tr>");
        sqlite3_prepare_v2(app->db,
            "SELECT id, sha, status, branch FROM ci_runs"
            " WHERE repo_id=? ORDER BY id DESC LIMIT 5",
            -1, &st, NULL);
        sqlite3_bind_int(st, 1, repo_id);
        while (sqlite3_step(st) == SQLITE_ROW) {
            long id = sqlite3_column_int64(st, 0);
            const char *sha = (const char *)sqlite3_column_text(st, 1);
            const char *stt = (const char *)sqlite3_column_text(st, 2);
            const char *br  = (const char *)sqlite3_column_text(st, 3);
            buf_printf(&b, "<tr><td class=mono>"
                           "<a href='/%s/%s/ci/%ld'>%.10s</a></td>"
                           "<td class=muted>",
                       o, n, id, sha ? sha : "?");
            gt_esc(&b, stt);
            buf_puts(&b, "</td><td class=muted>");
            gt_esc(&b, br);
            buf_puts(&b, "</td></tr>");
        }
        sqlite3_finalize(st);
        buf_puts(&b, "</table>");
    }

    buf_puts(&b, "<h3>access control</h3><table>"
                 "<tr><th>user</th><th>role</th><th></th></tr>");
    sqlite3_prepare_v2(app->db,
        "SELECT u.name, a.role FROM repo_acl a"
        " JOIN users u ON u.id=a.user_id WHERE a.repo_id=?",
        -1, &st, NULL);
    sqlite3_bind_int(st, 1, repo_id);
    while (sqlite3_step(st) == SQLITE_ROW) {
        const char *un = (const char *)sqlite3_column_text(st, 0);
        const char *ro = (const char *)sqlite3_column_text(st, 1);
        buf_puts(&b, "<tr><td>");
        gt_esc(&b, un);
        buf_puts(&b, "</td><td class=muted>");
        gt_esc(&b, ro);
        buf_puts(&b, "</td><td><form method=post style='display:inline'>");
        gt_csrf_input(&b, r);
        buf_puts(&b, "<input type=hidden name=action value=acl_rm>"
                     "<input type=hidden name=user value='");
        gt_esc(&b, un);
        buf_puts(&b, "'><button>remove</button></form></td></tr>");
    }
    sqlite3_finalize(st);
    buf_puts(&b, "</table><form method=post>");
    gt_csrf_input(&b, r);
    buf_puts(&b, "<input type=hidden name=action value=acl_add>"
                 "<p><input name=user required placeholder='user'> "
                 "<select name=role>"
                 "<option value=r>read</option>"
                 "<option value=w>write</option>"
                 "<option value=a>admin</option>"
                 "</select> <button>grant</button></p></form>");

    buf_puts(&b, "<h3>branch protection</h3><table>"
                 "<tr><th>pattern</th><th>push requires</th>"
                 "<th>ff-only</th><th>no-delete</th><th></th></tr>");
    sqlite3_prepare_v2(app->db,
        "SELECT id,pattern,min_role,ff_only,no_delete"
        " FROM branch_rules WHERE repo_id=? ORDER BY pattern",
        -1, &st, NULL);
    sqlite3_bind_int(st, 1, repo_id);
    while (sqlite3_step(st) == SQLITE_ROW) {
        long rid = sqlite3_column_int64(st, 0);
        const char *pat = (const char *)sqlite3_column_text(st, 1);
        const char *mro = (const char *)sqlite3_column_text(st, 2);
        buf_printf(&b, "<tr><td class=mono>");
        gt_esc(&b, pat);
        buf_puts(&b, "</td><td class=muted>");
        buf_puts(&b, mro && mro[0] == 'a' ? "admin" : "write");
        buf_printf(&b, "</td><td class=muted>%s</td>"
                       "<td class=muted>%s</td>"
                       "<td><form method=post style='display:inline'>",
                   sqlite3_column_int(st, 3) ? "yes" : "-",
                   sqlite3_column_int(st, 4) ? "yes" : "-");
        gt_csrf_input(&b, r);
        buf_printf(&b, "<input type=hidden name=action value=rule_rm>"
                       "<input type=hidden name=id value=%ld>"
                       "<button>remove</button></form></td></tr>", rid);
    }
    sqlite3_finalize(st);
    buf_puts(&b, "</table><form method=post>");
    gt_csrf_input(&b, r);
    buf_puts(&b, "<input type=hidden name=action value=rule_add>"
                 "<p><input name=pattern required"
                 " placeholder='master or release/*'> "
                 "<select name=role>"
                 "<option value=w>write</option>"
                 "<option value=a>admin</option>"
                 "</select> "
                 "<label><input type=checkbox name=ff> ff-only</label> "
                 "<label><input type=checkbox name=del>"
                 " no-delete</label> "
                 "<button>protect</button></p>"
                 "<p class=muted>matching branches refuse pushes below"
                 " the required role; merge requests and patch sets"
                 " stay the enforced path. rules only tighten access —"
                 " the built-in default-branch floor always applies.</p>"
                 "</form>");

    buf_puts(&b, "<h3>webhooks</h3><table>"
                 "<tr><th>url</th><th>events</th><th></th></tr>");
    sqlite3_prepare_v2(app->db,
        "SELECT id,url,events FROM repo_webhooks"
        " WHERE repo_id=? AND active=1 ORDER BY id",
        -1, &st, NULL);
    sqlite3_bind_int(st, 1, repo_id);
    while (sqlite3_step(st) == SQLITE_ROW) {
        long hid = sqlite3_column_int64(st, 0);
        const char *u = (const char *)sqlite3_column_text(st, 1);
        const char *ev = (const char *)sqlite3_column_text(st, 2);
        buf_puts(&b, "<tr><td class=mono>");
        gt_esc(&b, u);
        buf_puts(&b, "</td><td class=muted>");
        gt_esc(&b, ev);
        buf_puts(&b, "</td><td><form method=post"
                     " style='display:inline'>");
        gt_csrf_input(&b, r);
        buf_printf(&b, "<input type=hidden name=action value=wh_del>"
                       "<input type=hidden name=id value=%ld>"
                       "<button>remove</button></form></td></tr>", hid);
    }
    sqlite3_finalize(st);
    buf_puts(&b, "</table><form method=post>");
    gt_csrf_input(&b, r);
    buf_puts(&b, "<input type=hidden name=action value=wh_add>"
                 "<p><input name=url required size=50"
                 " placeholder='http://host:port/path'> "
                 "<input name=secret size=20"
                 " placeholder='secret (optional)'></p>"
                 "<p><label><input type=checkbox name=ev_push checked>"
                 " push</label> "
                 "<label><input type=checkbox name=ev_mr>"
                 " merge request</label> "
                 "<label><input type=checkbox name=ev_patch>"
                 " patch set</label> "
                 "<button>add webhook</button></p>"
                 "<p class=muted>POSTs a signed JSON payload"
                 " (X-Git-Trace-Signature: sha256=&lt;hmac&gt;)."
                 " http:// is delivered by the built-in client;"
                 " https:// needs curl installed.</p></form>");

    /* recent deliveries — an ops log, not a debug console */
    sqlite3_prepare_v2(app->db,
        "SELECT d.event, d.status, d.code, d.attempts,"
        " datetime(d.created_at,'unixepoch')"
        " FROM webhook_deliveries d"
        " JOIN repo_webhooks h ON h.id=d.hook_id"
        " WHERE h.repo_id=? ORDER BY d.id DESC LIMIT 10",
        -1, &st, NULL);
    sqlite3_bind_int(st, 1, repo_id);
    int any = 0;
    while (sqlite3_step(st) == SQLITE_ROW) {
        if (!any) {
            buf_puts(&b, "<table><tr><th>event</th><th>status</th>"
                         "<th>http</th><th>tries</th><th>at</th></tr>");
            any = 1;
        }
        const char *ev  = (const char *)sqlite3_column_text(st, 0);
        const char *stt = (const char *)sqlite3_column_text(st, 1);
        const char *dt  = (const char *)sqlite3_column_text(st, 4);
        buf_printf(&b, "<tr><td class=muted>");
        gt_esc(&b, ev);
        buf_puts(&b, "</td><td>");
        gt_esc(&b, stt);
        buf_printf(&b, "</td><td class=muted>%d</td>"
                       "<td class=muted>%d</td><td class=muted>",
                   sqlite3_column_int(st, 2),
                   sqlite3_column_int(st, 3));
        gt_esc(&b, dt);
        buf_puts(&b, "</td></tr>");
    }
    sqlite3_finalize(st);
    if (any)
        buf_puts(&b, "</table>");

    /* federation: pull mirrors land in refs/mirror/, push mirrors
     * fan out to remote forges on every heads update */
    buf_puts(&b, "<h3>mirrors</h3><table>"
                 "<tr><th>dir</th><th>remote</th><th>refspec</th>"
                 "<th>last run</th><th>result</th><th></th></tr>");
    sqlite3_prepare_v2(app->db,
        "SELECT id,url,refspec,enabled,last_ok,last_refs,last_err,"
        " datetime(last_ts,'unixepoch'),direction"
        " FROM repo_mirrors WHERE repo_id=? ORDER BY id",
        -1, &st, NULL);
    sqlite3_bind_int(st, 1, repo_id);
    int mrows = 0;
    while (sqlite3_step(st) == SQLITE_ROW) {
        mrows++;
        sqlite3_int64 mid = sqlite3_column_int64(st, 0);
        const char *u  = (const char *)sqlite3_column_text(st, 1);
        const char *sp = (const char *)sqlite3_column_text(st, 2);
        int ok = sqlite3_column_int(st, 4);
        int nref = sqlite3_column_int(st, 5);
        const char *err = (const char *)sqlite3_column_text(st, 6);
        const char *dt = (const char *)sqlite3_column_text(st, 7);
        const char *dir = (const char *)sqlite3_column_text(st, 8);
        char murl[512];
        mirror_url_mask(u ? u : "", murl, sizeof murl);
        buf_printf(&b, "<tr><td class=muted>%s</td><td class=mono>",
                   dir && *dir ? dir : "pull");
        gt_esc(&b, murl);
        buf_puts(&b, "</td><td class=mono muted>");
        gt_esc(&b, sp);
        buf_puts(&b, "</td><td class=muted>");
        gt_esc(&b, dt ? dt : "never");
        buf_puts(&b, "</td><td class=muted>");
        if (dt)
            buf_printf(&b, "%s · %d refs", ok ? "ok" : "FAILED", nref);
        buf_puts(&b, "</td><td><form method=post"
                     " style='display:inline'>");
        gt_csrf_input(&b, r);
        buf_printf(&b, "<input type=hidden name=action value=mir_del>"
                       "<input type=hidden name=id value=%ld>"
                       "<button>remove</button></form></td></tr>",
                   (long)mid);
        if (err && *err && dt && !ok)
            buf_printf(&b, "<tr><td></td><td colspan=3"
                           " class=muted style='font-size:.85em'>%.300s"
                           "</td></tr>", err);
    }
    sqlite3_finalize(st);
    if (!mrows)
        buf_puts(&b, "<tr><td></td><td class=muted>none configured</td>"
                     "<td></td><td></td><td></td><td></td></tr>");
    buf_puts(&b, "</table><form method=post>");
    gt_csrf_input(&b, r);
    buf_printf(&b,
        "<input type=hidden name=action value=mir_add>"
        "<p><select name=dir>"
        "<option value=pull>pull (follow upstream)</option>"
        "<option value=push>push (publish downstream)</option>"
        "</select> <input name=url required size=45"
        " placeholder='http://host/alice/repo'> "
        "<input name=secret size=16"
        " placeholder='trigger secret (pull)'></p>"
        "<p><input name=refspec size=50"
        " placeholder='default for the direction'> "
        "<button>add mirror</button></p>"
        "<p class=muted><b>pull</b>: fetched refs land read-only under"
        " refs/mirror/; an upstream git-trace drives sync via"
        " <b class=mono>POST /%s/%s/mirror-sync</b> signed with the"
        " trigger secret. <b>push</b>: every local heads update is"
        " pushed to the remote (embed credentials in the url:"
        " https://user:token@host/r.git); A⇄B cycles converge since"
        " an up-to-date push runs no remote hooks."
        " url schemes: http, https, git, ssh (file:// refused)."
        "</p></form><form method=post>",
        o, n);
    gt_csrf_input(&b, r);
    buf_puts(&b, "<p><button name=action value=mir_sync>"
                 "pull now</button> "
                 "<button name=action value=mir_push>"
                 "push now</button></p></form>");

    buf_puts(&b, "<h3>danger zone</h3><form method=post>");
    gt_csrf_input(&b, r);
    buf_puts(&b, "<input type=hidden name=action value=delete>"
                 "<p>delete this repository — type <b class=mono>");
    gt_esc(&b, n);
    buf_puts(&b, "</b> to confirm: <input name=confirm "
                 "autocomplete=off> <button>delete</button></p>"
                 "<p class=muted>removes the repository from disk and"
                 " its issues, merge requests, patch sets and ACLs."
                 " there is no undo.</p></form>");
    gt_repo_body_end(&b);
    gt_page_bottom(&b);
    gt_send_page(r, &b);
    buf_free(&b);
}

/* ---------------- unified changes view (MRs + patch sets) ---------------- */

void collab_changes(struct app_ctx *app, struct request *r,
                    const char *o, const char *n, int rk)
{
    int repo_id = gt_repo_rowid(app->db, o, n);
    struct buf b;
    buf_init(&b);
    gt_page_top(&b, n, r);
    gt_repo_tabs(&b, app, o, n, rk, "changes");
    buf_printf(&b, "<p><a class=btn2 href='/%s/%s/mr/new'>" I_PLUS
                   "new merge request</a> "
                   "<a class=btn2 href='/%s/%s/patches/new'>" I_PLUS
                   "submit patches</a></p>", o, n, o, n);
    /* rows buffered first, so an empty result renders a proper empty
     * state instead of a header-only table */
    struct buf rows;
    buf_init(&rows);
    int nrow = 0;
    sqlite3_stmt *st;
    if (repo_id > 0 &&
        sqlite3_prepare_v2(app->db,
            "SELECT kind,id,title,state,uname,"
            " strftime('%Y-%m-%d %H:%M',upd,'unixepoch') FROM ("
            " SELECT 'mr' AS kind, m.id, m.title, m.state,"
            "  COALESCE(u.name,'(deleted)') AS uname,"
            "  m.updated_at AS upd"
            " FROM merge_requests m LEFT JOIN users u ON u.id=m.author_id"
            " WHERE m.repo_id=?"
            " UNION ALL"
            " SELECT 'patch', p.id, p.title, p.state,"
            "  COALESCE(u2.name,'(deleted)'), p.updated_at"
            " FROM patch_sets p LEFT JOIN users u2 ON u2.id=p.author_id"
            " WHERE p.repo_id=?)"
            " ORDER BY upd DESC LIMIT 100",
            -1, &st, NULL) == SQLITE_OK) {
        sqlite3_bind_int(st, 1, repo_id);
        sqlite3_bind_int(st, 2, repo_id);
        while (sqlite3_step(st) == SQLITE_ROW) {
            const char *kind = (const char *)sqlite3_column_text(st, 0);
            long id = sqlite3_column_int64(st, 1);
            buf_puts(&rows, "<tr><td><span class=badge>");
            gt_esc(&rows, kind);
            buf_puts(&rows, "</span></td><td><a href='/");
            buf_printf(&rows, "%s/%s/%s/%ld'>", o, n,
                       kind && kind[0] == 'm' ? "mr" : "patches", id);
            gt_esc(&rows, (const char *)sqlite3_column_text(st, 2));
            buf_puts(&rows, "</a></td><td>");
            gt_esc(&rows, (const char *)sqlite3_column_text(st, 3));
            buf_puts(&rows, "</td><td>");
            gt_esc(&rows, (const char *)sqlite3_column_text(st, 4));
            buf_puts(&rows, "</td><td class=muted>");
            gt_esc(&rows, (const char *)sqlite3_column_text(st, 5));
            buf_puts(&rows, "</td></tr>");
            nrow++;
        }
        sqlite3_finalize(st);
    }
    if (nrow) {
        buf_puts(&b, "<table><tr><th></th><th>title</th><th>state</th>"
                    "<th>author</th><th>updated</th></tr>");
        buf_put(&b, rows.data, rows.len);
        buf_puts(&b, "</table>");
    } else {
        buf_puts(&b, "<div class=empty>"
                    "<svg width=34 height=34 viewBox='0 0 16 16' "
                    "fill=currentColor><circle cx=4.5 cy=4 r=1.9/>"
                    "<circle cx=4.5 cy=12 r=1.9/><circle cx=12 cy=8 r=1.9/>"
                    "<path d='M4.5 5.9v4.2M6 4.9c2.6 1 4 1.8 4 3.1' "
                    "stroke=currentColor stroke-width=1.3 fill=none/></svg>"
                    "no merge requests or patch sets yet</div>");
    }
    buf_free(&rows);
    gt_repo_body_end(&b);
    gt_page_bottom(&b);
    gt_send_page(r, &b);
    buf_free(&b);
}

void collab_activity(struct app_ctx *app, struct request *r,
                     const char *o, const char *n, int rk)
{
    int repo_id = gt_repo_rowid(app->db, o, n);
    struct buf b;
    buf_init(&b);
    gt_page_top(&b, n, r);
    gt_repo_tabs(&b, app, o, n, rk, "activity");
    buf_puts(&b, "<h3>activity</h3><table><tr><th>time</th><th>actor</th>"
                "<th>action</th><th>detail</th></tr>");
    sqlite3_stmt *st;
    if (repo_id > 0 &&
        sqlite3_prepare_v2(app->db,
            "SELECT strftime('%Y-%m-%d %H:%M', ts, 'unixepoch'),"
            " COALESCE(actor,'(anon)'), action, detail"
            " FROM audit_log WHERE repo_id=? ORDER BY id DESC LIMIT 100",
            -1, &st, NULL) == SQLITE_OK) {
        sqlite3_bind_int(st, 1, repo_id);
        while (sqlite3_step(st) == SQLITE_ROW) {
            buf_puts(&b, "<tr><td class=muted>");
            gt_esc(&b, (const char *)sqlite3_column_text(st, 0));
            buf_puts(&b, "</td><td>");
            gt_esc(&b, (const char *)sqlite3_column_text(st, 1));
            buf_puts(&b, "</td><td>");
            gt_esc(&b, (const char *)sqlite3_column_text(st, 2));
            buf_puts(&b, "</td><td class=muted>");
            gt_esc(&b, (const char *)sqlite3_column_text(st, 3));
            buf_puts(&b, "</td></tr>");
        }
        sqlite3_finalize(st);
    }
    buf_puts(&b, "</table>");
    gt_repo_body_end(&b);
    gt_page_bottom(&b);
    gt_send_page(r, &b);
    buf_free(&b);
}