diff --git a/go.mod b/go.mod index aefe15f..ffa4ee0 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ go 1.23.0 toolchain go1.24.7 require ( + code.gitea.io/sdk/gitea v0.21.0 github.com/gin-gonic/gin v1.9.1 github.com/golang-migrate/migrate/v4 v4.19.0 github.com/lib/pq v1.10.9 @@ -15,18 +16,22 @@ require ( ) require ( + github.com/42wim/httpsig v1.2.3 // indirect github.com/bytedance/sonic v1.9.1 // indirect github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/davidmz/go-pageant v1.0.2 // indirect github.com/fsnotify/fsnotify v1.7.0 // indirect github.com/gabriel-vasile/mimetype v1.4.2 // indirect github.com/gin-contrib/sse v0.1.0 // indirect + github.com/go-fed/httpsig v1.1.0 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/validator/v10 v10.14.0 // indirect github.com/goccy/go-json v0.10.2 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect github.com/hashicorp/go-multierror v1.1.1 // indirect + github.com/hashicorp/go-version v1.9.0 // indirect github.com/hashicorp/hcl v1.0.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/json-iterator/go v1.1.12 // indirect @@ -50,11 +55,11 @@ require ( github.com/ugorji/go/codec v1.2.11 // indirect go.uber.org/multierr v1.10.0 // indirect golang.org/x/arch v0.3.0 // indirect - golang.org/x/crypto v0.36.0 // indirect + golang.org/x/crypto v0.37.0 // indirect golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect golang.org/x/net v0.38.0 // indirect - golang.org/x/sys v0.31.0 // indirect - golang.org/x/text v0.23.0 // indirect + golang.org/x/sys v0.32.0 // indirect + golang.org/x/text v0.24.0 // indirect google.golang.org/protobuf v1.34.2 // indirect gopkg.in/ini.v1 v1.67.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect diff --git a/go.sum b/go.sum index dc99a9d..4ae4610 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,7 @@ +code.gitea.io/sdk/gitea v0.21.0 h1:69n6oz6kEVHRo1+APQQyizkhrZrLsTLXey9142pfkD4= +code.gitea.io/sdk/gitea v0.21.0/go.mod h1:tnBjVhuKJCn8ibdyyhvUyxrR1Ca2KHEoTWoukNhXQPA= +github.com/42wim/httpsig v1.2.3 h1:xb0YyWhkYj57SPtfSttIobJUPJZB9as1nsfo7KWVcEs= +github.com/42wim/httpsig v1.2.3/go.mod h1:nZq9OlYKDrUBhptd77IHx4/sZZD+IxTBADvAPI9G/EM= github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0= github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= @@ -17,6 +21,8 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davidmz/go-pageant v1.0.2 h1:bPblRCh5jGU+Uptpz6LgMZGD5hJoOt7otgT454WvHn0= +github.com/davidmz/go-pageant v1.0.2/go.mod h1:P2EDDnMqIwG5Rrp05dTRITj9z2zpGcD9efWSkTNKLIE= github.com/dhui/dktest v0.4.6 h1:+DPKyScKSEp3VLtbMDHcUq6V5Lm5zfZZVb0Sk7Ahom4= github.com/dhui/dktest v0.4.6/go.mod h1:JHTSYDtKkvFNFHJKqCzVzqXecyv+tKt8EzceOmQOgbU= github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= @@ -39,6 +45,8 @@ github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg= github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU= +github.com/go-fed/httpsig v1.1.0 h1:9M+hb0jkEICD8/cAiNqEB66R87tTINszBRTjwjQzWcI= +github.com/go-fed/httpsig v1.1.0/go.mod h1:RCMrTZvN1bJYtofsG4rd5NaO5obxQ5xBkdiS7xsT7bM= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= @@ -65,6 +73,8 @@ github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= +github.com/hashicorp/go-version v1.9.0 h1:CeOIz6k+LoN3qX9Z0tyQrPtiB1DFYRPfCIBtaXPSCnA= +github.com/hashicorp/go-version v1.9.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= @@ -166,18 +176,32 @@ go.uber.org/zap v1.26.0/go.mod h1:dtElttAiwGvoJ/vj4IwHBS/gXsEu/pZ50mUIRWuG0so= golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= golang.org/x/arch v0.3.0 h1:02VY4/ZcO/gBOH6PUaoiptASxtXU10jazRCP865E97k= golang.org/x/arch v0.3.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= -golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34= -golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8= +golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE= +golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc= golang.org/x/exp v0.0.0-20230905200255-921286631fa9 h1:GoHiUyI/Tp2nVkLI2mCxVkOjsbSXD66ic0XW0js0R9g= golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8= golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= -golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= -golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= -golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= +golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20= +golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.31.0 h1:erwDkOK1Msy6offm1mOgvspSkslFnIGsFnxOKoufg3o= +golang.org/x/term v0.31.0/go.mod h1:R4BeIy7D95HzImkxGkTW1UQTtP54tio2RyHz7PwK0aw= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0= +golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg= google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/internal/platform/forgejo/forgejo.go b/internal/platform/forgejo/forgejo.go index bd120fd..aea2d23 100644 --- a/internal/platform/forgejo/forgejo.go +++ b/internal/platform/forgejo/forgejo.go @@ -1,14 +1,18 @@ // Package forgejo implements the platform.Provider interface for Forgejo -// (and Gitea-compatible) instances. +// (and Gitea-compatible) instances using the gitea SDK. package forgejo import ( "context" + "errors" "fmt" "net/http" + "strings" + "sync" "time" "code.forgejo.org/forgejo/classroom/internal/platform" + "code.gitea.io/sdk/gitea" ) // Provider implements platform.Provider for Forgejo instances. @@ -18,6 +22,10 @@ type Provider struct { baseURL string token string httpClient *http.Client + + clientOnce sync.Once + client *gitea.Client + clientErr error } // Ensure compile-time interface compliance. @@ -41,127 +49,473 @@ func New(cfg platform.ProviderConfig) (platform.Provider, error) { } return &Provider{ - baseURL: cfg.BaseURL, - token: cfg.Token, - httpClient: &http.Client{ - Timeout: timeout, - }, + baseURL: cfg.BaseURL, + token: cfg.Token, + httpClient: &http.Client{Timeout: timeout}, }, nil } +// sdk returns a lazily-initialised gitea SDK client. Lazy creation avoids +// the SDK's compile-time server version probe firing during construction +// (which would block New() on network availability). +func (p *Provider) sdk() (*gitea.Client, error) { + p.clientOnce.Do(func() { + c, err := gitea.NewClient( + p.baseURL, + gitea.SetToken(p.token), + gitea.SetHTTPClient(p.httpClient), + ) + // gitea.NewClient may return both a usable client and an + // ErrUnknownVersion error; tolerate that case. + if err != nil && c == nil { + p.clientErr = fmt.Errorf("forgejo: failed to create gitea client: %w", err) + return + } + p.client = c + }) + return p.client, p.clientErr +} + // Type returns the provider type identifier. func (p *Provider) Type() platform.ProviderType { return platform.ProviderForgejo } +// --- error mapping --- + +// translateError maps Gitea SDK errors and HTTP responses to the +// platform's sentinel errors so callers can use errors.Is. +func translateError(resp *gitea.Response, err error) error { + if err == nil { + return nil + } + if resp != nil { + switch resp.StatusCode { + case http.StatusNotFound: + return fmt.Errorf("%w: %v", platform.ErrNotFound, err) + case http.StatusUnauthorized: + return fmt.Errorf("%w: %v", platform.ErrUnauthorized, err) + case http.StatusForbidden: + return fmt.Errorf("%w: %v", platform.ErrForbidden, err) + case http.StatusConflict, http.StatusUnprocessableEntity: + return fmt.Errorf("%w: %v", platform.ErrAlreadyExists, err) + case http.StatusTooManyRequests: + return fmt.Errorf("%w: %v", platform.ErrRateLimited, err) + } + if resp.StatusCode >= 500 { + return fmt.Errorf("%w: %v", platform.ErrUnavailable, err) + } + } + return err +} + +// --- type conversions --- + +func toRepository(r *gitea.Repository) *platform.Repository { + if r == nil { + return nil + } + return &platform.Repository{ + ID: r.ID, + Name: r.Name, + FullName: r.FullName, + Description: r.Description, + CloneURL: r.CloneURL, + HTMLURL: r.HTMLURL, + Private: r.Private, + Empty: r.Empty, + } +} + +func toUser(u *gitea.User) *platform.User { + if u == nil { + return nil + } + return &platform.User{ + ID: u.ID, + Username: u.UserName, + Email: u.Email, + FullName: u.FullName, + AvatarURL: u.AvatarURL, + IsAdmin: u.IsAdmin, + } +} + +func toOrganization(o *gitea.Organization) *platform.Organization { + if o == nil { + return nil + } + return &platform.Organization{ + ID: o.ID, + Name: o.UserName, + FullName: o.FullName, + Description: o.Description, + AvatarURL: o.AvatarURL, + } +} + +func toTeam(t *gitea.Team) *platform.Team { + if t == nil { + return nil + } + return &platform.Team{ + ID: t.ID, + Name: t.Name, + Description: t.Description, + Permission: toPlatformPermission(t.Permission), + } +} + +func toAccessMode(p platform.Permission) gitea.AccessMode { + switch p { + case platform.PermissionAdmin: + return gitea.AccessModeAdmin + case platform.PermissionWrite: + return gitea.AccessModeWrite + case platform.PermissionRead: + return gitea.AccessModeRead + default: + return gitea.AccessModeRead + } +} + +func toPlatformPermission(a gitea.AccessMode) platform.Permission { + switch a { + case gitea.AccessModeAdmin, gitea.AccessModeOwner: + return platform.PermissionAdmin + case gitea.AccessModeWrite: + return platform.PermissionWrite + default: + return platform.PermissionRead + } +} + // --- RepositoryProvider --- func (p *Provider) GetRepository(ctx context.Context, owner, name string) (*platform.Repository, error) { - // TODO: implement using Forgejo/Gitea API: GET /api/v1/repos/{owner}/{repo} - return nil, fmt.Errorf("forgejo: GetRepository not yet implemented") + if _, err := p.sdk(); err != nil { + return nil, err + } + repo, resp, err := p.client.GetRepo(owner, name) + if err != nil { + return nil, translateError(resp, err) + } + return toRepository(repo), nil } func (p *Provider) GetRepositoryByID(ctx context.Context, id int64) (*platform.Repository, error) { - // TODO: implement using Forgejo/Gitea API: GET /api/v1/repositories/{id} - return nil, fmt.Errorf("forgejo: GetRepositoryByID not yet implemented") + if _, err := p.sdk(); err != nil { + return nil, err + } + repo, resp, err := p.client.GetRepoByID(id) + if err != nil { + return nil, translateError(resp, err) + } + return toRepository(repo), nil } func (p *Provider) CreateRepositoryFromTemplate(ctx context.Context, templateID int64, opts platform.CreateRepoOptions) (*platform.Repository, error) { - // TODO: implement using Forgejo/Gitea API: POST /api/v1/repos/{template_owner}/{template_repo}/generate - return nil, fmt.Errorf("forgejo: CreateRepositoryFromTemplate not yet implemented") + if _, err := p.sdk(); err != nil { + return nil, err + } + tmpl, resp, err := p.client.GetRepoByID(templateID) + if err != nil { + return nil, translateError(resp, err) + } + if tmpl == nil || tmpl.Owner == nil { + return nil, fmt.Errorf("forgejo: template %d has no owner", templateID) + } + + repo, resp, err := p.client.CreateRepoFromTemplate(tmpl.Owner.UserName, tmpl.Name, gitea.CreateRepoFromTemplateOption{ + Owner: opts.Owner, + Name: opts.Name, + Description: opts.Description, + Private: opts.Private, + GitContent: true, + Topics: true, + Labels: true, + Avatar: true, + }) + if err != nil { + return nil, translateError(resp, err) + } + return toRepository(repo), nil } func (p *Provider) DeleteRepository(ctx context.Context, owner, name string) error { - // TODO: implement using Forgejo/Gitea API: DELETE /api/v1/repos/{owner}/{repo} - return fmt.Errorf("forgejo: DeleteRepository not yet implemented") + if _, err := p.sdk(); err != nil { + return err + } + resp, err := p.client.DeleteRepo(owner, name) + return translateError(resp, err) } // --- UserProvider --- func (p *Provider) GetAuthenticatedUser(ctx context.Context) (*platform.User, error) { - // TODO: implement using Forgejo/Gitea API: GET /api/v1/user - return nil, fmt.Errorf("forgejo: GetAuthenticatedUser not yet implemented") + if _, err := p.sdk(); err != nil { + return nil, err + } + user, resp, err := p.client.GetMyUserInfo() + if err != nil { + return nil, translateError(resp, err) + } + return toUser(user), nil } func (p *Provider) GetUser(ctx context.Context, username string) (*platform.User, error) { - // TODO: implement using Forgejo/Gitea API: GET /api/v1/users/{username} - return nil, fmt.Errorf("forgejo: GetUser not yet implemented") + if _, err := p.sdk(); err != nil { + return nil, err + } + user, resp, err := p.client.GetUserInfo(username) + if err != nil { + return nil, translateError(resp, err) + } + return toUser(user), nil } func (p *Provider) GetUserByEmail(ctx context.Context, email string) (*platform.User, error) { - // TODO: implement using Forgejo/Gitea API: GET /api/v1/users/search?q={email} - return nil, fmt.Errorf("forgejo: GetUserByEmail not yet implemented") + if _, err := p.sdk(); err != nil { + return nil, err + } + users, resp, err := p.client.SearchUsers(gitea.SearchUsersOption{ + KeyWord: email, + ListOptions: gitea.ListOptions{PageSize: 50}, + }) + if err != nil { + return nil, translateError(resp, err) + } + for _, u := range users { + if strings.EqualFold(u.Email, email) { + return toUser(u), nil + } + } + return nil, platform.ErrNotFound } // --- OrganizationProvider --- func (p *Provider) GetOrganization(ctx context.Context, name string) (*platform.Organization, error) { - // TODO: implement using Forgejo/Gitea API: GET /api/v1/orgs/{org} - return nil, fmt.Errorf("forgejo: GetOrganization not yet implemented") + if _, err := p.sdk(); err != nil { + return nil, err + } + org, resp, err := p.client.GetOrg(name) + if err != nil { + return nil, translateError(resp, err) + } + return toOrganization(org), nil } func (p *Provider) CreateOrganization(ctx context.Context, opts platform.CreateOrgOptions) (*platform.Organization, error) { - // TODO: implement using Forgejo/Gitea API: POST /api/v1/orgs - return nil, fmt.Errorf("forgejo: CreateOrganization not yet implemented") + if _, err := p.sdk(); err != nil { + return nil, err + } + org, resp, err := p.client.CreateOrg(gitea.CreateOrgOption{ + Name: opts.Name, + FullName: opts.FullName, + Description: opts.Description, + Visibility: gitea.VisibleTypePublic, + }) + if err != nil { + return nil, translateError(resp, err) + } + return toOrganization(org), nil } func (p *Provider) DeleteOrganization(ctx context.Context, name string) error { - // TODO: implement using Forgejo/Gitea API: DELETE /api/v1/orgs/{org} - return fmt.Errorf("forgejo: DeleteOrganization not yet implemented") + if _, err := p.sdk(); err != nil { + return err + } + resp, err := p.client.DeleteOrg(name) + return translateError(resp, err) } // --- TeamProvider --- func (p *Provider) CreateTeam(ctx context.Context, opts platform.CreateTeamOptions) (*platform.Team, error) { - // TODO: implement using Forgejo/Gitea API: POST /api/v1/orgs/{org}/teams - return nil, fmt.Errorf("forgejo: CreateTeam not yet implemented") + if _, err := p.sdk(); err != nil { + return nil, err + } + team, resp, err := p.client.CreateTeam(opts.OrgName, gitea.CreateTeamOption{ + Name: opts.Name, + Description: opts.Description, + Permission: toAccessMode(opts.Permission), + IncludesAllRepositories: false, + Units: []gitea.RepoUnitType{ + gitea.RepoUnitCode, + gitea.RepoUnitIssues, + gitea.RepoUnitPulls, + }, + }) + if err != nil { + return nil, translateError(resp, err) + } + return toTeam(team), nil } func (p *Provider) DeleteTeam(ctx context.Context, id int64) error { - // TODO: implement using Forgejo/Gitea API: DELETE /api/v1/teams/{id} - return fmt.Errorf("forgejo: DeleteTeam not yet implemented") + if _, err := p.sdk(); err != nil { + return err + } + resp, err := p.client.DeleteTeam(id) + return translateError(resp, err) } func (p *Provider) AddTeamMember(ctx context.Context, teamID, userID int64) error { - // TODO: implement using Forgejo/Gitea API: PUT /api/v1/teams/{id}/members/{username} - return fmt.Errorf("forgejo: AddTeamMember not yet implemented") + if _, err := p.sdk(); err != nil { + return err + } + user, resp, err := p.client.GetUserByID(userID) + if err != nil { + return translateError(resp, err) + } + resp, err = p.client.AddTeamMember(teamID, user.UserName) + return translateError(resp, err) } func (p *Provider) RemoveTeamMember(ctx context.Context, teamID, userID int64) error { - // TODO: implement using Forgejo/Gitea API: DELETE /api/v1/teams/{id}/members/{username} - return fmt.Errorf("forgejo: RemoveTeamMember not yet implemented") + if _, err := p.sdk(); err != nil { + return err + } + user, resp, err := p.client.GetUserByID(userID) + if err != nil { + return translateError(resp, err) + } + resp, err = p.client.RemoveTeamMember(teamID, user.UserName) + return translateError(resp, err) } func (p *Provider) AddTeamRepository(ctx context.Context, teamID, repoID int64) error { - // TODO: implement using Forgejo/Gitea API: PUT /api/v1/teams/{id}/repos/{org}/{repo} - return fmt.Errorf("forgejo: AddTeamRepository not yet implemented") + if _, err := p.sdk(); err != nil { + return err + } + repo, resp, err := p.client.GetRepoByID(repoID) + if err != nil { + return translateError(resp, err) + } + if repo == nil || repo.Owner == nil { + return fmt.Errorf("forgejo: repository %d has no owner", repoID) + } + resp, err = p.client.AddTeamRepository(teamID, repo.Owner.UserName, repo.Name) + return translateError(resp, err) } func (p *Provider) RemoveTeamRepository(ctx context.Context, teamID, repoID int64) error { - // TODO: implement using Forgejo/Gitea API: DELETE /api/v1/teams/{id}/repos/{org}/{repo} - return fmt.Errorf("forgejo: RemoveTeamRepository not yet implemented") + if _, err := p.sdk(); err != nil { + return err + } + repo, resp, err := p.client.GetRepoByID(repoID) + if err != nil { + return translateError(resp, err) + } + if repo == nil || repo.Owner == nil { + return fmt.Errorf("forgejo: repository %d has no owner", repoID) + } + resp, err = p.client.RemoveTeamRepository(teamID, repo.Owner.UserName, repo.Name) + return translateError(resp, err) } // --- CollaboratorProvider --- func (p *Provider) AddCollaborator(ctx context.Context, repoID, userID int64, perm platform.Permission) error { - // TODO: implement using Forgejo/Gitea API: PUT /api/v1/repos/{owner}/{repo}/collaborators/{collaborator} - return fmt.Errorf("forgejo: AddCollaborator not yet implemented") + if _, err := p.sdk(); err != nil { + return err + } + repo, resp, err := p.client.GetRepoByID(repoID) + if err != nil { + return translateError(resp, err) + } + if repo == nil || repo.Owner == nil { + return fmt.Errorf("forgejo: repository %d has no owner", repoID) + } + user, resp, err := p.client.GetUserByID(userID) + if err != nil { + return translateError(resp, err) + } + mode := toAccessMode(perm) + resp, err = p.client.AddCollaborator(repo.Owner.UserName, repo.Name, user.UserName, gitea.AddCollaboratorOption{ + Permission: &mode, + }) + return translateError(resp, err) } func (p *Provider) RemoveCollaborator(ctx context.Context, repoID, userID int64) error { - // TODO: implement using Forgejo/Gitea API: DELETE /api/v1/repos/{owner}/{repo}/collaborators/{collaborator} - return fmt.Errorf("forgejo: RemoveCollaborator not yet implemented") + if _, err := p.sdk(); err != nil { + return err + } + repo, resp, err := p.client.GetRepoByID(repoID) + if err != nil { + return translateError(resp, err) + } + if repo == nil || repo.Owner == nil { + return fmt.Errorf("forgejo: repository %d has no owner", repoID) + } + user, resp, err := p.client.GetUserByID(userID) + if err != nil { + return translateError(resp, err) + } + resp, err = p.client.DeleteCollaborator(repo.Owner.UserName, repo.Name, user.UserName) + return translateError(resp, err) } // --- BranchProvider --- func (p *Provider) ProtectBranch(ctx context.Context, repoID int64, protection platform.BranchProtection) error { - // TODO: implement using Forgejo/Gitea API: POST /api/v1/repos/{owner}/{repo}/branch_protections - return fmt.Errorf("forgejo: ProtectBranch not yet implemented") + if _, err := p.sdk(); err != nil { + return err + } + repo, resp, err := p.client.GetRepoByID(repoID) + if err != nil { + return translateError(resp, err) + } + if repo == nil || repo.Owner == nil { + return fmt.Errorf("forgejo: repository %d has no owner", repoID) + } + _, resp, err = p.client.CreateBranchProtection(repo.Owner.UserName, repo.Name, gitea.CreateBranchProtectionOption{ + BranchName: protection.BranchName, + RuleName: protection.BranchName, + EnablePush: protection.EnablePush, + EnablePushWhitelist: protection.EnablePushWhitelist, + PushWhitelistUsernames: protection.PushWhitelistUsernames, + }) + return translateError(resp, err) } func (p *Provider) GetLatestCommit(ctx context.Context, owner, repo, branch string) (*platform.Commit, error) { - // TODO: implement using Forgejo/Gitea API: GET /api/v1/repos/{owner}/{repo}/git/commits?sha={branch}&limit=1 - return nil, fmt.Errorf("forgejo: GetLatestCommit not yet implemented") + if _, err := p.sdk(); err != nil { + return nil, err + } + commits, resp, err := p.client.ListRepoCommits(owner, repo, gitea.ListCommitOptions{ + ListOptions: gitea.ListOptions{PageSize: 1}, + SHA: branch, + }) + if err != nil { + return nil, translateError(resp, err) + } + if len(commits) == 0 { + return nil, platform.ErrNotFound + } + c := commits[0] + out := &platform.Commit{} + if c.CommitMeta != nil { + out.SHA = c.SHA + out.Timestamp = c.Created + } + if c.RepoCommit != nil { + out.Message = c.RepoCommit.Message + if c.RepoCommit.Author != nil { + out.Author = c.RepoCommit.Author.Name + } + } + if c.Author != nil && out.Author == "" { + out.Author = c.Author.UserName + } + return out, nil } + +// errIsNotFound is a small helper for callers that use errors.Is internally. +// It is unexported and may be removed once consumers use platform.ErrNotFound +// directly. +func errIsNotFound(err error) bool { return errors.Is(err, platform.ErrNotFound) } + +var _ = errIsNotFound diff --git a/internal/platform/forgejo/forgejo_test.go b/internal/platform/forgejo/forgejo_test.go index 2f4bf2f..059bd89 100644 --- a/internal/platform/forgejo/forgejo_test.go +++ b/internal/platform/forgejo/forgejo_test.go @@ -1,7 +1,9 @@ package forgejo import ( - "context" + "errors" + "net/http" + "net/http/httptest" "testing" "github.com/stretchr/testify/assert" @@ -97,81 +99,84 @@ func TestProvider_Type(t *testing.T) { assert.Equal(t, platform.ProviderForgejo, provider.Type()) } -func TestProvider_MethodsReturnNotImplemented(t *testing.T) { - cfg := platform.ProviderConfig{ - BaseURL: "https://forgejo.example.com", - Token: "test-token", - } - provider, err := New(cfg) +// newTestServer returns an httptest server that always replies with the +// given status code, plus a matching provider configured to talk to it. +func newTestServer(t *testing.T, status int, body string) (*httptest.Server, *Provider) { + t.Helper() + // gitea SDK probes /api/v1/version on construction; honour that as 200. + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/api/v1/version" { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"version":"1.21.0"}`)) + return + } + w.WriteHeader(status) + if body != "" { + _, _ = w.Write([]byte(body)) + } + })) + + provider, err := New(platform.ProviderConfig{ + BaseURL: srv.URL, + Token: "test-token", + TimeoutSeconds: 5, + }) require.NoError(t, err) + return srv, provider.(*Provider) +} - ctx := context.Background() - p := provider.(*Provider) - - // RepositoryProvider - _, err = p.GetRepository(ctx, "owner", "repo") - assert.Error(t, err) - assert.Contains(t, err.Error(), "not yet implemented") - - _, err = p.GetRepositoryByID(ctx, 1) - assert.Error(t, err) - - _, err = p.CreateRepositoryFromTemplate(ctx, 1, platform.CreateRepoOptions{}) - assert.Error(t, err) - - err = p.DeleteRepository(ctx, "owner", "repo") - assert.Error(t, err) - - // UserProvider - _, err = p.GetAuthenticatedUser(ctx) - assert.Error(t, err) - - _, err = p.GetUser(ctx, "user") - assert.Error(t, err) - - _, err = p.GetUserByEmail(ctx, "user@example.com") - assert.Error(t, err) - - // OrganizationProvider - _, err = p.GetOrganization(ctx, "org") - assert.Error(t, err) - - _, err = p.CreateOrganization(ctx, platform.CreateOrgOptions{}) - assert.Error(t, err) - - err = p.DeleteOrganization(ctx, "org") - assert.Error(t, err) - - // TeamProvider - _, err = p.CreateTeam(ctx, platform.CreateTeamOptions{}) - assert.Error(t, err) +func TestProvider_TranslatesNotFound(t *testing.T) { + srv, p := newTestServer(t, http.StatusNotFound, `{"message":"not found"}`) + defer srv.Close() - err = p.DeleteTeam(ctx, 1) - assert.Error(t, err) + _, err := p.GetRepository(t.Context(), "owner", "repo") + require.Error(t, err) + assert.True(t, errors.Is(err, platform.ErrNotFound), "got %v", err) +} - err = p.AddTeamMember(ctx, 1, 2) - assert.Error(t, err) +func TestProvider_TranslatesUnauthorized(t *testing.T) { + srv, p := newTestServer(t, http.StatusUnauthorized, `{"message":"unauthorized"}`) + defer srv.Close() - err = p.RemoveTeamMember(ctx, 1, 2) - assert.Error(t, err) + _, err := p.GetAuthenticatedUser(t.Context()) + require.Error(t, err) + assert.True(t, errors.Is(err, platform.ErrUnauthorized), "got %v", err) +} - err = p.AddTeamRepository(ctx, 1, 2) - assert.Error(t, err) +func TestProvider_TranslatesForbidden(t *testing.T) { + srv, p := newTestServer(t, http.StatusForbidden, `{"message":"forbidden"}`) + defer srv.Close() - err = p.RemoveTeamRepository(ctx, 1, 2) - assert.Error(t, err) + err := p.DeleteRepository(t.Context(), "owner", "repo") + require.Error(t, err) + assert.True(t, errors.Is(err, platform.ErrForbidden), "got %v", err) +} - // CollaboratorProvider - err = p.AddCollaborator(ctx, 1, 2, platform.PermissionWrite) - assert.Error(t, err) +func TestProvider_TranslatesServerError(t *testing.T) { + srv, p := newTestServer(t, http.StatusInternalServerError, `{"message":"boom"}`) + defer srv.Close() - err = p.RemoveCollaborator(ctx, 1, 2) - assert.Error(t, err) + _, err := p.GetOrganization(t.Context(), "org") + require.Error(t, err) + assert.True(t, errors.Is(err, platform.ErrUnavailable), "got %v", err) +} - // BranchProvider - err = p.ProtectBranch(ctx, 1, platform.BranchProtection{}) - assert.Error(t, err) +func TestProvider_GetUserByEmail_NotFound(t *testing.T) { + // SearchUsers returns 200 with empty list -> we should map to ErrNotFound. + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if r.URL.Path == "/api/v1/version" { + _, _ = w.Write([]byte(`{"version":"1.21.0"}`)) + return + } + _, _ = w.Write([]byte(`{"data":[]}`)) + })) + defer srv.Close() + + provider, err := New(platform.ProviderConfig{BaseURL: srv.URL, Token: "t"}) + require.NoError(t, err) - _, err = p.GetLatestCommit(ctx, "owner", "repo", "main") - assert.Error(t, err) + _, err = provider.GetUserByEmail(t.Context(), "missing@example.com") + require.Error(t, err) + assert.True(t, errors.Is(err, platform.ErrNotFound), "got %v", err) }