mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-27 21:33:41 +02:00
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>
145 lines
5.1 KiB
Go
145 lines
5.1 KiB
Go
// Package vcs is the provider abstraction for token-based Git providers that
|
|
// Multica mirrors pull requests and CI status from: Forgejo, Gitea (Forgejo's
|
|
// upstream, wire-identical), and GitLab. GitHub is intentionally NOT a vcs
|
|
// provider — its App/installation model and check_suite CI differ enough that
|
|
// it keeps its own handler (server/internal/handler/github.go).
|
|
//
|
|
// Each provider only contributes the parts that actually differ between
|
|
// providers: how a webhook is authenticated, how its event/payload shapes map to
|
|
// the normalized PR/CI structs below, and how a token is validated. The shared
|
|
// storage, issue auto-link / auto-close, and broadcast logic live once in the
|
|
// handler layer and consume the normalized types.
|
|
package vcs
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"net/http"
|
|
)
|
|
|
|
// Kind identifies a provider. The string values are persisted on
|
|
// vcs_connection.provider and used as the registry key.
|
|
type Kind string
|
|
|
|
const (
|
|
KindForgejo Kind = "forgejo"
|
|
KindGitea Kind = "gitea"
|
|
KindGitLab Kind = "gitlab"
|
|
)
|
|
|
|
// Valid reports whether k is a known provider kind.
|
|
func (k Kind) Valid() bool {
|
|
switch k {
|
|
case KindForgejo, KindGitea, KindGitLab:
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
// ErrUnauthorized is returned by ValidateToken when the instance rejects the
|
|
// token (HTTP 401/403). Callers surface it as a connect-time validation
|
|
// failure distinct from transport/instance errors.
|
|
var ErrUnauthorized = errors.New("vcs: token unauthorized")
|
|
|
|
// EventKind is the normalized webhook event category. Anything a provider does
|
|
// not model maps to EventOther and is acknowledged but ignored.
|
|
type EventKind int
|
|
|
|
const (
|
|
EventOther EventKind = iota
|
|
EventPullRequest
|
|
EventCIStatus
|
|
)
|
|
|
|
// PullRequestEvent is the provider-agnostic shape of a pull/merge request
|
|
// webhook. State is already normalized to one of open/closed/merged/draft, so
|
|
// the handler never re-derives it. GitLab "merge requests" map onto the same
|
|
// struct.
|
|
type PullRequestEvent struct {
|
|
// Action is the raw provider action (e.g. "opened", "closed", "merge").
|
|
// The handler only needs to know whether it is terminal; see Terminal.
|
|
Action string
|
|
RepoOwner string
|
|
RepoName string
|
|
Number int32
|
|
Title string
|
|
Body string
|
|
State string // open | closed | merged | draft
|
|
HTMLURL string
|
|
Branch string
|
|
HeadSHA string
|
|
AuthorLogin string
|
|
AuthorAvatarURL string
|
|
Additions int32
|
|
Deletions int32
|
|
ChangedFiles int32
|
|
MergedAt string // RFC3339 or empty
|
|
ClosedAt string
|
|
CreatedAt string
|
|
UpdatedAt string
|
|
}
|
|
|
|
// Terminal reports whether this event is the PR's merge/close event, after
|
|
// which the close-intent decision must be frozen. Providers spell the terminal
|
|
// action differently (Forgejo "closed"/"merged", GitLab "merge"/"close"), so
|
|
// the set is matched here rather than in the handler.
|
|
func (e PullRequestEvent) Terminal() bool {
|
|
switch e.Action {
|
|
case "closed", "merged", "merge", "close":
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
// CIStatusEvent is the provider-agnostic shape of a commit-status / pipeline
|
|
// webhook. State is normalized to passed/failed/pending so the aggregation
|
|
// query is provider-independent.
|
|
type CIStatusEvent struct {
|
|
SHA string
|
|
Context string // status check / pipeline name; "" is allowed
|
|
State string // passed | failed | pending
|
|
TargetURL string
|
|
Description string
|
|
// UpdatedAt is the provider's own event timestamp (RFC3339 or empty). It
|
|
// feeds the commit-status monotonic guard so an out-of-order redelivery
|
|
// can't regress a status; empty means "unknown", and the handler falls back
|
|
// to ingestion time.
|
|
UpdatedAt string
|
|
}
|
|
|
|
// Account is the minimal identity returned by ValidateToken.
|
|
type Account struct {
|
|
Login string
|
|
}
|
|
|
|
// Provider is the per-provider adapter. Implementations are stateless and cheap
|
|
// to construct; the registry holds one instance per kind.
|
|
type Provider interface {
|
|
Kind() Kind
|
|
// EventKind classifies an inbound webhook from its headers.
|
|
EventKind(h http.Header) EventKind
|
|
// VerifySignature authenticates the raw body against the connection's
|
|
// stored secret. Forgejo/Gitea use HMAC-SHA256 (X-Gitea-Signature);
|
|
// GitLab uses a plaintext token compare (X-Gitlab-Token).
|
|
VerifySignature(secret string, h http.Header, body []byte) bool
|
|
// ParsePullRequest decodes a pull/merge request webhook body.
|
|
ParsePullRequest(body []byte) (PullRequestEvent, error)
|
|
// ParseCIStatus decodes a commit-status / pipeline webhook body.
|
|
ParseCIStatus(body []byte) (CIStatusEvent, error)
|
|
// ValidateToken confirms the token works against instanceURL and returns
|
|
// the authenticated account. Maps a 401/403 to ErrUnauthorized.
|
|
ValidateToken(ctx context.Context, instanceURL, token string) (Account, error)
|
|
}
|
|
|
|
// registry maps a Kind to its Provider. Populated by package init in the
|
|
// adapter files (forgejo.go, gitlab.go).
|
|
var registry = map[Kind]Provider{}
|
|
|
|
func register(p Provider) { registry[p.Kind()] = p }
|
|
|
|
// For returns the provider for kind, or (nil, false) if unknown.
|
|
func For(kind string) (Provider, bool) {
|
|
p, ok := registry[Kind(kind)]
|
|
return p, ok
|
|
}
|