Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
223 changes: 223 additions & 0 deletions backend/biz/git/handler/v1/webhook_codeup.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,223 @@
package v1

import (
"context"
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"strings"
"time"

"github.com/GoYoko/web"
"github.com/google/uuid"
"github.com/redis/go-redis/v9"
"github.com/samber/do"

"github.com/chaitin/MonkeyCode/backend/config"
"github.com/chaitin/MonkeyCode/backend/consts"
"github.com/chaitin/MonkeyCode/backend/domain"
"github.com/chaitin/MonkeyCode/backend/pkg/taskflow"
)

// CodeupWebhookHandler 云效 Codeup Webhook 处理器
type CodeupWebhookHandler struct {
cfg *config.Config
logger *slog.Logger
redis *redis.Client
gitbotUsecase domain.GitBotUsecase
pubhost domain.PublicHostUsecase
gitTaskUsecase domain.GitTaskUsecase
}

// NewCodeupWebhookHandler 创建 Codeup Webhook 处理器
func NewCodeupWebhookHandler(i *do.Injector) (*CodeupWebhookHandler, error) {
h := &CodeupWebhookHandler{
cfg: do.MustInvoke[*config.Config](i),
logger: do.MustInvoke[*slog.Logger](i).With("module", "CodeupWebhookHandler"),
redis: do.MustInvoke[*redis.Client](i),
gitbotUsecase: do.MustInvoke[domain.GitBotUsecase](i),
pubhost: do.MustInvoke[domain.PublicHostUsecase](i),
gitTaskUsecase: do.MustInvoke[domain.GitTaskUsecase](i),
}

w := do.MustInvoke[*web.Web](i)
w.Group("/api/v1").POST("/codeup/webhook/:id", web.BaseHandler(h.Webhook))

return h, nil
}

// Webhook 处理 Codeup Webhook 请求
//
// Codeup 在 Header `X-Codeup-Token` 或 `X-Yunxiao-Token` 中携带 secretToken 校验;
// 事件类型在 `X-Event-Type` / `X-Codeup-Event` 中。
func (h *CodeupWebhookHandler) Webhook(c *web.Context) error {
ctx := c.Request().Context()
id, err := uuid.Parse(c.Param("id"))
if err != nil {
return c.String(http.StatusBadRequest, "invalid id")
}

bot, err := h.gitbotUsecase.GetByID(ctx, id)
if err != nil {
return c.String(http.StatusNotFound, "bot not found")
}

token := firstNonEmptyHeader(c.Request().Header,
"X-Codeup-Token", "X-Yunxiao-Token", "X-Gitlab-Token")
if bot.SecretToken != "" && token != bot.SecretToken {
return c.String(http.StatusUnauthorized, "invalid token")
}

body, err := io.ReadAll(c.Request().Body)
if err != nil {
return err
}

event := firstNonEmptyHeader(c.Request().Header,
"X-Event-Type", "X-Codeup-Event", "X-Gitlab-Event")
if strings.Contains(strings.ToLower(event), "merge request") ||
strings.Contains(strings.ToLower(event), "pull request") ||
strings.Contains(strings.ToLower(event), "mergerequest") {
h.handlePullRequest(ctx, bot, body)
}

return c.String(http.StatusOK, "ok")
}

func (h *CodeupWebhookHandler) handlePullRequest(ctx context.Context, bot *domain.GitBot, payload []byte) {
// Codeup MR 事件大致遵循 GitLab 风格 payload
var ev struct {
ObjectKind string `json:"object_kind"`
EventType string `json:"event_type"`
ObjectAttributes *struct {
ID int `json:"id"`
IID int `json:"iid"`
Title string `json:"title"`
Description string `json:"description"`
State string `json:"state"`
Action string `json:"action"`
URL string `json:"url"`
SourceBranch string `json:"source_branch"`
TargetBranch string `json:"target_branch"`
} `json:"object_attributes"`
User *struct {
ID int `json:"id"`
Username string `json:"username"`
Name string `json:"name"`
Email string `json:"email"`
AvatarURL string `json:"avatar_url"`
} `json:"user"`
Repository *struct {
ID int `json:"id"`
Name string `json:"name"`
FullName string `json:"full_name"`
URL string `json:"url"`
GitHTTPURL string `json:"git_http_url"`
Description string `json:"description"`
Homepage string `json:"homepage"`
Visibility string `json:"visibility"`
} `json:"repository"`
Project *struct {
ID int `json:"id"`
Name string `json:"name"`
PathWithNamespace string `json:"path_with_namespace"`
WebURL string `json:"web_url"`
Description string `json:"description"`
Visibility string `json:"visibility"`
} `json:"project"`
}
if err := json.Unmarshal(payload, &ev); err != nil {
h.logger.With("error", err).ErrorContext(ctx, "failed to unmarshal codeup mr event")
return
}

mr := ev.ObjectAttributes
if mr == nil || ev.User == nil {
return
}

state := strings.ToLower(mr.State)
if state != "open" && state != "opened" {
return
}
switch strings.ToLower(mr.Action) {
case "open", "opened", "update", "updated", "reopen", "reopened", "synchronize":
default:
return
}

key := mr.URL
if key == "" {
key = fmt.Sprintf("%d", mr.ID)
}
if !dedup(ctx, h.redis, key, h.logger) {
return
}

repoID, repoName, repoFullName, repoURL, repoDesc := "", "", "", "", ""
isPrivate := false
switch {
case ev.Repository != nil:
repoID = fmt.Sprintf("%d", ev.Repository.ID)
repoName = ev.Repository.Name
repoFullName = ev.Repository.FullName
repoURL = firstNonEmpty(ev.Repository.URL, ev.Repository.Homepage, ev.Repository.GitHTTPURL)
repoDesc = ev.Repository.Description
isPrivate = strings.EqualFold(ev.Repository.Visibility, "private") || ev.Repository.Visibility == ""
case ev.Project != nil:
repoID = fmt.Sprintf("%d", ev.Project.ID)
repoName = ev.Project.Name
repoFullName = ev.Project.PathWithNamespace
repoURL = ev.Project.WebURL
repoDesc = ev.Project.Description
isPrivate = strings.EqualFold(ev.Project.Visibility, "private") || ev.Project.Visibility == ""
default:
return
}

host, err := h.pubhost.PickHost(ctx)
if err != nil {
h.logger.With("error", err).ErrorContext(ctx, "failed to pick host")
return
}

branch := mr.SourceBranch
if _, err := h.gitTaskUsecase.Create(ctx, domain.CreateGitTaskReq{
HostID: host.ID,
ImageID: uuid.MustParse(h.cfg.Task.ImageID),
Prompt: key,
Git: taskflow.Git{Token: bot.Token},
Subject: domain.Subject{
ID: fmt.Sprintf("%d", mr.ID), Type: "PullRequest",
Title: mr.Title, URL: key, Number: mr.IID,
},
Repo: domain.Repo{
ID: repoID, Name: repoName,
FullName: repoFullName, URL: repoURL,
Desc: repoDesc, IsPrivate: isPrivate, Branch: &branch,
},
Platform: consts.GitPlatformCodeup,
User: domain.User{
Name: firstNonEmpty(ev.User.Name, ev.User.Username),
AvatarURL: ev.User.AvatarURL,
Email: ev.User.Email,
},
Body: mr.Description,
Time: time.Now(),
Env: map[string]string{"CODEUP_TOKEN": bot.Token},
Bot: bot,
}); err != nil {
h.logger.With("error", err).ErrorContext(ctx, "failed to create git task")
}
}

func firstNonEmptyHeader(h http.Header, keys ...string) string {
for _, k := range keys {
if v := h.Get(k); v != "" {
return v
}
}
return ""
}
2 changes: 2 additions & 0 deletions backend/biz/git/register.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ func ProvideGit(i *do.Injector) {
do.Provide(i, v1.NewGitlabWebhookHandler)
do.Provide(i, v1.NewGiteeWebhookHandler)
do.Provide(i, v1.NewGiteaWebhookHandler)
do.Provide(i, v1.NewCodeupWebhookHandler)
}

// InvokeGit 触发 git 模块的 handler 初始化
Expand All @@ -32,4 +33,5 @@ func InvokeGit(i *do.Injector) {
do.MustInvoke[*v1.GitlabWebhookHandler](i)
do.MustInvoke[*v1.GiteeWebhookHandler](i)
do.MustInvoke[*v1.GiteaWebhookHandler](i)
do.MustInvoke[*v1.CodeupWebhookHandler](i)
}
4 changes: 4 additions & 0 deletions backend/biz/git/repo/identity.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ func (r *GitIdentityRepo) Create(ctx context.Context, uid uuid.UUID, req *domain
SetUsername(req.Username).
SetEmail(req.Email).
SetRemark(req.Remark).
SetOrganizationID(req.OrganizationID).
Save(ctx)
}

Expand Down Expand Up @@ -82,6 +83,9 @@ func (r *GitIdentityRepo) Update(ctx context.Context, uid uuid.UUID, id uuid.UUI
if req.OAuthExpiresAt != nil {
upt.SetOauthExpiresAt(*req.OAuthExpiresAt)
}
if req.OrganizationID != nil {
upt.SetOrganizationID(*req.OrganizationID)
}
return upt.Exec(ctx)
}

Expand Down
38 changes: 38 additions & 0 deletions backend/biz/git/usecase/identity.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@ import (
"github.com/chaitin/MonkeyCode/backend/errcode"
"github.com/chaitin/MonkeyCode/backend/pkg/cvt"
gitpkg "github.com/chaitin/MonkeyCode/backend/pkg/git"
"github.com/chaitin/MonkeyCode/backend/pkg/git/atomgit"
"github.com/chaitin/MonkeyCode/backend/pkg/git/cnb"
"github.com/chaitin/MonkeyCode/backend/pkg/git/codeup"
"github.com/chaitin/MonkeyCode/backend/pkg/git/gitea"
"github.com/chaitin/MonkeyCode/backend/pkg/git/gitee"
"github.com/chaitin/MonkeyCode/backend/pkg/git/github"
Expand Down Expand Up @@ -67,6 +70,12 @@ func (u *GitIdentityUsecase) gitClienter(identity *db.GitIdentity) domain.GitCli
inner = gitea.NewGitea(u.logger, identity.BaseURL)
case consts.GitPlatformGitee:
inner = gitee.NewGitee(identity.BaseURL, u.logger)
case consts.GitPlatformCodeup:
inner = codeup.NewCodeup(identity.BaseURL, identity.OrganizationID, u.logger)
case consts.GitPlatformCnb:
inner = cnb.NewCnb(identity.BaseURL, u.logger)
case consts.GitPlatformAtomgit:
inner = atomgit.NewAtomgit(identity.BaseURL, u.logger)
default:
return nil
}
Expand Down Expand Up @@ -136,10 +145,33 @@ func (u *GitIdentityUsecase) Add(ctx context.Context, uid uuid.UUID, req *domain
u.logger.ErrorContext(ctx, "failed to create git identity", "error", err, "user_id", uid)
return nil, err
}
// Codeup 绑定时若未提供 organization_id,尝试通过 token 自动解析并回写
if identity.Platform == consts.GitPlatformCodeup && identity.OrganizationID == "" && identity.AccessToken != "" {
if orgID, rerr := u.resolveCodeupOrgID(ctx, identity); rerr == nil && orgID != "" {
if uerr := u.repo.Update(ctx, uid, identity.ID, &domain.UpdateGitIdentityReq{
ID: identity.ID,
OrganizationID: &orgID,
}); uerr == nil {
identity.OrganizationID = orgID
} else {
u.logger.WarnContext(ctx, "failed to persist resolved codeup org id",
"identity_id", identity.ID, "error", uerr)
}
} else if rerr != nil {
u.logger.WarnContext(ctx, "failed to resolve codeup org id",
"identity_id", identity.ID, "error", rerr)
}
}
u.prefetchRepositories(identity)
return cvt.From(identity, &domain.GitIdentity{}), nil
}

// resolveCodeupOrgID 用 token 拉取云效组织信息,返回首个可用 orgID
func (u *GitIdentityUsecase) resolveCodeupOrgID(ctx context.Context, identity *db.GitIdentity) (string, error) {
c := codeup.NewCodeup(identity.BaseURL, "", u.logger)
return c.ResolveOrgID(ctx, identity.AccessToken)
}

// Update 更新 Git 身份认证
func (u *GitIdentityUsecase) Update(ctx context.Context, uid uuid.UUID, req *domain.UpdateGitIdentityReq) error {
if err := u.repo.Update(ctx, uid, req.ID, req); err != nil {
Expand Down Expand Up @@ -225,6 +257,12 @@ func (u *GitIdentityUsecase) ListBranches(ctx context.Context, uid uuid.UUID, id
client = gitea.NewGitea(u.logger, identity.BaseURL)
case consts.GitPlatformGitee:
client = gitee.NewGitee(identity.BaseURL, u.logger)
case consts.GitPlatformCodeup:
client = codeup.NewCodeup(identity.BaseURL, identity.OrganizationID, u.logger)
case consts.GitPlatformCnb:
client = cnb.NewCnb(identity.BaseURL, u.logger)
case consts.GitPlatformAtomgit:
client = atomgit.NewAtomgit(identity.BaseURL, u.logger)
default:
return nil, errcode.ErrInvalidPlatform
}
Expand Down
39 changes: 39 additions & 0 deletions backend/biz/git/usecase/token.go
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,8 @@ func (p *TokenProvider) resolveToken(ctx context.Context, gi *db.GitIdentity) (s
return p.resolveOAuth(ctx, gi, p.refreshGitea)
case consts.GitPlatformGitee:
return p.resolveOAuth(ctx, gi, p.refreshGitee)
case consts.GitPlatformCnb:
return p.resolveOAuth(ctx, gi, p.refreshCnb)
default:
if gi.AccessToken == "" {
return "", 0, fmt.Errorf("git identity %s has no access token", gi.ID)
Expand Down Expand Up @@ -215,6 +217,43 @@ func (p *TokenProvider) refreshGitee(_ context.Context, gi *db.GitIdentity) (str
return resp.AccessToken, resp.RefreshToken, time.Unix(resp.ExpiresAt(), 0), nil
}

// refreshCodeup 走阿里云通用 OAuth2 token endpoint 刷新云效 access_token。
// site 配置中 BaseURL 视为可选的 token endpoint 覆盖,常规情况下留空走 account.aliyun.com。
//
// ⚠️ 暂未启用:云效 OAuth(CreateOAuthToken)官方仍在内测,且响应不返回 refresh_token,
// 当前 Provide() 已在 case consts.GitPlatformCodeup 处直接拦截 OAuth identity。本函数保留
// 作为占位,等云效公网 OAuth 放开并补齐响应字段后再启用。
//
//nolint:unused // 占位实现,等云效 OAuth 放开后再启用
func (p *TokenProvider) refreshCodeup(ctx context.Context, gi *db.GitIdentity) (string, string, time.Time, error) {
site, err := p.resolveSiteConfig(ctx, gi.BaseURL)
if err != nil {
return "", "", time.Time{}, fmt.Errorf("resolve codeup site: %w", err)
}
resp, err := oauth.RefreshCodeup(site.BaseURL, site.ClientID, site.ClientSecret, gi.OauthRefreshToken, p.proxies...)
if err != nil {
return "", "", time.Time{}, err
}
return resp.AccessToken, resp.RefreshToken, time.Unix(resp.ExpiresAt(), 0), nil
}

// refreshCnb 走 https://cnb.cool/oauth2/token 刷新 access_token。
//
// CNB OAuth 走标准 OAuth2: refresh_token (180d) 换 access_token (8h),客户端凭证以
// HTTP Basic Auth 头传递。site.BaseURL 视为可选的 token endpoint 覆盖, 常规情况下
// 留空走默认 https://cnb.cool。
func (p *TokenProvider) refreshCnb(ctx context.Context, gi *db.GitIdentity) (string, string, time.Time, error) {
site, err := p.resolveSiteConfig(ctx, gi.BaseURL)
if err != nil {
return "", "", time.Time{}, fmt.Errorf("resolve cnb site: %w", err)
}
resp, err := oauth.RefreshCnb(site.BaseURL, site.ClientID, site.ClientSecret, gi.OauthRefreshToken, p.proxies...)
if err != nil {
return "", "", time.Time{}, err
}
return resp.AccessToken, resp.RefreshToken, time.Unix(resp.ExpiresAt(), 0), nil
}

// ClearCache 清除指定 GitIdentity 的 token 缓存
func (p *TokenProvider) ClearCache(identityID uuid.UUID) {
p.tokenCache.Delete(identityID.String())
Expand Down
2 changes: 1 addition & 1 deletion backend/biz/host/repo/host.go
Original file line number Diff line number Diff line change
Expand Up @@ -685,7 +685,7 @@ func (h *HostRepo) GetGitCredentialByTask(ctx context.Context, taskID string) (*
}
if pt.Edges.Task != nil && pt.Edges.Task.Edges.User != nil {
info.GitUsername = pt.Edges.Task.Edges.User.Name
if gi.Platform == consts.GitPlatformGitee {
if gi.Platform == consts.GitPlatformGitee || gi.Platform == consts.GitPlatformCnb {
info.GitUsername = gi.Username
}
}
Expand Down
Loading