diff --git a/backend/biz/git/handler/v1/webhook_codeup.go b/backend/biz/git/handler/v1/webhook_codeup.go new file mode 100644 index 00000000..1f46658b --- /dev/null +++ b/backend/biz/git/handler/v1/webhook_codeup.go @@ -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 "" +} diff --git a/backend/biz/git/register.go b/backend/biz/git/register.go index 86efc495..7cf267bf 100644 --- a/backend/biz/git/register.go +++ b/backend/biz/git/register.go @@ -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 初始化 @@ -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) } diff --git a/backend/biz/git/repo/identity.go b/backend/biz/git/repo/identity.go index 44c1b4db..f2fef67f 100644 --- a/backend/biz/git/repo/identity.go +++ b/backend/biz/git/repo/identity.go @@ -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) } @@ -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) } diff --git a/backend/biz/git/usecase/identity.go b/backend/biz/git/usecase/identity.go index edaec81a..4b9c0c34 100644 --- a/backend/biz/git/usecase/identity.go +++ b/backend/biz/git/usecase/identity.go @@ -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" @@ -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 } @@ -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 { @@ -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 } diff --git a/backend/biz/git/usecase/token.go b/backend/biz/git/usecase/token.go index a80a0914..d1e63c8a 100644 --- a/backend/biz/git/usecase/token.go +++ b/backend/biz/git/usecase/token.go @@ -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) @@ -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()) diff --git a/backend/biz/host/repo/host.go b/backend/biz/host/repo/host.go index 516ba911..415fd395 100644 --- a/backend/biz/host/repo/host.go +++ b/backend/biz/host/repo/host.go @@ -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 } } diff --git a/backend/biz/project/usecase/project.go b/backend/biz/project/usecase/project.go index fabd6102..23daab99 100644 --- a/backend/biz/project/usecase/project.go +++ b/backend/biz/project/usecase/project.go @@ -18,6 +18,9 @@ import ( "github.com/chaitin/MonkeyCode/backend/domain" "github.com/chaitin/MonkeyCode/backend/errcode" "github.com/chaitin/MonkeyCode/backend/pkg/cvt" + "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" @@ -312,6 +315,39 @@ func (u *ProjectUsecase) getClient(p *db.Project) (domain.GitClienter, *ClientCo Owner: owner, Repo: repo, DefaultBranch: p.Branch, Token: token, IsOAuth: gi.OauthRefreshToken != "", }, nil + case consts.GitPlatformCodeup: + orgID, identity, perr := codeup.ParseRepoPath(p.RepoURL) + if perr != nil { + return nil, nil, errcode.ErrGitOperation.Wrap(perr) + } + if gi.OrganizationID == "" { + gi.OrganizationID = orgID + } + client := codeup.NewCodeup(gi.BaseURL, gi.OrganizationID, u.logger) + return client, &ClientContext{ + Owner: "", Repo: identity, DefaultBranch: p.Branch, Token: token, IsOAuth: gi.OauthRefreshToken != "", + }, nil + + case consts.GitPlatformCnb: + slug, perr := cnb.ParseRepoPath(p.RepoURL) + if perr != nil { + return nil, nil, errcode.ErrGitOperation.Wrap(perr) + } + client := cnb.NewCnb(gi.BaseURL, u.logger) + return client, &ClientContext{ + Owner: "", Repo: slug, DefaultBranch: p.Branch, Token: token, IsOAuth: gi.OauthRefreshToken != "", + }, nil + + case consts.GitPlatformAtomgit: + owner, repo, perr := atomgit.ParseRepoPath(p.RepoURL) + if perr != nil { + return nil, nil, errcode.ErrGitOperation.Wrap(perr) + } + client := atomgit.NewAtomgit(gi.BaseURL, u.logger) + return client, &ClientContext{ + Owner: owner, Repo: repo, DefaultBranch: p.Branch, Token: token, IsOAuth: gi.OauthRefreshToken != "", + }, nil + default: return nil, nil, errcode.ErrGitOperation.Wrap(fmt.Errorf("unsupported platform: %s", p.Platform)) } diff --git a/backend/biz/task/usecase/task.go b/backend/biz/task/usecase/task.go index f56be27d..bd8efaf9 100644 --- a/backend/biz/task/usecase/task.go +++ b/backend/biz/task/usecase/task.go @@ -478,6 +478,9 @@ func (a *TaskUsecase) Create(ctx context.Context, user *domain.User, req domain. git.Email = identity.Email } + // 打印一下 codeup 的 git 凭证我要看看为什么没有 clone 下来 + a.logger.InfoContext(ctx, "codeup git identity is", git) + limit := 1 if a.taskHook != nil { if req.SystemPrompt == "" { diff --git a/backend/config/config.go b/backend/config/config.go index 4683e8af..277d007b 100644 --- a/backend/config/config.go +++ b/backend/config/config.go @@ -68,6 +68,9 @@ type Config struct { Gitlab GitlabConfig `mapstructure:"gitlab"` Gitea GiteaConfig `mapstructure:"gitea"` Gitee GiteeConfig `mapstructure:"gitee"` + Codeup CodeupConfig `mapstructure:"codeup"` + Cnb CnbConfig `mapstructure:"cnb"` + Atomgit AtomgitConfig `mapstructure:"atomgit"` // 微信配置(开放平台 OAuth 登录 + 公众号消息推送) Wechat WechatConfig `mapstructure:"wechat"` @@ -472,6 +475,59 @@ type GiteeOAuthConfig struct { RedirectURL string `mapstructure:"redirect_url"` } +// CodeupConfig 阿里云云效 Codeup 配置 +type CodeupConfig struct { + // OpenAPI 域名(默认 https://openapi-rdc.aliyuncs.com,专有云可覆盖) + BaseURL string `mapstructure:"base_url"` + Enabled bool `mapstructure:"enabled"` + OAuth CodeupOAuthConfig `mapstructure:"oauth"` +} + +// CodeupOAuthConfig Codeup OAuth 配置(走阿里云账号 OAuth2) +// +// ⚠️ 暂未启用:云效 OAuth(POST /login/oauth/create)官方仍在内测,且响应不返回 +// refresh_token / expires_in,与现有 OAuth2 刷新模型不兼容。当前 codeup 一律走 PAT +// 模式(identity.AccessToken 存用户填写的 PAT)。等云效放开公网接入并补齐响应字段后, +// 再补 Authorize/Callback handler 和 token 持久化逻辑。 +type CodeupOAuthConfig struct { + ClientID string `mapstructure:"client_id"` + ClientSecret string `mapstructure:"client_secret"` + RedirectURL string `mapstructure:"redirect_url"` + // TokenURL 阿里云 OAuth2 token endpoint,默认 https://account.aliyun.com/oauth2/v1/token + TokenURL string `mapstructure:"token_url"` + // AuthorizeURL 阿里云 OAuth2 authorize endpoint,默认 https://account.aliyun.com/oauth2/v1/auth + AuthorizeURL string `mapstructure:"authorize_url"` +} + +// CnbConfig 腾讯 CNB (cnb.cool) 配置 +type CnbConfig struct { + // OpenAPI 域名(默认 https://api.cnb.cool) + BaseURL string `mapstructure:"base_url"` + // Web 域名(默认 https://cnb.cool,OAuth authorize / token endpoint 走这个) + WebBaseURL string `mapstructure:"web_base_url"` + Enabled bool `mapstructure:"enabled"` + OAuth CnbOAuthConfig `mapstructure:"oauth"` +} + +// CnbOAuthConfig CNB OAuth2 配置(标准授权码模式 + refresh_token) +type CnbOAuthConfig struct { + ClientID string `mapstructure:"client_id"` + ClientSecret string `mapstructure:"client_secret"` + RedirectURL string `mapstructure:"redirect_url"` + // Scope 默认 "repo-basic-info repo-code account-profile" + Scope string `mapstructure:"scope"` +} + +// AtomgitConfig atomgit (https://atomgit.com) 配置 +// +// 当前实现仅支持 PAT 模式; OAuth authorize/callback 链路待补 (token endpoint 已在文档中: +// POST https://api.atomgit.com/login/oauth/access_token, refresh_token 也走该地址)。 +type AtomgitConfig struct { + // OpenAPI 域名(默认 https://api.atomgit.com) + BaseURL string `mapstructure:"base_url"` + Enabled bool `mapstructure:"enabled"` +} + // IsGithubEnabled 检查 GitHub 是否启用 func (c *Config) IsGithubEnabled() bool { return c.Github.Enabled @@ -612,6 +668,113 @@ func (c *Config) GetGiteeOAuthRedirectURL() string { return c.Server.BaseURL + "/api/v1/oauth/gitee/callback" } +// IsCodeupEnabled 检查 Codeup 是否启用 +func (c *Config) IsCodeupEnabled() bool { + return c.Codeup.Enabled +} + +// GetCodeupBaseURL 获取 Codeup OpenAPI 域名(默认公共站) +func (c *Config) GetCodeupBaseURL() string { + if c.Codeup.BaseURL != "" { + return c.Codeup.BaseURL + } + return "https://openapi-rdc.aliyuncs.com" +} + +// GetCodeupOAuthClientID 获取 Codeup OAuth Client ID +func (c *Config) GetCodeupOAuthClientID() string { + return c.Codeup.OAuth.ClientID +} + +// GetCodeupOAuthClientSecret 获取 Codeup OAuth Client Secret +func (c *Config) GetCodeupOAuthClientSecret() string { + return c.Codeup.OAuth.ClientSecret +} + +// GetCodeupOAuthRedirectURL 获取 Codeup OAuth Redirect URL +func (c *Config) GetCodeupOAuthRedirectURL() string { + if c.Codeup.OAuth.RedirectURL != "" { + return c.Codeup.OAuth.RedirectURL + } + return c.Server.BaseURL + "/api/v1/oauth/codeup/callback" +} + +// GetCodeupOAuthTokenURL 获取 Codeup OAuth token endpoint(默认阿里云账号通用网关) +func (c *Config) GetCodeupOAuthTokenURL() string { + if c.Codeup.OAuth.TokenURL != "" { + return c.Codeup.OAuth.TokenURL + } + return "https://account.aliyun.com/oauth2/v1/token" +} + +// GetCodeupOAuthAuthorizeURL 获取 Codeup OAuth authorize endpoint +func (c *Config) GetCodeupOAuthAuthorizeURL() string { + if c.Codeup.OAuth.AuthorizeURL != "" { + return c.Codeup.OAuth.AuthorizeURL + } + return "https://account.aliyun.com/oauth2/v1/auth" +} + +// IsCnbEnabled 检查 CNB 是否启用 +func (c *Config) IsCnbEnabled() bool { + return c.Cnb.Enabled +} + +// GetCnbBaseURL 获取 CNB OpenAPI 域名(默认 https://api.cnb.cool) +func (c *Config) GetCnbBaseURL() string { + if c.Cnb.BaseURL != "" { + return c.Cnb.BaseURL + } + return "https://api.cnb.cool" +} + +// GetCnbWebBaseURL 获取 CNB Web 域名(OAuth authorize/token 走此域名) +func (c *Config) GetCnbWebBaseURL() string { + if c.Cnb.WebBaseURL != "" { + return c.Cnb.WebBaseURL + } + return "https://cnb.cool" +} + +// GetCnbOAuthClientID 获取 CNB OAuth Client ID +func (c *Config) GetCnbOAuthClientID() string { + return c.Cnb.OAuth.ClientID +} + +// GetCnbOAuthClientSecret 获取 CNB OAuth Client Secret +func (c *Config) GetCnbOAuthClientSecret() string { + return c.Cnb.OAuth.ClientSecret +} + +// GetCnbOAuthRedirectURL 获取 CNB OAuth Redirect URL +func (c *Config) GetCnbOAuthRedirectURL() string { + if c.Cnb.OAuth.RedirectURL != "" { + return c.Cnb.OAuth.RedirectURL + } + return c.Server.BaseURL + "/api/v1/oauth/cnb/callback" +} + +// GetCnbOAuthScope 获取 CNB OAuth scope(多 scope 空格分隔) +func (c *Config) GetCnbOAuthScope() string { + if c.Cnb.OAuth.Scope != "" { + return c.Cnb.OAuth.Scope + } + return "repo-basic-info repo-code account-profile" +} + +// IsAtomgitEnabled 检查 atomgit 是否启用 +func (c *Config) IsAtomgitEnabled() bool { + return c.Atomgit.Enabled +} + +// GetAtomgitBaseURL 获取 atomgit OpenAPI 域名(默认 https://api.atomgit.com) +func (c *Config) GetAtomgitBaseURL() string { + if c.Atomgit.BaseURL != "" { + return c.Atomgit.BaseURL + } + return "https://api.atomgit.com" +} + // WechatConfig 微信配置(包含开放平台和公众号两部分) type WechatConfig struct { Open WechatOpenConfig `mapstructure:"open"` diff --git a/backend/consts/git.go b/backend/consts/git.go index c406ae37..1c3fe2e7 100644 --- a/backend/consts/git.go +++ b/backend/consts/git.go @@ -8,4 +8,7 @@ const ( GitPlatformGitLab GitPlatform = "gitlab" GitPlatformGitea GitPlatform = "gitea" GitPlatformGitee GitPlatform = "gitee" + GitPlatformCodeup GitPlatform = "codeup" + GitPlatformCnb GitPlatform = "cnb" + GitPlatformAtomgit GitPlatform = "atomgit" ) diff --git a/backend/db/gitidentity.go b/backend/db/gitidentity.go index 4a2940a6..e56b127f 100644 --- a/backend/db/gitidentity.go +++ b/backend/db/gitidentity.go @@ -36,6 +36,8 @@ type GitIdentity struct { Email string `json:"email,omitempty"` // InstallationID holds the value of the "installation_id" field. InstallationID int64 `json:"installation_id,omitempty"` + // OrganizationID holds the value of the "organization_id" field. + OrganizationID string `json:"organization_id,omitempty"` // Remark holds the value of the "remark" field. Remark string `json:"remark,omitempty"` // OauthRefreshToken holds the value of the "oauth_refresh_token" field. @@ -112,7 +114,7 @@ func (*GitIdentity) scanValues(columns []string) ([]any, error) { switch columns[i] { case gitidentity.FieldInstallationID: values[i] = new(sql.NullInt64) - case gitidentity.FieldPlatform, gitidentity.FieldBaseURL, gitidentity.FieldAccessToken, gitidentity.FieldUsername, gitidentity.FieldEmail, gitidentity.FieldRemark, gitidentity.FieldOauthRefreshToken: + case gitidentity.FieldPlatform, gitidentity.FieldBaseURL, gitidentity.FieldAccessToken, gitidentity.FieldUsername, gitidentity.FieldEmail, gitidentity.FieldOrganizationID, gitidentity.FieldRemark, gitidentity.FieldOauthRefreshToken: values[i] = new(sql.NullString) case gitidentity.FieldDeletedAt, gitidentity.FieldOauthExpiresAt, gitidentity.FieldCreatedAt, gitidentity.FieldUpdatedAt: values[i] = new(sql.NullTime) @@ -187,6 +189,12 @@ func (_m *GitIdentity) assignValues(columns []string, values []any) error { } else if value.Valid { _m.InstallationID = value.Int64 } + case gitidentity.FieldOrganizationID: + if value, ok := values[i].(*sql.NullString); !ok { + return fmt.Errorf("unexpected type %T for field organization_id", values[i]) + } else if value.Valid { + _m.OrganizationID = value.String + } case gitidentity.FieldRemark: if value, ok := values[i].(*sql.NullString); !ok { return fmt.Errorf("unexpected type %T for field remark", values[i]) @@ -298,6 +306,9 @@ func (_m *GitIdentity) String() string { builder.WriteString("installation_id=") builder.WriteString(fmt.Sprintf("%v", _m.InstallationID)) builder.WriteString(", ") + builder.WriteString("organization_id=") + builder.WriteString(_m.OrganizationID) + builder.WriteString(", ") builder.WriteString("remark=") builder.WriteString(_m.Remark) builder.WriteString(", ") diff --git a/backend/db/gitidentity/gitidentity.go b/backend/db/gitidentity/gitidentity.go index a59914ef..bf2f6251 100644 --- a/backend/db/gitidentity/gitidentity.go +++ b/backend/db/gitidentity/gitidentity.go @@ -31,6 +31,8 @@ const ( FieldEmail = "email" // FieldInstallationID holds the string denoting the installation_id field in the database. FieldInstallationID = "installation_id" + // FieldOrganizationID holds the string denoting the organization_id field in the database. + FieldOrganizationID = "organization_id" // FieldRemark holds the string denoting the remark field in the database. FieldRemark = "remark" // FieldOauthRefreshToken holds the string denoting the oauth_refresh_token field in the database. @@ -92,6 +94,7 @@ var Columns = []string{ FieldUsername, FieldEmail, FieldInstallationID, + FieldOrganizationID, FieldRemark, FieldOauthRefreshToken, FieldOauthExpiresAt, @@ -173,6 +176,11 @@ func ByInstallationID(opts ...sql.OrderTermOption) OrderOption { return sql.OrderByField(FieldInstallationID, opts...).ToFunc() } +// ByOrganizationID orders the results by the organization_id field. +func ByOrganizationID(opts ...sql.OrderTermOption) OrderOption { + return sql.OrderByField(FieldOrganizationID, opts...).ToFunc() +} + // ByRemark orders the results by the remark field. func ByRemark(opts ...sql.OrderTermOption) OrderOption { return sql.OrderByField(FieldRemark, opts...).ToFunc() diff --git a/backend/db/gitidentity/where.go b/backend/db/gitidentity/where.go index 13f9e41a..f43c1438 100644 --- a/backend/db/gitidentity/where.go +++ b/backend/db/gitidentity/where.go @@ -98,6 +98,11 @@ func InstallationID(v int64) predicate.GitIdentity { return predicate.GitIdentity(sql.FieldEQ(FieldInstallationID, v)) } +// OrganizationID applies equality check predicate on the "organization_id" field. It's identical to OrganizationIDEQ. +func OrganizationID(v string) predicate.GitIdentity { + return predicate.GitIdentity(sql.FieldEQ(FieldOrganizationID, v)) +} + // Remark applies equality check predicate on the "remark" field. It's identical to RemarkEQ. func Remark(v string) predicate.GitIdentity { return predicate.GitIdentity(sql.FieldEQ(FieldRemark, v)) @@ -627,6 +632,81 @@ func InstallationIDNotNil() predicate.GitIdentity { return predicate.GitIdentity(sql.FieldNotNull(FieldInstallationID)) } +// OrganizationIDEQ applies the EQ predicate on the "organization_id" field. +func OrganizationIDEQ(v string) predicate.GitIdentity { + return predicate.GitIdentity(sql.FieldEQ(FieldOrganizationID, v)) +} + +// OrganizationIDNEQ applies the NEQ predicate on the "organization_id" field. +func OrganizationIDNEQ(v string) predicate.GitIdentity { + return predicate.GitIdentity(sql.FieldNEQ(FieldOrganizationID, v)) +} + +// OrganizationIDIn applies the In predicate on the "organization_id" field. +func OrganizationIDIn(vs ...string) predicate.GitIdentity { + return predicate.GitIdentity(sql.FieldIn(FieldOrganizationID, vs...)) +} + +// OrganizationIDNotIn applies the NotIn predicate on the "organization_id" field. +func OrganizationIDNotIn(vs ...string) predicate.GitIdentity { + return predicate.GitIdentity(sql.FieldNotIn(FieldOrganizationID, vs...)) +} + +// OrganizationIDGT applies the GT predicate on the "organization_id" field. +func OrganizationIDGT(v string) predicate.GitIdentity { + return predicate.GitIdentity(sql.FieldGT(FieldOrganizationID, v)) +} + +// OrganizationIDGTE applies the GTE predicate on the "organization_id" field. +func OrganizationIDGTE(v string) predicate.GitIdentity { + return predicate.GitIdentity(sql.FieldGTE(FieldOrganizationID, v)) +} + +// OrganizationIDLT applies the LT predicate on the "organization_id" field. +func OrganizationIDLT(v string) predicate.GitIdentity { + return predicate.GitIdentity(sql.FieldLT(FieldOrganizationID, v)) +} + +// OrganizationIDLTE applies the LTE predicate on the "organization_id" field. +func OrganizationIDLTE(v string) predicate.GitIdentity { + return predicate.GitIdentity(sql.FieldLTE(FieldOrganizationID, v)) +} + +// OrganizationIDContains applies the Contains predicate on the "organization_id" field. +func OrganizationIDContains(v string) predicate.GitIdentity { + return predicate.GitIdentity(sql.FieldContains(FieldOrganizationID, v)) +} + +// OrganizationIDHasPrefix applies the HasPrefix predicate on the "organization_id" field. +func OrganizationIDHasPrefix(v string) predicate.GitIdentity { + return predicate.GitIdentity(sql.FieldHasPrefix(FieldOrganizationID, v)) +} + +// OrganizationIDHasSuffix applies the HasSuffix predicate on the "organization_id" field. +func OrganizationIDHasSuffix(v string) predicate.GitIdentity { + return predicate.GitIdentity(sql.FieldHasSuffix(FieldOrganizationID, v)) +} + +// OrganizationIDIsNil applies the IsNil predicate on the "organization_id" field. +func OrganizationIDIsNil() predicate.GitIdentity { + return predicate.GitIdentity(sql.FieldIsNull(FieldOrganizationID)) +} + +// OrganizationIDNotNil applies the NotNil predicate on the "organization_id" field. +func OrganizationIDNotNil() predicate.GitIdentity { + return predicate.GitIdentity(sql.FieldNotNull(FieldOrganizationID)) +} + +// OrganizationIDEqualFold applies the EqualFold predicate on the "organization_id" field. +func OrganizationIDEqualFold(v string) predicate.GitIdentity { + return predicate.GitIdentity(sql.FieldEqualFold(FieldOrganizationID, v)) +} + +// OrganizationIDContainsFold applies the ContainsFold predicate on the "organization_id" field. +func OrganizationIDContainsFold(v string) predicate.GitIdentity { + return predicate.GitIdentity(sql.FieldContainsFold(FieldOrganizationID, v)) +} + // RemarkEQ applies the EQ predicate on the "remark" field. func RemarkEQ(v string) predicate.GitIdentity { return predicate.GitIdentity(sql.FieldEQ(FieldRemark, v)) diff --git a/backend/db/gitidentity_create.go b/backend/db/gitidentity_create.go index 8a1a70df..18ace3c1 100644 --- a/backend/db/gitidentity_create.go +++ b/backend/db/gitidentity_create.go @@ -125,6 +125,20 @@ func (_c *GitIdentityCreate) SetNillableInstallationID(v *int64) *GitIdentityCre return _c } +// SetOrganizationID sets the "organization_id" field. +func (_c *GitIdentityCreate) SetOrganizationID(v string) *GitIdentityCreate { + _c.mutation.SetOrganizationID(v) + return _c +} + +// SetNillableOrganizationID sets the "organization_id" field if the given value is not nil. +func (_c *GitIdentityCreate) SetNillableOrganizationID(v *string) *GitIdentityCreate { + if v != nil { + _c.SetOrganizationID(*v) + } + return _c +} + // SetRemark sets the "remark" field. func (_c *GitIdentityCreate) SetRemark(v string) *GitIdentityCreate { _c.mutation.SetRemark(v) @@ -386,6 +400,10 @@ func (_c *GitIdentityCreate) createSpec() (*GitIdentity, *sqlgraph.CreateSpec) { _spec.SetField(gitidentity.FieldInstallationID, field.TypeInt64, value) _node.InstallationID = value } + if value, ok := _c.mutation.OrganizationID(); ok { + _spec.SetField(gitidentity.FieldOrganizationID, field.TypeString, value) + _node.OrganizationID = value + } if value, ok := _c.mutation.Remark(); ok { _spec.SetField(gitidentity.FieldRemark, field.TypeString, value) _node.Remark = value @@ -661,6 +679,24 @@ func (u *GitIdentityUpsert) ClearInstallationID() *GitIdentityUpsert { return u } +// SetOrganizationID sets the "organization_id" field. +func (u *GitIdentityUpsert) SetOrganizationID(v string) *GitIdentityUpsert { + u.Set(gitidentity.FieldOrganizationID, v) + return u +} + +// UpdateOrganizationID sets the "organization_id" field to the value that was provided on create. +func (u *GitIdentityUpsert) UpdateOrganizationID() *GitIdentityUpsert { + u.SetExcluded(gitidentity.FieldOrganizationID) + return u +} + +// ClearOrganizationID clears the value of the "organization_id" field. +func (u *GitIdentityUpsert) ClearOrganizationID() *GitIdentityUpsert { + u.SetNull(gitidentity.FieldOrganizationID) + return u +} + // SetRemark sets the "remark" field. func (u *GitIdentityUpsert) SetRemark(v string) *GitIdentityUpsert { u.Set(gitidentity.FieldRemark, v) @@ -948,6 +984,27 @@ func (u *GitIdentityUpsertOne) ClearInstallationID() *GitIdentityUpsertOne { }) } +// SetOrganizationID sets the "organization_id" field. +func (u *GitIdentityUpsertOne) SetOrganizationID(v string) *GitIdentityUpsertOne { + return u.Update(func(s *GitIdentityUpsert) { + s.SetOrganizationID(v) + }) +} + +// UpdateOrganizationID sets the "organization_id" field to the value that was provided on create. +func (u *GitIdentityUpsertOne) UpdateOrganizationID() *GitIdentityUpsertOne { + return u.Update(func(s *GitIdentityUpsert) { + s.UpdateOrganizationID() + }) +} + +// ClearOrganizationID clears the value of the "organization_id" field. +func (u *GitIdentityUpsertOne) ClearOrganizationID() *GitIdentityUpsertOne { + return u.Update(func(s *GitIdentityUpsert) { + s.ClearOrganizationID() + }) +} + // SetRemark sets the "remark" field. func (u *GitIdentityUpsertOne) SetRemark(v string) *GitIdentityUpsertOne { return u.Update(func(s *GitIdentityUpsert) { @@ -1415,6 +1472,27 @@ func (u *GitIdentityUpsertBulk) ClearInstallationID() *GitIdentityUpsertBulk { }) } +// SetOrganizationID sets the "organization_id" field. +func (u *GitIdentityUpsertBulk) SetOrganizationID(v string) *GitIdentityUpsertBulk { + return u.Update(func(s *GitIdentityUpsert) { + s.SetOrganizationID(v) + }) +} + +// UpdateOrganizationID sets the "organization_id" field to the value that was provided on create. +func (u *GitIdentityUpsertBulk) UpdateOrganizationID() *GitIdentityUpsertBulk { + return u.Update(func(s *GitIdentityUpsert) { + s.UpdateOrganizationID() + }) +} + +// ClearOrganizationID clears the value of the "organization_id" field. +func (u *GitIdentityUpsertBulk) ClearOrganizationID() *GitIdentityUpsertBulk { + return u.Update(func(s *GitIdentityUpsert) { + s.ClearOrganizationID() + }) +} + // SetRemark sets the "remark" field. func (u *GitIdentityUpsertBulk) SetRemark(v string) *GitIdentityUpsertBulk { return u.Update(func(s *GitIdentityUpsert) { diff --git a/backend/db/gitidentity_update.go b/backend/db/gitidentity_update.go index 1c0f9154..e3b1afb7 100644 --- a/backend/db/gitidentity_update.go +++ b/backend/db/gitidentity_update.go @@ -190,6 +190,26 @@ func (_u *GitIdentityUpdate) ClearInstallationID() *GitIdentityUpdate { return _u } +// SetOrganizationID sets the "organization_id" field. +func (_u *GitIdentityUpdate) SetOrganizationID(v string) *GitIdentityUpdate { + _u.mutation.SetOrganizationID(v) + return _u +} + +// SetNillableOrganizationID sets the "organization_id" field if the given value is not nil. +func (_u *GitIdentityUpdate) SetNillableOrganizationID(v *string) *GitIdentityUpdate { + if v != nil { + _u.SetOrganizationID(*v) + } + return _u +} + +// ClearOrganizationID clears the value of the "organization_id" field. +func (_u *GitIdentityUpdate) ClearOrganizationID() *GitIdentityUpdate { + _u.mutation.ClearOrganizationID() + return _u +} + // SetRemark sets the "remark" field. func (_u *GitIdentityUpdate) SetRemark(v string) *GitIdentityUpdate { _u.mutation.SetRemark(v) @@ -504,6 +524,12 @@ func (_u *GitIdentityUpdate) sqlSave(ctx context.Context) (_node int, err error) if _u.mutation.InstallationIDCleared() { _spec.ClearField(gitidentity.FieldInstallationID, field.TypeInt64) } + if value, ok := _u.mutation.OrganizationID(); ok { + _spec.SetField(gitidentity.FieldOrganizationID, field.TypeString, value) + } + if _u.mutation.OrganizationIDCleared() { + _spec.ClearField(gitidentity.FieldOrganizationID, field.TypeString) + } if value, ok := _u.mutation.Remark(); ok { _spec.SetField(gitidentity.FieldRemark, field.TypeString, value) } @@ -869,6 +895,26 @@ func (_u *GitIdentityUpdateOne) ClearInstallationID() *GitIdentityUpdateOne { return _u } +// SetOrganizationID sets the "organization_id" field. +func (_u *GitIdentityUpdateOne) SetOrganizationID(v string) *GitIdentityUpdateOne { + _u.mutation.SetOrganizationID(v) + return _u +} + +// SetNillableOrganizationID sets the "organization_id" field if the given value is not nil. +func (_u *GitIdentityUpdateOne) SetNillableOrganizationID(v *string) *GitIdentityUpdateOne { + if v != nil { + _u.SetOrganizationID(*v) + } + return _u +} + +// ClearOrganizationID clears the value of the "organization_id" field. +func (_u *GitIdentityUpdateOne) ClearOrganizationID() *GitIdentityUpdateOne { + _u.mutation.ClearOrganizationID() + return _u +} + // SetRemark sets the "remark" field. func (_u *GitIdentityUpdateOne) SetRemark(v string) *GitIdentityUpdateOne { _u.mutation.SetRemark(v) @@ -1213,6 +1259,12 @@ func (_u *GitIdentityUpdateOne) sqlSave(ctx context.Context) (_node *GitIdentity if _u.mutation.InstallationIDCleared() { _spec.ClearField(gitidentity.FieldInstallationID, field.TypeInt64) } + if value, ok := _u.mutation.OrganizationID(); ok { + _spec.SetField(gitidentity.FieldOrganizationID, field.TypeString, value) + } + if _u.mutation.OrganizationIDCleared() { + _spec.ClearField(gitidentity.FieldOrganizationID, field.TypeString) + } if value, ok := _u.mutation.Remark(); ok { _spec.SetField(gitidentity.FieldRemark, field.TypeString, value) } diff --git a/backend/db/migrate/schema.go b/backend/db/migrate/schema.go index a6b71e22..3c179a40 100644 --- a/backend/db/migrate/schema.go +++ b/backend/db/migrate/schema.go @@ -131,6 +131,7 @@ var ( {Name: "username", Type: field.TypeString, Nullable: true}, {Name: "email", Type: field.TypeString, Nullable: true}, {Name: "installation_id", Type: field.TypeInt64, Nullable: true}, + {Name: "organization_id", Type: field.TypeString, Nullable: true}, {Name: "remark", Type: field.TypeString, Nullable: true}, {Name: "oauth_refresh_token", Type: field.TypeString, Nullable: true}, {Name: "oauth_expires_at", Type: field.TypeTime, Nullable: true}, @@ -146,7 +147,7 @@ var ( ForeignKeys: []*schema.ForeignKey{ { Symbol: "git_identities_users_git_identities", - Columns: []*schema.Column{GitIdentitiesColumns[13]}, + Columns: []*schema.Column{GitIdentitiesColumns[14]}, RefColumns: []*schema.Column{UsersColumns[0]}, OnDelete: schema.NoAction, }, diff --git a/backend/db/mutation.go b/backend/db/mutation.go index c201b380..f359602c 100644 --- a/backend/db/mutation.go +++ b/backend/db/mutation.go @@ -3194,6 +3194,7 @@ type GitIdentityMutation struct { email *string installation_id *int64 addinstallation_id *int64 + organization_id *string remark *string oauth_refresh_token *string oauth_expires_at *time.Time @@ -3707,6 +3708,55 @@ func (m *GitIdentityMutation) ResetInstallationID() { delete(m.clearedFields, gitidentity.FieldInstallationID) } +// SetOrganizationID sets the "organization_id" field. +func (m *GitIdentityMutation) SetOrganizationID(s string) { + m.organization_id = &s +} + +// OrganizationID returns the value of the "organization_id" field in the mutation. +func (m *GitIdentityMutation) OrganizationID() (r string, exists bool) { + v := m.organization_id + if v == nil { + return + } + return *v, true +} + +// OldOrganizationID returns the old "organization_id" field's value of the GitIdentity entity. +// If the GitIdentity object wasn't provided to the builder, the object is fetched from the database. +// An error is returned if the mutation operation is not UpdateOne, or the database query fails. +func (m *GitIdentityMutation) OldOrganizationID(ctx context.Context) (v string, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldOrganizationID is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldOrganizationID requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldOrganizationID: %w", err) + } + return oldValue.OrganizationID, nil +} + +// ClearOrganizationID clears the value of the "organization_id" field. +func (m *GitIdentityMutation) ClearOrganizationID() { + m.organization_id = nil + m.clearedFields[gitidentity.FieldOrganizationID] = struct{}{} +} + +// OrganizationIDCleared returns if the "organization_id" field was cleared in this mutation. +func (m *GitIdentityMutation) OrganizationIDCleared() bool { + _, ok := m.clearedFields[gitidentity.FieldOrganizationID] + return ok +} + +// ResetOrganizationID resets all changes to the "organization_id" field. +func (m *GitIdentityMutation) ResetOrganizationID() { + m.organization_id = nil + delete(m.clearedFields, gitidentity.FieldOrganizationID) +} + // SetRemark sets the "remark" field. func (m *GitIdentityMutation) SetRemark(s string) { m.remark = &s @@ -4149,7 +4199,7 @@ func (m *GitIdentityMutation) Type() string { // order to get all numeric fields that were incremented/decremented, call // AddedFields(). func (m *GitIdentityMutation) Fields() []string { - fields := make([]string, 0, 13) + fields := make([]string, 0, 14) if m.deleted_at != nil { fields = append(fields, gitidentity.FieldDeletedAt) } @@ -4174,6 +4224,9 @@ func (m *GitIdentityMutation) Fields() []string { if m.installation_id != nil { fields = append(fields, gitidentity.FieldInstallationID) } + if m.organization_id != nil { + fields = append(fields, gitidentity.FieldOrganizationID) + } if m.remark != nil { fields = append(fields, gitidentity.FieldRemark) } @@ -4213,6 +4266,8 @@ func (m *GitIdentityMutation) Field(name string) (ent.Value, bool) { return m.Email() case gitidentity.FieldInstallationID: return m.InstallationID() + case gitidentity.FieldOrganizationID: + return m.OrganizationID() case gitidentity.FieldRemark: return m.Remark() case gitidentity.FieldOauthRefreshToken: @@ -4248,6 +4303,8 @@ func (m *GitIdentityMutation) OldField(ctx context.Context, name string) (ent.Va return m.OldEmail(ctx) case gitidentity.FieldInstallationID: return m.OldInstallationID(ctx) + case gitidentity.FieldOrganizationID: + return m.OldOrganizationID(ctx) case gitidentity.FieldRemark: return m.OldRemark(ctx) case gitidentity.FieldOauthRefreshToken: @@ -4323,6 +4380,13 @@ func (m *GitIdentityMutation) SetField(name string, value ent.Value) error { } m.SetInstallationID(v) return nil + case gitidentity.FieldOrganizationID: + v, ok := value.(string) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetOrganizationID(v) + return nil case gitidentity.FieldRemark: v, ok := value.(string) if !ok { @@ -4421,6 +4485,9 @@ func (m *GitIdentityMutation) ClearedFields() []string { if m.FieldCleared(gitidentity.FieldInstallationID) { fields = append(fields, gitidentity.FieldInstallationID) } + if m.FieldCleared(gitidentity.FieldOrganizationID) { + fields = append(fields, gitidentity.FieldOrganizationID) + } if m.FieldCleared(gitidentity.FieldRemark) { fields = append(fields, gitidentity.FieldRemark) } @@ -4462,6 +4529,9 @@ func (m *GitIdentityMutation) ClearField(name string) error { case gitidentity.FieldInstallationID: m.ClearInstallationID() return nil + case gitidentity.FieldOrganizationID: + m.ClearOrganizationID() + return nil case gitidentity.FieldRemark: m.ClearRemark() return nil @@ -4503,6 +4573,9 @@ func (m *GitIdentityMutation) ResetField(name string) error { case gitidentity.FieldInstallationID: m.ResetInstallationID() return nil + case gitidentity.FieldOrganizationID: + m.ResetOrganizationID() + return nil case gitidentity.FieldRemark: m.ResetRemark() return nil diff --git a/backend/db/runtime/runtime.go b/backend/db/runtime/runtime.go index 36b4ede0..3d784fc7 100644 --- a/backend/db/runtime/runtime.go +++ b/backend/db/runtime/runtime.go @@ -99,11 +99,11 @@ func init() { gitidentityFields := schema.GitIdentity{}.Fields() _ = gitidentityFields // gitidentityDescCreatedAt is the schema descriptor for created_at field. - gitidentityDescCreatedAt := gitidentityFields[11].Descriptor() + gitidentityDescCreatedAt := gitidentityFields[12].Descriptor() // gitidentity.DefaultCreatedAt holds the default value on creation for the created_at field. gitidentity.DefaultCreatedAt = gitidentityDescCreatedAt.Default.(func() time.Time) // gitidentityDescUpdatedAt is the schema descriptor for updated_at field. - gitidentityDescUpdatedAt := gitidentityFields[12].Descriptor() + gitidentityDescUpdatedAt := gitidentityFields[13].Descriptor() // gitidentity.DefaultUpdatedAt holds the default value on creation for the updated_at field. gitidentity.DefaultUpdatedAt = gitidentityDescUpdatedAt.Default.(func() time.Time) // gitidentity.UpdateDefaultUpdatedAt holds the default value on update for the updated_at field. diff --git a/backend/domain/gitidentity.go b/backend/domain/gitidentity.go index b749a47c..a61a6071 100644 --- a/backend/domain/gitidentity.go +++ b/backend/domain/gitidentity.go @@ -47,6 +47,7 @@ type GitIdentity struct { Username string `json:"username"` Email string `json:"email"` Remark string `json:"remark"` + OrganizationID string `json:"organization_id,omitempty"` // 云效 Codeup 组织 ID IsInstallationApp bool `json:"is_installation_app"` AuthorizedRepositories []AuthRepository `json:"authorized_repositories"` CreatedAt time.Time `json:"created_at"` @@ -64,6 +65,7 @@ func (g *GitIdentity) From(src *db.GitIdentity) *GitIdentity { g.Username = src.Username g.Email = src.Email g.Remark = src.Remark + g.OrganizationID = src.OrganizationID g.CreatedAt = src.CreatedAt g.IsInstallationApp = src.InstallationID != 0 return g @@ -71,12 +73,13 @@ func (g *GitIdentity) From(src *db.GitIdentity) *GitIdentity { // AddGitIdentityReq 添加 Git 身份认证请求 type AddGitIdentityReq struct { - Platform consts.GitPlatform `json:"platform" validate:"required"` - BaseURL string `json:"base_url" validate:"required"` - AccessToken string `json:"access_token" validate:"required"` - Username string `json:"username" validate:"required"` - Email string `json:"email" validate:"required"` - Remark string `json:"remark,omitempty"` + Platform consts.GitPlatform `json:"platform" validate:"required"` + BaseURL string `json:"base_url" validate:"required"` + AccessToken string `json:"access_token" validate:"required"` + Username string `json:"username" validate:"required"` + Email string `json:"email" validate:"required"` + Remark string `json:"remark,omitempty"` + OrganizationID string `json:"organization_id,omitempty"` // 云效 Codeup 组织 ID,绑定后自动解析填充 } // UpdateGitIdentityReq 更新 Git 身份认证请求 @@ -88,6 +91,7 @@ type UpdateGitIdentityReq struct { Username *string `json:"username,omitempty"` Email *string `json:"email,omitempty"` Remark *string `json:"remark,omitempty"` + OrganizationID *string `json:"organization_id,omitempty"` OAuthRefreshToken *string `json:"-"` // 内部使用,OAuth 刷新 token OAuthExpiresAt *time.Time `json:"-"` // 内部使用,OAuth 过期时间 } diff --git a/backend/ent/schema/gitidentity.go b/backend/ent/schema/gitidentity.go index d3872e2a..827d7401 100644 --- a/backend/ent/schema/gitidentity.go +++ b/backend/ent/schema/gitidentity.go @@ -42,6 +42,7 @@ func (GitIdentity) Fields() []ent.Field { field.String("username").Optional(), field.String("email").Optional(), field.Int64("installation_id").Optional(), + field.String("organization_id").Optional(), field.String("remark").Optional(), field.String("oauth_refresh_token").Optional(), field.Time("oauth_expires_at").Optional().Nillable(), diff --git a/backend/errcode/errcode.go b/backend/errcode/errcode.go index 56daa7c6..d69a8c67 100644 --- a/backend/errcode/errcode.go +++ b/backend/errcode/errcode.go @@ -70,6 +70,7 @@ var ( ErrRepoAlreadyLinked = web.NewErr(http.StatusOK, 10406, "err-repo-already-linked") ErrGitIdentityInUseByProject = web.NewErr(http.StatusOK, 10407, "err-git-identity-in-use-by-project") ErrForbiddenBaseURL = web.NewErr(http.StatusOK, 10408, "err-forbidden-base-url") + ErrCodeupOAuthNotSupported = web.NewErr(http.StatusOK, 10409, "err-codeup-oauth-not-supported") // 团队管理 ErrTeamMemberLimitExceeded = web.NewErr(http.StatusOK, 10500, "err-team-member-limit-exceeded") diff --git a/backend/errcode/locale.en.toml b/backend/errcode/locale.en.toml index 702a7adc..ee4f2fca 100644 --- a/backend/errcode/locale.en.toml +++ b/backend/errcode/locale.en.toml @@ -79,6 +79,9 @@ other = "Repository already linked, duplicate binding not allowed" [err-git-identity-in-use-by-project] other = "This Git identity is in use by project(s), cannot delete" +[err-codeup-oauth-not-supported] +other = "Codeup OAuth is not supported yet; please bind with a Personal Access Token (PAT) instead" + [err-team-member-limit-exceeded] other = "Team member limit exceeded" diff --git a/backend/errcode/locale.zh.toml b/backend/errcode/locale.zh.toml index d2c4126c..5b330452 100644 --- a/backend/errcode/locale.zh.toml +++ b/backend/errcode/locale.zh.toml @@ -88,6 +88,9 @@ other = "重复绑定仓库" [err-git-identity-in-use-by-project] other = "该 Git 身份已被项目使用,无法删除" +[err-codeup-oauth-not-supported] +other = "云效(Codeup)暂不支持 OAuth 接入,请改用个人访问令牌(PAT)绑定" + [err-team-member-limit-exceeded] other = "团队成员数量已达上限" diff --git a/backend/migration/000013_alter_git_identities_add_organization_id.down.sql b/backend/migration/000013_alter_git_identities_add_organization_id.down.sql new file mode 100644 index 00000000..71ea3f6c --- /dev/null +++ b/backend/migration/000013_alter_git_identities_add_organization_id.down.sql @@ -0,0 +1 @@ +ALTER TABLE git_identities DROP COLUMN IF EXISTS organization_id; diff --git a/backend/migration/000013_alter_git_identities_add_organization_id.up.sql b/backend/migration/000013_alter_git_identities_add_organization_id.up.sql new file mode 100644 index 00000000..8fcee65b --- /dev/null +++ b/backend/migration/000013_alter_git_identities_add_organization_id.up.sql @@ -0,0 +1 @@ +ALTER TABLE git_identities ADD COLUMN IF NOT EXISTS organization_id VARCHAR; diff --git a/backend/pkg/git/atomgit/atomgit.go b/backend/pkg/git/atomgit/atomgit.go new file mode 100644 index 00000000..ebee515f --- /dev/null +++ b/backend/pkg/git/atomgit/atomgit.go @@ -0,0 +1,290 @@ +// Package atomgit 提供 atomgit (https://atomgit.com) 客户端。 +// +// 鉴权: HTTP Header `Authorization: Bearer `, PAT 和 OAuth access_token 共用此格式。 +// 所有 OpenAPI 调用走 https://api.atomgit.com + `/api/v5/` 前缀 (后端复用 GitCode/Gitee +// 那套, 跟 docs.atomgit.com 上写的裸路径文档对不上)。仓库以 {owner}/{repo} 两段路径定位。 +package atomgit + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "log/slog" + "net/http" + "net/url" + "strings" + "time" + + "github.com/chaitin/MonkeyCode/backend/domain" + "github.com/chaitin/MonkeyCode/backend/pkg/request" +) + +// Atomgit 客户端 +type Atomgit struct { + client *request.Client + logger *slog.Logger + host string // OpenAPI host, 如 api.atomgit.com + scheme string +} + +// NewAtomgit 创建 atomgit 客户端。 +// - baseURL: OpenAPI 域名 (含 scheme), 空则使用默认 https://api.atomgit.com +func NewAtomgit(baseURL string, logger *slog.Logger) *Atomgit { + scheme, host := normalizeBase(baseURL, DefaultAPIHost) + return &Atomgit{ + logger: logger.With("module", "atomgit"), + host: host, + scheme: scheme, + client: request.NewClient(scheme, host, 30*time.Second), + } +} + +// BaseURL 返回 OpenAPI base URL +func (a *Atomgit) BaseURL() string { + return a.scheme + "://" + a.host +} + +func (a *Atomgit) authHeader(token string) request.Header { + return request.Header{ + "Authorization": "Bearer " + token, + "Accept": "application/json", + } +} + +func normalizeBase(input, fallback string) (scheme, host string) { + scheme, host = "https", fallback + if input == "" { + return + } + if strings.HasPrefix(input, "http://") { + scheme = "http" + host = strings.TrimPrefix(input, "http://") + } else if strings.HasPrefix(input, "https://") { + host = strings.TrimPrefix(input, "https://") + } else { + host = input + } + host = strings.TrimSuffix(host, "/") + return +} + +// ParseRepoPath 从仓库 URL 解析出 owner / repo。 +// +// 支持以下形式: +// +// https://atomgit.com/{owner}/{repo}.git +// https://atomgit.com/{owner}/{repo} +// git@atomgit.com:{owner}/{repo}.git +func ParseRepoPath(repoURL string) (owner, repo string, err error) { + raw := strings.TrimSpace(repoURL) + raw = strings.TrimSuffix(raw, ".git") + if raw == "" { + return "", "", fmt.Errorf("empty repo url") + } + + if strings.HasPrefix(raw, "git@") { + at := strings.Index(raw, "@") + col := strings.Index(raw, ":") + if col <= at { + return "", "", fmt.Errorf("invalid atomgit ssh url: %s", repoURL) + } + return splitOwnerRepo(strings.Trim(raw[col+1:], "/"), repoURL) + } + + u, perr := url.Parse(raw) + if perr != nil { + return "", "", fmt.Errorf("parse atomgit url: %w", perr) + } + return splitOwnerRepo(strings.Trim(u.Path, "/"), repoURL) +} + +func splitOwnerRepo(path, raw string) (string, string, error) { + parts := strings.Split(path, "/") + if len(parts) != 2 || parts[0] == "" || parts[1] == "" { + return "", "", fmt.Errorf("invalid atomgit url, expect owner/repo: %s", raw) + } + return parts[0], parts[1], nil +} + +// CheckPAT 校验 token (PAT 或 OAuth access_token) 对指定仓库的可访问性。 +// +// 通过 GET /api/v5/repos/{owner}/{repo} 验证。 +func (a *Atomgit) CheckPAT(ctx context.Context, token, repoURL string) (bool, *domain.BindRepository, error) { + owner, repo, err := ParseRepoPath(repoURL) + if err != nil { + return false, nil, err + } + r, err := a.getRepo(ctx, token, owner, repo) + if err != nil { + return false, nil, err + } + if r == nil || (r.ID == 0 && r.FullName == "" && r.Name == "") { + return false, nil, fmt.Errorf("repository not found or token has no access") + } + web := firstNonEmpty(r.WebURL, r.HTTPURL) + if web == "" { + web = "https://" + DefaultWebHost + "/" + owner + "/" + repo + } + full := r.FullName + if full == "" { + full = owner + "/" + repo + } + return true, &domain.BindRepository{ + RepoID: fmt.Sprintf("%d", r.ID), + RepoName: firstNonEmpty(r.Name, repo), + FullName: full, + RepoURL: web, + RepoDescription: r.Description, + IsPrivate: r.Private, + Platform: "atomgit", + }, nil +} + +// getRepo GET /api/v5/repos/{owner}/{repo} +func (a *Atomgit) getRepo(ctx context.Context, token, owner, repo string) (*Repository, error) { + if owner == "" || repo == "" { + return nil, fmt.Errorf("atomgit: missing owner/repo") + } + path := APIPrefix + "/repos/" + url.PathEscape(owner) + "/" + url.PathEscape(repo) + r, err := request.Get[Repository](a.client, ctx, path, + request.WithHeader(a.authHeader(token))) + if err != nil { + return nil, fmt.Errorf("get atomgit repo: %w", err) + } + return r, nil +} + +// UserInfo 实现 GitClienter 接口。 +// +// GET /api/v5/user 返回当前用户详情。 +func (a *Atomgit) UserInfo(ctx context.Context, token string) (*domain.PlatformUserInfo, error) { + user, err := a.fetchCurrentUser(ctx, token) + if err != nil { + return nil, err + } + if user == nil { + return &domain.PlatformUserInfo{}, nil + } + return &domain.PlatformUserInfo{ + Name: firstNonEmpty(user.Name, user.Login), + }, nil +} + +// fetchCurrentUser GET /api/v5/user, 内部复用 +func (a *Atomgit) fetchCurrentUser(ctx context.Context, token string) (*User, error) { + user, err := request.Get[User](a.client, ctx, APIPrefix+"/user", + request.WithHeader(a.authHeader(token))) + if err != nil { + return nil, fmt.Errorf("get atomgit user: %w", err) + } + return user, nil +} + +// Repositories 列出当前 token 所属用户可访问的仓库。 +// +// 走 GET /api/v5/user/repos (Gitee 风格, 拿当前 token 用户的全部仓库, +// 包括所属组织的仓库)。上限 50 页 = 5000 个仓库。 +func (a *Atomgit) Repositories(ctx context.Context, opts *domain.RepositoryOptions) ([]domain.AuthRepository, error) { + result := make([]domain.AuthRepository, 0, 64) + page, perPage := 1, 100 + apiPath := APIPrefix + "/user/repos" + for { + query := request.Query{ + "page": fmt.Sprintf("%d", page), + "per_page": fmt.Sprintf("%d", perPage), + } + repos, err := request.Get[[]*Repository](a.client, ctx, apiPath, + request.WithHeader(a.authHeader(opts.Token)), + request.WithQuery(query), + ) + if err != nil { + return nil, fmt.Errorf("list atomgit repositories: %w", err) + } + if repos == nil || len(*repos) == 0 { + break + } + for _, r := range *repos { + if r == nil { + continue + } + full := r.FullName + if full == "" { + full = r.Name + } + web := firstNonEmpty(r.WebURL, r.HTTPURL) + if web == "" && full != "" { + web = "https://" + DefaultWebHost + "/" + full + } + result = append(result, domain.AuthRepository{ + FullName: full, + URL: web, + Description: r.Description, + }) + } + if len(*repos) < perPage { + break + } + page++ + if page > 50 { + break + } + } + return result, nil +} + +// rawRequest 透传 HTTP 调用, 用于二进制响应 (如归档下载)。 +func (a *Atomgit) rawRequest(ctx context.Context, method, fullURL, token string) (*http.Response, error) { + req, err := http.NewRequestWithContext(ctx, method, fullURL, nil) + if err != nil { + return nil, err + } + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Accept", "application/json") + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, err + } + if resp.StatusCode >= 400 { + body, _ := io.ReadAll(resp.Body) + resp.Body.Close() + return nil, fmt.Errorf("atomgit %s %s returned %d: %s", method, fullURL, resp.StatusCode, parseError(body)) + } + return resp, nil +} + +func parseError(body []byte) string { + body = bytes.TrimSpace(body) + if len(body) == 0 { + return "" + } + var er errorResponse + if err := json.Unmarshal(body, &er); err == nil { + if er.Message != "" { + return er.Message + } + if er.Msg != "" { + return er.Msg + } + if er.ErrorDescription != "" { + return er.ErrorDescription + } + if er.Error != "" { + return er.Error + } + } + if len(body) > 512 { + return string(body[:512]) + } + return string(body) +} + +func firstNonEmpty(vals ...string) string { + for _, v := range vals { + if v != "" { + return v + } + } + return "" +} diff --git a/backend/pkg/git/atomgit/operation.go b/backend/pkg/git/atomgit/operation.go new file mode 100644 index 00000000..960d947d --- /dev/null +++ b/backend/pkg/git/atomgit/operation.go @@ -0,0 +1,281 @@ +package atomgit + +import ( + "bytes" + "context" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "net/url" + "strings" + + "github.com/chaitin/MonkeyCode/backend/domain" + "github.com/chaitin/MonkeyCode/backend/pkg/request" +) + +// Tree 实现 GitClienter 接口。 +// +// GET /api/v5/repos/{owner}/{repo}/git/trees/{sha} 返回 sha 所指 tree 的子项数组 +// (Gitee 风格, 路径里多一段 `/git/`)。{sha} 可以是分支名或 commit sha。 +// 调用方传 Recursive=true 时透传 ?recursive=1。 +// +// 兼容两种响应形态: +// - 数组形态: 顶层直接是 [TreeNode] +// - 对象形态: {sha, tree: [TreeNode]} +func (a *Atomgit) Tree(ctx context.Context, opts *domain.TreeOptions) (*domain.GetRepoTreeResp, error) { + owner, repo := opts.Owner, opts.Repo + if owner == "" || repo == "" { + return nil, fmt.Errorf("atomgit tree: missing owner/repo") + } + ref := opts.Ref + if ref == "" { + ref = "HEAD" + } + + path := APIPrefix + "/repos/" + url.PathEscape(owner) + "/" + url.PathEscape(repo) + "/git/trees/" + url.PathEscape(ref) + query := request.Query{} + if opts.Recursive { + query["recursive"] = "1" + } + + body, err := a.rawJSONGet(ctx, path, opts.Token, query) + if err != nil { + return nil, fmt.Errorf("get atomgit tree: %w", err) + } + + nodes := decodeTreeNodes(body) + entries := make([]*domain.TreeEntry, 0, len(nodes)) + prefix := strings.Trim(opts.Path, "/") + for _, n := range nodes { + if n == nil { + continue + } + // Path 过滤: 仅当传入了 opts.Path 时, 保留前缀匹配的子项。 + if prefix != "" { + if !strings.HasPrefix(n.Path, prefix+"/") && n.Path != prefix { + continue + } + } + entries = append(entries, &domain.TreeEntry{ + Mode: atomgitTypeToMode(n.Type), + Name: leafName(n.Path), + Path: n.Path, + Sha: n.SHA, + Size: int(n.Size), + }) + } + return &domain.GetRepoTreeResp{ + Entries: entries, + SHA: ref, + }, nil +} + +// decodeTreeNodes 兼容数组形态与 {tree:[]} 对象形态。 +func decodeTreeNodes(body []byte) []*TreeNode { + body = bytes.TrimSpace(body) + if len(body) == 0 { + return nil + } + if body[0] == '[' { + var arr []*TreeNode + if err := json.Unmarshal(body, &arr); err == nil { + return arr + } + return nil + } + var obj TreeResp + if err := json.Unmarshal(body, &obj); err == nil { + return obj.Tree + } + return nil +} + +// Blob 实现 GitClienter 接口。 +// +// GET /api/v5/repos/{owner}/{repo}/contents/{path}?ref=... 返回 Content{type=file, content=base64}。 +// 对 type=dir / symlink / submodule 给出空响应; type=file 时按 encoding 解码。 +func (a *Atomgit) Blob(ctx context.Context, opts *domain.BlobOptions) (*domain.GetBlobResp, error) { + owner, repo := opts.Owner, opts.Repo + if owner == "" || repo == "" { + return nil, fmt.Errorf("atomgit blob: missing owner/repo") + } + if opts.Path == "" { + return nil, fmt.Errorf("atomgit blob: file path is required") + } + + // contents/{path} 的 path 段保留 `/` 分隔,但每一段都要 URL-encode。 + apiPath := APIPrefix + "/repos/" + owner + "/" + repo + "/contents/" + encodeFilePath(opts.Path) + query := request.Query{} + if opts.Ref != "" { + query["ref"] = opts.Ref + } + + content, err := request.Get[Content](a.client, ctx, apiPath, + request.WithHeader(a.authHeader(opts.Token)), + request.WithQuery(query), + ) + if err != nil { + return nil, fmt.Errorf("get atomgit blob: %w", err) + } + if content == nil { + return &domain.GetBlobResp{}, nil + } + + switch strings.ToLower(content.Type) { + case "file": + var body []byte + if strings.EqualFold(content.Encoding, "base64") { + cleaned := strings.ReplaceAll(content.Content, "\n", "") + decoded, decErr := base64.StdEncoding.DecodeString(cleaned) + if decErr != nil { + return nil, fmt.Errorf("decode atomgit blob base64: %w", decErr) + } + body = decoded + } else { + body = []byte(content.Content) + } + return &domain.GetBlobResp{ + Content: body, + IsBinary: isBinaryContent(body), + Sha: content.SHA, + Size: int(content.Size), + }, nil + default: + // dir / symlink / submodule 不返回内容 + return &domain.GetBlobResp{ + Sha: content.SHA, + Size: int(content.Size), + }, nil + } +} + +// Logs 实现 GitClienter 接口。 +// +// atomgit OpenAPI 暂未提供「commit 列表」入口 (仅有 GET /repos/:o/:r/commits/{ref} 拿单条), +// 这里返回未实现错误。当前业务调用方 (project tree/logs) 已无前端入口, 不影响功能。 +func (a *Atomgit) Logs(ctx context.Context, opts *domain.LogsOptions) (*domain.GetGitLogsResp, error) { + return nil, fmt.Errorf("atomgit logs: not supported") +} + +// Archive 实现 GitClienter 接口。 +// +// atomgit OpenAPI 暂未提供仓库归档下载入口, 返回未实现错误。 +func (a *Atomgit) Archive(ctx context.Context, opts *domain.ArchiveOptions) (*domain.GetRepoArchiveResp, error) { + return nil, fmt.Errorf("atomgit archive: not supported") +} + +// Branches 实现 GitClienter 接口。 +// +// GET /api/v5/repos/{owner}/{repo}/branches?page=&per_page= +func (a *Atomgit) Branches(ctx context.Context, opts *domain.BranchesOptions) ([]*domain.BranchInfo, error) { + owner, repo := opts.Owner, opts.Repo + if owner == "" || repo == "" { + return nil, fmt.Errorf("atomgit branches: missing owner/repo") + } + + page := opts.Page + if page <= 0 { + page = 1 + } + perPage := opts.PerPage + if perPage <= 0 { + perPage = 50 + } + if perPage > 100 { + perPage = 100 + } + + query := request.Query{ + "page": fmt.Sprintf("%d", page), + "per_page": fmt.Sprintf("%d", perPage), + } + apiPath := APIPrefix + "/repos/" + owner + "/" + repo + "/branches" + branches, err := request.Get[[]*Branch](a.client, ctx, apiPath, + request.WithHeader(a.authHeader(opts.Token)), + request.WithQuery(query), + ) + if err != nil { + return nil, fmt.Errorf("list atomgit branches: %w", err) + } + + result := []*domain.BranchInfo{} + if branches != nil { + for _, b := range *branches { + if b == nil { + continue + } + result = append(result, &domain.BranchInfo{Name: b.Name}) + } + } + return result, nil +} + +// CreateWebhook 实现 GitClienter 接口。 +// +// atomgit OpenAPI 暂未提供 webhook 管理接口, 自动 review 暂不可用 (需用户在仓库设置页手工配置)。 +// 同 CNB 的处理方式: Create 返回未实现错误, Delete 走 no-op 让"解绑"动作不至于失败。 +func (a *Atomgit) CreateWebhook(ctx context.Context, opts *domain.CreateWebhookOptions) error { + return fmt.Errorf("atomgit webhook: not supported") +} + +// DeleteWebhook 实现 GitClienter 接口。 +func (a *Atomgit) DeleteWebhook(ctx context.Context, opts *domain.WebhookOptions) error { + return nil +} + +// rawJSONGet 透传 JSON GET, 用于 Tree 这种需要按字节判断响应形态的场景。 +func (a *Atomgit) rawJSONGet(ctx context.Context, apiPath, token string, query request.Query) ([]byte, error) { + fullURL := a.BaseURL() + apiPath + if len(query) > 0 { + vals := url.Values{} + for k, v := range query { + vals.Set(k, v) + } + fullURL += "?" + vals.Encode() + } + resp, err := a.rawRequest(ctx, "GET", fullURL, token) + if err != nil { + return nil, err + } + defer resp.Body.Close() + return io.ReadAll(resp.Body) +} + +// encodeFilePath 将文件路径每段 URL-encode,但保留 `/` 分隔符 (atomgit contents 走层级 path)。 +func encodeFilePath(p string) string { + p = strings.TrimLeft(p, "/") + segs := strings.Split(p, "/") + for i, s := range segs { + segs[i] = url.PathEscape(s) + } + return strings.Join(segs, "/") +} + +func atomgitTypeToMode(t string) int { + switch strings.ToLower(t) { + case "tree", "dir", "directory": + return 4 + default: + return 1 + } +} + +func isBinaryContent(content []byte) bool { + if len(content) == 0 { + return false + } + check := content + if len(check) > 8000 { + check = check[:8000] + } + return bytes.Contains(check, []byte{0}) +} + +func leafName(p string) string { + p = strings.TrimRight(p, "/") + if i := strings.LastIndex(p, "/"); i >= 0 { + return p[i+1:] + } + return p +} diff --git a/backend/pkg/git/atomgit/types.go b/backend/pkg/git/atomgit/types.go new file mode 100644 index 00000000..d9757182 --- /dev/null +++ b/backend/pkg/git/atomgit/types.go @@ -0,0 +1,123 @@ +package atomgit + +// 默认 OpenAPI 域名 (atomgit 后台共用 GitCode 基础设施) +const ( + DefaultAPIHost = "api.atomgit.com" + DefaultWebHost = "atomgit.com" + + // APIPrefix atomgit OpenAPI 全部路径前缀 + // + // 官方文档站 (docs.atomgit.com) 写的是裸路径 (如 /user/info、/repos/:o/:r), + // 但实际部署的后端是 GitCode (Gitee fork), 必须用 /api/v5/ 前缀。亲测: + // GET /user/info → 404 openresty + // GET /api/v5/user → 200 {login,name,id,avatar_url,...} + // 后端返回的 self URL (followers_url 等) 也都带 /api/v5/。 + APIPrefix = "/api/v5" +) + +// User 用户信息 (GET /api/v5/user) +// +// 字段以亲测响应为准: {login, name, id, avatar_url, html_url, type, url, bio, blog, company, ...}。 +// id 是字符串形态 (类似 MongoDB ObjectId, 如 "676a6cbb75ce0a14eb004d1c"), 不是 int。 +type User struct { + Login string `json:"login,omitempty"` + ID string `json:"id,omitempty"` + Name string `json:"name,omitempty"` + Email string `json:"email,omitempty"` + AvatarURL string `json:"avatar_url,omitempty"` + HTMLURL string `json:"html_url,omitempty"` +} + +// Repository 仓库信息 (GET /api/v5/repos/:o/:r 与 /api/v5/user/repos 元素结构一致) +// +// 亲测响应: +// +// {"id":10085664,"full_name":"caiqj/test","name":"test","path":"test", +// "description":"","ssh_url_to_repo":"git@gitcode.com:caiqj/test.git", +// "http_url_to_repo":"https://atomgit.com/caiqj/test.git", +// "web_url":"https://atomgit.com/caiqj/test","default_branch":"...","private":...} +// +// id 是 int64; clone/web URL 三个字段都是 _to_repo / web_url 风格 (Gitee 命名)。 +type Repository struct { + ID int64 `json:"id,omitempty"` + Name string `json:"name,omitempty"` + Path string `json:"path,omitempty"` + FullName string `json:"full_name,omitempty"` + Description string `json:"description,omitempty"` + DefaultBranch string `json:"default_branch,omitempty"` + Private bool `json:"private,omitempty"` + SSHURL string `json:"ssh_url_to_repo,omitempty"` // SSH clone URL + HTTPURL string `json:"http_url_to_repo,omitempty"` // HTTP clone URL + WebURL string `json:"web_url,omitempty"` // 浏览器访问的页面 URL +} + +// Branch 分支信息 (GET /repos/:o/:r/branches) +type Branch struct { + Name string `json:"name,omitempty"` + Protected bool `json:"protected,omitempty"` + Commit *CommitSummary `json:"commit,omitempty"` +} + +// CommitSummary 分支挂载的提交摘要 +type CommitSummary struct { + SHA string `json:"sha,omitempty"` +} + +// TreeNode 文件树节点 (GET /repos/:o/:r/trees/:sha) +// +// type 枚举: blob / tree / symlink / commit +type TreeNode struct { + Mode string `json:"mode,omitempty"` + Path string `json:"path,omitempty"` + SHA string `json:"sha,omitempty"` + Type string `json:"type,omitempty"` + Size int64 `json:"size,omitempty"` +} + +// TreeResp GET /repos/:o/:r/trees/:sha 响应外层 +// +// atomgit 的 trees 接口实际返回是数组形态还是带 `tree` 字段的对象, +// 文档没给完整 sample。为兼容两种形态: +// - 数组形态:由 Tree() 自行 fallback 反序列化为 []TreeNode +// - 对象形态:这里给出 sha + tree 字段方便复用 +type TreeResp struct { + SHA string `json:"sha,omitempty"` + Tree []*TreeNode `json:"tree,omitempty"` +} + +// Content 仓库文件或目录内容 (GET /repos/:o/:r/contents/{path}) +// +// type 枚举: file / dir / symlink / submodule +// - type=file 时 content 为 base64, entries 为空数组 +// - type=dir 时 entries 列出子项, content 为空 +type Content struct { + Name string `json:"name,omitempty"` + Path string `json:"path,omitempty"` + SHA string `json:"sha,omitempty"` + Size int64 `json:"size,omitempty"` + Type string `json:"type,omitempty"` + Encoding string `json:"encoding,omitempty"` // base64 + Content string `json:"content,omitempty"` + Entries []*ContentItem `json:"entries,omitempty"` +} + +// ContentItem dir 类型 Content 的 entries 元素 (与 Content 同形,只是不带 entries) +type ContentItem struct { + Name string `json:"name,omitempty"` + Path string `json:"path,omitempty"` + SHA string `json:"sha,omitempty"` + Size int64 `json:"size,omitempty"` + Type string `json:"type,omitempty"` + Encoding string `json:"encoding,omitempty"` +} + +// errorResponse atomgit 通用错误响应 +// +// 未见统一字段约定, 覆盖常见的几种: message / msg / error / error_description。 +type errorResponse struct { + Code int `json:"code,omitempty"` + Message string `json:"message,omitempty"` + Msg string `json:"msg,omitempty"` + Error string `json:"error,omitempty"` + ErrorDescription string `json:"error_description,omitempty"` +} diff --git a/backend/pkg/git/cnb/cnb.go b/backend/pkg/git/cnb/cnb.go new file mode 100644 index 00000000..2db1af73 --- /dev/null +++ b/backend/pkg/git/cnb/cnb.go @@ -0,0 +1,289 @@ +// Package cnb 提供腾讯 CNB (Cloud Native Build, cnb.cool) 客户端。 +// +// 鉴权: HTTP Header `Authorization: Bearer `, PAT 和 OAuth access_token 共用此格式。 +// 所有 OpenAPI 调用走 https://api.cnb.cool, 仓库以完整路径 slug (如 "cnb/test") 作为 +// {repo} 路径参数, 无需组织 ID。 +package cnb + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "log/slog" + "net/http" + "net/url" + "strings" + "time" + + "github.com/chaitin/MonkeyCode/backend/domain" + "github.com/chaitin/MonkeyCode/backend/pkg/request" +) + +// Cnb 客户端 +type Cnb struct { + client *request.Client + logger *slog.Logger + host string // OpenAPI host, 如 api.cnb.cool + scheme string +} + +// NewCnb 创建 CNB 客户端。 +// - baseURL: OpenAPI 域名 (含 scheme), 空则使用默认 https://api.cnb.cool +func NewCnb(baseURL string, logger *slog.Logger) *Cnb { + scheme, host := normalizeBase(baseURL, DefaultAPIHost) + return &Cnb{ + logger: logger.With("module", "cnb"), + host: host, + scheme: scheme, + client: request.NewClient(scheme, host, 30*time.Second), + } +} + +// BaseURL 返回 OpenAPI base URL +func (c *Cnb) BaseURL() string { + return c.scheme + "://" + c.host +} + +func (c *Cnb) authHeader(token string) request.Header { + return request.Header{ + "Authorization": "Bearer " + token, + "Accept": "application/json", + } +} + +func normalizeBase(input, fallback string) (scheme, host string) { + scheme, host = "https", fallback + if input == "" { + return + } + if strings.HasPrefix(input, "http://") { + scheme = "http" + host = strings.TrimPrefix(input, "http://") + } else if strings.HasPrefix(input, "https://") { + host = strings.TrimPrefix(input, "https://") + } else { + host = input + } + host = strings.TrimSuffix(host, "/") + return +} + +// ParseRepoPath 从仓库 URL 解析出仓库 slug (owner/repo)。 +// +// 支持以下形式: +// +// https://cnb.cool/{owner}/{repo}.git +// https://cnb.cool/{owner}/{repo} +// git@cnb.cool:{owner}/{repo}.git +// +// owner 可以是多级 group (如 a/b/c), repo 是最后一段。 +func ParseRepoPath(repoURL string) (string, error) { + raw := strings.TrimSpace(repoURL) + raw = strings.TrimSuffix(raw, ".git") + if raw == "" { + return "", fmt.Errorf("empty repo url") + } + + // SSH 形式: git@host:owner/repo + if strings.HasPrefix(raw, "git@") { + at := strings.Index(raw, "@") + col := strings.Index(raw, ":") + if col <= at { + return "", fmt.Errorf("invalid cnb ssh url: %s", repoURL) + } + path := strings.Trim(raw[col+1:], "/") + if !strings.Contains(path, "/") { + return "", fmt.Errorf("invalid cnb url, expect owner/repo: %s", repoURL) + } + return path, nil + } + + u, perr := url.Parse(raw) + if perr != nil { + return "", fmt.Errorf("parse cnb url: %w", perr) + } + path := strings.Trim(u.Path, "/") + if !strings.Contains(path, "/") { + return "", fmt.Errorf("invalid cnb url, expect owner/repo: %s", repoURL) + } + return path, nil +} + +// repoSlug 由 owner/repo 组装仓库 slug。Tree/Blob/Logs/Branches 调用方传入 Owner + Repo。 +func repoSlug(owner, repo string) string { + owner = strings.Trim(strings.TrimSpace(owner), "/") + repo = strings.Trim(strings.TrimSpace(repo), "/") + switch { + case owner == "" && repo == "": + return "" + case owner == "": + return repo + case repo == "": + return owner + default: + return owner + "/" + repo + } +} + +// CheckPAT 校验 token (PAT 或 OAuth access_token) 对指定仓库的可访问性。 +// +// 通过 GET /{repo} 验证: 200 → 有权限, 4xx → 无权限或 token 失效。 +func (c *Cnb) CheckPAT(ctx context.Context, token, repoURL string) (bool, *domain.BindRepository, error) { + slug, err := ParseRepoPath(repoURL) + if err != nil { + return false, nil, err + } + repo, err := c.getRepo(ctx, token, slug) + if err != nil { + return false, nil, err + } + if repo == nil || (repo.ID == "" && repo.Path == "") { + return false, nil, fmt.Errorf("repository not found or token has no access") + } + web := repo.WebURL + if web == "" { + web = "https://" + DefaultWebHost + "/" + slug + } + return true, &domain.BindRepository{ + RepoID: repo.ID, + RepoName: firstNonEmpty(repo.Name, slug), + FullName: firstNonEmpty(repo.Path, slug), + RepoURL: web, + RepoDescription: repo.Description, + IsPrivate: strings.EqualFold(repo.VisibilityLevel, "private"), + Platform: "cnb", + }, nil +} + +// getRepo GET /{repo} 获取仓库详情 +func (c *Cnb) getRepo(ctx context.Context, token, slug string) (*Repository, error) { + if slug == "" { + return nil, fmt.Errorf("cnb: empty repo slug") + } + path := "/" + slug + repo, err := request.Get[Repository](c.client, ctx, path, + request.WithHeader(c.authHeader(token))) + if err != nil { + return nil, fmt.Errorf("get cnb repo: %w", err) + } + return repo, nil +} + +// UserInfo 实现 GitClienter 接口。 +// +// GET /user 返回当前用户详情。 +func (c *Cnb) UserInfo(ctx context.Context, token string) (*domain.PlatformUserInfo, error) { + user, err := request.Get[User](c.client, ctx, "/user", + request.WithHeader(c.authHeader(token))) + if err != nil { + return nil, fmt.Errorf("get cnb user: %w", err) + } + if user == nil { + return &domain.PlatformUserInfo{}, nil + } + return &domain.PlatformUserInfo{ + Name: firstNonEmpty(user.Nickname, user.Username), + }, nil +} + +// Repositories 列出当前 token 可访问的仓库 (按更新时间倒序)。 +// +// GET /user/repos 翻页拉取, 上限 50 页 = 5000 个仓库。 +func (c *Cnb) Repositories(ctx context.Context, opts *domain.RepositoryOptions) ([]domain.AuthRepository, error) { + result := make([]domain.AuthRepository, 0, 64) + page, perPage := 1, 100 + for { + query := request.Query{ + "page": fmt.Sprintf("%d", page), + "page_size": fmt.Sprintf("%d", perPage), + "order_by": "updated_at", + "desc": "true", + } + repos, err := request.Get[[]*Repository](c.client, ctx, "/user/repos", + request.WithHeader(c.authHeader(opts.Token)), + request.WithQuery(query), + ) + if err != nil { + return nil, fmt.Errorf("list cnb repositories: %w", err) + } + if repos == nil || len(*repos) == 0 { + break + } + for _, r := range *repos { + if r == nil { + continue + } + web := r.WebURL + if web == "" && r.Path != "" { + web = "https://" + DefaultWebHost + "/" + r.Path + } + result = append(result, domain.AuthRepository{ + FullName: firstNonEmpty(r.Path, r.Name), + URL: web, + Description: r.Description, + }) + } + if len(*repos) < perPage { + break + } + page++ + if page > 50 { + break + } + } + return result, nil +} + +// rawRequest 透传 HTTP 调用, 用于 OpenAPI 的二进制响应 (Archive)。 +func (c *Cnb) rawRequest(ctx context.Context, method, fullURL, token string) (*http.Response, error) { + req, err := http.NewRequestWithContext(ctx, method, fullURL, nil) + if err != nil { + return nil, err + } + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Accept", "application/json") + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, err + } + if resp.StatusCode >= 400 { + body, _ := io.ReadAll(resp.Body) + resp.Body.Close() + return nil, fmt.Errorf("cnb %s %s returned %d: %s", method, fullURL, resp.StatusCode, parseError(body)) + } + return resp, nil +} + +func parseError(body []byte) string { + body = bytes.TrimSpace(body) + if len(body) == 0 { + return "" + } + var er errorResponse + if err := json.Unmarshal(body, &er); err == nil { + if er.Message != "" { + return er.Message + } + if er.Msg != "" { + return er.Msg + } + if er.Error != "" { + return er.Error + } + } + if len(body) > 512 { + return string(body[:512]) + } + return string(body) +} + +func firstNonEmpty(vals ...string) string { + for _, v := range vals { + if v != "" { + return v + } + } + return "" +} diff --git a/backend/pkg/git/cnb/operation.go b/backend/pkg/git/cnb/operation.go new file mode 100644 index 00000000..a95e8ca0 --- /dev/null +++ b/backend/pkg/git/cnb/operation.go @@ -0,0 +1,355 @@ +package cnb + +import ( + "bytes" + "context" + "encoding/base64" + "fmt" + "strings" + "time" + + "github.com/chaitin/MonkeyCode/backend/domain" + "github.com/chaitin/MonkeyCode/backend/pkg/request" +) + +// Tree 实现 GitClienter 接口。 +// +// GET /{repo}/-/git/contents/{file_path}?ref=... 当 file_path 为空时走 /{repo}/-/git/contents。 +// 返回 Content{type=tree, entries=[...]}; CNB OpenAPI 不支持 recursive 参数, +// 调用方传 Recursive=true 时仅返回顶层(语义降级)。 +func (c *Cnb) Tree(ctx context.Context, opts *domain.TreeOptions) (*domain.GetRepoTreeResp, error) { + slug := repoSlug(opts.Owner, opts.Repo) + if slug == "" { + return nil, fmt.Errorf("cnb tree: missing repo slug") + } + + path := "/" + slug + "/-/git/contents" + if p := strings.Trim(opts.Path, "/"); p != "" { + path += "/" + p + } + query := request.Query{} + if opts.Ref != "" { + query["ref"] = opts.Ref + } + + content, err := request.Get[Content](c.client, ctx, path, + request.WithHeader(c.authHeader(opts.Token)), + request.WithQuery(query), + ) + if err != nil { + return nil, fmt.Errorf("get cnb tree: %w", err) + } + + entries := []*domain.TreeEntry{} + if content != nil { + for _, e := range content.Entries { + if e == nil { + continue + } + entries = append(entries, &domain.TreeEntry{ + Mode: cnbTypeToMode(e.Type), + Name: e.Name, + Path: e.Path, + Sha: e.SHA, + Size: int(e.Size), + }) + } + } + return &domain.GetRepoTreeResp{ + Entries: entries, + SHA: opts.Ref, + }, nil +} + +// Blob 实现 GitClienter 接口。 +// +// GET /{repo}/-/git/contents/{file_path}?ref=... 返回 Content{type=blob, content=base64}。 +// LFS 对象 (type=lfs) 暂不解引用, 直接返回空内容并标记 IsBinary。 +func (c *Cnb) Blob(ctx context.Context, opts *domain.BlobOptions) (*domain.GetBlobResp, error) { + slug := repoSlug(opts.Owner, opts.Repo) + if slug == "" { + return nil, fmt.Errorf("cnb blob: missing repo slug") + } + if opts.Path == "" { + return nil, fmt.Errorf("cnb blob: file path is required") + } + + path := "/" + slug + "/-/git/contents/" + strings.TrimLeft(opts.Path, "/") + query := request.Query{} + if opts.Ref != "" { + query["ref"] = opts.Ref + } + + content, err := request.Get[Content](c.client, ctx, path, + request.WithHeader(c.authHeader(opts.Token)), + request.WithQuery(query), + ) + if err != nil { + return nil, fmt.Errorf("get cnb blob: %w", err) + } + if content == nil { + return &domain.GetBlobResp{}, nil + } + + var body []byte + switch strings.ToLower(content.Type) { + case "blob": + if strings.EqualFold(content.Encoding, "base64") { + cleaned := strings.ReplaceAll(content.Content, "\n", "") + decoded, decErr := base64.StdEncoding.DecodeString(cleaned) + if decErr != nil { + return nil, fmt.Errorf("decode cnb blob base64: %w", decErr) + } + body = decoded + } else { + body = []byte(content.Content) + } + case "lfs": + // LFS 对象暂不下载, 返回空; 大小取 lfs_size + return &domain.GetBlobResp{ + Sha: content.SHA, + Size: int(content.LFSSize), + IsBinary: true, + }, nil + default: + // tree / empty: 给个空响应 + return &domain.GetBlobResp{ + Sha: content.SHA, + Size: int(content.Size), + }, nil + } + + return &domain.GetBlobResp{ + Content: body, + IsBinary: isBinaryContent(body), + Sha: content.SHA, + Size: int(content.Size), + }, nil +} + +// Logs 实现 GitClienter 接口。 +// +// GET /{repo}/-/git/commits?sha=&page=&page_size= +func (c *Cnb) Logs(ctx context.Context, opts *domain.LogsOptions) (*domain.GetGitLogsResp, error) { + slug := repoSlug(opts.Owner, opts.Repo) + if slug == "" { + return nil, fmt.Errorf("cnb logs: missing repo slug") + } + + limit := opts.Limit + if limit <= 0 { + limit = 100 + } + page := (opts.Offset / limit) + 1 + + query := request.Query{ + "page": fmt.Sprintf("%d", page), + "page_size": fmt.Sprintf("%d", limit), + } + if opts.Ref != "" { + query["sha"] = opts.Ref + } + if opts.Path != "" { + query["path"] = opts.Path + } + + apiPath := "/" + slug + "/-/git/commits" + commits, err := request.Get[[]*Commit](c.client, ctx, apiPath, + request.WithHeader(c.authHeader(opts.Token)), + request.WithQuery(query), + ) + if err != nil { + return nil, fmt.Errorf("list cnb commits: %w", err) + } + + list := []*Commit{} + if commits != nil { + list = *commits + } + skip := opts.Offset % limit + if skip > 0 { + if skip >= len(list) { + list = nil + } else { + list = list[skip:] + } + } + + entries := make([]*domain.GitCommitEntry, 0, len(list)) + for _, c := range list { + if c == nil { + continue + } + entry := &domain.GitCommitEntry{Commit: &domain.GitCommit{ + Sha: c.SHA, + }} + if c.Commit != nil { + entry.Commit.Message = c.Commit.Message + if c.Commit.Tree != nil { + entry.Commit.TreeSha = c.Commit.Tree.SHA + } + if c.Commit.Author != nil { + entry.Commit.Author = &domain.GitUser{ + Name: c.Commit.Author.Name, + Email: c.Commit.Author.Email, + When: parseCnbTime(c.Commit.Author.Date), + } + } + if c.Commit.Committer != nil { + entry.Commit.Committer = &domain.GitUser{ + Name: c.Commit.Committer.Name, + Email: c.Commit.Committer.Email, + When: parseCnbTime(c.Commit.Committer.Date), + } + } + } + if entry.Commit.Author == nil && c.Author != nil { + entry.Commit.Author = &domain.GitUser{ + Name: c.Author.Name, + Email: c.Author.Email, + When: parseCnbTime(c.Author.Date), + } + } + if entry.Commit.Committer == nil && c.Committer != nil { + entry.Commit.Committer = &domain.GitUser{ + Name: c.Committer.Name, + Email: c.Committer.Email, + When: parseCnbTime(c.Committer.Date), + } + } + for _, p := range c.Parents { + if p != nil { + entry.Commit.ParentShas = append(entry.Commit.ParentShas, p.SHA) + } + } + entries = append(entries, entry) + } + return &domain.GetGitLogsResp{ + Count: len(entries), + Entries: entries, + }, nil +} + +// Archive 实现 GitClienter 接口。 +// +// GET /{repo}/-/git/archive/{ref_with_path} 直接流式返回归档。 +func (c *Cnb) Archive(ctx context.Context, opts *domain.ArchiveOptions) (*domain.GetRepoArchiveResp, error) { + slug := repoSlug(opts.Owner, opts.Repo) + if slug == "" { + return nil, fmt.Errorf("cnb archive: missing repo slug") + } + ref := opts.Ref + if ref == "" { + ref = "main" + } + fullURL := c.BaseURL() + "/" + slug + "/-/git/archive/" + ref + resp, err := c.rawRequest(ctx, "GET", fullURL, opts.Token) + if err != nil { + return nil, fmt.Errorf("download cnb archive: %w", err) + } + return &domain.GetRepoArchiveResp{ + ContentLength: resp.ContentLength, + ContentType: resp.Header.Get("Content-Type"), + Reader: resp.Body, + }, nil +} + +// Branches 实现 GitClienter 接口。 +// +// GET /{repo}/-/git/branches?page=&page_size= +func (c *Cnb) Branches(ctx context.Context, opts *domain.BranchesOptions) ([]*domain.BranchInfo, error) { + slug := repoSlug(opts.Owner, opts.Repo) + if slug == "" { + return nil, fmt.Errorf("cnb branches: missing repo slug") + } + + page := opts.Page + if page <= 0 { + page = 1 + } + perPage := opts.PerPage + if perPage <= 0 { + perPage = 50 + } + if perPage > 100 { + perPage = 100 + } + + query := request.Query{ + "page": fmt.Sprintf("%d", page), + "page_size": fmt.Sprintf("%d", perPage), + } + apiPath := "/" + slug + "/-/git/branches" + branches, err := request.Get[[]*Branch](c.client, ctx, apiPath, + request.WithHeader(c.authHeader(opts.Token)), + request.WithQuery(query), + ) + if err != nil { + return nil, fmt.Errorf("list cnb branches: %w", err) + } + + result := []*domain.BranchInfo{} + if branches != nil { + for _, b := range *branches { + if b == nil { + continue + } + result = append(result, &domain.BranchInfo{Name: b.Name}) + } + } + return result, nil +} + +// CreateWebhook 实现 GitClienter 接口。 +// +// CNB OpenAPI 暂未提供 webhook 管理接口, 「自动 review」对 CNB 不可用。 +// 调用方应在 buildAutoWebhookOpt 阶段直接拒绝 cnb 平台, 这里仅作为接口契约的兜底。 +func (c *Cnb) CreateWebhook(ctx context.Context, opts *domain.CreateWebhookOptions) error { + return fmt.Errorf("cnb webhook: not supported") +} + +// DeleteWebhook 实现 GitClienter 接口。 +// +// 同 CreateWebhook, no-op 返回 nil 让"解绑"动作不至于失败。 +func (c *Cnb) DeleteWebhook(ctx context.Context, opts *domain.WebhookOptions) error { + return nil +} + +func cnbTypeToMode(t string) int { + switch strings.ToLower(t) { + case "tree", "dir", "directory": + return 4 + default: + return 1 + } +} + +func isBinaryContent(content []byte) bool { + if len(content) == 0 { + return false + } + check := content + if len(check) > 8000 { + check = check[:8000] + } + return bytes.Contains(check, []byte{0}) +} + +func parseCnbTime(s string) int64 { + if s == "" { + return 0 + } + layouts := []string{ + time.RFC3339, + time.RFC3339Nano, + "2006-01-02T15:04:05Z07:00", + "2006-01-02T15:04:05+08:00", + "2006-01-02 15:04:05", + } + for _, layout := range layouts { + if t, err := time.Parse(layout, s); err == nil { + return t.Unix() + } + } + return 0 +} diff --git a/backend/pkg/git/cnb/types.go b/backend/pkg/git/cnb/types.go new file mode 100644 index 00000000..b88df522 --- /dev/null +++ b/backend/pkg/git/cnb/types.go @@ -0,0 +1,117 @@ +package cnb + +// 默认 OpenAPI 域名 +const ( + DefaultAPIHost = "api.cnb.cool" + DefaultWebHost = "cnb.cool" +) + +// Repository CNB 仓库信息 (dto.Repos4User 子集) +// +// 字段以 https://api.cnb.cool/swagger.json 为准, 只保留 MonkeyCode 关心的部分。 +type Repository struct { + ID string `json:"id,omitempty"` + Name string `json:"name,omitempty"` + Path string `json:"path,omitempty"` // 完整仓库路径, 如 "cnb/test" + Description string `json:"description,omitempty"` + WebURL string `json:"web_url,omitempty"` + VisibilityLevel string `json:"visibility_level,omitempty"` // public / internal / private + DefaultBranch string `json:"default_branch,omitempty"` + Site string `json:"site,omitempty"` +} + +// User CNB 用户信息 (dto.UsersResult 子集) +type User struct { + ID string `json:"id,omitempty"` + Username string `json:"username,omitempty"` + Nickname string `json:"nickname,omitempty"` + Email string `json:"email,omitempty"` + Avatar string `json:"avatar,omitempty"` +} + +// Branch 分支信息 (api.Branch) +type Branch struct { + Name string `json:"name,omitempty"` + Protected bool `json:"protected,omitempty"` + Locked bool `json:"locked,omitempty"` + Commit *CommitSummary `json:"commit,omitempty"` +} + +// CommitSummary 分支挂载的提交摘要 +type CommitSummary struct { + SHA string `json:"sha,omitempty"` + Message string `json:"message,omitempty"` +} + +// CommitUser 提交人 (api.CommitUser) +type CommitUser struct { + Name string `json:"name,omitempty"` + Email string `json:"email,omitempty"` + Date string `json:"date,omitempty"` +} + +// CommitDetail commit 主体内容 (api.CommitDetail) +type CommitDetail struct { + Author *CommitUser `json:"author,omitempty"` + Committer *CommitUser `json:"committer,omitempty"` + Message string `json:"message,omitempty"` + Tree *TreeRef `json:"tree,omitempty"` +} + +// TreeRef commit 关联的 tree 引用 +type TreeRef struct { + SHA string `json:"sha,omitempty"` +} + +// CommitParent commit 父节点 +type CommitParent struct { + SHA string `json:"sha,omitempty"` +} + +// Commit 完整提交信息 (api.Commit) +type Commit struct { + SHA string `json:"sha,omitempty"` + Commit *CommitDetail `json:"commit,omitempty"` + Author *CommitUser `json:"author,omitempty"` + Committer *CommitUser `json:"committer,omitempty"` + Parents []*CommitParent `json:"parents,omitempty"` +} + +// Content 仓库内容节点 (api.Content) +// +// 同一结构既描述 tree (目录, type=tree) 也描述 blob (文件, type=blob)。 +// - type=tree 时 entries 有值, content 为空 +// - type=blob 时 content 为 base64, entries 为空 +// - type=lfs 时走 lfs_download_url +type Content struct { + Name string `json:"name,omitempty"` + Path string `json:"path,omitempty"` + SHA string `json:"sha,omitempty"` + Type string `json:"type,omitempty"` // tree / blob / lfs / empty + Size int64 `json:"size,omitempty"` + Content string `json:"content,omitempty"` // blob 时为 base64 + Encoding string `json:"encoding,omitempty"` // base64 + Entries []*TreeEntry `json:"entries,omitempty"` // tree 时返回 + LFSDownloadURL string `json:"lfs_download_url,omitempty"` + LFSOID string `json:"lfs_oid,omitempty"` + LFSSize int64 `json:"lfs_size,omitempty"` +} + +// TreeEntry tree 目录下的子项 +type TreeEntry struct { + Name string `json:"name,omitempty"` + Path string `json:"path,omitempty"` + SHA string `json:"sha,omitempty"` + Type string `json:"type,omitempty"` // tree / blob + Size int64 `json:"size,omitempty"` +} + +// errorResponse CNB 通用错误响应 +// +// CNB 错误返回格式没有严格统一, 这里覆盖常见字段; parseError 会按顺序挑第一个非空的。 +type errorResponse struct { + Code int `json:"code,omitempty"` + Message string `json:"message,omitempty"` + Msg string `json:"msg,omitempty"` + Error string `json:"error,omitempty"` +} diff --git a/backend/pkg/git/codeup/codeup.go b/backend/pkg/git/codeup/codeup.go new file mode 100644 index 00000000..c2abb7a8 --- /dev/null +++ b/backend/pkg/git/codeup/codeup.go @@ -0,0 +1,394 @@ +// Package codeup 提供阿里云云效 Codeup 客户端。 +// +// 鉴权:HTTP Header `x-yunxiao-token: `,统一走云效 OpenAPI(公共站 +// openapi-rdc.aliyuncs.com)。绝大多数接口需要 organizationId 路径参数。 +package codeup + +import ( + "context" + "encoding/json" + "fmt" + "io" + "log/slog" + "net/http" + "net/url" + "strings" + "time" + + "github.com/chaitin/MonkeyCode/backend/domain" + "github.com/chaitin/MonkeyCode/backend/pkg/request" +) + +// Codeup 客户端 +type Codeup struct { + client *request.Client + logger *slog.Logger + openapi string // OpenAPI host,如 openapi-rdc.aliyuncs.com + scheme string + orgID string // 默认组织 ID;当 GitIdentity 已存储 OrganizationID 时注入 +} + +// NewCodeup 创建 Codeup 客户端。 +// - openapiBase: OpenAPI 域名(包含 scheme),空则使用默认 https://openapi-rdc.aliyuncs.com +// - orgID: 组织 ID,可为空(CheckPAT / UserInfo / 自动解析时无需提前知道) +func NewCodeup(openapiBase, orgID string, logger *slog.Logger) *Codeup { + scheme, host := normalizeBase(openapiBase, DefaultOpenAPIHost) + return &Codeup{ + logger: logger.With("module", "codeup"), + openapi: host, + scheme: scheme, + orgID: orgID, + client: request.NewClient( + scheme, + host, + 30*time.Second, + ), + } +} + +// BaseURL 返回 OpenAPI base URL +func (c *Codeup) BaseURL() string { + return c.scheme + "://" + c.openapi +} + +// OrgID 返回当前默认组织 ID(可能为空) +func (c *Codeup) OrgID() string { return c.orgID } + +func (c *Codeup) authHeader(token string) request.Header { + return request.Header{"x-yunxiao-token": token} +} + +func normalizeBase(input, fallback string) (scheme, host string) { + scheme, host = "https", fallback + if input == "" { + return + } + if strings.HasPrefix(input, "http://") { + scheme = "http" + host = strings.TrimPrefix(input, "http://") + } else if strings.HasPrefix(input, "https://") { + host = strings.TrimPrefix(input, "https://") + } else { + host = input + } + host = strings.TrimSuffix(host, "/") + return +} + +// ParseRepoPath 从仓库 URL 解析 orgId 和 repo 标识(groupPath/repoName)。 +// 支持以下形式: +// https://codeup.aliyun.com/{orgId}/{group}/{repo}.git +// https://codeup.aliyun.com/{orgId}/{group}/{repo} +// git@codeup.aliyun.com:{orgId}/{group}/{repo}.git +// https://{orgId}.codeup.aliyun.com/{group}/{repo}.git +func ParseRepoPath(repoURL string) (orgID, identity string, err error) { + raw := strings.TrimSpace(repoURL) + raw = strings.TrimSuffix(raw, ".git") + + // SSH 形式:git@host:org/group/repo + if strings.HasPrefix(raw, "git@") { + at := strings.Index(raw, "@") + col := strings.Index(raw, ":") + if col <= at { + return "", "", fmt.Errorf("invalid codeup ssh url: %s", repoURL) + } + path := raw[col+1:] + return splitOrgAndIdentity(path, repoURL) + } + + u, perr := url.Parse(raw) + if perr != nil { + return "", "", fmt.Errorf("parse codeup url: %w", perr) + } + host := u.Host + path := strings.TrimPrefix(u.Path, "/") + + // 子域名形式:{orgId}.codeup.aliyun.com/{group}/{repo} + if idx := strings.Index(host, ".codeup."); idx > 0 { + orgID = host[:idx] + identity = path + if orgID == "" || identity == "" { + return "", "", fmt.Errorf("invalid codeup subdomain url: %s", repoURL) + } + return orgID, identity, nil + } + + // 路径形式:codeup.aliyun.com/{orgId}/{group}/{repo} + return splitOrgAndIdentity(path, repoURL) +} + +func splitOrgAndIdentity(path, raw string) (string, string, error) { + parts := strings.Split(strings.Trim(path, "/"), "/") + if len(parts) < 3 { + return "", "", fmt.Errorf("invalid codeup url, expect orgId/group/repo: %s", raw) + } + orgID := parts[0] + identity := strings.Join(parts[1:], "/") + return orgID, identity, nil +} + +// repoPath 返回 /oapi/v1/codeup/organizations/{orgId}/repositories/{repoIdent} +// +// 云效 OpenAPI 的 repositoryId 接受两种格式: +// - 数字 ID(如 2813489) +// - URL-encoded 全路径(如 60de7a6852743a5162b5f957%2FDemoRepo,即 {orgId}/{groupPath}/{repoName}) +// +// repoIdent 入参可以是「数字 ID」或「不含 orgId 前缀的 {groupPath}/{repoName}」。 +// 这里识别全数字按数字 ID 直传;否则统一拼成全路径再 URL-encode。 +func (c *Codeup) repoPath(orgID, repoIdent string) string { + encoded := encodeRepoIdent(orgID, repoIdent) + return fmt.Sprintf("/oapi/v1/codeup/organizations/%s/repositories/%s", + url.PathEscape(orgID), encoded) +} + +// encodeRepoIdent 把 repoIdent 编码为云效 OpenAPI 期望的 repositoryId 形式。 +func encodeRepoIdent(orgID, repoIdent string) string { + repoIdent = strings.TrimSpace(repoIdent) + if repoIdent == "" { + return "" + } + if isAllDigits(repoIdent) { + return url.PathEscape(repoIdent) + } + // 已经带 orgId 前缀就不要重复拼 + prefix := orgID + "/" + if !strings.HasPrefix(repoIdent, prefix) { + repoIdent = prefix + repoIdent + } + return url.PathEscape(repoIdent) +} + +// encodeFilePath 把文件路径编成云效 OpenAPI 文件接口期望的 path 段:URL-encode 后把 / 换成 %2F。 +func encodeFilePath(p string) string { + return strings.ReplaceAll(url.PathEscape(p), "/", "%2F") +} + +func isAllDigits(s string) bool { + if s == "" { + return false + } + for _, r := range s { + if r < '0' || r > '9' { + return false + } + } + return true +} + +// resolveRepoCtx 解析当前 orgID 与 repo identity。 +// +// - orgID 优先用客户端构造时注入的;为空时用 token 调 ResolveOrgID 取 PAT 的第一个组织。 +// - identity 由 owner / repo 拼成「{groupPath}/{repoName}」形式(不含 orgId)。 +func (c *Codeup) resolveRepoCtx(ctx context.Context, token, owner, repo string) (string, string, error) { + orgID := c.orgID + if orgID == "" { + resolved, err := c.ResolveOrgID(ctx, token) + if err != nil { + return "", "", fmt.Errorf("resolve organization: %w", err) + } + orgID = resolved + } + identity := assembleIdentity(owner, repo) + if identity == "" { + return "", "", fmt.Errorf("missing repo identity") + } + return orgID, identity, nil +} + +func assembleIdentity(owner, repo string) string { + owner = strings.TrimSpace(owner) + repo = strings.TrimSpace(repo) + switch { + case owner == "" && repo == "": + return "" + case owner == "": + return repo + case repo == "": + return owner + default: + return owner + "/" + repo + } +} + +// ListOrganizations 列出 PAT 所属的全部云效组织。 +// +// GET /oapi/v1/platform/organizations +// Header: x-yunxiao-token +// +// 中心版/标准版均支持 PAT 调用,返回结构为 [{id, name, ...}]。 +func (c *Codeup) ListOrganizations(ctx context.Context, token string) ([]*OrganizationItem, error) { + orgs, err := request.Get[[]*OrganizationItem](c.client, ctx, "/oapi/v1/platform/organizations", + request.WithHeader(c.authHeader(token)), + request.WithQuery(request.Query{"perPage": "100"}), + ) + if err != nil { + return nil, fmt.Errorf("list organizations: %w", err) + } + if orgs == nil { + return nil, nil + } + return *orgs, nil +} + +// ResolveOrgID 取 PAT 所属的第一个组织的 ID。 +// +// 多组织时只取第一个;用户若希望使用另一组织,可在编辑身份时手动覆盖 organization_id。 +func (c *Codeup) ResolveOrgID(ctx context.Context, token string) (string, error) { + orgs, err := c.ListOrganizations(ctx, token) + if err != nil { + return "", err + } + if len(orgs) == 0 { + return "", fmt.Errorf("token has no organization") + } + return orgs[0].ID, nil +} + +// GetRepoByIdentity 根据 group/repo 标识获取仓库信息 +func (c *Codeup) GetRepoByIdentity(ctx context.Context, token, orgID, identity string) (*Repository, error) { + if orgID == "" { + return nil, fmt.Errorf("organization id is required") + } + path := c.repoPath(orgID, identity) + repo, err := request.Get[Repository](c.client, ctx, path, + request.WithHeader(c.authHeader(token))) + if err != nil { + return nil, fmt.Errorf("get repo: %w", err) + } + return repo, nil +} + +// CheckPAT 校验 PAT。先解析 repoURL 得到 orgId + identity,再调 OpenAPI 验证可访问。 +func (c *Codeup) CheckPAT(ctx context.Context, token, repoURL string) (bool, *domain.BindRepository, error) { + orgID, identity, err := ParseRepoPath(repoURL) + if err != nil { + return false, nil, err + } + repo, err := c.GetRepoByIdentity(ctx, token, orgID, identity) + if err != nil { + return false, nil, err + } + if repo == nil || repo.ID == 0 { + return false, nil, fmt.Errorf("repository not found or token has no access") + } + web := repo.WebURL + if web == "" { + web = repo.HTTPCloneURL + } + return true, &domain.BindRepository{ + RepoID: fmt.Sprintf("%d", repo.ID), + RepoName: repo.Name, + FullName: firstNonEmpty(repo.PathWithNs, repo.NameWithNs, identity), + RepoURL: web, + RepoDescription: repo.Description, + IsPrivate: strings.EqualFold(repo.Visibility, "private"), + Platform: "codeup", + }, nil +} + +// UserInfo 实现 GitClienter 接口。 +// +// 云效 OpenAPI 没有提供仅凭 PAT 获取当前用户的接口(GetCurrentUser 系列均不存在)。 +// 此处返回空名以满足接口约定;调用方需要时应当从 GitIdentity.Username 字段读取用户绑定时填写的名字。 +func (c *Codeup) UserInfo(ctx context.Context, token string) (*domain.PlatformUserInfo, error) { + return &domain.PlatformUserInfo{Name: ""}, nil +} + +// Repositories 列出当前用户在组织内可访问的仓库 +func (c *Codeup) Repositories(ctx context.Context, opts *domain.RepositoryOptions) ([]domain.AuthRepository, error) { + orgID := c.orgID + if orgID == "" { + resolved, err := c.ResolveOrgID(ctx, opts.Token) + if err != nil { + return nil, fmt.Errorf("resolve organization: %w", err) + } + orgID = resolved + } + + result := make([]domain.AuthRepository, 0, 64) + page, perPage := 1, 100 + for { + path := fmt.Sprintf("/oapi/v1/codeup/organizations/%s/repositories", url.PathEscape(orgID)) + query := request.Query{ + "page": fmt.Sprintf("%d", page), + "perPage": fmt.Sprintf("%d", perPage), + "orderBy": "last_activity_at", + "sort": "desc", + } + repos, err := request.Get[[]*Repository](c.client, ctx, path, + request.WithHeader(c.authHeader(opts.Token)), + request.WithQuery(query), + ) + if err != nil { + return nil, fmt.Errorf("list codeup repositories: %w", err) + } + if repos == nil || len(*repos) == 0 { + break + } + for _, r := range *repos { + result = append(result, domain.AuthRepository{ + FullName: firstNonEmpty(r.PathWithNs, r.NameWithNs, r.Name), + URL: firstNonEmpty(r.WebURL, r.HTTPCloneURL), + Description: r.Description, + }) + } + if len(*repos) < perPage { + break + } + page++ + if page > 50 { // 安全上限:5000 个仓库 + break + } + } + return result, nil +} + +// rawRequest 透传 HTTP 调用,用于 OpenAPI 之外的简单请求(如 webhook 删除) +func (c *Codeup) rawRequest(ctx context.Context, method, fullURL, token string) (*http.Response, error) { + req, err := http.NewRequestWithContext(ctx, method, fullURL, nil) + if err != nil { + return nil, err + } + req.Header.Set("x-yunxiao-token", token) + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, err + } + if resp.StatusCode >= 400 { + body, _ := io.ReadAll(resp.Body) + resp.Body.Close() + return nil, fmt.Errorf("codeup %s %s returned %d: %s", method, fullURL, resp.StatusCode, parseError(body)) + } + return resp, nil +} + +func parseError(body []byte) string { + if len(body) == 0 { + return "" + } + var er errorResponse + if err := json.Unmarshal(body, &er); err == nil { + if er.ErrorMessage != "" { + return er.ErrorMessage + } + if er.Message != "" { + return er.Message + } + if er.ErrorCode != "" { + return er.ErrorCode + } + } + if len(body) > 512 { + return string(body[:512]) + } + return string(body) +} + +func firstNonEmpty(vals ...string) string { + for _, v := range vals { + if v != "" { + return v + } + } + return "" +} diff --git a/backend/pkg/git/codeup/operation.go b/backend/pkg/git/codeup/operation.go new file mode 100644 index 00000000..689bc653 --- /dev/null +++ b/backend/pkg/git/codeup/operation.go @@ -0,0 +1,374 @@ +package codeup + +import ( + "bytes" + "context" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" + + "github.com/chaitin/MonkeyCode/backend/domain" + "github.com/chaitin/MonkeyCode/backend/pkg/request" +) + +// Tree 实现 GitClienter 接口 +// +// Codeup 文件树接口:GET /oapi/v1/codeup/organizations/{orgId}/repositories/{repoIdent}/files/tree +// query: ref=branch, path=subdir, type=DIRECT|RECURSIVE|FLATTEN +// +// 响应是 TreeNode 数组(当前目录或递归目录下的所有节点)。 +func (c *Codeup) Tree(ctx context.Context, opts *domain.TreeOptions) (*domain.GetRepoTreeResp, error) { + orgID, identity, err := c.resolveRepoCtx(ctx, opts.Token, opts.Owner, opts.Repo) + if err != nil { + return nil, fmt.Errorf("codeup tree: %w", err) + } + + path := c.repoPath(orgID, identity) + "/files/tree" + query := request.Query{} + if opts.Ref != "" { + query["ref"] = opts.Ref + } + if opts.Path != "" { + query["path"] = opts.Path + } + if opts.Recursive { + query["type"] = "RECURSIVE" + } else { + query["type"] = "DIRECT" + } + + nodes, err := request.Get[[]*TreeNode](c.client, ctx, path, + request.WithHeader(c.authHeader(opts.Token)), + request.WithQuery(query), + ) + if err != nil { + return nil, fmt.Errorf("get codeup tree: %w", err) + } + + list := []*TreeNode{} + if nodes != nil { + list = *nodes + } + entries := make([]*domain.TreeEntry, 0, len(list)) + for _, node := range list { + if node == nil { + continue + } + if node.ID == "" && node.Name == "" && node.Path == "" { + continue + } + entries = append(entries, &domain.TreeEntry{ + Mode: codeupTypeToMode(node.Type), + Name: node.Name, + Path: node.Path, + Sha: node.ID, + }) + } + return &domain.GetRepoTreeResp{ + Entries: entries, + SHA: opts.Ref, + }, nil +} + +// Blob 实现 GitClienter 接口 +// +// Codeup 文件内容接口:GET /oapi/v1/codeup/organizations/{orgId}/repositories/{repoIdent}/files/{filePath} +// filePath 走 path 段,需要 URL-encode(含把 / 编成 %2F);ref 是必填 query。 +func (c *Codeup) Blob(ctx context.Context, opts *domain.BlobOptions) (*domain.GetBlobResp, error) { + orgID, identity, err := c.resolveRepoCtx(ctx, opts.Token, opts.Owner, opts.Repo) + if err != nil { + return nil, fmt.Errorf("codeup blob: %w", err) + } + if opts.Path == "" { + return nil, fmt.Errorf("codeup blob: file path is required") + } + + apiPath := c.repoPath(orgID, identity) + "/files/" + encodeFilePath(opts.Path) + query := request.Query{} + if opts.Ref != "" { + query["ref"] = opts.Ref + } + + blob, err := request.Get[FileBlob](c.client, ctx, apiPath, + request.WithHeader(c.authHeader(opts.Token)), + request.WithQuery(query), + ) + if err != nil { + return nil, fmt.Errorf("get codeup blob: %w", err) + } + + var content []byte + switch strings.ToLower(blob.Encoding) { + case "base64": + cleaned := strings.ReplaceAll(blob.Content, "\n", "") + decoded, decErr := base64.StdEncoding.DecodeString(cleaned) + if decErr != nil { + return nil, fmt.Errorf("decode base64: %w", decErr) + } + content = decoded + default: + content = []byte(blob.Content) + } + + return &domain.GetBlobResp{ + Content: content, + IsBinary: isBinaryContent(content), + Sha: firstNonEmpty(blob.BlobID, blob.LastCommitID, blob.CommitID), + Size: int(blob.Size), + }, nil +} + +// Logs 实现 GitClienter 接口 +// +// Codeup commits 接口:GET /oapi/v1/codeup/organizations/{orgId}/repositories/{repoIdent}/commits +// query: refName, path, page, perPage +func (c *Codeup) Logs(ctx context.Context, opts *domain.LogsOptions) (*domain.GetGitLogsResp, error) { + orgID, identity, err := c.resolveRepoCtx(ctx, opts.Token, opts.Owner, opts.Repo) + if err != nil { + return nil, fmt.Errorf("codeup logs: %w", err) + } + + limit := opts.Limit + if limit <= 0 { + limit = 100 + } + page := (opts.Offset / limit) + 1 + + apiPath := c.repoPath(orgID, identity) + "/commits" + query := request.Query{ + "page": fmt.Sprintf("%d", page), + "perPage": fmt.Sprintf("%d", limit), + } + if opts.Ref != "" { + query["refName"] = opts.Ref + } + if opts.Path != "" { + query["path"] = opts.Path + } + + commits, err := request.Get[[]*CommitItem](c.client, ctx, apiPath, + request.WithHeader(c.authHeader(opts.Token)), + request.WithQuery(query), + ) + if err != nil { + return nil, fmt.Errorf("list codeup commits: %w", err) + } + + list := *commits + skip := opts.Offset % limit + if skip > 0 { + if skip >= len(list) { + list = nil + } else { + list = list[skip:] + } + } + + entries := make([]*domain.GitCommitEntry, 0, len(list)) + for _, c := range list { + entry := &domain.GitCommitEntry{Commit: &domain.GitCommit{ + Sha: c.ID, + Message: firstNonEmpty(c.Message, c.Title), + ParentShas: append([]string(nil), c.ParentIDs...), + }} + entry.Commit.Author = &domain.GitUser{ + Name: c.AuthorName, + Email: c.AuthorEmail, + When: parseCodeupTime(c.AuthoredDate), + } + entry.Commit.Committer = &domain.GitUser{ + Name: firstNonEmpty(c.CommitterName, entry.Commit.Author.Name), + Email: firstNonEmpty(c.CommitterEmail, entry.Commit.Author.Email), + When: parseCodeupTime(firstNonEmpty(c.CommittedDate, c.AuthoredDate)), + } + entries = append(entries, entry) + } + return &domain.GetGitLogsResp{ + Count: len(entries), + Entries: entries, + }, nil +} + +// Archive 实现 GitClienter 接口 +// +// 云效 Codeup 不提供归档下载接口,前端也未调用,这里返回未实现错误以满足接口约定。 +func (c *Codeup) Archive(ctx context.Context, opts *domain.ArchiveOptions) (*domain.GetRepoArchiveResp, error) { + return nil, fmt.Errorf("codeup archive: not supported") +} + +// Branches 实现 GitClienter 接口 +func (c *Codeup) Branches(ctx context.Context, opts *domain.BranchesOptions) ([]*domain.BranchInfo, error) { + orgID, identity, err := c.resolveRepoCtx(ctx, opts.Token, opts.Owner, opts.Repo) + if err != nil { + return nil, fmt.Errorf("codeup branches: %w", err) + } + + page := opts.Page + if page <= 0 { + page = 1 + } + perPage := opts.PerPage + if perPage <= 0 { + perPage = 50 + } + if perPage > 100 { + perPage = 100 + } + + apiPath := c.repoPath(orgID, identity) + "/branches" + query := request.Query{ + "page": fmt.Sprintf("%d", page), + "perPage": fmt.Sprintf("%d", perPage), + } + branches, err := request.Get[[]*Branch](c.client, ctx, apiPath, + request.WithHeader(c.authHeader(opts.Token)), + request.WithQuery(query), + ) + if err != nil { + return nil, fmt.Errorf("list codeup branches: %w", err) + } + result := make([]*domain.BranchInfo, 0, len(*branches)) + for _, b := range *branches { + result = append(result, &domain.BranchInfo{Name: b.Name}) + } + return result, nil +} + +// CreateWebhook 实现 GitClienter 接口 +// +// Codeup webhook 接口:POST /oapi/v1/codeup/organizations/{orgId}/repositories/{repoIdent}/webhooks +func (c *Codeup) CreateWebhook(ctx context.Context, opts *domain.CreateWebhookOptions) error { + orgID, identity, err := ParseRepoPath(opts.RepoURL) + if err != nil { + return err + } + + apiPath := c.repoPath(orgID, identity) + "/webhooks" + payload := map[string]any{ + "url": opts.WebhookURL, + "token": opts.SecretToken, + "pushEvents": true, + "mergeRequestsEvents": true, + "tagPushEvents": true, + "noteEvents": true, + "description": "MonkeyCode webhook", + } + + body, err := json.Marshal(payload) + if err != nil { + return fmt.Errorf("marshal webhook payload: %w", err) + } + + apiURL := c.BaseURL() + apiPath + resp, err := c.rawJSON(ctx, "POST", apiURL, opts.Token, body) + if err != nil { + return fmt.Errorf("create codeup webhook: %w", err) + } + resp.Body.Close() + return nil +} + +// DeleteWebhook 实现 GitClienter 接口 +func (c *Codeup) DeleteWebhook(ctx context.Context, opts *domain.WebhookOptions) error { + orgID, identity, err := ParseRepoPath(opts.RepoURL) + if err != nil { + return err + } + + apiPath := c.repoPath(orgID, identity) + "/webhooks" + // 分页查找匹配 webhook 后删除 + for page := 1; page <= 20; page++ { + query := request.Query{ + "page": fmt.Sprintf("%d", page), + "perPage": "100", + } + hooks, err := request.Get[[]*WebhookItem](c.client, ctx, apiPath, + request.WithHeader(c.authHeader(opts.Token)), + request.WithQuery(query), + ) + if err != nil { + return fmt.Errorf("list codeup webhooks: %w", err) + } + if hooks == nil || len(*hooks) == 0 { + return nil + } + for _, h := range *hooks { + if h.URL != opts.WebhookURL { + continue + } + delURL := fmt.Sprintf("%s%s/%d", c.BaseURL(), apiPath, h.ID) + resp, derr := c.rawRequest(ctx, "DELETE", delURL, opts.Token) + if derr != nil { + return derr + } + resp.Body.Close() + return nil + } + if len(*hooks) < 100 { + return nil + } + } + return nil +} + +func (c *Codeup) rawJSON(ctx context.Context, method, fullURL, token string, body []byte) (*http.Response, error) { + req, err := http.NewRequestWithContext(ctx, method, fullURL, bytes.NewReader(body)) + if err != nil { + return nil, err + } + req.Header.Set("x-yunxiao-token", token) + req.Header.Set("Content-Type", "application/json") + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, err + } + if resp.StatusCode >= 400 { + buf, _ := io.ReadAll(resp.Body) + resp.Body.Close() + return nil, fmt.Errorf("codeup %s returned %d: %s", method, resp.StatusCode, parseError(buf)) + } + return resp, nil +} + +func codeupTypeToMode(t string) int { + switch strings.ToLower(t) { + case "tree", "dir", "directory": + return 4 + default: + return 1 + } +} + +func isBinaryContent(content []byte) bool { + if len(content) == 0 { + return false + } + check := content + if len(check) > 8000 { + check = check[:8000] + } + return bytes.Contains(check, []byte{0}) +} + +func parseCodeupTime(s string) int64 { + if s == "" { + return 0 + } + layouts := []string{ + time.RFC3339, + time.RFC3339Nano, + "2006-01-02T15:04:05Z07:00", + "2006-01-02T15:04:05+08:00", + "2006-01-02 15:04:05", + } + for _, layout := range layouts { + if t, err := time.Parse(layout, s); err == nil { + return t.Unix() + } + } + return 0 +} diff --git a/backend/pkg/git/codeup/types.go b/backend/pkg/git/codeup/types.go new file mode 100644 index 00000000..c421b0b6 --- /dev/null +++ b/backend/pkg/git/codeup/types.go @@ -0,0 +1,141 @@ +package codeup + +import ( + "bytes" + "strconv" +) + +// 默认 OpenAPI 域名(公网公共站) +const DefaultOpenAPIHost = "openapi-rdc.aliyuncs.com" + +// flexibleInt 兼容云效返回的 int 或字符串数字(如 "1666")。 +type flexibleInt int + +func (f *flexibleInt) UnmarshalJSON(data []byte) error { + data = bytes.TrimSpace(data) + if len(data) == 0 || bytes.Equal(data, []byte("null")) { + return nil + } + if data[0] == '"' && data[len(data)-1] == '"' { + data = data[1 : len(data)-1] + } + if len(data) == 0 { + return nil + } + n, err := strconv.ParseInt(string(data), 10, 64) + if err != nil { + return err + } + *f = flexibleInt(n) + return nil +} + +// Repository 仓库信息 +// +// 字段以「查询代码库列表」文档为准;HTTPCloneURL / SSHCloneURL / DefaultBranch / Permissions +// 在列表接口里不一定出现,但「查询单个代码库」可能返回,保留为可选字段不影响反序列化。 +type Repository struct { + ID int64 `json:"id,omitempty"` + Name string `json:"name,omitempty"` + Path string `json:"path,omitempty"` + NameWithNs string `json:"nameWithNamespace,omitempty"` + PathWithNs string `json:"pathWithNamespace,omitempty"` + Description string `json:"description,omitempty"` + Visibility string `json:"visibility,omitempty"` // private / internal + WebURL string `json:"webUrl,omitempty"` + HTTPCloneURL string `json:"httpCloneUrl,omitempty"` + SSHCloneURL string `json:"sshCloneUrl,omitempty"` + DefaultBranch string `json:"defaultBranch,omitempty"` + Permissions []string `json:"permissions,omitempty"` + Archived bool `json:"archived,omitempty"` +} + +// Branch 分支 +type Branch struct { + Name string `json:"name,omitempty"` + Protected bool `json:"protected,omitempty"` + Commit *CommitSummary `json:"commit,omitempty"` +} + +// CommitSummary 分支挂载的提交摘要 +type CommitSummary struct { + ID string `json:"id,omitempty"` + ShortID string `json:"shortId,omitempty"` + Title string `json:"title,omitempty"` + AuthoredAt string `json:"authoredDate,omitempty"` +} + +// TreeNode 文件树节点 +type TreeNode struct { + ID string `json:"id,omitempty"` + Name string `json:"name,omitempty"` + Type string `json:"type,omitempty"` // tree / blob / commit + Path string `json:"path,omitempty"` + Mode string `json:"mode,omitempty"` + IsLFS bool `json:"isLFS,omitempty"` +} + +// FileBlob 文件内容 +// +// 注意:云效返回的 size 字段有时是字符串(如 "1666"),有时是数字,用 flexibleInt 兼容。 +type FileBlob struct { + FileName string `json:"fileName,omitempty"` + FilePath string `json:"filePath,omitempty"` + Size flexibleInt `json:"size,omitempty"` + Encoding string `json:"encoding,omitempty"` // base64 / text + Content string `json:"content,omitempty"` + CommitID string `json:"commitId,omitempty"` + LastCommitID string `json:"lastCommitId,omitempty"` + Ref string `json:"ref,omitempty"` + BlobID string `json:"blobId,omitempty"` +} + +// CommitStats 提交变更行数统计 +type CommitStats struct { + Additions int `json:"additions,omitempty"` + Deletions int `json:"deletions,omitempty"` + Total int `json:"total,omitempty"` +} + +// CommitItem 完整提交信息 +type CommitItem struct { + ID string `json:"id,omitempty"` + ShortID string `json:"shortId,omitempty"` + Title string `json:"title,omitempty"` + Message string `json:"message,omitempty"` + ParentIDs []string `json:"parentIds,omitempty"` + AuthorName string `json:"authorName,omitempty"` + AuthorEmail string `json:"authorEmail,omitempty"` + AuthoredDate string `json:"authoredDate,omitempty"` + CommitterName string `json:"committerName,omitempty"` + CommitterEmail string `json:"committerEmail,omitempty"` + CommittedDate string `json:"committedDate,omitempty"` + Stats *CommitStats `json:"stats,omitempty"` + WebURL string `json:"webUrl,omitempty"` +} + +// OrganizationItem 云效组织条目(来自 /oapi/v1/platform/organizations) +type OrganizationItem struct { + ID string `json:"id,omitempty"` + Name string `json:"name,omitempty"` + Description string `json:"description,omitempty"` + CreatorID string `json:"creatorId,omitempty"` + DefaultRole string `json:"defaultRole,omitempty"` + CreatedAt string `json:"createdAt,omitempty"` + UpdateAt string `json:"updateAt,omitempty"` +} + +// WebhookItem 仓库 webhook +type WebhookItem struct { + ID int64 `json:"id,omitempty"` + URL string `json:"url,omitempty"` + Description string `json:"description,omitempty"` +} + +// errorResponse 云效错误响应 +type errorResponse struct { + ErrorCode string `json:"errorCode,omitempty"` + ErrorMessage string `json:"errorMessage,omitempty"` + Message string `json:"message,omitempty"` + RequestID string `json:"requestId,omitempty"` +} diff --git a/backend/pkg/git/oauth/refresh.go b/backend/pkg/git/oauth/refresh.go index 93163ad6..b7929f0a 100644 --- a/backend/pkg/git/oauth/refresh.go +++ b/backend/pkg/git/oauth/refresh.go @@ -1,6 +1,7 @@ package oauth import ( + "encoding/base64" "fmt" "net/http" "net/url" @@ -108,6 +109,144 @@ func RefreshGitea(baseURL, clientID, clientSecret, refreshToken string, proxies return result, nil } +// CodeupTokenResponse 阿里云云效 OAuth Token 响应 +// +// ⚠️ 当前为占位实现,云效 OAuth 接入尚未启用。 +// 云效官方 OpenAPI 的 CreateOAuthToken(POST /login/oauth/create)目前处于"内测中, +// 暂不支持使用"状态,且响应中不包含 refresh_token / expires_in 字段,与标准 OAuth2 +// 刷新模型不兼容。等云效放开公网接入并补齐字段后再启用整条链路。 +// +// 在此之前,codeup 一律走 PAT 模式:identity.AccessToken 直接存用户填写的 PAT。 +type CodeupTokenResponse struct { + AccessToken string `json:"access_token"` + TokenType string `json:"token_type"` + ExpiresIn int `json:"expires_in"` + RefreshToken string `json:"refresh_token"` + Scope string `json:"scope"` +} + +func (r *CodeupTokenResponse) ExpiresAt() int64 { + return time.Now().Unix() + int64(r.ExpiresIn) +} + +// RefreshCodeup 刷新云效 OAuth access_token +// +// ⚠️ 暂未启用:云效 OAuth 接口尚在内测,且其响应不返回 refresh_token,本函数实际不会 +// 被走通。保留实现是为了 token.go 里的 case consts.GitPlatformCodeup 编译通过;待云效 +// 放开公网接入后再回头调整。 +// +// 默认 token endpoint 走阿里云账号通用网关:https://account.aliyun.com/oauth2/v1/token +// 如有定制(如专有云)可通过 tokenURL 显式覆盖 +func RefreshCodeup(tokenURL, clientID, clientSecret, refreshToken string, proxies ...string) (*CodeupTokenResponse, error) { + if tokenURL == "" { + tokenURL = "https://account.aliyun.com/oauth2/v1/token" + } + params := url.Values{} + params.Add("grant_type", "refresh_token") + params.Add("refresh_token", refreshToken) + params.Add("client_id", clientID) + params.Add("client_secret", clientSecret) + + result, err := fetchWithProxyAndBody[CodeupTokenResponse]( + http.MethodPost, tokenURL, + map[string]string{"Content-Type": "application/x-www-form-urlencoded", "Accept": "application/json"}, + strings.NewReader(params.Encode()), proxies..., + ) + if err != nil { + return nil, fmt.Errorf("refresh codeup token: %w", err) + } + if result.AccessToken == "" { + return nil, fmt.Errorf("refreshed codeup access token is empty") + } + return result, nil +} + +// CnbTokenResponse CNB (cnb.cool) OAuth Token 响应 +// +// CNB 走标准 OAuth2: access_token 8h 有效, refresh_token 180d 有效。 +type CnbTokenResponse struct { + AccessToken string `json:"access_token"` + TokenType string `json:"token_type"` + ExpiresIn int `json:"expires_in"` + RefreshToken string `json:"refresh_token"` + Scope string `json:"scope"` +} + +func (r *CnbTokenResponse) ExpiresAt() int64 { + return time.Now().Unix() + int64(r.ExpiresIn) +} + +// RefreshCnb 刷新 CNB OAuth access_token +// +// POST {webBaseURL}/oauth2/token +// Header: Authorization: Basic b64(client_id:client_secret) +// Body: grant_type=refresh_token&refresh_token= +// +// webBaseURL 留空默认 https://cnb.cool +func RefreshCnb(webBaseURL, clientID, clientSecret, refreshToken string, proxies ...string) (*CnbTokenResponse, error) { + if webBaseURL == "" { + webBaseURL = "https://cnb.cool" + } + tokenURL := strings.TrimSuffix(webBaseURL, "/") + "/oauth2/token" + + params := url.Values{} + params.Add("grant_type", "refresh_token") + params.Add("refresh_token", refreshToken) + + basic := base64.StdEncoding.EncodeToString([]byte(clientID + ":" + clientSecret)) + result, err := fetchWithProxyAndBody[CnbTokenResponse]( + http.MethodPost, tokenURL, + map[string]string{ + "Content-Type": "application/x-www-form-urlencoded", + "Accept": "application/json", + "Authorization": "Basic " + basic, + }, + strings.NewReader(params.Encode()), proxies..., + ) + if err != nil { + return nil, fmt.Errorf("refresh cnb token: %w", err) + } + if result.AccessToken == "" { + return nil, fmt.Errorf("refreshed cnb access token is empty") + } + return result, nil +} + +// ExchangeCnbCode 用授权码换 access_token + refresh_token +// +// 同 RefreshCnb 走 POST {webBaseURL}/oauth2/token, 区别仅在 form 内容。 +func ExchangeCnbCode(webBaseURL, clientID, clientSecret, code, redirectURI string, proxies ...string) (*CnbTokenResponse, error) { + if webBaseURL == "" { + webBaseURL = "https://cnb.cool" + } + tokenURL := strings.TrimSuffix(webBaseURL, "/") + "/oauth2/token" + + params := url.Values{} + params.Add("grant_type", "authorization_code") + params.Add("code", code) + if redirectURI != "" { + params.Add("redirect_uri", redirectURI) + } + + basic := base64.StdEncoding.EncodeToString([]byte(clientID + ":" + clientSecret)) + result, err := fetchWithProxyAndBody[CnbTokenResponse]( + http.MethodPost, tokenURL, + map[string]string{ + "Content-Type": "application/x-www-form-urlencoded", + "Accept": "application/json", + "Authorization": "Basic " + basic, + }, + strings.NewReader(params.Encode()), proxies..., + ) + if err != nil { + return nil, fmt.Errorf("exchange cnb code: %w", err) + } + if result.AccessToken == "" { + return nil, fmt.Errorf("exchanged cnb access token is empty") + } + return result, nil +} + // RefreshGitee 刷新 Gitee OAuth access_token(无需 client 凭证) func RefreshGitee(refreshToken string) (*GiteeTokenResponse, error) { params := url.Values{}