Files
multica/server/internal/handler/vcs.go
dixonl90 581d9527ba feat(vcs): self-hosted Git providers (Forgejo, Gitea, GitLab) alongside GitHub (MUL-3772) (#5006)
Adds self-hosted Git provider support (Forgejo, Gitea, GitLab) alongside GitHub:
per-workspace token connection, a provider-dispatched webhook, PR/MR and CI
mirroring, and the shared issue auto-link / auto-close machinery. Off until
MULTICA_VCS_SECRET_KEY is set, so existing deployments are unaffected.

Co-authored-by: Bohan <bohan@devv.ai>
2026-07-24 15:01:27 +08:00

307 lines
10 KiB
Go

package handler
import (
"crypto/rand"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"net/http"
"net/url"
"strings"
"github.com/go-chi/chi/v5"
"github.com/jackc/pgx/v5/pgtype"
"github.com/multica-ai/multica/server/internal/integrations/vcs"
"github.com/multica-ai/multica/server/internal/middleware"
db "github.com/multica-ai/multica/server/pkg/db/generated"
"github.com/multica-ai/multica/server/pkg/protocol"
)
// ── Response shapes ─────────────────────────────────────────────────────────
// VCSConnectionResponse is the JSON shape for a stored Git provider connection.
// Secrets are never included; the webhook secret is returned exactly once at
// create time via VCSConnectResponse.
type VCSConnectionResponse struct {
ID string `json:"id"`
WorkspaceID string `json:"workspace_id"`
Provider string `json:"provider"`
InstanceURL string `json:"instance_url"`
AccountLogin string `json:"account_login"`
WebhookURL string `json:"webhook_url"`
WebhookPath string `json:"webhook_path"`
CreatedAt string `json:"created_at"`
}
// VCSConnectResponse embeds the stored connection plus the one-time plaintext
// webhook secret the user must paste into the provider (the HMAC secret for
// Forgejo/Gitea, the X-Gitlab-Token value for GitLab). Not retrievable after.
type VCSConnectResponse struct {
VCSConnectionResponse
WebhookSecret string `json:"webhook_secret"`
}
const vcsWebhookPathPrefix = "/api/webhooks/vcs/"
func (h *Handler) isVCSConfigured() bool { return h.VCSSecretBox != nil }
func (h *Handler) vcsWebhookPath(connID string) string { return vcsWebhookPathPrefix + connID }
func (h *Handler) vcsWebhookURL(connID string) string {
base := strings.TrimRight(h.cfg.PublicURL, "/")
if base == "" {
return ""
}
return base + h.vcsWebhookPath(connID)
}
func (h *Handler) vcsConnectionToResponse(c db.VcsConnection) VCSConnectionResponse {
id := uuidToString(c.ID)
return VCSConnectionResponse{
ID: id,
WorkspaceID: uuidToString(c.WorkspaceID),
Provider: c.Provider,
InstanceURL: c.InstanceUrl,
AccountLogin: c.AccountLogin,
WebhookURL: h.vcsWebhookURL(id),
WebhookPath: h.vcsWebhookPath(id),
CreatedAt: timestampToString(c.CreatedAt),
}
}
func (h *Handler) sealVCSSecret(plaintext string) (string, error) {
sealed, err := h.VCSSecretBox.Seal([]byte(plaintext))
if err != nil {
return "", err
}
return base64.StdEncoding.EncodeToString(sealed), nil
}
func (h *Handler) openVCSSecret(enc string) (string, error) {
if enc == "" {
return "", nil
}
ciphertext, err := base64.StdEncoding.DecodeString(enc)
if err != nil {
return "", err
}
plaintext, err := h.VCSSecretBox.Open(ciphertext)
if err != nil {
return "", err
}
return string(plaintext), nil
}
// ── Handlers ────────────────────────────────────────────────────────────────
// ListVCSConnections (GET /workspaces/{id}/vcs/connections) is member-visible;
// connect/disconnect are admin-gated by the router. No secrets returned.
func (h *Handler) ListVCSConnections(w http.ResponseWriter, r *http.Request) {
workspaceID := chi.URLParam(r, "id")
wsUUID, ok := parseUUIDOrBadRequest(w, workspaceID, "workspace id")
if !ok {
return
}
member, _ := middleware.MemberFromContext(r.Context())
canManage := roleAllowed(member.Role, "owner", "admin")
rows, err := h.Queries.ListVCSConnectionsByWorkspace(r.Context(), wsUUID)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to list connections")
return
}
out := make([]VCSConnectionResponse, 0, len(rows))
for _, row := range rows {
out = append(out, h.vcsConnectionToResponse(row))
}
writeJSON(w, http.StatusOK, map[string]any{
"connections": out,
"configured": h.isVCSConfigured(),
"can_manage": canManage,
})
}
type connectVCSRequest struct {
Provider string `json:"provider"`
InstanceURL string `json:"instance_url"`
AccessToken string `json:"access_token"`
}
// ConnectVCS (POST /workspaces/{id}/vcs/connections) validates the supplied
// instance URL + token against the live instance for the chosen provider, mints a
// webhook secret, stores both secrets encrypted, and returns the connection
// plus the one-time webhook secret. Reconnecting the same instance rotates it.
func (h *Handler) ConnectVCS(w http.ResponseWriter, r *http.Request) {
workspaceID := chi.URLParam(r, "id")
wsUUID, ok := parseUUIDOrBadRequest(w, workspaceID, "workspace id")
if !ok {
return
}
if !h.isVCSConfigured() {
writeError(w, http.StatusServiceUnavailable, "vcs integration not configured (MULTICA_VCS_SECRET_KEY unset)")
return
}
var req connectVCSRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
provider, ok := vcs.For(req.Provider)
if !ok {
writeError(w, http.StatusBadRequest, "unsupported provider")
return
}
instanceURL := vcs.NormalizeInstanceURL(req.InstanceURL)
token := strings.TrimSpace(req.AccessToken)
if instanceURL == "" || token == "" {
writeError(w, http.StatusBadRequest, "instance_url and access_token are required")
return
}
parsed, err := url.Parse(instanceURL)
if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.Host == "" {
writeError(w, http.StatusBadRequest, "instance_url must be an absolute http(s) URL")
return
}
account, err := provider.ValidateToken(r.Context(), instanceURL, token)
if err != nil {
if errors.Is(err, vcs.ErrUnauthorized) {
writeError(w, http.StatusBadRequest, "the provider rejected the access token")
return
}
writeError(w, http.StatusBadGateway, "could not reach the provider instance")
return
}
webhookSecret, err := newVCSWebhookSecret()
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to mint webhook secret")
return
}
tokenEnc, err := h.sealVCSSecret(token)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to encrypt token")
return
}
secretEnc, err := h.sealVCSSecret(webhookSecret)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to encrypt webhook secret")
return
}
var connectedBy pgtype.UUID
if member, ok := middleware.MemberFromContext(r.Context()); ok {
connectedBy = member.UserID
}
conn, err := h.Queries.UpsertVCSConnection(r.Context(), db.UpsertVCSConnectionParams{
WorkspaceID: wsUUID,
Provider: string(provider.Kind()),
InstanceUrl: instanceURL,
AccountLogin: account.Login,
AccessTokenEncrypted: tokenEnc,
WebhookSecretEncrypted: secretEnc,
ConnectedByID: connectedBy,
})
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to save connection")
return
}
resp := h.vcsConnectionToResponse(conn)
h.publish(protocol.EventVCSConnectionCreated, workspaceID, "system", "", map[string]any{"id": resp.ID})
writeJSON(w, http.StatusOK, VCSConnectResponse{
VCSConnectionResponse: resp,
WebhookSecret: webhookSecret,
})
}
// DeleteVCSConnection (DELETE /workspaces/{id}/vcs/connections/{connectionId}).
func (h *Handler) DeleteVCSConnection(w http.ResponseWriter, r *http.Request) {
workspaceID := chi.URLParam(r, "id")
wsUUID, ok := parseUUIDOrBadRequest(w, workspaceID, "workspace id")
if !ok {
return
}
idUUID, ok := parseUUIDOrBadRequest(w, chi.URLParam(r, "connectionId"), "connection id")
if !ok {
return
}
if err := h.Queries.DeleteVCSConnection(r.Context(), db.DeleteVCSConnectionParams{
ID: idUUID,
WorkspaceID: wsUUID,
}); err != nil {
writeError(w, http.StatusInternalServerError, "failed to remove connection")
return
}
h.publish(protocol.EventVCSConnectionDeleted, workspaceID, "system", "", map[string]any{
"id": chi.URLParam(r, "connectionId"),
})
w.WriteHeader(http.StatusNoContent)
}
// RotateVCSConnectionWebhook (POST /workspaces/{id}/vcs/connections/{connectionId}/rotate-webhook)
// generates a fresh webhook secret for an existing VCS connection, stores it encrypted,
// and returns the connection plus the one-time plaintext secret.
func (h *Handler) RotateVCSConnectionWebhook(w http.ResponseWriter, r *http.Request) {
workspaceID := chi.URLParam(r, "id")
wsUUID, ok := parseUUIDOrBadRequest(w, workspaceID, "workspace id")
if !ok {
return
}
connID := chi.URLParam(r, "connectionId")
connUUID, ok := parseUUIDOrBadRequest(w, connID, "connection id")
if !ok {
return
}
if !h.isVCSConfigured() {
writeError(w, http.StatusServiceUnavailable, "vcs integration not configured (MULTICA_VCS_SECRET_KEY unset)")
return
}
conn, err := h.Queries.GetVCSConnectionByID(r.Context(), connUUID)
if err != nil || uuidToString(conn.WorkspaceID) != uuidToString(wsUUID) {
writeError(w, http.StatusNotFound, "vcs connection not found")
return
}
webhookSecret, err := newVCSWebhookSecret()
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to mint webhook secret")
return
}
secretEnc, err := h.sealVCSSecret(webhookSecret)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to encrypt webhook secret")
return
}
updated, err := h.Queries.RotateVCSConnectionWebhookSecret(r.Context(), db.RotateVCSConnectionWebhookSecretParams{
ID: connUUID,
WorkspaceID: wsUUID,
WebhookSecretEncrypted: secretEnc,
})
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to rotate webhook secret")
return
}
resp := h.vcsConnectionToResponse(updated)
h.publish(protocol.EventVCSConnectionCreated, workspaceID, "system", "", map[string]any{"id": resp.ID})
writeJSON(w, http.StatusOK, VCSConnectResponse{
VCSConnectionResponse: resp,
WebhookSecret: webhookSecret,
})
}
// newVCSWebhookSecret returns a 32-byte random secret as hex (64 chars).
func newVCSWebhookSecret() (string, error) {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
return "", err
}
return hex.EncodeToString(b), nil
}