Hi… I am well aware that this diff view is very suboptimal. It will be fixed when the refactored server comes along!
Per-repo merge request sequences
// SPDX-License-Identifier: AGPL-3.0-only
// SPDX-FileCopyrightText: Copyright (c) 2025 Runxi Yu <https://runxiyu.org>
//
//go:build linux
package main
import (
"bytes"
"context"
"encoding/binary"
"errors"
"fmt"
"io"
"net"
"os"
"path/filepath"
"strconv"
"strings"
"syscall"
"github.com/go-git/go-git/v5/plumbing"
"github.com/go-git/go-git/v5/plumbing/object"
"github.com/jackc/pgx/v5"
"go.lindenii.runxiyu.org/lindenii-common/ansiec"
"go.lindenii.runxiyu.org/lindenii-common/clog"
)
var (
errGetFD = errors.New("unable to get file descriptor")
errGetUcred = errors.New("failed getsockopt")
)
// hooksHandler handles a connection from hookc via the
// unix socket.
func hooksHandler(conn net.Conn) {
var ctx context.Context
var cancel context.CancelFunc
var ucred *syscall.Ucred
var err error
var cookie []byte
var packPass packPass
var sshStderr io.Writer
var hookRet byte
defer conn.Close()
ctx, cancel = context.WithCancel(context.Background())
defer cancel()
// There aren't reasonable cases where someone would run this as
// another user.
if ucred, err = getUcred(conn); err != nil {
if _, err = conn.Write([]byte{1}); err != nil {
return
}
writeRedError(conn, "\nUnable to get peer credentials: %v", err)
return
}
uint32uid := uint32(os.Getuid()) //#nosec G115
if ucred.Uid != uint32uid {
if _, err = conn.Write([]byte{1}); err != nil {
return
}
writeRedError(conn, "\nUID mismatch")
return
}
cookie = make([]byte, 64)
if _, err = conn.Read(cookie); err != nil {
if _, err = conn.Write([]byte{1}); err != nil {
return
}
writeRedError(conn, "\nFailed to read cookie: %v", err)
return
}
{
var ok bool
packPass, ok = packPasses.Load(bytesToString(cookie))
if !ok {
if _, err = conn.Write([]byte{1}); err != nil {
return
}
writeRedError(conn, "\nInvalid handler cookie")
return
}
}
sshStderr = packPass.session.Stderr()
_, _ = sshStderr.Write([]byte{'\n'})
hookRet = func() byte {
var argc64 uint64
if err = binary.Read(conn, binary.NativeEndian, &argc64); err != nil {
writeRedError(sshStderr, "Failed to read argc: %v", err)
return 1
}
var args []string
for range argc64 {
var arg bytes.Buffer
for {
nextByte := make([]byte, 1)
n, err := conn.Read(nextByte)
if err != nil || n != 1 {
writeRedError(sshStderr, "Failed to read arg: %v", err)
return 1
}
if nextByte[0] == 0 {
break
}
arg.WriteByte(nextByte[0])
}
args = append(args, arg.String())
}
gitEnv := make(map[string]string)
for {
var envLine bytes.Buffer
for {
nextByte := make([]byte, 1)
n, err := conn.Read(nextByte)
if err != nil || n != 1 {
writeRedError(sshStderr, "Failed to read environment variable: %v", err)
return 1
}
if nextByte[0] == 0 {
break
}
envLine.WriteByte(nextByte[0])
}
if envLine.Len() == 0 {
break
}
kv := envLine.String()
parts := strings.SplitN(kv, "=", 2)
if len(parts) < 2 {
writeRedError(sshStderr, "Invalid environment variable line: %v", kv)
return 1
}
gitEnv[parts[0]] = parts[1]
}
var stdin bytes.Buffer
if _, err = io.Copy(&stdin, conn); err != nil {
writeRedError(conn, "Failed to read to the stdin buffer: %v", err)
}
switch filepath.Base(args[0]) {
case "pre-receive":
if packPass.directAccess {
return 0
}
allOK := true
for {
var line, oldOID, rest, newIOID, refName string
var found bool
var oldHash, newHash plumbing.Hash
var oldCommit, newCommit *object.Commit
var pushOptCount int
pushOptCount, err = strconv.Atoi(gitEnv["GIT_PUSH_OPTION_COUNT"])
if err != nil {
writeRedError(sshStderr, "Failed to parse GIT_PUSH_OPTION_COUNT: %v", err)
return 1
}
// TODO: Allow existing users (even if they are already federated or registered) to add a federated user ID... though perhaps this should be in the normal SSH interface instead of the git push interface?
// Also it'd be nice to be able to combine users or whatever
if packPass.contribReq == "federated" && packPass.userType != "federated" && packPass.userType != "registered" {
if pushOptCount == 0 {
writeRedError(sshStderr, "This repo requires contributors to be either federated or registered users. You must supply your federated user ID as a push option. For example, git push -o fedid=sr.ht:runxiyu")
return 1
}
for pushOptIndex := range pushOptCount {
pushOpt, ok := gitEnv[fmt.Sprintf("GIT_PUSH_OPTION_%d", pushOptIndex)]
if !ok {
writeRedError(sshStderr, "Failed to get push option %d", pushOptIndex)
return 1
}
if strings.HasPrefix(pushOpt, "fedid=") {
fedUserID := strings.TrimPrefix(pushOpt, "fedid=")
service, username, found := strings.Cut(fedUserID, ":")
if !found {
writeRedError(sshStderr, "Invalid federated user identifier %#v does not contain a colon", fedUserID)
return 1
}
ok, err := fedauth(ctx, packPass.userID, service, username, packPass.pubkey)
if err != nil {
writeRedError(sshStderr, "Failed to verify federated user identifier %#v: %v", fedUserID, err)
return 1
}
if !ok {
writeRedError(sshStderr, "Failed to verify federated user identifier %#v: you don't seem to be on the list", fedUserID)
return 1
}
break
}
if pushOptIndex == pushOptCount-1 {
writeRedError(sshStderr, "This repo requires contributors to be either federated or registered users. You must supply your federated user ID as a push option. For example, git push -o fedid=sr.ht:runxiyu")
return 1
}
}
}
line, err = stdin.ReadString('\n')
if errors.Is(err, io.EOF) {
break
} else if err != nil {
writeRedError(sshStderr, "Failed to read pre-receive line: %v", err)
return 1
}
line = line[:len(line)-1]
oldOID, rest, found = strings.Cut(line, " ")
if !found {
writeRedError(sshStderr, "Invalid pre-receive line: %v", line)
return 1
}
newIOID, refName, found = strings.Cut(rest, " ")
if !found {
writeRedError(sshStderr, "Invalid pre-receive line: %v", line)
return 1
}
if strings.HasPrefix(refName, "refs/heads/contrib/") {
if allZero(oldOID) { // New branch
fmt.Fprintln(sshStderr, ansiec.Blue+"POK"+ansiec.Reset, refName)
var newMRID int
var newMRLocalID int
if packPass.userID != 0 {
err = database.QueryRow(ctx,
"INSERT INTO merge_requests (repo_id, creator, source_ref, status) VALUES ($1, $2, $3, 'open') RETURNING id",
"INSERT INTO merge_requests (repo_id, creator, source_ref, status) VALUES ($1, $2, $3, 'open') RETURNING repo_local_id",
packPass.repoID, packPass.userID, strings.TrimPrefix(refName, "refs/heads/"),
).Scan(&newMRID)
).Scan(&newMRLocalID)
} else {
err = database.QueryRow(ctx,
"INSERT INTO merge_requests (repo_id, source_ref, status) VALUES ($1, $2, 'open') RETURNING id",
"INSERT INTO merge_requests (repo_id, source_ref, status) VALUES ($1, $2, 'open') RETURNING repo_local_id",
packPass.repoID, strings.TrimPrefix(refName, "refs/heads/"),
).Scan(&newMRID)
).Scan(&newMRLocalID)
}
if err != nil {
writeRedError(sshStderr, "Error creating merge request: %v", err)
return 1
}
mergeRequestWebURL := fmt.Sprintf("%s/contrib/%d/", genHTTPRemoteURL(packPass.groupPath, packPass.repoName), newMRID)
mergeRequestWebURL := fmt.Sprintf("%s/contrib/%d/", genHTTPRemoteURL(packPass.groupPath, packPass.repoName), newMRLocalID)
fmt.Fprintln(sshStderr, ansiec.Blue+"Created merge request at", mergeRequestWebURL+ansiec.Reset)
select {
case ircSendBuffered <- "PRIVMSG #chat :New merge request at " + mergeRequestWebURL:
default:
clog.Error("IRC SendQ exceeded")
}
} else { // Existing contrib branch
var existingMRUser int
var isAncestor bool
err = database.QueryRow(ctx,
"SELECT COALESCE(creator, 0) FROM merge_requests WHERE source_ref = $1 AND repo_id = $2",
strings.TrimPrefix(refName, "refs/heads/"), packPass.repoID,
).Scan(&existingMRUser)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
writeRedError(sshStderr, "No existing merge request for existing contrib branch: %v", err)
} else {
writeRedError(sshStderr, "Error querying for existing merge request: %v", err)
}
return 1
}
if existingMRUser == 0 {
allOK = false
fmt.Fprintln(sshStderr, ansiec.Red+"NAK"+ansiec.Reset, refName, "(branch belongs to unowned MR)")
continue
}
if existingMRUser != packPass.userID {
allOK = false
fmt.Fprintln(sshStderr, ansiec.Red+"NAK"+ansiec.Reset, refName, "(branch belongs another user's MR)")
continue
}
oldHash = plumbing.NewHash(oldOID)
if oldCommit, err = packPass.repo.CommitObject(oldHash); err != nil {
writeRedError(sshStderr, "Daemon failed to get old commit: %v", err)
return 1
}
// Potential BUG: I'm not sure if new_commit is guaranteed to be
// detectable as they haven't been merged into the main repo's
// objects yet. But it seems to work, and I don't think there's
// any reason for this to only work intermitently.
newHash = plumbing.NewHash(newIOID)
if newCommit, err = packPass.repo.CommitObject(newHash); err != nil {
writeRedError(sshStderr, "Daemon failed to get new commit: %v", err)
return 1
}
if isAncestor, err = oldCommit.IsAncestor(newCommit); err != nil {
writeRedError(sshStderr, "Daemon failed to check if old commit is ancestor: %v", err)
return 1
}
if !isAncestor {
// TODO: Create MR snapshot ref instead
allOK = false
fmt.Fprintln(sshStderr, ansiec.Red+"NAK"+ansiec.Reset, refName, "(force pushes are not supported yet)")
continue
}
fmt.Fprintln(sshStderr, ansiec.Blue+"POK"+ansiec.Reset, refName)
}
} else { // Non-contrib branch
allOK = false
fmt.Fprintln(sshStderr, ansiec.Red+"NAK"+ansiec.Reset, refName, "(you cannot push to branches outside of contrib/*)")
}
}
fmt.Fprintln(sshStderr)
if allOK {
fmt.Fprintln(sshStderr, "Overall "+ansiec.Green+"ACK"+ansiec.Reset+" (all checks passed)")
return 0
}
fmt.Fprintln(sshStderr, "Overall "+ansiec.Red+"NAK"+ansiec.Reset+" (one or more branches failed checks)")
return 1
default:
fmt.Fprintln(sshStderr, ansiec.Red+"Invalid hook:", args[0]+ansiec.Reset)
return 1
}
}()
fmt.Fprintln(sshStderr)
_, _ = conn.Write([]byte{hookRet})
}
func serveGitHooks(listener net.Listener) error {
for {
conn, err := listener.Accept()
if err != nil {
return err
}
go hooksHandler(conn)
}
}
func getUcred(conn net.Conn) (ucred *syscall.Ucred, err error) {
unixConn := conn.(*net.UnixConn)
var unixConnFD *os.File
if unixConnFD, err = unixConn.File(); err != nil {
return nil, errGetFD
}
defer unixConnFD.Close()
if ucred, err = syscall.GetsockoptUcred(int(unixConnFD.Fd()), syscall.SOL_SOCKET, syscall.SO_PEERCRED); err != nil {
return nil, errGetUcred
}
return ucred, nil
}
func allZero(s string) bool {
for _, r := range s {
if r != '0' {
return false
}
}
return true
}
// SPDX-License-Identifier: AGPL-3.0-only
// SPDX-FileCopyrightText: Copyright (c) 2025 Runxi Yu <https://runxiyu.org>
//
//go:build !linux
package main
import (
"bytes"
"context"
"encoding/binary"
"errors"
"fmt"
"io"
"net"
"path/filepath"
"strconv"
"strings"
"github.com/go-git/go-git/v5/plumbing"
"github.com/go-git/go-git/v5/plumbing/object"
"github.com/jackc/pgx/v5"
"go.lindenii.runxiyu.org/lindenii-common/ansiec"
"go.lindenii.runxiyu.org/lindenii-common/clog"
)
var errGetFD = errors.New("unable to get file descriptor")
// hooksHandler handles a connection from hookc via the
// unix socket.
func hooksHandler(conn net.Conn) {
var ctx context.Context
var cancel context.CancelFunc
var err error
var cookie []byte
var packPass packPass
var sshStderr io.Writer
var hookRet byte
defer conn.Close()
ctx, cancel = context.WithCancel(context.Background())
defer cancel()
// TODO: Validate that the connection is from the right user.
cookie = make([]byte, 64)
if _, err = conn.Read(cookie); err != nil {
if _, err = conn.Write([]byte{1}); err != nil {
return
}
writeRedError(conn, "\nFailed to read cookie: %v", err)
return
}
{
var ok bool
packPass, ok = packPasses.Load(bytesToString(cookie))
if !ok {
if _, err = conn.Write([]byte{1}); err != nil {
return
}
writeRedError(conn, "\nInvalid handler cookie")
return
}
}
sshStderr = packPass.session.Stderr()
_, _ = sshStderr.Write([]byte{'\n'})
hookRet = func() byte {
var argc64 uint64
if err = binary.Read(conn, binary.NativeEndian, &argc64); err != nil {
writeRedError(sshStderr, "Failed to read argc: %v", err)
return 1
}
var args []string
for range argc64 {
var arg bytes.Buffer
for {
nextByte := make([]byte, 1)
n, err := conn.Read(nextByte)
if err != nil || n != 1 {
writeRedError(sshStderr, "Failed to read arg: %v", err)
return 1
}
if nextByte[0] == 0 {
break
}
arg.WriteByte(nextByte[0])
}
args = append(args, arg.String())
}
gitEnv := make(map[string]string)
for {
var envLine bytes.Buffer
for {
nextByte := make([]byte, 1)
n, err := conn.Read(nextByte)
if err != nil || n != 1 {
writeRedError(sshStderr, "Failed to read environment variable: %v", err)
return 1
}
if nextByte[0] == 0 {
break
}
envLine.WriteByte(nextByte[0])
}
if envLine.Len() == 0 {
break
}
kv := envLine.String()
parts := strings.SplitN(kv, "=", 2)
if len(parts) < 2 {
writeRedError(sshStderr, "Invalid environment variable line: %v", kv)
return 1
}
gitEnv[parts[0]] = parts[1]
}
var stdin bytes.Buffer
if _, err = io.Copy(&stdin, conn); err != nil {
writeRedError(conn, "Failed to read to the stdin buffer: %v", err)
}
switch filepath.Base(args[0]) {
case "pre-receive":
if packPass.directAccess {
return 0
}
allOK := true
for {
var line, oldOID, rest, newIOID, refName string
var found bool
var oldHash, newHash plumbing.Hash
var oldCommit, newCommit *object.Commit
var pushOptCount int
pushOptCount, err = strconv.Atoi(gitEnv["GIT_PUSH_OPTION_COUNT"])
if err != nil {
writeRedError(sshStderr, "Failed to parse GIT_PUSH_OPTION_COUNT: %v", err)
return 1
}
// TODO: Allow existing users (even if they are already federated or registered) to add a federated user ID... though perhaps this should be in the normal SSH interface instead of the git push interface?
// Also it'd be nice to be able to combine users or whatever
if packPass.contribReq == "federated" && packPass.userType != "federated" && packPass.userType != "registered" {
if pushOptCount == 0 {
writeRedError(sshStderr, "This repo requires contributors to be either federated or registered users. You must supply your federated user ID as a push option. For example, git push -o fedid=sr.ht:runxiyu")
return 1
}
for pushOptIndex := range pushOptCount {
pushOpt, ok := gitEnv[fmt.Sprintf("GIT_PUSH_OPTION_%d", pushOptIndex)]
if !ok {
writeRedError(sshStderr, "Failed to get push option %d", pushOptIndex)
return 1
}
if strings.HasPrefix(pushOpt, "fedid=") {
fedUserID := strings.TrimPrefix(pushOpt, "fedid=")
service, username, found := strings.Cut(fedUserID, ":")
if !found {
writeRedError(sshStderr, "Invalid federated user identifier %#v does not contain a colon", fedUserID)
return 1
}
ok, err := fedauth(ctx, packPass.userID, service, username, packPass.pubkey)
if err != nil {
writeRedError(sshStderr, "Failed to verify federated user identifier %#v: %v", fedUserID, err)
return 1
}
if !ok {
writeRedError(sshStderr, "Failed to verify federated user identifier %#v: you don't seem to be on the list", fedUserID)
return 1
}
break
}
if pushOptIndex == pushOptCount-1 {
writeRedError(sshStderr, "This repo requires contributors to be either federated or registered users. You must supply your federated user ID as a push option. For example, git push -o fedid=sr.ht:runxiyu")
return 1
}
}
}
line, err = stdin.ReadString('\n')
if errors.Is(err, io.EOF) {
break
} else if err != nil {
writeRedError(sshStderr, "Failed to read pre-receive line: %v", err)
return 1
}
line = line[:len(line)-1]
oldOID, rest, found = strings.Cut(line, " ")
if !found {
writeRedError(sshStderr, "Invalid pre-receive line: %v", line)
return 1
}
newIOID, refName, found = strings.Cut(rest, " ")
if !found {
writeRedError(sshStderr, "Invalid pre-receive line: %v", line)
return 1
}
if strings.HasPrefix(refName, "refs/heads/contrib/") {
if allZero(oldOID) { // New branch
fmt.Fprintln(sshStderr, ansiec.Blue+"POK"+ansiec.Reset, refName)
var newMRID int
var newMRLocalID int
if packPass.userID != 0 {
err = database.QueryRow(ctx,
"INSERT INTO merge_requests (repo_id, creator, source_ref, status) VALUES ($1, $2, $3, 'open') RETURNING id",
"INSERT INTO merge_requests (repo_id, creator, source_ref, status) VALUES ($1, $2, $3, 'open') RETURNING repo_local_id",
packPass.repoID, packPass.userID, strings.TrimPrefix(refName, "refs/heads/"),
).Scan(&newMRID)
).Scan(&newMRLocalID)
} else {
err = database.QueryRow(ctx,
"INSERT INTO merge_requests (repo_id, source_ref, status) VALUES ($1, $2, 'open') RETURNING id",
"INSERT INTO merge_requests (repo_id, source_ref, status) VALUES ($1, $2, 'open') RETURNING repo_local_id",
packPass.repoID, strings.TrimPrefix(refName, "refs/heads/"),
).Scan(&newMRID)
).Scan(&newMRLocalID)
}
if err != nil {
writeRedError(sshStderr, "Error creating merge request: %v", err)
return 1
}
mergeRequestWebURL := fmt.Sprintf("%s/contrib/%d/", genHTTPRemoteURL(packPass.groupPath, packPass.repoName), newMRID)
mergeRequestWebURL := fmt.Sprintf("%s/contrib/%d/", genHTTPRemoteURL(packPass.groupPath, packPass.repoName), newMRLocalID)
fmt.Fprintln(sshStderr, ansiec.Blue+"Created merge request at", mergeRequestWebURL+ansiec.Reset)
select {
case ircSendBuffered <- "PRIVMSG #chat :New merge request at " + mergeRequestWebURL:
default:
clog.Error("IRC SendQ exceeded")
}
} else { // Existing contrib branch
var existingMRUser int
var isAncestor bool
err = database.QueryRow(ctx,
"SELECT COALESCE(creator, 0) FROM merge_requests WHERE source_ref = $1 AND repo_id = $2",
strings.TrimPrefix(refName, "refs/heads/"), packPass.repoID,
).Scan(&existingMRUser)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
writeRedError(sshStderr, "No existing merge request for existing contrib branch: %v", err)
} else {
writeRedError(sshStderr, "Error querying for existing merge request: %v", err)
}
return 1
}
if existingMRUser == 0 {
allOK = false
fmt.Fprintln(sshStderr, ansiec.Red+"NAK"+ansiec.Reset, refName, "(branch belongs to unowned MR)")
continue
}
if existingMRUser != packPass.userID {
allOK = false
fmt.Fprintln(sshStderr, ansiec.Red+"NAK"+ansiec.Reset, refName, "(branch belongs another user's MR)")
continue
}
oldHash = plumbing.NewHash(oldOID)
if oldCommit, err = packPass.repo.CommitObject(oldHash); err != nil {
writeRedError(sshStderr, "Daemon failed to get old commit: %v", err)
return 1
}
// Potential BUG: I'm not sure if new_commit is guaranteed to be
// detectable as they haven't been merged into the main repo's
// objects yet. But it seems to work, and I don't think there's
// any reason for this to only work intermitently.
newHash = plumbing.NewHash(newIOID)
if newCommit, err = packPass.repo.CommitObject(newHash); err != nil {
writeRedError(sshStderr, "Daemon failed to get new commit: %v", err)
return 1
}
if isAncestor, err = oldCommit.IsAncestor(newCommit); err != nil {
writeRedError(sshStderr, "Daemon failed to check if old commit is ancestor: %v", err)
return 1
}
if !isAncestor {
// TODO: Create MR snapshot ref instead
allOK = false
fmt.Fprintln(sshStderr, ansiec.Red+"NAK"+ansiec.Reset, refName, "(force pushes are not supported yet)")
continue
}
fmt.Fprintln(sshStderr, ansiec.Blue+"POK"+ansiec.Reset, refName)
}
} else { // Non-contrib branch
allOK = false
fmt.Fprintln(sshStderr, ansiec.Red+"NAK"+ansiec.Reset, refName, "(you cannot push to branches outside of contrib/*)")
}
}
fmt.Fprintln(sshStderr)
if allOK {
fmt.Fprintln(sshStderr, "Overall "+ansiec.Green+"ACK"+ansiec.Reset+" (all checks passed)")
return 0
}
fmt.Fprintln(sshStderr, "Overall "+ansiec.Red+"NAK"+ansiec.Reset+" (one or more branches failed checks)")
return 1
default:
fmt.Fprintln(sshStderr, ansiec.Red+"Invalid hook:", args[0]+ansiec.Reset)
return 1
}
}()
fmt.Fprintln(sshStderr)
_, _ = conn.Write([]byte{hookRet})
}
func serveGitHooks(listener net.Listener) error {
for {
conn, err := listener.Accept()
if err != nil {
return err
}
go hooksHandler(conn)
}
}
func allZero(s string) bool {
for _, r := range s {
if r != '0' {
return false
}
}
return true
}
// SPDX-License-Identifier: AGPL-3.0-only
// SPDX-FileCopyrightText: Copyright (c) 2025 Runxi Yu <https://runxiyu.org>
package main
import (
"net/http"
"github.com/jackc/pgx/v5"
)
type idTitleStatus struct {
ID int
Title string
Status string
}
func httpHandleRepoContribIndex(writer http.ResponseWriter, request *http.Request, params map[string]any) {
var rows pgx.Rows
var result []idTitleStatus
var err error
if rows, err = database.Query(request.Context(),
"SELECT id, COALESCE(title, 'Untitled'), status FROM merge_requests WHERE repo_id = $1",
"SELECT repo_local_id, COALESCE(title, 'Untitled'), status FROM merge_requests WHERE repo_id = $1",
params["repo_id"],
); err != nil {
errorPage500(writer, params, "Error querying merge requests: "+err.Error())
return
}
defer rows.Close()
for rows.Next() {
var mrID int
var mrTitle, mrStatus string
if err = rows.Scan(&mrID, &mrTitle, &mrStatus); err != nil {
errorPage500(writer, params, "Error scanning merge request: "+err.Error())
return
}
result = append(result, idTitleStatus{mrID, mrTitle, mrStatus})
}
if err = rows.Err(); err != nil {
errorPage500(writer, params, "Error ranging over merge requests: "+err.Error())
return
}
params["merge_requests"] = result
renderTemplate(writer, "repo_contrib_index", params)
}
// SPDX-License-Identifier: AGPL-3.0-only
// SPDX-FileCopyrightText: Copyright (c) 2025 Runxi Yu <https://runxiyu.org>
package main
import (
"net/http"
"strconv"
"github.com/go-git/go-git/v5"
"github.com/go-git/go-git/v5/plumbing"
"github.com/go-git/go-git/v5/plumbing/object"
)
func httpHandleRepoContribOne(writer http.ResponseWriter, request *http.Request, params map[string]any) {
var mrIDStr string
var mrIDInt int
var err error
var title, status, srcRefStr, dstBranchStr string
var repo *git.Repository
var srcRefHash plumbing.Hash
var dstBranchHash plumbing.Hash
var srcCommit, dstCommit, mergeBaseCommit *object.Commit
var mergeBases []*object.Commit
mrIDStr = params["mr_id"].(string)
mrIDInt64, err := strconv.ParseInt(mrIDStr, 10, strconv.IntSize)
if err != nil {
errorPage400(writer, params, "Merge request ID not an integer")
return
}
mrIDInt = int(mrIDInt64)
if err = database.QueryRow(request.Context(),
"SELECT COALESCE(title, ''), status, source_ref, COALESCE(destination_branch, '') FROM merge_requests WHERE id = $1", mrIDInt,
"SELECT COALESCE(title, ''), status, source_ref, COALESCE(destination_branch, '') FROM merge_requests WHERE repo_id = $1 AND repo_local_id = $2", params["repo_id"], mrIDInt,
).Scan(&title, &status, &srcRefStr, &dstBranchStr); err != nil {
errorPage500(writer, params, "Error querying merge request: "+err.Error())
return
}
repo = params["repo"].(*git.Repository)
if srcRefHash, err = getRefHash(repo, "branch", srcRefStr); err != nil {
errorPage500(writer, params, "Error getting source ref hash: "+err.Error())
return
}
if srcCommit, err = repo.CommitObject(srcRefHash); err != nil {
errorPage500(writer, params, "Error getting source commit: "+err.Error())
return
}
params["source_commit"] = srcCommit
if dstBranchStr == "" {
dstBranchStr = "HEAD"
dstBranchHash, err = getRefHash(repo, "", "")
} else {
dstBranchHash, err = getRefHash(repo, "branch", dstBranchStr)
}
if err != nil {
errorPage500(writer, params, "Error getting destination branch hash: "+err.Error())
return
}
if dstCommit, err = repo.CommitObject(dstBranchHash); err != nil {
errorPage500(writer, params, "Error getting destination commit: "+err.Error())
return
}
params["destination_commit"] = dstCommit
if mergeBases, err = srcCommit.MergeBase(dstCommit); err != nil {
errorPage500(writer, params, "Error getting merge base: "+err.Error())
return
}
if len(mergeBases) < 1 {
errorPage500(writer, params, "No merge base found for this merge request; these two branches do not share any common history")
// TODO
return
}
mergeBaseCommit = mergeBases[0]
params["merge_base"] = mergeBaseCommit
patch, err := mergeBaseCommit.Patch(srcCommit)
if err != nil {
errorPage500(writer, params, "Error getting patch: "+err.Error())
return
}
params["file_patches"] = makeUsableFilePatches(patch)
params["mr_title"], params["mr_status"], params["mr_source_ref"], params["mr_destination_branch"] = title, status, srcRefStr, dstBranchStr
renderTemplate(writer, "repo_contrib_one", params)
}
-- SPDX-License-Identifier: AGPL-3.0-only
-- SPDX-FileCopyrightText: Copyright (c) 2025 Runxi Yu <https://runxiyu.org>
CREATE TABLE groups (
id INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
name TEXT NOT NULL,
parent_group INTEGER REFERENCES groups(id) ON DELETE CASCADE,
description TEXT,
UNIQUE NULLS NOT DISTINCT (parent_group, name)
);
CREATE TABLE repos (
id INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
group_id INTEGER NOT NULL REFERENCES groups(id) ON DELETE RESTRICT, -- I mean, should be CASCADE but deleting Git repos on disk also needs to be considered
contrib_requirements TEXT NOT NULL CHECK (contrib_requirements IN ('closed', 'registered_user', 'federated', 'ssh_pubkey', 'public')),
name TEXT NOT NULL,
UNIQUE(group_id, name),
description TEXT,
filesystem_path TEXT
);
CREATE TABLE mailing_lists (
id INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
group_id INTEGER NOT NULL REFERENCES groups(id) ON DELETE RESTRICT,
name TEXT NOT NULL,
UNIQUE(group_id, name),
description TEXT
);
CREATE TABLE mailing_list_emails (
id INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
list_id INTEGER NOT NULL REFERENCES mailing_lists(id) ON DELETE CASCADE,
title TEXT NOT NULL,
sender TEXT NOT NULL,
date TIMESTAMP NOT NULL,
content BYTEA NOT NULL
);
CREATE TABLE users (
id INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
username TEXT UNIQUE,
type TEXT NOT NULL CHECK (type IN ('pubkey_only', 'federated', 'registered')),
password TEXT
);
CREATE TABLE ssh_public_keys (
id INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
key_string TEXT NOT NULL,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
CONSTRAINT unique_key_string EXCLUDE USING HASH (key_string WITH =)
);
CREATE TABLE sessions (
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
session_id TEXT PRIMARY KEY NOT NULL,
UNIQUE(user_id, session_id)
);
-- TODO:
CREATE TABLE merge_requests (
id INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
title TEXT,
repo_id INTEGER NOT NULL REFERENCES repos(id) ON DELETE CASCADE,
creator INTEGER REFERENCES users(id) ON DELETE SET NULL,
source_ref TEXT NOT NULL,
destination_branch TEXT,
status TEXT NOT NULL CHECK (status IN ('open', 'merged', 'closed')),
UNIQUE (repo_id, source_ref, destination_branch),
UNIQUE (repo_id, id)
);
CREATE TABLE user_group_roles ( group_id INTEGER NOT NULL REFERENCES groups(id) ON DELETE CASCADE, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, PRIMARY KEY(user_id, group_id) ); CREATE TABLE federated_identities ( user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, service TEXT NOT NULL, remote_username TEXT NOT NULL, PRIMARY KEY(user_id, service) );
-- Ticket tracking
CREATE TABLE ticket_trackers (
id INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
group_id INTEGER NOT NULL REFERENCES groups(id) ON DELETE RESTRICT,
name TEXT NOT NULL,
description TEXT,
UNIQUE(group_id, name)
);
CREATE TABLE tickets (
id INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
tracker_id INTEGER NOT NULL REFERENCES ticket_trackers(id) ON DELETE CASCADE,
tracker_local_id INTEGER NOT NULL,
title TEXT NOT NULL,
description TEXT,
UNIQUE(tracker_id, tracker_local_id)
);
CREATE OR REPLACE FUNCTION create_tracker_ticket_sequence()
RETURNS TRIGGER AS $$
DECLARE
seq_name TEXT := 'tracker_ticket_seq_' || NEW.id;
BEGIN
EXECUTE format('CREATE SEQUENCE %I', seq_name);
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER after_insert_ticket_tracker
AFTER INSERT ON ticket_trackers
FOR EACH ROW
EXECUTE FUNCTION create_tracker_ticket_sequence();
CREATE OR REPLACE FUNCTION drop_tracker_ticket_sequence()
RETURNS TRIGGER AS $$
DECLARE
seq_name TEXT := 'tracker_ticket_seq_' || OLD.id;
BEGIN
EXECUTE format('DROP SEQUENCE IF EXISTS %I', seq_name);
RETURN OLD;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER before_delete_ticket_tracker
BEFORE DELETE ON ticket_trackers
FOR EACH ROW
EXECUTE FUNCTION drop_tracker_ticket_sequence();
CREATE OR REPLACE FUNCTION assign_tracker_local_id()
RETURNS TRIGGER AS $$
DECLARE
seq_name TEXT := 'tracker_ticket_seq_' || NEW.tracker_id;
BEGIN
IF NEW.tracker_local_id IS NULL THEN
EXECUTE format('SELECT nextval(%L)', seq_name)
INTO NEW.tracker_local_id;
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER before_insert_ticket
BEFORE INSERT ON tickets
FOR EACH ROW
EXECUTE FUNCTION assign_tracker_local_id();
-- Merge requests
CREATE TABLE merge_requests (
id INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
repo_id INTEGER NOT NULL REFERENCES repos(id) ON DELETE CASCADE,
repo_local_id INTEGER NOT NULL,
title TEXT,
creator INTEGER REFERENCES users(id) ON DELETE SET NULL,
source_ref TEXT NOT NULL,
destination_branch TEXT,
status TEXT NOT NULL CHECK (status IN ('open', 'merged', 'closed')),
UNIQUE (repo_id, repo_local_id),
UNIQUE (repo_id, source_ref, destination_branch)
);
CREATE OR REPLACE FUNCTION create_repo_mr_sequence()
RETURNS TRIGGER AS $$
DECLARE
seq_name TEXT := 'repo_mr_seq_' || NEW.id;
BEGIN
EXECUTE format('CREATE SEQUENCE %I', seq_name);
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER after_insert_repo
AFTER INSERT ON repos
FOR EACH ROW
EXECUTE FUNCTION create_repo_mr_sequence();
CREATE OR REPLACE FUNCTION drop_repo_mr_sequence()
RETURNS TRIGGER AS $$
DECLARE
seq_name TEXT := 'repo_mr_seq_' || OLD.id;
BEGIN
EXECUTE format('DROP SEQUENCE IF EXISTS %I', seq_name);
RETURN OLD;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER before_delete_repo
BEFORE DELETE ON repos
FOR EACH ROW
EXECUTE FUNCTION drop_repo_mr_sequence();
CREATE OR REPLACE FUNCTION assign_repo_local_id()
RETURNS TRIGGER AS $$
DECLARE
seq_name TEXT := 'repo_mr_seq_' || NEW.repo_id;
BEGIN
IF NEW.repo_local_id IS NULL THEN
EXECUTE format('SELECT nextval(%L)', seq_name)
INTO NEW.repo_local_id;
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER before_insert_merge_request
BEFORE INSERT ON merge_requests
FOR EACH ROW
EXECUTE FUNCTION assign_repo_local_id();