git-trace

romele / git-trace public

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

src/patchset.c

#include "patchset.h"

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

#include <ctype.h>
#include <fcntl.h>
#include <limits.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>

/* ============ mbox parsing (git format-patch output) ============
 * A series is split at "^From <40-hex-sha> " lines.  Each chunk is a
 * mail: headers (From:, Date:, Subject:), a blank line, the message
 * body up to "---", then the diff. */

struct patch {
    const char *chunk;
    size_t      clen;
    char        author_name[128];
    char        author_mail[128];
    char        date[64];
    char        subject[300];
    const char *body;             /* message body, before "---" */
    size_t      blen;
    const char *diff;             /* first "diff --git" line onward */
    size_t      dlen;
    int         version;          /* [PATCH vN ...] tag; 1 when absent */
};

static int is_from_line(const char *l, size_t n)
{
    if (n < 47 || memcmp(l, "From ", 5) != 0)
        return 0;
    for (int i = 0; i < 40; i++)
        if (!isxdigit((unsigned char)l[5 + i]))
            return 0;
    return l[45] == ' ';
}

/* find next line start, or end */
static const char *next_line(const char *p, const char *end)
{
    const char *nl = memchr(p, '\n', end - p);
    return nl ? nl + 1 : end;
}

static void hdr_value(const char *chunk, size_t clen, const char *name,
                      char *out, size_t outlen)
{
    out[0] = '\0';
    size_t nl_len = strlen(name);
    const char *end = chunk + clen;
    for (const char *l = chunk; l < end; l = next_line(l, end)) {
        size_t ln = (size_t)(next_line(l, end) - l);
        if (ln <= 1)
            break;                        /* end of headers */
        if (ln >= nl_len + 1 &&
            strncasecmp(l, name, nl_len) == 0 && l[nl_len] == ':') {
            const char *v = l + nl_len + 1;
            while (v < end && (*v == ' ' || *v == '\t'))
                v++;
            const char *ve = l + ln;
            while (ve > v && (ve[-1] == '\n' || ve[-1] == '\r'))
                ve--;
            size_t n = (size_t)(ve - v);
            if (n >= outlen)
                n = outlen - 1;
            memcpy(out, v, n);
            out[n] = '\0';
            /* folded continuation lines: append " rest" roughly */
            for (const char *c = next_line(l, end); c < end &&
                 (*c == ' ' || *c == '\t'); c = next_line(c, end)) {
                size_t cn = (size_t)(next_line(c, end) - c);
                const char *cv = c;
                while (cv < c + cn && (*cv == ' ' || *cv == '\t'))
                    cv++;
                size_t cur = strlen(out);
                if (cur + 1 < outlen) {
                    out[cur++] = ' ';
                    size_t add = cn - (size_t)(cv - c);
                    while (add > 0 && (cv[add-1] == '\n' || cv[add-1] == '\r'))
                        add--;
                    if (cur + add >= outlen)
                        add = outlen - 1 - cur;
                    memcpy(out + cur, cv, add);
                    out[cur + add] = '\0';
                }
            }
            return;
        }
    }
}

static void split_author(const char *v, char *name, size_t nl,
                         char *mail, size_t ml)
{
    name[0] = mail[0] = '\0';
    const char *lt = strchr(v, '<');
    const char *gt = lt ? strchr(lt, '>') : NULL;
    if (lt && gt) {
        size_t n = (size_t)(lt - v);
        while (n && v[n - 1] == ' ')
            n--;
        if (n >= nl) n = nl - 1;
        memcpy(name, v, n);
        name[n] = '\0';
        size_t m = (size_t)(gt - lt - 1);
        if (m >= ml) m = ml - 1;
        memcpy(mail, lt + 1, m);
        mail[m] = '\0';
    } else {
        size_t x = strlen(v);
        if (x >= nl) x = nl - 1;
        memcpy(name, v, x);
        name[x] = '\0';
    }
}

/* "[PATCH vN n/m] foo" / "[PATCH n/m] foo" -> "foo"; returns vN (1
 * when the tag carries no version). */
static int strip_patch_tag(char *s)
{
    int ver = 1;
    char *b = strchr(s, '[');
    char *e = b ? strchr(b, ']') : NULL;
    if (b && e && e - b >= 6 && memcmp(b, "[PATCH", 6) == 0) {
        const char *t = b + 6;
        while (t < e && *t == ' ')
            t++;
        if (t < e && *t == 'v') {
            long v = strtol(t + 1, NULL, 10);
            if (v > 0 && v < 1000)
                ver = (int)v;
        }
        char *p = e + 1;
        while (*p == ' ')
            p++;
        memmove(s, p, strlen(p) + 1);
    }
    return ver;
}

/* parse one mail chunk; returns 0 ok */
static int parse_patch(const char *chunk, size_t clen, struct patch *p)
{
    const char *end = chunk + clen;
    p->chunk = chunk;
    p->clen = clen;
    p->body = p->diff = NULL;
    p->blen = p->dlen = 0;

    char a[256];
    hdr_value(chunk, clen, "From", a, sizeof a);
    split_author(a, p->author_name, sizeof p->author_name,
                 p->author_mail, sizeof p->author_mail);
    hdr_value(chunk, clen, "Subject", p->subject, sizeof p->subject);
    hdr_value(chunk, clen, "Date", p->date, sizeof p->date);
    p->version = strip_patch_tag(p->subject);

    /* body: after first blank line, until "---" or first "diff --git" */
    const char *l = chunk;
    while (l < end) {
        size_t ln = (size_t)(next_line(l, end) - l);
        if (ln <= 2 && (l[0] == '\n' || l[0] == '\r')) {
            l = next_line(l, end);
            break;
        }
        l = next_line(l, end);
    }
    p->body = l;
    const char *b = l;
    int seen_sep = 0;
    while (b < end) {
        const char *nl = next_line(b, end);
        size_t ln = (size_t)(nl - b);
        if (!seen_sep && ln >= 4 && ln <= 5 &&
            b[0] == '-' && b[1] == '-' && b[2] == '-' &&
            (b[3] == '\n' || b[3] == '\r')) {
            seen_sep = 1;               /* "---" mail separator */
            p->blen = (size_t)(b - p->body);
        } else if (ln >= 11 && memcmp(b, "diff --git ", 11) == 0) {
            p->diff = b;
            p->dlen = (size_t)(end - b);
            if (!seen_sep)
                p->blen = (size_t)(b - p->body);
            break;
        }
        b = nl;
    }
    if (!seen_sep && !p->diff)
        p->blen = (size_t)(end - p->body);
    return 0;
}

static int is_cover(const struct patch *p)
{
    return p->diff == NULL;
}

static struct patch *mbox_parse(const char *mbox, size_t len, int *outn)
{
    /* count patches: number of "From <sha>" lines */
    int np = 0;
    const char *end = mbox + len;
    for (const char *l = mbox; l < end; l = next_line(l, end)) {
        size_t ln = (size_t)(next_line(l, end) - l);
        if (is_from_line(l, ln))
            np++;
    }
    if (np == 0)
        return NULL;
    struct patch *ps = xmalloc(sizeof *ps * (size_t)np);
    int i = 0;
    const char *start = NULL;
    for (const char *l = mbox; l < end; l = next_line(l, end)) {
        size_t ln = (size_t)(next_line(l, end) - l);
        if (is_from_line(l, ln)) {
            if (start)
                parse_patch(start, (size_t)(l - start), &ps[i++]);
            start = l;
        }
    }
    if (start)
        parse_patch(start, (size_t)(end - start), &ps[i++]);
    *outn = i;
    return ps;
}

/* ============ server-side apply (no work tree) ============
 * A scratch index is seeded from the base commit; each patch diff is
 * `git apply --cached` into it, then committed with the original author
 * identity.  The destination ref moves via compare-and-swap. */

static int mk_temp(char *path, size_t n, const char *tag)
{
    snprintf(path, n, "/tmp/gt-%s-%ld-XXXXXX", tag, (long)getpid());
    int fd = mkstemp(path);
    return fd;
}

/* run git with GIT_INDEX_FILE=idx (+ author env for commit-tree) */
static int git_idx(const char *repo, const char *idx,
                   const char *const extra_env[],
                   const char *const argv[], struct buf *out)
{
    char idxenv[600];
    snprintf(idxenv, sizeof idxenv, "GIT_INDEX_FILE=%s", idx);
    const char *envs[16];
    int i = 0;
    envs[i++] = idxenv;
    if (extra_env)
        for (int k = 0; extra_env[k] && i < 15; k++)
            envs[i++] = extra_env[k];
    envs[i] = NULL;
    return git_capture_env(repo, envs, argv, out);
}

/* returns 0 applied, 1 patch failed, -1 error.
 * target == NULL updates the base ref itself; otherwise the series
 * lands on refs/heads/<target> created fresh from the base tip
 * (CAS on the null old value — refuses to overwrite).
 * tip receives the final commit sha (if applied). */
static int apply_series(const char *repo, const char *base_ref,
                        const char *target,
                        struct patch *ps, int np, int check_only,
                        const char *committer,
                        char tip[41], struct buf *err)
{
    char idx[256], pfile[256];
    int ifd = mk_temp(idx, sizeof idx, "idx");
    if (ifd < 0) {
        buf_puts(err, "cannot create temp index");
        return -1;
    }
    close(ifd);
    unlink(idx);                       /* git creates it on first write */

    struct buf out;
    buf_init(&out);
    const char *rt[] = { "git", "read-tree", base_ref, NULL };
    if (git_idx(repo, idx, NULL, rt, &out) != 0) {
        buf_puts(err, "read-tree failed on base");
        goto fail;
    }

    char base[41];
    {
        char ref[300];
        snprintf(ref, sizeof ref, "refs/heads/%s", base_ref);
        struct buf rb;
        buf_init(&rb);
        const char *rv[] = { "git", "rev-parse", "--verify", "-q", ref,
                             NULL };
        if (git_capture(repo, rv, &rb) != 0 || rb.len < 40) {
            buf_free(&rb);
            buf_puts(err, "unknown base branch");
            goto fail;
        }
        memcpy(base, rb.data, 40);
        base[40] = '\0';
        buf_free(&rb);
    }

    int pfd = mk_temp(pfile, sizeof pfile, "patch");
    if (pfd < 0) {
        buf_puts(err, "cannot create temp patch file");
        goto fail;
    }

    char cur[41];
    memcpy(cur, base, 41);
    int applied = 0;
    for (int i = 0; i < np; i++) {
        if (is_cover(&ps[i]))
            continue;
        /* rewrite the temp patch file with this diff */
        if (ftruncate(pfd, 0) != 0 || lseek(pfd, 0, SEEK_SET) < 0)
            goto io_fail;
        size_t left = ps[i].dlen;
        const char *dp = ps[i].diff;
        while (left) {
            ssize_t w = write(pfd, dp, left);
            if (w <= 0)
                goto io_fail;
            dp += w;
            left -= (size_t)w;
        }
        fsync(pfd);

        out.len = 0;
        if (out.data)
            out.data[0] = '\0';
        const char *ap[] = { "git", "apply", "--cached",
                             "--whitespace=nowarn", "-v", pfile, NULL };
        if (git_idx(repo, idx, NULL, ap, &out) != 0) {
            buf_printf(err, "patch %d (%s) does not apply cleanly:\n",
                       i + 1, ps[i].subject);
            if (out.data)
                buf_put(err, out.data, out.len);
            goto patch_fail;
        }
        applied++;

        if (check_only)
            continue;

        out.len = 0;
        const char *wt[] = { "git", "write-tree", NULL };
        if (git_idx(repo, idx, NULL, wt, &out) != 0 || out.len < 40)
            goto patch_fail;
        char tree[41];
        memcpy(tree, out.data, 40);
        tree[40] = '\0';

        char an[160], am[160], ad[80], cn[160], ce[160];
        snprintf(an, sizeof an, "GIT_AUTHOR_NAME=%s",
                 ps[i].author_name[0] ? ps[i].author_name : "unknown");
        snprintf(am, sizeof am, "GIT_AUTHOR_EMAIL=%s",
                 ps[i].author_mail[0] ? ps[i].author_mail : "unknown");
        snprintf(ad, sizeof ad, "GIT_AUTHOR_DATE=%s", ps[i].date);
        snprintf(cn, sizeof cn, "GIT_COMMITTER_NAME=%s", committer);
        snprintf(ce, sizeof ce, "GIT_COMMITTER_EMAIL=%s@git-trace",
                 committer);
        const char *cenv[] = { an, am, ad, cn, ce, NULL };

        /* message: subject + body up to "---" */
        char msg[8192];
        int ml = snprintf(msg, sizeof msg, "%s", ps[i].subject);
        if (ps[i].blen > 0)
            ml += snprintf(msg + ml, sizeof msg - (size_t)ml, "\n\n%.*s",
                           (int)ps[i].blen, ps[i].body);

        out.len = 0;
        const char *ct[] = { "git", "commit-tree", tree,
                             "-p", cur, "-m", msg, NULL };
        if (git_idx(repo, idx, cenv, ct, &out) != 0 || out.len < 40)
            goto patch_fail;
        memcpy(cur, out.data, 40);
        cur[40] = '\0';
    }
    close(pfd);
    unlink(pfile);
    unlink(idx);

    if (!applied) {
        buf_puts(err, "series contains no patches");
        buf_free(&out);
        return -1;
    }
    if (check_only) {
        buf_free(&out);
        return 0;
    }

    /* compare-and-swap the destination ref */
    char ref[300];
    const char *zero = "0000000000000000000000000000000000000000";
    if (target)
        snprintf(ref, sizeof ref, "refs/heads/%s", target);
    else
        snprintf(ref, sizeof ref, "refs/heads/%s", base_ref);
    out.len = 0;
    const char *ur[] = { "git", "update-ref", ref, cur,
                         target ? zero : base, NULL };
    if (git_capture(repo, ur, &out) != 0) {
        buf_printf(err, "update-ref failed — %s",
                   target ? "target branch exists"
                          : "base moved meanwhile");
        buf_free(&out);
        return -1;
    }
    memcpy(tip, cur, 41);
    buf_free(&out);
    return 0;

io_fail:
    close(pfd);
    unlink(pfile);
    unlink(idx);
    buf_free(&out);
    return -1;
patch_fail:
    close(pfd);
    unlink(pfile);
fail:
    unlink(idx);
    buf_free(&out);
    return 1;
}

/* ============ pages ============ */

static void ps_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/patches/new'>" I_PLUS
                       "submit patch set"
                       "</a></p><p class=muted>or from your tree: "
                       "<code>git format-patch --stdout &lt;base&gt;.. | "
                       "curl -u user:token --data-binary @- "
                       "/%s/%s/patches/new</code></p>", o, n, o, n);
    sqlite3_stmt *st;
    if (sqlite3_prepare_v2(app->db,
            "SELECT p.id,p.title,p.state,p.dst_branch,"
            " COALESCE(u.name,'(deleted)'),"
            " strftime('%Y-%m-%d', p.created_at, 'unixepoch'),"
            " (SELECT MAX(version) FROM patch_revisions v"
            "  WHERE v.patch_set_id=p.id)"
            " FROM patch_sets p LEFT JOIN users u ON u.id=p.author_id"
            " WHERE p.repo_id=?"
            " ORDER BY (p.state='open') DESC, p.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>base</th>"
                "<th>state</th><th>author</th><th>date</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/patches/%lld'>",
                   sqlite3_column_int64(st, 0), o, n,
                   sqlite3_column_int64(st, 0));
        gt_esc(&b, (const char *)sqlite3_column_text(st, 1));
        if (sqlite3_column_int(st, 6) > 1)
            buf_printf(&b, " <span class=muted>v%d</span>",
                       sqlite3_column_int(st, 6));
        buf_puts(&b, "</a></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, 2));
        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=6>no patch sets</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 ps_new_form(struct app_ctx *app, struct request *r,
                        const char *o, const char *n,
                        int rk, const char *defbase)
{
    char setv[16] = "";
    req_query(r, "set", setv, sizeof setv);
    long setid = setv[0] ? strtol(setv, NULL, 10) : 0;

    struct buf b;
    buf_init(&b);
    gt_page_top(&b, n, r);
    gt_repo_tabs(&b, app, o, n, rk, "changes");
    if (setid > 0) {
        buf_printf(&b, "<h3>new version of patch set #%ld</h3>"
            "<form method=post action='/%s/%s/patches/new'>",
            setid, o, n);
        gt_csrf_input(&b, r);
        buf_printf(&b, "<input type=hidden name=set value=%ld>"
            "<p class=muted>title, cover letter and base follow the"
            " series being revised.</p>"
            "<p><textarea name=mbox rows=20 cols=90 required "
            "placeholder='git format-patch --stdout -v2 output'>"
            "</textarea></p>"
            "<p><button type=submit>submit new version</button></p>"
            "</form>", setid);
    } else {
        buf_printf(&b, "<h3>submit patch set</h3>"
            "<form method=post action='/%s/%s/patches/new'>", o, n);
        gt_csrf_input(&b, r);
        buf_printf(&b, "<p><input name=title maxlength=200 size=60 "
                       "placeholder='title (optional: taken from cover "
                       "letter)'></p>"
                       "<p>base branch <input name=base value='%s'></p>"
                       "<p><textarea name=mbox rows=20 cols=90 required "
                       "placeholder='git format-patch --stdout output'>"
                       "</textarea></p>"
                       "<p><button type=submit>submit patch set"
                       "</button></p>"
                       "</form>", defbase);
    }
    gt_repo_body_end(&b);
    gt_page_bottom(&b);
    gt_send_page(r, &b);
    buf_free(&b);
}

static int ps_insert(struct app_ctx *app, int repo_id, int uid,
                     const char *title, const char *cover,
                     const char *base, const char *mbox, size_t mlen)
{
    sqlite3_stmt *st;
    if (sqlite3_prepare_v2(app->db,
            "INSERT INTO patch_sets(repo_id,author_id,title,cover_letter,"
            " dst_branch) VALUES(?,?,?,?,?)", -1, &st, NULL) != SQLITE_OK)
        return -1;
    sqlite3_bind_int(st, 1, repo_id);
    sqlite3_bind_int(st, 2, uid);
    sqlite3_bind_text(st, 3, title, -1, SQLITE_TRANSIENT);
    sqlite3_bind_text(st, 4, cover ? cover : "", -1, SQLITE_TRANSIENT);
    sqlite3_bind_text(st, 5, base, -1, SQLITE_TRANSIENT);
    if (sqlite3_step(st) != SQLITE_DONE) {
        sqlite3_finalize(st);
        return -1;
    }
    long id = sqlite3_last_insert_rowid(app->db);
    sqlite3_finalize(st);
    if (sqlite3_prepare_v2(app->db,
            "INSERT INTO patch_revisions(patch_set_id,version,mbox)"
            " VALUES(?,1,?)", -1, &st, NULL) != SQLITE_OK)
        return -1;
    sqlite3_bind_int64(st, 1, id);
    sqlite3_bind_blob(st, 2, mbox, (int)mlen, SQLITE_TRANSIENT);
    sqlite3_step(st);
    sqlite3_finalize(st);
    return (int)id;
}

/* attach a new revision to an existing patch set; the set's title,
 * cover letter and updated_at follow the latest series.
 * returns the new version number, or -1. */
static int ps_add_revision(struct app_ctx *app, long set_id,
                           const char *title, const char *cover,
                           const char *mbox, size_t mlen)
{
    sqlite3_stmt *st;
    if (sqlite3_prepare_v2(app->db,
            "SELECT COALESCE(MAX(version),0) FROM patch_revisions"
            " WHERE patch_set_id=?", -1, &st, NULL) != SQLITE_OK)
        return -1;
    sqlite3_bind_int64(st, 1, set_id);
    int ver = 1;
    if (sqlite3_step(st) == SQLITE_ROW)
        ver = sqlite3_column_int(st, 0) + 1;
    sqlite3_finalize(st);
    if (sqlite3_prepare_v2(app->db,
            "INSERT INTO patch_revisions(patch_set_id,version,mbox)"
            " VALUES(?,?,?)", -1, &st, NULL) != SQLITE_OK)
        return -1;
    sqlite3_bind_int64(st, 1, set_id);
    sqlite3_bind_int(st, 2, ver);
    sqlite3_bind_blob(st, 3, mbox, (int)mlen, SQLITE_TRANSIENT);
    int ok = sqlite3_step(st) == SQLITE_DONE;
    sqlite3_finalize(st);
    if (!ok)
        return -1;
    sqlite3_prepare_v2(app->db,
        "UPDATE patch_sets SET title=?, cover_letter=?,"
        " updated_at=strftime('%s','now') WHERE id=?",
        -1, &st, NULL);
    sqlite3_bind_text(st, 1, title, -1, SQLITE_TRANSIENT);
    sqlite3_bind_text(st, 2, cover ? cover : "", -1, SQLITE_TRANSIENT);
    sqlite3_bind_int64(st, 3, set_id);
    sqlite3_step(st);
    sqlite3_finalize(st);
    return ver;
}

/* result of a patch-set submission (new set or new revision) */
struct ps_out {
    long set_id;
    int  ver;
    int  is_new;
    int  status;          /* 0 ok, else an HTTP-style status */
    char err[160];
};

static int ps_fail(struct ps_out *out, int status, const char *msg)
{
    out->status = status;
    snprintf(out->err, sizeof out->err, "%s", msg);
    return -1;
}

/*
 * Shared submission core: web form, raw-mbox API and mailrecv all
 * funnel here.  The caller resolves uid/rk; actor/ip feed the audit
 * journal (ip may be NULL).  mbox must stay alive for the call.
 * Returns 0 on success (out->set_id/ver/is_new), -1 with out->status.
 */
/* fire a 'patch' webhook event; owner/name are resolved from the DB
 * so the same helper serves web submit and mailrecv alike */
static void ps_notify(struct app_ctx *app, long repo_id, long set_id,
                      int ver, const char *title, const char *actor)
{
    char on[200] = "?/?";
    sqlite3_stmt *st;
    if (sqlite3_prepare_v2(app->db,
            "SELECT COALESCE(u.name,'_'), r.name FROM repos r"
            " LEFT JOIN users u ON u.id=r.owner_id WHERE r.id=?",
            -1, &st, NULL) == SQLITE_OK) {
        sqlite3_bind_int64(st, 1, repo_id);
        if (sqlite3_step(st) == SQLITE_ROW) {
            const char *o = (const char *)sqlite3_column_text(st, 0);
            const char *nm = (const char *)sqlite3_column_text(st, 1);
            snprintf(on, sizeof on, "%s/%s",
                     o ? o : "?", nm ? nm : "?");
        }
        sqlite3_finalize(st);
    }
    struct buf p;
    buf_init(&p);
    buf_puts(&p, "{\"event\":\"patch\",\"repo\":\"");
    json_put_esc(&p, on, strlen(on));
    buf_printf(&p, "\",\"ps\":%ld,\"version\":%d,\"title\":\"",
               set_id, ver);
    json_put_esc(&p, title, strlen(title));
    buf_puts(&p, "\",\"actor\":\"");
    if (actor)
        json_put_esc(&p, actor, strlen(actor));
    buf_puts(&p, "\"}");
    wh_notify(app, repo_id, "patch", p.data ? p.data : "{}");
    buf_free(&p);
}

static int gt_ps_submit(struct app_ctx *app, const char *repo_dir,
                 int repo_id, int uid, int rk,
                 const char *actor, const char *ip,
                 const char *title_in, const char *base_in, long set_req,
                 const char *mbox, size_t mlen, struct ps_out *out)
{
    memset(out, 0, sizeof *out);

    int np = 0;
    struct patch *ps = mbox_parse(mbox, mlen, &np);
    if (!ps || np == 0) {
        free(ps);
        return ps_fail(out, 400, "not a format-patch mbox");
    }
    int real = 0, first_ver = 1;
    const char *first_subj = NULL, *cover_subj = NULL;
    char cover[8192] = "";
    for (int i = 0; i < np; i++) {
        if (is_cover(&ps[i])) {
            if (!cover_subj)
                cover_subj = ps[i].subject;
            snprintf(cover, sizeof cover, "%.*s",
                     (int)ps[i].blen > 8000 ? 8000 : (int)ps[i].blen,
                     ps[i].body ? ps[i].body : "");
        } else {
            real++;
            if (!first_subj) {
                first_subj = ps[i].subject;
                first_ver = ps[i].version;
            }
        }
    }
    if (!real) {
        free(ps);
        return ps_fail(out, 400, "series contains no patches");
    }
    char title[210], base[256];
    /* the cover subject is the series title when one is present */
    snprintf(title, sizeof title, "%s",
             title_in && title_in[0] ? title_in :
             cover_subj ? cover_subj :
             first_subj ? first_subj : "patch set");
    snprintf(base, sizeof base, "%s", base_in ? base_in : "");

    char defbase[64] = "";
    if (!base[0]) {
        char *d = git_default_branch(repo_dir);
        if (d) {
            snprintf(defbase, sizeof defbase, "%s", d);
            free(d);
        }
        snprintf(base, sizeof base, "%s", defbase[0] ? defbase : "master");
    }
    if (!git_ref_ok(base)) {
        free(ps);
        return ps_fail(out, 400, "bad base branch");
    }
    {
        char ref[300];
        snprintf(ref, sizeof ref, "refs/heads/%s", base);
        struct buf rb;
        buf_init(&rb);
        const char *rv[] = { "git", "rev-parse", "--verify", "-q", ref,
                             NULL };
        int rc = git_capture(repo_dir, rv, &rb);
        int ok = (rc == 0 && rb.len >= 40);
        buf_free(&rb);
        if (!ok) {
            free(ps);
            return ps_fail(out, 400, "unknown base branch");
        }
    }

    /* revision attach: explicit set wins; a [PATCH vN>=2] tag
     * auto-attaches when exactly one open set by the same author
     * carries the same title — `format-patch -v2` just works. */
    long set_id = set_req;
    if (set_id <= 0 && first_ver >= 2) {
        sqlite3_stmt *st;
        if (sqlite3_prepare_v2(app->db,
                "SELECT id FROM patch_sets WHERE repo_id=?"
                " AND author_id=? AND title=? AND state='open'",
                -1, &st, NULL) == SQLITE_OK) {
            sqlite3_bind_int(st, 1, repo_id);
            sqlite3_bind_int(st, 2, uid);
            sqlite3_bind_text(st, 3, title, -1, SQLITE_TRANSIENT);
            long cand = 0;
            int cnt = 0;
            while (sqlite3_step(st) == SQLITE_ROW) {
                cand = sqlite3_column_int64(st, 0);
                cnt++;
            }
            if (cnt == 1)
                set_id = cand;
            sqlite3_finalize(st);
        }
    }
    if (set_id > 0) {
        sqlite3_stmt *st;
        sqlite3_prepare_v2(app->db,
            "SELECT author_id,state FROM patch_sets"
            " WHERE id=? AND repo_id=?", -1, &st, NULL);
        sqlite3_bind_int64(st, 1, set_id);
        sqlite3_bind_int(st, 2, repo_id);
        int author = -1;
        char sstate[16] = "";
        if (sqlite3_step(st) == SQLITE_ROW) {
            author = sqlite3_column_int(st, 0);
            snprintf(sstate, sizeof sstate, "%s",
                     (const char *)sqlite3_column_text(st, 1));
        }
        sqlite3_finalize(st);
        if (author < 0) {
            free(ps);
            return ps_fail(out, 404, "no such patch set");
        }
        if (strcmp(sstate, "open") != 0) {
            free(ps);
            return ps_fail(out, 409, "patch set not open");
        }
        if (uid != author && rk < 3) {
            free(ps);
            return ps_fail(out, 403,
                           "only the author can add revisions");
        }
        int ver = ps_add_revision(app, set_id, title, cover, mbox, mlen);
        free(ps);
        if (ver < 0)
            return ps_fail(out, 500, "db error");
        db_audit(app->db, uid, actor, ip, repo_id, "pset_rev",
                 "#%ld v%d", set_id, ver);
        ps_notify(app, repo_id, set_id, ver, title, actor);
        out->set_id = set_id;
        out->ver = ver;
        return 0;
    }

    int id = ps_insert(app, repo_id, uid, title, cover, base,
                       mbox, mlen);
    free(ps);
    if (id < 0)
        return ps_fail(out, 500, "db error");
    db_audit(app->db, uid, actor, ip, repo_id, "pset_create",
             "#%d %.80s", id, title);
    ps_notify(app, repo_id, id, 1, title, actor);
    out->set_id = id;
    out->ver = 1;
    out->is_new = 1;
    return 0;
}

static void ps_create(struct app_ctx *app, struct request *r,
                      const char *repo_dir,
                      const char *o, const char *n, int rk, int repo_id)
{
    if (!gt_require_post(r))
        return;
    size_t blen;
    char *body = gt_read_form(r, 4 * 1024 * 1024, &blen);
    if (!body) {
        http_error(r, 400, "bad body (max 4 MiB)");
        return;
    }

    char title[210] = "", base[256] = "", setv[24] = "";
    const char *mbox = NULL;
    size_t mlen = 0;
    int is_form = 0;

    const char *ct = req_header(r, "Content-Type");
    if (ct && strstr(ct, "form-urlencoded")) {
        is_form = 1;
        char *mb = xmalloc(blen + 1);
        if (form_get(body, blen, "mbox", mb, blen + 1) != 0 || !mb[0]) {
            free(mb);
            free(body);
            http_error(r, 400, "mbox required");
            return;
        }
        if (!gt_csrf_ok(r, body, blen)) {
            free(mb);
            free(body);
            http_error(r, 403, "bad csrf token");
            return;
        }
        form_get(body, blen, "title", title, sizeof title);
        form_get(body, blen, "base", base, sizeof base);
        form_get(body, blen, "set", setv, sizeof setv);
        mbox = mb;
        mlen = strlen(mb);
        /* keep mb alive through body lifetime use below */
        free(body);
        body = mb;
    } else {
        /* raw mbox: API path (curl --data-binary @-).  gt_csrf_ok
         * still enforces the token when a session cookie is present,
         * and requires an authenticated caller otherwise. */
        if (!gt_csrf_ok(r, body, blen)) {
            free(body);
            http_error(r, 403, "bad csrf token");
            return;
        }
        req_query(r, "base", base, sizeof base);
        req_query(r, "set", setv, sizeof setv);
        mbox = body;
        mlen = blen;
    }

    struct ps_out out;
    int rc = gt_ps_submit(app, repo_dir, repo_id, r->user_id, rk,
                          r->user_name, r->remote_ip, title, base,
                          setv[0] ? strtol(setv, NULL, 10) : 0,
                          mbox, mlen, &out);
    free(body);
    if (rc != 0) {
        http_error(r, out.status, out.err);
        return;
    }
    char loc[256];
    snprintf(loc, sizeof loc, "/%s/%s/patches/%ld", o, n, out.set_id);
    if (!is_form) {
        /* API clients get a plain-text answer */
        char txt[300];
        int tl = out.is_new
            ? snprintf(txt, sizeof txt, "patch set #%ld: %s\n",
                       out.set_id, loc)
            : snprintf(txt, sizeof txt, "patch set #%ld v%d: %s\n",
                       out.set_id, out.ver, loc);
        http_respond(r, 200, "text/plain", txt, (size_t)tl);
        return;
    }
    http_redirect(r, loc);
}

static void ps_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)
{
    (void)repo_dir;
    sqlite3_stmt *st;
    if (sqlite3_prepare_v2(app->db,
            "SELECT p.title,p.state,p.dst_branch,p.cover_letter,"
            " COALESCE(u.name,'(deleted)'),p.author_id,"
            " strftime('%Y-%m-%d %H:%M', p.created_at, 'unixepoch')"
            " FROM patch_sets p LEFT JOIN users u ON u.id=p.author_id"
            " WHERE p.id=? AND p.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 patch set");
        return;
    }

    char title[210], state[16], base[256];
    snprintf(title, sizeof title, "%s",
             (const char *)sqlite3_column_text(st, 0));
    snprintf(state, sizeof state, "%s",
             (const char *)sqlite3_column_text(st, 1));
    snprintf(base, sizeof base, "%s",
             (const char *)sqlite3_column_text(st, 2));
    int author = sqlite3_column_int(st, 5);

    /* revision timeline: ?v=N picks one, default is the latest */
    int vcount = 0, latest = 0;
    sqlite3_stmt *vs;
    if (sqlite3_prepare_v2(app->db,
            "SELECT version FROM patch_revisions WHERE patch_set_id=?"
            " ORDER BY version", -1, &vs, NULL) == SQLITE_OK) {
        sqlite3_bind_int64(vs, 1, id);
        while (sqlite3_step(vs) == SQLITE_ROW) {
            latest = sqlite3_column_int(vs, 0);
            vcount++;
        }
        sqlite3_finalize(vs);
    }
    char vq[16] = "";
    req_query(r, "v", vq, sizeof vq);
    int cur = vq[0] ? atoi(vq) : 0;
    if (cur <= 0 || cur > latest)
        cur = latest;

    /* copy the mbox while the statement is alive: column_blob points
     * into the row buffer, which finalize() frees */
    char *mcopy = NULL;
    int mlen = 0;
    if (cur > 0 && sqlite3_prepare_v2(app->db,
            "SELECT mbox FROM patch_revisions"
            " WHERE patch_set_id=? AND version=?",
            -1, &vs, NULL) == SQLITE_OK) {
        sqlite3_bind_int64(vs, 1, id);
        sqlite3_bind_int(vs, 2, cur);
        if (sqlite3_step(vs) == SQLITE_ROW) {
            const void *mb = sqlite3_column_blob(vs, 0);
            mlen = sqlite3_column_bytes(vs, 0);
            mcopy = xmalloc((size_t)mlen + 1);
            memcpy(mcopy, mb ? mb : "", (size_t)mlen);
            mcopy[mlen] = '\0';
        }
        sqlite3_finalize(vs);
    }
    if (!mcopy) {
        mcopy = xmalloc(1);
        mcopy[0] = '\0';
    }

    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>patch set #%ld <span class=muted>(%s)</span>"
                   " <span class=bpub>v%d</span></h3>",
               id, state, cur);
    if (vcount > 1) {
        buf_puts(&b, "<p class=muted>revisions:");
        for (int v = 1; v <= vcount; v++) {
            if (v == cur)
                buf_printf(&b, " <b>v%d</b>", v);
            else
                buf_printf(&b,
                    " <a href='/%s/%s/patches/%ld?v=%d'>v%d</a>",
                    o, n, id, v, v);
        }
        buf_puts(&b, "</p>");
        if (cur != latest)
            buf_printf(&b, "<p class=muted>viewing v%d — "
                       "<a href='/%s/%s/patches/%ld'>latest is v%d</a>"
                       "</p>", cur, o, n, id, latest);
    }
    buf_puts(&b, "<p><b>");
    gt_esc(&b, title);
    buf_puts(&b, "</b></p><p class=muted>against ");
    gt_esc(&b, base);
    buf_puts(&b, " &middot; by ");
    gt_esc(&b, (const char *)sqlite3_column_text(st, 4));
    buf_puts(&b, " on ");
    gt_esc(&b, (const char *)sqlite3_column_text(st, 6));
    buf_puts(&b, "</p>");
    const char *cov = (const char *)sqlite3_column_text(st, 3);
    if (cov && cov[0]) {
        buf_puts(&b, "<pre style='border:0;background:none'>");
        gt_esc(&b, cov);
        buf_puts(&b, "</pre>");
    }
    sqlite3_finalize(st);

    /* patch list + combined diff */
    int np = 0;
    struct patch *ps = mbox_parse(mcopy, (size_t)mlen, &np);
    if (ps) {
        buf_puts(&b, "<h4>series</h4><ol>");
        int shown = 0;
        for (int i = 0; i < np; i++) {
            if (is_cover(&ps[i]))
                continue;
            shown++;
            buf_puts(&b, "<li>");
            gt_esc(&b, ps[i].subject);
            buf_puts(&b, " <span class=muted>— ");
            gt_esc(&b, ps[i].author_name);
            buf_puts(&b, "</span></li>");
        }
        buf_puts(&b, "</ol>");
        buf_printf(&b, "<h4>diff (%d patch%s)</h4><pre>",
                   shown, shown == 1 ? "" : "es");
        for (int i = 0; i < np; i++)
            if (!is_cover(&ps[i]) && ps[i].diff)
                gt_render_diff(&b, ps[i].diff, ps[i].dlen);
        buf_puts(&b, "</pre>");
        free(ps);
    }
    free(mcopy);

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

    /* cross-link: has this series been materialized into an MR? */
    {
        char pbr[64];
        snprintf(pbr, sizeof pbr, "psets/%ld", id);
        sqlite3_stmt *mst;
        if (sqlite3_prepare_v2(app->db,
                "SELECT id,state FROM merge_requests"
                " WHERE repo_id=? AND src_branch=?",
                -1, &mst, NULL) == SQLITE_OK) {
            sqlite3_bind_int(mst, 1, repo_id);
            sqlite3_bind_text(mst, 2, pbr, -1, SQLITE_TRANSIENT);
            if (sqlite3_step(mst) == SQLITE_ROW)
                buf_printf(&b,
                    "<p class=muted>&#8596; merge request "
                    "<a href='/%s/%s/mr/%lld'>#%lld</a> (%s)</p>",
                    o, n, sqlite3_column_int64(mst, 0),
                    sqlite3_column_int64(mst, 0),
                    (const char *)sqlite3_column_text(mst, 1));
            sqlite3_finalize(mst);
        }
    }

    if (r->user_id > 0 && strcmp(state, "open") == 0) {
        gt_comment_form(&b, r, "");
        if (cur == latest) {
            buf_puts(&b, "<form method=post style='display:inline'>");
            gt_csrf_input(&b, r);
            buf_puts(&b, "<input type=hidden name=action value=check>"
                         "<button>check applies</button></form> ");
            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=apply>"
                             "<button>apply to base</button></form> ");
                buf_puts(&b, "<form method=post style='display:inline'>");
                gt_csrf_input(&b, r);
                buf_puts(&b,
                    "<input type=hidden name=action value=materialize>"
                    "<button>open as merge request</button></form> ");
            }
        }
        if (r->user_id == author)
            buf_printf(&b, "<a class=btn2 href='/%s/%s/patches/new"
                           "?set=%ld'>submit new version</a> ",
                       o, n, id);
        if (cur == latest && (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=reject>"
                         "<button>reject</button></form>");
        }
    } else if (r->user_id > 0 && strcmp(state, "rejected") == 0 &&
               (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=reopen>"
                     "<button>reopen</button></form>");
    }
    gt_repo_body_end(&b);
    gt_page_bottom(&b);
    gt_send_page(r, &b);
    buf_free(&b);
}

static void ps_result(struct app_ctx *app, struct request *r,
                      const char *o, const char *n,
                      int rk, const char *head, struct buf *det)
{
    struct buf b;
    buf_init(&b);
    gt_page_top(&b, n, r);
    gt_repo_tabs(&b, app, o, n, rk, "changes");
    buf_puts(&b, "<h3>");
    gt_esc(&b, head);
    buf_puts(&b, "</h3><pre>");
    if (det->data)
        gt_esc(&b, det->data);
    buf_printf(&b, "</pre><p><a href='/%s/%s/patches'>back to patch "
                   "sets</a></p>", o, n);
    gt_repo_body_end(&b);
    gt_page_bottom(&b);
    gt_send_page(r, &b);
    buf_free(&b);
}

static void ps_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)
{
    size_t blen;
    char *body = gt_read_post(r, 65536, &blen);
    if (!body)
        return;
    char act[16] = "", cbody[8192] = "";
    form_get(body, blen, "action", act, sizeof act);
    form_get(body, blen, "body", cbody, sizeof cbody);
    free(body);

    char loc[256];
    snprintf(loc, sizeof loc, "/%s/%s/patches/%ld", o, n, id);

    if (strcmp(act, "comment") == 0) {
        if (cbody[0])
            gt_add_comment(app->db, "patch_set", id, r->user_id, cbody);
        http_redirect(r, loc);
        return;
    }

    if (strcmp(act, "check") == 0 || strcmp(act, "apply") == 0) {
        if (strcmp(act, "apply") == 0 && rk < 2) {
            http_error(r, 403, "write permission required");
            return;
        }
        /* load state + mbox */
        sqlite3_stmt *st;
        sqlite3_prepare_v2(app->db,
            "SELECT p.state,p.dst_branch,"
            " (SELECT mbox FROM patch_revisions v"
            "  WHERE v.patch_set_id=p.id ORDER BY v.version DESC LIMIT 1)"
            " FROM patch_sets p WHERE p.id=? AND p.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 patch set");
            return;
        }
        char state[16], base[256];
        snprintf(state, sizeof state, "%s",
                 (const char *)sqlite3_column_text(st, 0));
        snprintf(base, sizeof base, "%s",
                 (const char *)sqlite3_column_text(st, 1));
        const void *mbox = sqlite3_column_blob(st, 2);
        int mlen = sqlite3_column_bytes(st, 2);
        char *mcopy = xmalloc((size_t)mlen + 1);
        memcpy(mcopy, mbox ? mbox : "", (size_t)mlen);
        mcopy[mlen] = '\0';
        sqlite3_finalize(st);

        if (strcmp(state, "open") != 0) {
            free(mcopy);
            http_error(r, 409, "patch set not open");
            return;
        }
        int np = 0;
        struct patch *ps = mbox_parse(mcopy, (size_t)mlen, &np);
        if (!ps) {
            free(mcopy);
            http_error(r, 400, "corrupt mbox");
            return;
        }
        int check_only = strcmp(act, "check") == 0;
        char tip[41] = "";
        struct buf err;
        buf_init(&err);
        int rc = apply_series(repo_dir, base, NULL, ps, np, check_only,
                              r->user_name, tip, &err);
        free(ps);
        free(mcopy);
        if (check_only) {
            const char *h = rc == 0 ? "series applies cleanly"
                                    : "series does not apply";
            if (rc == 0)
                buf_puts(&err, "all patches apply on top of base");
            ps_result(app, r, o, n, rk, h, &err);
            buf_free(&err);
            return;
        }
        if (rc == 0) {
            sqlite3_prepare_v2(app->db,
                "UPDATE patch_sets SET state='applied',"
                " updated_at=strftime('%s','now') WHERE id=?",
                -1, &st, NULL);
            sqlite3_bind_int64(st, 1, id);
            sqlite3_step(st);
            sqlite3_finalize(st);
            sqlite3_prepare_v2(app->db,
                "UPDATE patch_revisions SET tip_commit=?"
                " WHERE patch_set_id=? AND version="
                " (SELECT MAX(version) FROM patch_revisions"
                "  WHERE patch_set_id=?)",
                -1, &st, NULL);
            sqlite3_bind_text(st, 1, tip, -1, SQLITE_TRANSIENT);
            sqlite3_bind_int64(st, 2, id);
            sqlite3_bind_int64(st, 3, id);
            sqlite3_step(st);
            sqlite3_finalize(st);
            buf_free(&err);
            http_redirect(r, loc);
            return;
        }
        ps_result(app, r, o, n, rk, "apply failed", &err);
        buf_free(&err);
        return;
    }

    if (strcmp(act, "materialize") == 0) {
        /* GNU -> web flow: apply the series onto a synthetic branch
         * refs/heads/psets/<id> and open a merge request for it. */
        if (rk < 2) {
            http_error(r, 403, "write permission required");
            return;
        }
        sqlite3_stmt *st;
        sqlite3_prepare_v2(app->db,
            "SELECT p.state,p.dst_branch,p.title,p.author_id,"
            " (SELECT mbox FROM patch_revisions v"
            "  WHERE v.patch_set_id=p.id ORDER BY v.version DESC LIMIT 1)"
            " FROM patch_sets p WHERE p.id=? AND p.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 patch set");
            return;
        }
        char state[16], base[256], title[210];
        int author;
        snprintf(state, sizeof state, "%s",
                 (const char *)sqlite3_column_text(st, 0));
        snprintf(base, sizeof base, "%s",
                 (const char *)sqlite3_column_text(st, 1));
        snprintf(title, sizeof title, "%s",
                 (const char *)sqlite3_column_text(st, 2));
        author = sqlite3_column_int(st, 3);
        const void *mbox = sqlite3_column_blob(st, 4);
        int mlen = sqlite3_column_bytes(st, 4);
        char *mcopy = xmalloc((size_t)mlen + 1);
        memcpy(mcopy, mbox ? mbox : "", (size_t)mlen);
        mcopy[mlen] = '\0';
        sqlite3_finalize(st);

        if (strcmp(state, "open") != 0) {
            free(mcopy);
            http_error(r, 409, "patch set not open");
            return;
        }
        int np = 0;
        struct patch *ps = mbox_parse(mcopy, (size_t)mlen, &np);
        if (!ps) {
            free(mcopy);
            http_error(r, 400, "corrupt mbox");
            return;
        }
        char branch[64], tip[41] = "";
        snprintf(branch, sizeof branch, "psets/%ld", id);
        struct buf err;
        buf_init(&err);
        int rc = apply_series(repo_dir, base, branch, ps, np, 0,
                              r->user_name, tip, &err);
        free(ps);
        free(mcopy);
        if (rc != 0) {
            ps_result(app, r, o, n, rk, "materialize failed", &err);
            buf_free(&err);
            return;
        }
        buf_free(&err);

        /* record the applied tip, then create the merge request */
        sqlite3_prepare_v2(app->db,
            "UPDATE patch_revisions SET tip_commit=?"
            " WHERE patch_set_id=? AND version="
            " (SELECT MAX(version) FROM patch_revisions"
            "  WHERE patch_set_id=?)", -1, &st, NULL);
        sqlite3_bind_text(st, 1, tip, -1, SQLITE_TRANSIENT);
        sqlite3_bind_int64(st, 2, id);
        sqlite3_bind_int64(st, 3, id);
        sqlite3_step(st);
        sqlite3_finalize(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, author > 0 ? author : r->user_id);
        sqlite3_bind_text(st, 3, title, -1, SQLITE_TRANSIENT);
        sqlite3_bind_text(st, 4, "(materialized from patch set)",
                          -1, SQLITE_STATIC);
        sqlite3_bind_text(st, 5, branch, -1, SQLITE_TRANSIENT);
        sqlite3_bind_text(st, 6, base, -1, SQLITE_TRANSIENT);
        sqlite3_bind_text(st, 7, tip, -1, SQLITE_TRANSIENT);
        sqlite3_step(st);
        long mrid = sqlite3_last_insert_rowid(app->db);
        sqlite3_finalize(st);
        db_audit(app->db, r->user_id, r->user_name, r->remote_ip,
                 repo_id, "mr_create", "#%ld (materialized pset #%ld)",
                 mrid, id);

        char mloc[256];
        snprintf(mloc, sizeof mloc, "/%s/%s/mr/%ld", o, n, mrid);
        http_redirect(r, mloc);
        return;
    }

    if (strcmp(act, "reject") == 0 || strcmp(act, "reopen") == 0) {
        sqlite3_stmt *st;
        sqlite3_prepare_v2(app->db,
            "SELECT author_id,state FROM patch_sets"
            " WHERE id=? AND repo_id=?",
            -1, &st, NULL);
        sqlite3_bind_int64(st, 1, id);
        sqlite3_bind_int(st, 2, repo_id);
        int author = -1;
        char state[16] = "";
        if (sqlite3_step(st) == SQLITE_ROW) {
            author = sqlite3_column_int(st, 0);
            snprintf(state, sizeof state, "%s",
                     (const char *)sqlite3_column_text(st, 1));
        }
        sqlite3_finalize(st);
        int rej = strcmp(act, "reject") == 0;
        if ((author == r->user_id || rk >= 2) &&
            (rej ? strcmp(state, "open") == 0
                 : strcmp(state, "rejected") == 0)) {
            sqlite3_prepare_v2(app->db,
                "UPDATE patch_sets SET state=?,"
                " updated_at=strftime('%s','now')"
                " WHERE id=? AND repo_id=?",
                -1, &st, NULL);
            sqlite3_bind_text(st, 1, rej ? "rejected" : "open",
                              -1, SQLITE_STATIC);
            sqlite3_bind_int64(st, 2, id);
            sqlite3_bind_int(st, 3, repo_id);
            sqlite3_step(st);
            sqlite3_finalize(st);
        }
    }
    http_redirect(r, loc);
}

void collab_patches(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) {
        ps_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; }
            char *d = git_default_branch(repo_dir);
            ps_new_form(app, r, o, n, rk, d ? d : "master");
            free(d);
        } else {
            if (r->user_id == 0) {
                http_unauthorized(r, "authentication required");
                return;
            }
            ps_create(app, r, repo_dir, o, n, rk, repo_id);
        }
        return;
    }
    long id = strtol(tail + 1, NULL, 10);
    if (id <= 0) {
        http_error(r, 404, "no such patch set");
        return;
    }
    if (strcmp(r->method, "POST") == 0) {
        ps_post(app, r, repo_dir, o, n, rk, repo_id, id);
        return;
    }
    ps_view(app, r, repo_dir, o, n, rk, repo_id, id);
}

/* ============ mail-in endpoint ============
 * `git-trace mailrecv <owner> <repo>` — invoked by a real MTA through
 * a pipe alias (Postfix: `patches-demo: |"/usr/bin/git-trace mailrecv
 * alice demo"`).  Reads one RFC822 message on stdin, authenticates it
 * and feeds the patch-set workflow.  git send-email series arrive as
 * one mail per part; parts collect in mail_items and the assembled
 * series is submitted once the thread is complete.
 *
 * Auth: X-Git-Trace-Token header ("<token>" or "<user>:<token>"),
 * else From: matching a registered users.email — the MTA is expected
 * to enforce SPF/DKIM upstream.  The token header is stripped before
 * storage so credentials never land in patch_revisions.mbox. */

/* does the buffer contain an mbox "From <40hex> " separator? */
static int has_mbox_sep(const char *p, size_t len)
{
    const char *end = p + len;
    for (const char *l = p; l < end; l = next_line(l, end))
        if (is_from_line(l, (size_t)(next_line(l, end) - l)))
            return 1;
    return 0;
}

/* copy the message to dst dropping one named header + its folded
 * continuation lines */
static void strip_header(struct buf *dst, const char *src, size_t n,
                         const char *name)
{
    size_t nl = strlen(name);
    const char *end = src + n;
    int in_headers = 1;
    for (const char *l = src; l < end; ) {
        const char *nx = next_line(l, end);
        size_t ln = (size_t)(nx - l);
        if (ln <= 1)
            in_headers = 0;
        if (in_headers && ln > nl + 1 &&
            strncasecmp(l, name, nl) == 0 && l[nl] == ':') {
            l = nx;
            while (l < end && (*l == ' ' || *l == '\t'))
                l = next_line(l, end);
            continue;
        }
        buf_put(dst, l, ln);
        l = nx;
    }
}

/* "[PATCH vN n/m] foo": fills ver/n/m, returns 1 if a tag was found */
static int parse_mail_tag(const char *subj, int *ver, int *n, int *m)
{
    const char *b = strchr(subj, '[');
    const char *e = b ? strchr(b, ']') : NULL;
    *ver = 1;
    *n = *m = 0;
    if (!b || !e || e - b < 7 || memcmp(b, "[PATCH", 6) != 0)
        return 0;
    char tok[96];
    size_t tl = (size_t)(e - (b + 6));
    if (tl >= sizeof tok)
        tl = sizeof tok - 1;
    memcpy(tok, b + 6, tl);
    tok[tl] = '\0';
    char *sv = NULL;
    for (char *t = strtok_r(tok, " \t", &sv); t;
         t = strtok_r(NULL, " \t", &sv)) {
        if (t[0] == 'v' && isdigit((unsigned char)t[1]))
            *ver = atoi(t + 1);
        else if (*n == 0)
            sscanf(t, "%d/%d", n, m);
    }
    return 1;
}

static int mail_submit(struct app_ctx *app, const char *repo_dir,
                       int repo_id, int uid, int rk, const char *uname,
                       const char *mbox, size_t mlen)
{
    struct ps_out out;
    int rc = gt_ps_submit(app, repo_dir, repo_id, uid, rk, uname, NULL,
                          NULL, NULL, 0, mbox, mlen, &out);
    if (rc != 0) {
        fprintf(stderr, "mailrecv: rejected (%d: %s)\n",
                out.status, out.err);
        return 1;
    }
    printf("mailrecv: patch set #%ld v%d\n", out.set_id, out.ver);
    return 0;
}

int gt_mailrecv(struct app_ctx *app, const char *owner, const char *name)
{
    char rdir[PATH_MAX];
    if (repo_path(rdir, sizeof rdir, app->repos_root, owner, name) != 0) {
        fprintf(stderr, "mailrecv: no such repo %s/%s\n", owner, name);
        return 1;
    }
    int repo_id = gt_repo_rowid(app->db, owner, name);
    if (repo_id < 0) {
        fprintf(stderr, "mailrecv: unregistered repo\n");
        return 1;
    }

    /* whole message, capped like the web form */
    struct buf raw;
    buf_init(&raw);
    char rd[16384];
    ssize_t rn;
    int cap = 0;
    while ((rn = read(0, rd, sizeof rd)) > 0) {
        if (raw.len + (size_t)rn > 4 * 1024 * 1024) {
            cap = 1;
            break;
        }
        buf_put(&raw, rd, (size_t)rn);
    }
    if (cap) {
        fprintf(stderr, "mailrecv: message over 4 MiB\n");
        buf_free(&raw);
        return 1;
    }
    if (!raw.data || raw.len == 0) {
        fprintf(stderr, "mailrecv: empty message\n");
        buf_free(&raw);
        return 1;
    }

    char from[256], subj[512], msgid[256], irt[256], tok[200], date[128];
    hdr_value(raw.data ? raw.data : "", raw.len, "From", from, sizeof from);
    hdr_value(raw.data ? raw.data : "", raw.len, "Subject", subj, sizeof subj);
    hdr_value(raw.data ? raw.data : "", raw.len, "Message-ID", msgid,
              sizeof msgid);
    hdr_value(raw.data ? raw.data : "", raw.len, "In-Reply-To", irt,
              sizeof irt);
    hdr_value(raw.data ? raw.data : "", raw.len, "X-Git-Trace-Token",
              tok, sizeof tok);
    hdr_value(raw.data ? raw.data : "", raw.len, "Date", date, sizeof date);

    int uid = -1;
    if (tok[0]) {
        char *colon = strchr(tok, ':');
        if (colon) {
            *colon = '\0';
            uid = auth_token(app->db, tok, colon + 1);
        } else {
            uid = auth_token_lookup(app->db, tok);
        }
    }
    if (uid < 0 && from[0]) {
        char nm[128], em[128];
        split_author(from, nm, sizeof nm, em, sizeof em);
        if (em[0])
            uid = auth_email_uid(app->db, em);
    }
    if (uid < 0) {
        fprintf(stderr, "mailrecv: unauthenticated message rejected\n");
        buf_free(&raw);
        return 1;
    }
    int rk = perm_rank(repo_perm(app->db, owner, name, uid));
    if (rk < 1) {
        fprintf(stderr, "mailrecv: sender has no repo access\n");
        buf_free(&raw);
        return 1;
    }
    char uname[64] = "(mail)";
    {
        sqlite3_stmt *st;
        if (sqlite3_prepare_v2(app->db,
                "SELECT name FROM users WHERE id=?",
                -1, &st, NULL) == SQLITE_OK) {
            sqlite3_bind_int(st, 1, uid);
            if (sqlite3_step(st) == SQLITE_ROW)
                snprintf(uname, sizeof uname, "%s",
                         (const char *)sqlite3_column_text(st, 0));
            sqlite3_finalize(st);
        }
    }

    sqlite3_stmt *st;
    sqlite3_prepare_v2(app->db,
        "DELETE FROM mail_items WHERE"
        " received < strftime('%s','now') - 604800",
        -1, &st, NULL);
    sqlite3_step(st);
    sqlite3_finalize(st);

    /* fast path: the body already is an mbox (pasted format-patch) */
    if (has_mbox_sep(raw.data, raw.len)) {
        int rc = mail_submit(app, rdir, repo_id, uid, rk, uname,
                             raw.data, raw.len);
        buf_free(&raw);
        return rc;
    }

    /* synthesize an mbox chunk, token header stripped */
    struct buf chunk;
    buf_init(&chunk);
    buf_printf(&chunk, "From %040d %s\n", 0,
               date[0] ? date : "Mon Sep 17 00:00:00 2001");
    strip_header(&chunk, raw.data, raw.len, "X-Git-Trace-Token");
    buf_free(&raw);
    if (chunk.len && chunk.data[chunk.len - 1] != '\n')
        buf_put(&chunk, "\n", 1);

    int ver, part, total;
    parse_mail_tag(subj, &ver, &part, &total);
    if (!(total >= 1 && part <= total && msgid[0])) {
        /* not a numbered series part: single-patch submission */
        int rc = mail_submit(app, rdir, repo_id, uid, rk, uname,
                             chunk.data, chunk.len);
        buf_free(&chunk);
        return rc;
    }

    /* send-email series part: drop tombstoned threads, then collect */
    char grp[300];
    snprintf(grp, sizeof grp, "%s",
             part == 0 ? msgid : (irt[0] ? irt : msgid));
    sqlite3_prepare_v2(app->db,
        "SELECT 1 FROM mail_items WHERE repo_id=? AND grp=?"
        " AND part=-1 LIMIT 1", -1, &st, NULL);
    sqlite3_bind_int(st, 1, repo_id);
    sqlite3_bind_text(st, 2, grp, -1, SQLITE_TRANSIENT);
    int dead = sqlite3_step(st) == SQLITE_ROW;
    sqlite3_finalize(st);
    if (dead) {
        buf_free(&chunk);
        return 0;                       /* already assembled once */
    }

    sqlite3_prepare_v2(app->db,
        "INSERT OR IGNORE INTO mail_items"
        " (repo_id,grp,msgid,part,total,raw) VALUES(?,?,?,?,?,?)",
        -1, &st, NULL);
    sqlite3_bind_int(st, 1, repo_id);
    sqlite3_bind_text(st, 2, grp, -1, SQLITE_TRANSIENT);
    sqlite3_bind_text(st, 3, msgid, -1, SQLITE_TRANSIENT);
    sqlite3_bind_int(st, 4, part);
    sqlite3_bind_int(st, 5, total);
    sqlite3_bind_blob(st, 6, chunk.data, (int)chunk.len,
                      SQLITE_TRANSIENT);
    sqlite3_step(st);
    sqlite3_finalize(st);
    buf_free(&chunk);

    /* complete when every part 1..total of the thread has arrived */
    int present[256];
    memset(present, 0, sizeof present);
    int maxt = 0;
    sqlite3_prepare_v2(app->db,
        "SELECT DISTINCT part,total FROM mail_items"
        " WHERE repo_id=? AND grp=? AND part>=0",
        -1, &st, NULL);
    sqlite3_bind_int(st, 1, repo_id);
    sqlite3_bind_text(st, 2, grp, -1, SQLITE_TRANSIENT);
    while (sqlite3_step(st) == SQLITE_ROW) {
        int p = sqlite3_column_int(st, 0);
        int t = sqlite3_column_int(st, 1);
        if (p >= 0 && p < 256)
            present[p] = 1;
        if (t > maxt)
            maxt = t;
    }
    sqlite3_finalize(st);
    if (maxt < 1 || maxt >= 256) {
        printf("mailrecv: queued part %d\n", part);
        return 0;
    }
    for (int i = 1; i <= maxt; i++)
        if (!present[i]) {
            printf("mailrecv: queued part %d/%d\n", part, total);
            return 0;
        }

    /* thread complete: assemble cover(0) + parts in order */
    struct buf mbox;
    buf_init(&mbox);
    sqlite3_prepare_v2(app->db,
        "SELECT raw FROM mail_items WHERE repo_id=? AND grp=?"
        " AND part>=0 ORDER BY part", -1, &st, NULL);
    sqlite3_bind_int(st, 1, repo_id);
    sqlite3_bind_text(st, 2, grp, -1, SQLITE_TRANSIENT);
    while (sqlite3_step(st) == SQLITE_ROW)
        buf_put(&mbox, sqlite3_column_blob(st, 0),
                (size_t)sqlite3_column_bytes(st, 0));
    sqlite3_finalize(st);

    int rc = mail_submit(app, rdir, repo_id, uid, rk, uname,
                         mbox.data, mbox.len);
    buf_free(&mbox);
    if (rc == 0) {
        /* tombstone the thread: an MTA redelivery must not open a
         * duplicate set */
        sqlite3_prepare_v2(app->db,
            "DELETE FROM mail_items WHERE repo_id=? AND grp=?",
            -1, &st, NULL);
        sqlite3_bind_int(st, 1, repo_id);
        sqlite3_bind_text(st, 2, grp, -1, SQLITE_TRANSIENT);
        sqlite3_step(st);
        sqlite3_finalize(st);
        sqlite3_prepare_v2(app->db,
            "INSERT OR IGNORE INTO mail_items"
            " (repo_id,grp,msgid,part,total,raw)"
            " VALUES(?,?,?,-1,0,'')", -1, &st, NULL);
        sqlite3_bind_int(st, 1, repo_id);
        sqlite3_bind_text(st, 2, grp, -1, SQLITE_TRANSIENT);
        sqlite3_bind_text(st, 3, grp, -1, SQLITE_TRANSIENT);
        sqlite3_step(st);
        sqlite3_finalize(st);
    }
    return rc;
}