Native forgejo support (#3684)

Co-authored-by: Robert Kaussow <xoxys@rknet.org>
Co-authored-by: John Olheiser <john.olheiser@gmail.com>
This commit is contained in:
qwerty287
2024-06-01 11:23:19 +02:00
committed by GitHub
parent a3fb6f6b8b
commit 91b122e1ce
21 changed files with 3889 additions and 18 deletions

View File

@@ -0,0 +1,210 @@
// Copyright 2024 Woodpecker Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package fixtures
import (
"net/http"
"github.com/gin-gonic/gin"
)
// Handler returns an http.Handler that is capable of handling a variety of mock
// Forgejo requests and returning mock responses.
func Handler() http.Handler {
gin.SetMode(gin.TestMode)
e := gin.New()
e.GET("/api/v1/repos/:owner/:name", getRepo)
e.GET("/api/v1/repositories/:id", getRepoByID)
e.GET("/api/v1/repos/:owner/:name/raw/:file", getRepoFile)
e.POST("/api/v1/repos/:owner/:name/hooks", createRepoHook)
e.GET("/api/v1/repos/:owner/:name/hooks", listRepoHooks)
e.DELETE("/api/v1/repos/:owner/:name/hooks/:id", deleteRepoHook)
e.POST("/api/v1/repos/:owner/:name/statuses/:commit", createRepoCommitStatus)
e.GET("/api/v1/repos/:owner/:name/pulls/:index/files", getPRFiles)
e.GET("/api/v1/user/repos", getUserRepos)
e.GET("/api/v1/version", getVersion)
return e
}
func listRepoHooks(c *gin.Context) {
page := c.Query("page")
if page != "" && page != "1" {
c.String(http.StatusOK, "[]")
} else {
c.String(http.StatusOK, listRepoHookPayloads)
}
}
func getRepo(c *gin.Context) {
switch c.Param("name") {
case "repo_not_found":
c.String(http.StatusNotFound, "")
default:
c.String(http.StatusOK, repoPayload)
}
}
func getRepoByID(c *gin.Context) {
switch c.Param("id") {
case "repo_not_found":
c.String(http.StatusNotFound, "")
default:
c.String(http.StatusOK, repoPayload)
}
}
func createRepoCommitStatus(c *gin.Context) {
if c.Param("commit") == "v1.0.0" || c.Param("commit") == "9ecad50" {
c.String(http.StatusOK, repoPayload)
}
c.String(http.StatusNotFound, "")
}
func getRepoFile(c *gin.Context) {
file := c.Param("file")
ref := c.Query("ref")
if file == "file_not_found" {
c.String(http.StatusNotFound, "")
}
if ref == "v1.0.0" || ref == "9ecad50" {
c.String(http.StatusOK, repoFilePayload)
}
c.String(http.StatusNotFound, "")
}
func createRepoHook(c *gin.Context) {
in := struct {
Type string `json:"type"`
Conf struct {
Type string `json:"content_type"`
URL string `json:"url"`
} `json:"config"`
}{}
_ = c.BindJSON(&in)
if in.Type != "forgejo" ||
in.Conf.Type != "json" ||
in.Conf.URL != "http://localhost" {
c.String(http.StatusInternalServerError, "")
return
}
c.String(http.StatusOK, "{}")
}
func deleteRepoHook(c *gin.Context) {
c.String(http.StatusOK, "{}")
}
func getUserRepos(c *gin.Context) {
switch c.Request.Header.Get("Authorization") {
case "token repos_not_found":
c.String(http.StatusNotFound, "")
default:
page := c.Query("page")
if page != "" && page != "1" {
c.String(http.StatusOK, "[]")
} else {
c.String(http.StatusOK, userRepoPayload)
}
}
}
func getVersion(c *gin.Context) {
c.JSON(http.StatusOK, map[string]any{"version": "1.18.0"})
}
func getPRFiles(c *gin.Context) {
page := c.Query("page")
if page == "1" {
c.String(http.StatusOK, prFilesPayload)
} else {
c.String(http.StatusOK, "[]")
}
}
const listRepoHookPayloads = `
[
{
"id": 1,
"type": "forgejo",
"config": {
"content_type": "json",
"url": "http:\/\/localhost\/hook?access_token=1234567890"
}
}
]
`
const repoPayload = `
{
"id": 5,
"owner": {
"login": "test_name",
"email": "octocat@github.com",
"avatar_url": "https:\/\/secure.gravatar.com\/avatar\/8c58a0be77ee441bb8f8595b7f1b4e87"
},
"full_name": "test_name\/repo_name",
"private": true,
"html_url": "http:\/\/localhost\/test_name\/repo_name",
"clone_url": "http:\/\/localhost\/test_name\/repo_name.git",
"permissions": {
"admin": true,
"push": true,
"pull": true
}
}
`
const repoFilePayload = `{ platform: linux/amd64 }`
const userRepoPayload = `
[
{
"id": 5,
"owner": {
"login": "test_name",
"email": "octocat@github.com",
"avatar_url": "https:\/\/secure.gravatar.com\/avatar\/8c58a0be77ee441bb8f8595b7f1b4e87"
},
"full_name": "test_name\/repo_name",
"private": true,
"html_url": "http:\/\/localhost\/test_name\/repo_name",
"clone_url": "http:\/\/localhost\/test_name\/repo_name.git",
"permissions": {
"admin": true,
"push": true,
"pull": true
}
}
]
`
const prFilesPayload = `
[
{
"filename": "README.md",
"status": "changed",
"additions": 2,
"deletions": 0,
"changes": 2,
"html_url": "http://localhost/username/repo/src/commit/e79e4b0e8d9dd6f72b70e776c3317db7c19ca0fd/README.md",
"contents_url": "http://localhost:3000/api/v1/repos/username/repo/contents/README.md?ref=e79e4b0e8d9dd6f72b70e776c3317db7c19ca0fd",
"raw_url": "http://localhost/username/repo/raw/commit/e79e4b0e8d9dd6f72b70e776c3317db7c19ca0fd/README.md"
}
]
`

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,706 @@
// Copyright 2024 Woodpecker Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package forgejo
import (
"context"
"crypto/tls"
"errors"
"fmt"
"net/http"
"path"
"path/filepath"
"strconv"
"strings"
"time"
"codeberg.org/mvdkleijn/forgejo-sdk/forgejo"
"github.com/rs/zerolog/log"
"golang.org/x/oauth2"
"go.woodpecker-ci.org/woodpecker/v2/server"
"go.woodpecker-ci.org/woodpecker/v2/server/forge"
"go.woodpecker-ci.org/woodpecker/v2/server/forge/common"
forge_types "go.woodpecker-ci.org/woodpecker/v2/server/forge/types"
"go.woodpecker-ci.org/woodpecker/v2/server/model"
"go.woodpecker-ci.org/woodpecker/v2/server/store"
shared_utils "go.woodpecker-ci.org/woodpecker/v2/shared/utils"
)
const (
authorizeTokenURL = "%s/login/oauth/authorize"
accessTokenURL = "%s/login/oauth/access_token"
defaultPageSize = 50
forgejoDevVersion = "v7.0.2"
)
type Forgejo struct {
url string
oauth2URL string
ClientID string
ClientSecret string
SkipVerify bool
pageSize int
}
// Opts defines configuration options.
type Opts struct {
URL string // Forgejo server url.
OAuth2URL string // User-facing Forgejo server url for OAuth2.
Client string // OAuth2 Client ID
Secret string // OAuth2 Client Secret
SkipVerify bool // Skip ssl verification.
}
// New returns a Forge implementation that integrates with Forgejo,
// an open source Git service written in Go. See https://forgejo.org/
func New(opts Opts) (forge.Forge, error) {
if opts.OAuth2URL == "" {
opts.OAuth2URL = opts.URL
}
return &Forgejo{
url: opts.URL,
oauth2URL: opts.OAuth2URL,
ClientID: opts.Client,
ClientSecret: opts.Secret,
SkipVerify: opts.SkipVerify,
}, nil
}
// Name returns the string name of this driver.
func (c *Forgejo) Name() string {
return "forgejo"
}
// URL returns the root url of a configured forge.
func (c *Forgejo) URL() string {
return c.url
}
func (c *Forgejo) oauth2Config(ctx context.Context) (*oauth2.Config, context.Context) {
return &oauth2.Config{
ClientID: c.ClientID,
ClientSecret: c.ClientSecret,
Endpoint: oauth2.Endpoint{
AuthURL: fmt.Sprintf(authorizeTokenURL, c.oauth2URL),
TokenURL: fmt.Sprintf(accessTokenURL, c.oauth2URL),
},
RedirectURL: fmt.Sprintf("%s/authorize", server.Config.Server.OAuthHost),
},
context.WithValue(ctx, oauth2.HTTPClient, &http.Client{Transport: &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: c.SkipVerify},
Proxy: http.ProxyFromEnvironment,
}})
}
// Login authenticates an account with Forgejo using basic authentication. The
// Forgejo account details are returned when the user is successfully authenticated.
func (c *Forgejo) Login(ctx context.Context, req *forge_types.OAuthRequest) (*model.User, string, error) {
config, oauth2Ctx := c.oauth2Config(ctx)
redirectURL := config.AuthCodeURL("woodpecker")
// check the OAuth errors
if req.Error != "" {
return nil, redirectURL, &forge_types.AuthError{
Err: req.Error,
Description: req.ErrorDescription,
URI: req.ErrorURI,
}
}
// check the OAuth code
if len(req.Code) == 0 {
return nil, redirectURL, nil
}
token, err := config.Exchange(oauth2Ctx, req.Code)
if err != nil {
return nil, redirectURL, err
}
client, err := c.newClientToken(ctx, token.AccessToken)
if err != nil {
return nil, redirectURL, err
}
account, _, err := client.GetMyUserInfo()
if err != nil {
return nil, redirectURL, err
}
return &model.User{
Token: token.AccessToken,
Secret: token.RefreshToken,
Expiry: token.Expiry.UTC().Unix(),
Login: account.UserName,
Email: account.Email,
ForgeRemoteID: model.ForgeRemoteID(fmt.Sprint(account.ID)),
Avatar: expandAvatar(c.url, account.AvatarURL),
}, redirectURL, nil
}
// Auth uses the Forgejo oauth2 access token and refresh token to authenticate
// a session and return the Forgejo account login.
func (c *Forgejo) Auth(ctx context.Context, token, _ string) (string, error) {
client, err := c.newClientToken(ctx, token)
if err != nil {
return "", err
}
user, _, err := client.GetMyUserInfo()
if err != nil {
return "", err
}
return user.UserName, nil
}
// Refresh refreshes the Forgejo oauth2 access token. If the token is
// refreshed, the user is updated and a true value is returned.
func (c *Forgejo) Refresh(ctx context.Context, user *model.User) (bool, error) {
config, oauth2Ctx := c.oauth2Config(ctx)
config.RedirectURL = ""
source := config.TokenSource(oauth2Ctx, &oauth2.Token{
AccessToken: user.Token,
RefreshToken: user.Secret,
Expiry: time.Unix(user.Expiry, 0),
})
token, err := source.Token()
if err != nil || len(token.AccessToken) == 0 {
return false, err
}
user.Token = token.AccessToken
user.Secret = token.RefreshToken
user.Expiry = token.Expiry.UTC().Unix()
return true, nil
}
// Teams is supported by the Forgejo driver.
func (c *Forgejo) Teams(ctx context.Context, u *model.User) ([]*model.Team, error) {
client, err := c.newClientToken(ctx, u.Token)
if err != nil {
return nil, err
}
return shared_utils.Paginate(func(page int) ([]*model.Team, error) {
orgs, _, err := client.ListMyOrgs(
forgejo.ListOrgsOptions{
ListOptions: forgejo.ListOptions{
Page: page,
PageSize: c.perPage(ctx),
},
},
)
teams := make([]*model.Team, 0, len(orgs))
for _, org := range orgs {
teams = append(teams, toTeam(org, c.url))
}
return teams, err
})
}
// TeamPerm is not supported by the Forgejo driver.
func (c *Forgejo) TeamPerm(_ *model.User, _ string) (*model.Perm, error) {
return nil, nil
}
// Repo returns the Forgejo repository.
func (c *Forgejo) Repo(ctx context.Context, u *model.User, remoteID model.ForgeRemoteID, owner, name string) (*model.Repo, error) {
client, err := c.newClientToken(ctx, u.Token)
if err != nil {
return nil, err
}
if remoteID.IsValid() {
intID, err := strconv.ParseInt(string(remoteID), 10, 64)
if err != nil {
return nil, err
}
repo, _, err := client.GetRepoByID(intID)
if err != nil {
return nil, err
}
return toRepo(repo), nil
}
repo, _, err := client.GetRepo(owner, name)
if err != nil {
return nil, err
}
return toRepo(repo), nil
}
// Repos returns a list of all repositories for the Forgejo account, including
// organization repositories.
func (c *Forgejo) Repos(ctx context.Context, u *model.User) ([]*model.Repo, error) {
client, err := c.newClientToken(ctx, u.Token)
if err != nil {
return nil, err
}
repos, err := shared_utils.Paginate(func(page int) ([]*forgejo.Repository, error) {
repos, _, err := client.ListMyRepos(
forgejo.ListReposOptions{
ListOptions: forgejo.ListOptions{
Page: page,
PageSize: c.perPage(ctx),
},
},
)
return repos, err
})
result := make([]*model.Repo, 0, len(repos))
for _, repo := range repos {
if repo.Archived {
continue
}
result = append(result, toRepo(repo))
}
return result, err
}
// File fetches the file from the Forgejo repository and returns its contents.
func (c *Forgejo) File(ctx context.Context, u *model.User, r *model.Repo, b *model.Pipeline, f string) ([]byte, error) {
client, err := c.newClientToken(ctx, u.Token)
if err != nil {
return nil, err
}
cfg, resp, err := client.GetFile(r.Owner, r.Name, b.Commit, f)
if err != nil && resp != nil && resp.StatusCode == http.StatusNotFound {
return nil, errors.Join(err, &forge_types.ErrConfigNotFound{Configs: []string{f}})
}
return cfg, err
}
func (c *Forgejo) Dir(ctx context.Context, u *model.User, r *model.Repo, b *model.Pipeline, f string) ([]*forge_types.FileMeta, error) {
var configs []*forge_types.FileMeta
client, err := c.newClientToken(ctx, u.Token)
if err != nil {
return nil, err
}
// List files in repository. Path from root
tree, _, err := client.GetTrees(r.Owner, r.Name, b.Commit, true)
if err != nil {
return nil, err
}
f = path.Clean(f) // We clean path and remove trailing slash
f += "/" + "*" // construct pattern for match i.e. file in subdir
for _, e := range tree.Entries {
// Filter path matching pattern and type file (blob)
if m, _ := filepath.Match(f, e.Path); m && e.Type == "blob" {
data, err := c.File(ctx, u, r, b, e.Path)
if err != nil {
if errors.Is(err, &forge_types.ErrConfigNotFound{}) {
return nil, fmt.Errorf("git tree reported existence of file but we got: %s", err.Error())
}
return nil, fmt.Errorf("multi-pipeline cannot get %s: %w", e.Path, err)
}
configs = append(configs, &forge_types.FileMeta{
Name: e.Path,
Data: data,
})
}
}
return configs, nil
}
// Status is supported by the Forgejo driver.
func (c *Forgejo) Status(ctx context.Context, user *model.User, repo *model.Repo, pipeline *model.Pipeline, workflow *model.Workflow) error {
client, err := c.newClientToken(ctx, user.Token)
if err != nil {
return err
}
_, _, err = client.CreateStatus(
repo.Owner,
repo.Name,
pipeline.Commit,
forgejo.CreateStatusOption{
State: getStatus(workflow.State),
TargetURL: common.GetPipelineStatusURL(repo, pipeline, workflow),
Description: common.GetPipelineStatusDescription(workflow.State),
Context: common.GetPipelineStatusContext(repo, pipeline, workflow),
},
)
return err
}
// Netrc returns a netrc file capable of authenticating Forgejo requests and
// cloning Forgejo repositories. The netrc will use the global machine account
// when configured.
func (c *Forgejo) Netrc(u *model.User, r *model.Repo) (*model.Netrc, error) {
login := ""
token := ""
if u != nil {
login = u.Login
token = u.Token
}
host, err := common.ExtractHostFromCloneURL(r.Clone)
if err != nil {
return nil, err
}
return &model.Netrc{
Login: login,
Password: token,
Machine: host,
}, nil
}
// Activate activates the repository by registering post-commit hooks with
// the Forgejo repository.
func (c *Forgejo) Activate(ctx context.Context, u *model.User, r *model.Repo, link string) error {
config := map[string]string{
"url": link,
"secret": r.Hash,
"content_type": "json",
}
hook := forgejo.CreateHookOption{
Type: forgejo.HookTypeForgejo,
Config: config,
Events: []string{"push", "create", "pull_request"},
Active: true,
}
client, err := c.newClientToken(ctx, u.Token)
if err != nil {
return err
}
_, response, err := client.CreateRepoHook(r.Owner, r.Name, hook)
if err != nil {
if response != nil {
if response.StatusCode == http.StatusNotFound {
return fmt.Errorf("could not find repository")
}
if response.StatusCode == http.StatusOK {
return fmt.Errorf("could not find repository, repository was probably renamed")
}
}
return err
}
return nil
}
// Deactivate deactivates the repository be removing repository push hooks from
// the Forgejo repository.
func (c *Forgejo) Deactivate(ctx context.Context, u *model.User, r *model.Repo, link string) error {
client, err := c.newClientToken(ctx, u.Token)
if err != nil {
return err
}
hooks, err := shared_utils.Paginate(func(page int) ([]*forgejo.Hook, error) {
hooks, _, err := client.ListRepoHooks(r.Owner, r.Name, forgejo.ListHooksOptions{
ListOptions: forgejo.ListOptions{
Page: page,
PageSize: c.perPage(ctx),
},
})
return hooks, err
})
if err != nil {
return err
}
hook := matchingHooks(hooks, link)
if hook != nil {
_, err := client.DeleteRepoHook(r.Owner, r.Name, hook.ID)
return err
}
return nil
}
// Branches returns the names of all branches for the named repository.
func (c *Forgejo) Branches(ctx context.Context, u *model.User, r *model.Repo, p *model.ListOptions) ([]string, error) {
token := common.UserToken(ctx, r, u)
client, err := c.newClientToken(ctx, token)
if err != nil {
return nil, err
}
branches, _, err := client.ListRepoBranches(r.Owner, r.Name,
forgejo.ListRepoBranchesOptions{ListOptions: forgejo.ListOptions{Page: p.Page, PageSize: p.PerPage}})
if err != nil {
return nil, err
}
result := make([]string, len(branches))
for i := range branches {
result[i] = branches[i].Name
}
return result, err
}
// BranchHead returns the sha of the head (latest commit) of the specified branch.
func (c *Forgejo) BranchHead(ctx context.Context, u *model.User, r *model.Repo, branch string) (*model.Commit, error) {
token := common.UserToken(ctx, r, u)
client, err := c.newClientToken(ctx, token)
if err != nil {
return nil, err
}
b, _, err := client.GetRepoBranch(r.Owner, r.Name, branch)
if err != nil {
return nil, err
}
return &model.Commit{
SHA: b.Commit.ID,
ForgeURL: b.Commit.URL,
}, nil
}
func (c *Forgejo) PullRequests(ctx context.Context, u *model.User, r *model.Repo, p *model.ListOptions) ([]*model.PullRequest, error) {
token := common.UserToken(ctx, r, u)
client, err := c.newClientToken(ctx, token)
if err != nil {
return nil, err
}
pullRequests, _, err := client.ListRepoPullRequests(r.Owner, r.Name, forgejo.ListPullRequestsOptions{
ListOptions: forgejo.ListOptions{Page: p.Page, PageSize: p.PerPage},
State: forgejo.StateOpen,
})
if err != nil {
return nil, err
}
result := make([]*model.PullRequest, len(pullRequests))
for i := range pullRequests {
result[i] = &model.PullRequest{
Index: model.ForgeRemoteID(strconv.Itoa(int(pullRequests[i].Index))),
Title: pullRequests[i].Title,
}
}
return result, err
}
// Hook parses the incoming Forgejo hook and returns the Repository and Pipeline
// details. If the hook is unsupported nil values are returned.
func (c *Forgejo) Hook(ctx context.Context, r *http.Request) (*model.Repo, *model.Pipeline, error) {
repo, pipeline, err := parseHook(r)
if err != nil {
return nil, nil, err
}
if pipeline != nil && pipeline.Event == model.EventRelease && pipeline.Commit == "" {
tagName := strings.Split(pipeline.Ref, "/")[2]
sha, err := c.getTagCommitSHA(ctx, repo, tagName)
if err != nil {
return nil, nil, err
}
pipeline.Commit = sha
}
if pipeline != nil && (pipeline.Event == model.EventPull || pipeline.Event == model.EventPullClosed) && len(pipeline.ChangedFiles) == 0 {
index, err := strconv.ParseInt(strings.Split(pipeline.Ref, "/")[2], 10, 64)
if err != nil {
return nil, nil, err
}
pipeline.ChangedFiles, err = c.getChangedFilesForPR(ctx, repo, index)
if err != nil {
log.Error().Err(err).Msgf("could not get changed files for PR %s#%d", repo.FullName, index)
}
}
return repo, pipeline, nil
}
// OrgMembership returns if user is member of organization and if user
// is admin/owner in this organization.
func (c *Forgejo) OrgMembership(ctx context.Context, u *model.User, owner string) (*model.OrgPerm, error) {
client, err := c.newClientToken(ctx, u.Token)
if err != nil {
return nil, err
}
member, _, err := client.CheckOrgMembership(owner, u.Login)
if err != nil {
return nil, err
}
if !member {
return &model.OrgPerm{}, nil
}
perm, _, err := client.GetOrgPermissions(owner, u.Login)
if err != nil {
return &model.OrgPerm{Member: member}, err
}
return &model.OrgPerm{Member: member, Admin: perm.IsAdmin || perm.IsOwner}, nil
}
func (c *Forgejo) Org(ctx context.Context, u *model.User, owner string) (*model.Org, error) {
client, err := c.newClientToken(ctx, u.Token)
if err != nil {
return nil, err
}
org, _, orgErr := client.GetOrg(owner)
if orgErr == nil && org != nil {
return &model.Org{
Name: org.UserName,
Private: forgejo.VisibleType(org.Visibility) != forgejo.VisibleTypePublic,
}, nil
}
user, _, err := client.GetUserInfo(owner)
if err != nil {
if orgErr != nil {
err = errors.Join(orgErr, err)
}
return nil, err
}
return &model.Org{
Name: user.UserName,
IsUser: true,
Private: user.Visibility != forgejo.VisibleTypePublic,
}, nil
}
// newClientToken returns a Forgejo client with token.
func (c *Forgejo) newClientToken(ctx context.Context, token string) (*forgejo.Client, error) {
httpClient := &http.Client{}
if c.SkipVerify {
httpClient.Transport = &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
}
}
client, err := forgejo.NewClient(c.url, forgejo.SetToken(token), forgejo.SetHTTPClient(httpClient), forgejo.SetContext(ctx))
if err != nil &&
(errors.Is(err, &forgejo.ErrUnknownVersion{}) || strings.Contains(err.Error(), "Malformed version")) {
// we guess it's a dev forgejo version
log.Error().Err(err).Msgf("could not detect forgejo version, assume dev version %s", forgejoDevVersion)
client, err = forgejo.NewClient(c.url, forgejo.SetForgejoVersion(forgejoDevVersion), forgejo.SetToken(token), forgejo.SetHTTPClient(httpClient), forgejo.SetContext(ctx))
}
return client, err
}
// getStatus is a helper function that converts a Woodpecker
// status to a Forgejo status.
func getStatus(status model.StatusValue) forgejo.StatusState {
switch status {
case model.StatusPending, model.StatusBlocked:
return forgejo.StatusPending
case model.StatusRunning:
return forgejo.StatusPending
case model.StatusSuccess:
return forgejo.StatusSuccess
case model.StatusFailure:
return forgejo.StatusFailure
case model.StatusKilled:
return forgejo.StatusFailure
case model.StatusDeclined:
return forgejo.StatusWarning
case model.StatusError:
return forgejo.StatusError
default:
return forgejo.StatusFailure
}
}
func (c *Forgejo) getChangedFilesForPR(ctx context.Context, repo *model.Repo, index int64) ([]string, error) {
_store, ok := store.TryFromContext(ctx)
if !ok {
log.Error().Msg("could not get store from context")
return []string{}, nil
}
repo, err := _store.GetRepoNameFallback(repo.ForgeRemoteID, repo.FullName)
if err != nil {
return nil, err
}
user, err := _store.GetUser(repo.UserID)
if err != nil {
return nil, err
}
client, err := c.newClientToken(ctx, user.Token)
if err != nil {
return nil, err
}
return shared_utils.Paginate(func(page int) ([]string, error) {
forgejoFiles, _, err := client.ListPullRequestFiles(repo.Owner, repo.Name, index,
forgejo.ListPullRequestFilesOptions{ListOptions: forgejo.ListOptions{Page: page}})
if err != nil {
return nil, err
}
var files []string
for _, file := range forgejoFiles {
files = append(files, file.Filename)
}
return files, nil
})
}
func (c *Forgejo) getTagCommitSHA(ctx context.Context, repo *model.Repo, tagName string) (string, error) {
_store, ok := store.TryFromContext(ctx)
if !ok {
log.Error().Msg("could not get store from context")
return "", nil
}
repo, err := _store.GetRepoNameFallback(repo.ForgeRemoteID, repo.FullName)
if err != nil {
return "", err
}
user, err := _store.GetUser(repo.UserID)
if err != nil {
return "", err
}
client, err := c.newClientToken(ctx, user.Token)
if err != nil {
return "", err
}
tag, _, err := client.GetTag(repo.Owner, repo.Name, tagName)
if err != nil {
return "", err
}
return tag.Commit.SHA, nil
}
func (c *Forgejo) perPage(ctx context.Context) int {
if c.pageSize == 0 {
client, err := c.newClientToken(ctx, "")
if err != nil {
return defaultPageSize
}
api, _, err := client.GetGlobalAPISettings()
if err != nil {
return defaultPageSize
}
c.pageSize = api.MaxResponseItems
}
return c.pageSize
}

View File

@@ -0,0 +1,199 @@
// Copyright 2024 Woodpecker Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package forgejo
import (
"bytes"
"context"
"net/http"
"net/http/httptest"
"testing"
"github.com/franela/goblin"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/mock"
"go.woodpecker-ci.org/woodpecker/v2/server/forge/forgejo/fixtures"
"go.woodpecker-ci.org/woodpecker/v2/server/model"
"go.woodpecker-ci.org/woodpecker/v2/server/store"
mocks_store "go.woodpecker-ci.org/woodpecker/v2/server/store/mocks"
"go.woodpecker-ci.org/woodpecker/v2/shared/utils"
)
func Test_forgejo(t *testing.T) {
gin.SetMode(gin.TestMode)
s := httptest.NewServer(fixtures.Handler())
c, _ := New(Opts{
URL: s.URL,
SkipVerify: true,
})
mockStore := mocks_store.NewStore(t)
ctx := store.InjectToContext(context.Background(), mockStore)
g := goblin.Goblin(t)
g.Describe("Forgejo", func() {
g.After(func() {
s.Close()
})
g.Describe("Creating a forge", func() {
g.It("Should return client with specified options", func() {
forge, _ := New(Opts{
URL: "http://localhost:8080",
SkipVerify: true,
})
f, _ := forge.(*Forgejo)
g.Assert(f.url).Equal("http://localhost:8080")
g.Assert(f.SkipVerify).Equal(true)
})
})
g.Describe("Generating a netrc file", func() {
g.It("Should return a netrc with the user token", func() {
forge, _ := New(Opts{})
netrc, _ := forge.Netrc(fakeUser, fakeRepo)
g.Assert(netrc.Machine).Equal("forgejo.org")
g.Assert(netrc.Login).Equal(fakeUser.Login)
g.Assert(netrc.Password).Equal(fakeUser.Token)
})
g.It("Should return a netrc with the machine account", func() {
forge, _ := New(Opts{})
netrc, _ := forge.Netrc(nil, fakeRepo)
g.Assert(netrc.Machine).Equal("forgejo.org")
g.Assert(netrc.Login).Equal("")
g.Assert(netrc.Password).Equal("")
})
})
g.Describe("Requesting a repository", func() {
g.It("Should return the repository details", func() {
repo, err := c.Repo(ctx, fakeUser, fakeRepo.ForgeRemoteID, fakeRepo.Owner, fakeRepo.Name)
g.Assert(err).IsNil()
g.Assert(repo.Owner).Equal(fakeRepo.Owner)
g.Assert(repo.Name).Equal(fakeRepo.Name)
g.Assert(repo.FullName).Equal(fakeRepo.Owner + "/" + fakeRepo.Name)
g.Assert(repo.IsSCMPrivate).IsTrue()
g.Assert(repo.Clone).Equal("http://localhost/test_name/repo_name.git")
g.Assert(repo.ForgeURL).Equal("http://localhost/test_name/repo_name")
})
g.It("Should handle a not found error", func() {
_, err := c.Repo(ctx, fakeUser, "0", fakeRepoNotFound.Owner, fakeRepoNotFound.Name)
g.Assert(err).IsNotNil()
})
})
g.Describe("Requesting a repository list", func() {
g.It("Should return the repository list", func() {
repos, err := c.Repos(ctx, fakeUser)
g.Assert(err).IsNil()
g.Assert(repos[0].ForgeRemoteID).Equal(fakeRepo.ForgeRemoteID)
g.Assert(repos[0].Owner).Equal(fakeRepo.Owner)
g.Assert(repos[0].Name).Equal(fakeRepo.Name)
g.Assert(repos[0].FullName).Equal(fakeRepo.Owner + "/" + fakeRepo.Name)
})
g.It("Should handle a not found error", func() {
_, err := c.Repos(ctx, fakeUserNoRepos)
g.Assert(err).IsNotNil()
})
})
g.It("Should register repository hooks", func() {
err := c.Activate(ctx, fakeUser, fakeRepo, "http://localhost")
g.Assert(err).IsNil()
})
g.It("Should remove repository hooks", func() {
err := c.Deactivate(ctx, fakeUser, fakeRepo, "http://localhost")
g.Assert(err).IsNil()
})
g.It("Should return a repository file", func() {
raw, err := c.File(ctx, fakeUser, fakeRepo, fakePipeline, ".woodpecker.yml")
g.Assert(err).IsNil()
g.Assert(string(raw)).Equal("{ platform: linux/amd64 }")
})
g.It("Should return nil from send pipeline status", func() {
err := c.Status(ctx, fakeUser, fakeRepo, fakePipeline, fakeWorkflow)
g.Assert(err).IsNil()
})
g.Describe("Given an authentication request", func() {
g.It("Should redirect to login form")
g.It("Should create an access token")
g.It("Should handle an access token error")
g.It("Should return the authenticated user")
})
g.Describe("Given a repository hook", func() {
g.It("Should skip non-push events")
g.It("Should return push details")
g.It("Should handle a parsing error")
})
g.It("Given a PR hook", func() {
buf := bytes.NewBufferString(fixtures.HookPullRequest)
req, _ := http.NewRequest("POST", "/hook", buf)
req.Header = http.Header{}
req.Header.Set(hookEvent, hookPullRequest)
mockStore.On("GetRepoNameFallback", mock.Anything, mock.Anything).Return(fakeRepo, nil)
mockStore.On("GetUser", mock.Anything).Return(fakeUser, nil)
r, b, err := c.Hook(ctx, req)
g.Assert(r).IsNotNil()
g.Assert(b).IsNotNil()
g.Assert(err).IsNil()
g.Assert(b.Event).Equal(model.EventPull)
g.Assert(utils.EqualSliceValues(b.ChangedFiles, []string{"README.md"})).IsTrue()
})
})
}
var (
fakeUser = &model.User{
Login: "someuser",
Token: "cfcd2084",
}
fakeUserNoRepos = &model.User{
Login: "someuser",
Token: "repos_not_found",
}
fakeRepo = &model.Repo{
Clone: "http://forgejo.org/test_name/repo_name.git",
ForgeRemoteID: "5",
Owner: "test_name",
Name: "repo_name",
FullName: "test_name/repo_name",
}
fakeRepoNotFound = &model.Repo{
Owner: "test_name",
Name: "repo_not_found",
FullName: "test_name/repo_not_found",
}
fakePipeline = &model.Pipeline{
Commit: "9ecad50",
}
fakeWorkflow = &model.Workflow{
Name: "test",
State: model.StatusSuccess,
}
)

View File

@@ -0,0 +1,277 @@
// Copyright 2024 Woodpecker Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package forgejo
import (
"encoding/json"
"fmt"
"io"
"net/url"
"strings"
"time"
"codeberg.org/mvdkleijn/forgejo-sdk/forgejo"
"go.woodpecker-ci.org/woodpecker/v2/server/model"
"go.woodpecker-ci.org/woodpecker/v2/shared/utils"
)
// toRepo converts a Forgejo repository to a Woodpecker repository.
func toRepo(from *forgejo.Repository) *model.Repo {
name := strings.Split(from.FullName, "/")[1]
avatar := expandAvatar(
from.HTMLURL,
from.Owner.AvatarURL,
)
return &model.Repo{
ForgeRemoteID: model.ForgeRemoteID(fmt.Sprint(from.ID)),
SCMKind: model.RepoGit,
Name: name,
Owner: from.Owner.UserName,
FullName: from.FullName,
Avatar: avatar,
ForgeURL: from.HTMLURL,
IsSCMPrivate: from.Private || from.Owner.Visibility != forgejo.VisibleTypePublic,
Clone: from.CloneURL,
CloneSSH: from.SSHURL,
Branch: from.DefaultBranch,
Perm: toPerm(from.Permissions),
PREnabled: from.HasPullRequests,
}
}
// toPerm converts a Forgejo permission to a Woodpecker permission.
func toPerm(from *forgejo.Permission) *model.Perm {
return &model.Perm{
Pull: from.Pull,
Push: from.Push,
Admin: from.Admin,
}
}
// toTeam converts a Forgejo team to a Woodpecker team.
func toTeam(from *forgejo.Organization, link string) *model.Team {
return &model.Team{
Login: from.UserName,
Avatar: expandAvatar(link, from.AvatarURL),
}
}
// pipelineFromPush extracts the Pipeline data from a Forgejo push hook.
func pipelineFromPush(hook *pushHook) *model.Pipeline {
avatar := expandAvatar(
hook.Repo.HTMLURL,
fixMalformedAvatar(hook.Sender.AvatarURL),
)
var message string
link := hook.Compare
if len(hook.Commits) > 0 {
message = hook.Commits[0].Message
if len(hook.Commits) == 1 {
link = hook.Commits[0].URL
}
} else {
message = hook.HeadCommit.Message
link = hook.HeadCommit.URL
}
return &model.Pipeline{
Event: model.EventPush,
Commit: hook.After,
Ref: hook.Ref,
ForgeURL: link,
Branch: strings.TrimPrefix(hook.Ref, "refs/heads/"),
Message: message,
Avatar: avatar,
Author: hook.Sender.UserName,
Email: hook.Sender.Email,
Timestamp: time.Now().UTC().Unix(),
Sender: hook.Sender.UserName,
ChangedFiles: getChangedFilesFromPushHook(hook),
}
}
func getChangedFilesFromPushHook(hook *pushHook) []string {
// assume a capacity of 4 changed files per commit
files := make([]string, 0, len(hook.Commits)*4)
for _, c := range hook.Commits {
files = append(files, c.Added...)
files = append(files, c.Removed...)
files = append(files, c.Modified...)
}
files = append(files, hook.HeadCommit.Added...)
files = append(files, hook.HeadCommit.Removed...)
files = append(files, hook.HeadCommit.Modified...)
return utils.DeduplicateStrings(files)
}
// pipelineFromTag extracts the Pipeline data from a Forgejo tag hook.
func pipelineFromTag(hook *pushHook) *model.Pipeline {
avatar := expandAvatar(
hook.Repo.HTMLURL,
fixMalformedAvatar(hook.Sender.AvatarURL),
)
ref := strings.TrimPrefix(hook.Ref, "refs/tags/")
return &model.Pipeline{
Event: model.EventTag,
Commit: hook.Sha,
Ref: fmt.Sprintf("refs/tags/%s", ref),
ForgeURL: fmt.Sprintf("%s/src/tag/%s", hook.Repo.HTMLURL, ref),
Message: fmt.Sprintf("created tag %s", ref),
Avatar: avatar,
Author: hook.Sender.UserName,
Sender: hook.Sender.UserName,
Email: hook.Sender.Email,
Timestamp: time.Now().UTC().Unix(),
}
}
// pipelineFromPullRequest extracts the Pipeline data from a Forgejo pull_request hook.
func pipelineFromPullRequest(hook *pullRequestHook) *model.Pipeline {
avatar := expandAvatar(
hook.Repo.HTMLURL,
fixMalformedAvatar(hook.PullRequest.Poster.AvatarURL),
)
event := model.EventPull
if hook.Action == actionClose {
event = model.EventPullClosed
}
pipeline := &model.Pipeline{
Event: event,
Commit: hook.PullRequest.Head.Sha,
ForgeURL: hook.PullRequest.HTMLURL,
Ref: fmt.Sprintf("refs/pull/%d/head", hook.Number),
Branch: hook.PullRequest.Base.Ref,
Message: hook.PullRequest.Title,
Author: hook.PullRequest.Poster.UserName,
Avatar: avatar,
Sender: hook.Sender.UserName,
Email: hook.Sender.Email,
Title: hook.PullRequest.Title,
Refspec: fmt.Sprintf("%s:%s",
hook.PullRequest.Head.Ref,
hook.PullRequest.Base.Ref,
),
PullRequestLabels: convertLabels(hook.PullRequest.Labels),
}
return pipeline
}
func pipelineFromRelease(hook *releaseHook) *model.Pipeline {
avatar := expandAvatar(
hook.Repo.HTMLURL,
fixMalformedAvatar(hook.Sender.AvatarURL),
)
return &model.Pipeline{
Event: model.EventRelease,
Ref: fmt.Sprintf("refs/tags/%s", hook.Release.TagName),
ForgeURL: hook.Release.HTMLURL,
Branch: hook.Release.Target,
Message: fmt.Sprintf("created release %s", hook.Release.Title),
Avatar: avatar,
Author: hook.Sender.UserName,
Sender: hook.Sender.UserName,
Email: hook.Sender.Email,
IsPrerelease: hook.Release.IsPrerelease,
}
}
// helper function that parses a push hook from a read closer.
func parsePush(r io.Reader) (*pushHook, error) {
push := new(pushHook)
err := json.NewDecoder(r).Decode(push)
return push, err
}
func parsePullRequest(r io.Reader) (*pullRequestHook, error) {
pr := new(pullRequestHook)
err := json.NewDecoder(r).Decode(pr)
return pr, err
}
func parseRelease(r io.Reader) (*releaseHook, error) {
pr := new(releaseHook)
err := json.NewDecoder(r).Decode(pr)
return pr, err
}
// fixMalformedAvatar is a helper function that fixes an avatar url if malformed
// (currently a known bug with forgejo).
func fixMalformedAvatar(url string) string {
index := strings.Index(url, "///")
if index != -1 {
return url[index+1:]
}
index = strings.Index(url, "//avatars/")
if index != -1 {
return strings.ReplaceAll(url, "//avatars/", "/avatars/")
}
return url
}
// expandAvatar is a helper function that converts a relative avatar URL to the
// absolute url.
func expandAvatar(repo, rawurl string) string {
aurl, err := url.Parse(rawurl)
if err != nil {
return rawurl
}
if aurl.IsAbs() {
// Url is already absolute
return aurl.String()
}
// Resolve to base
burl, err := url.Parse(repo)
if err != nil {
return rawurl
}
aurl = burl.ResolveReference(aurl)
return aurl.String()
}
// helper function to return matching hooks.
func matchingHooks(hooks []*forgejo.Hook, rawurl string) *forgejo.Hook {
link, err := url.Parse(rawurl)
if err != nil {
return nil
}
for _, hook := range hooks {
if val, ok := hook.Config["url"]; ok {
hookurl, err := url.Parse(val)
if err == nil && hookurl.Host == link.Host {
return hook
}
}
}
return nil
}
func convertLabels(from []*forgejo.Label) []string {
labels := make([]string, len(from))
for i, label := range from {
labels[i] = label.Name
}
return labels
}

View File

@@ -0,0 +1,273 @@
// Copyright 2024 Woodpecker Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package forgejo
import (
"bytes"
"testing"
"codeberg.org/mvdkleijn/forgejo-sdk/forgejo"
"github.com/franela/goblin"
"go.woodpecker-ci.org/woodpecker/v2/server/forge/forgejo/fixtures"
"go.woodpecker-ci.org/woodpecker/v2/server/model"
"go.woodpecker-ci.org/woodpecker/v2/shared/utils"
)
func Test_parse(t *testing.T) {
g := goblin.Goblin(t)
g.Describe("Forgejo", func() {
g.It("Should parse push hook payload", func() {
buf := bytes.NewBufferString(fixtures.HookPush)
hook, err := parsePush(buf)
g.Assert(err).IsNil()
g.Assert(hook.Ref).Equal("refs/heads/main")
g.Assert(hook.After).Equal("ef98532add3b2feb7a137426bba1248724367df5")
g.Assert(hook.Before).Equal("4b2626259b5a97b6b4eab5e6cca66adb986b672b")
g.Assert(hook.Compare).Equal("http://forgejo.golang.org/gordon/hello-world/compare/4b2626259b5a97b6b4eab5e6cca66adb986b672b...ef98532add3b2feb7a137426bba1248724367df5")
g.Assert(hook.Repo.Name).Equal("hello-world")
g.Assert(hook.Repo.HTMLURL).Equal("http://forgejo.golang.org/gordon/hello-world")
g.Assert(hook.Repo.Owner.UserName).Equal("gordon")
g.Assert(hook.Repo.FullName).Equal("gordon/hello-world")
g.Assert(hook.Repo.Owner.Email).Equal("gordon@golang.org")
g.Assert(hook.Repo.Private).Equal(true)
g.Assert(hook.Pusher.Email).Equal("gordon@golang.org")
g.Assert(hook.Pusher.UserName).Equal("gordon")
g.Assert(hook.Sender.UserName).Equal("gordon")
g.Assert(hook.Sender.AvatarURL).Equal("http://forgejo.golang.org///1.gravatar.com/avatar/8c58a0be77ee441bb8f8595b7f1b4e87")
})
g.It("Should parse tag hook payload", func() {
buf := bytes.NewBufferString(fixtures.HookTag)
hook, err := parsePush(buf)
g.Assert(err).IsNil()
g.Assert(hook.Ref).Equal("v1.0.0")
g.Assert(hook.Sha).Equal("ef98532add3b2feb7a137426bba1248724367df5")
g.Assert(hook.Repo.Name).Equal("hello-world")
g.Assert(hook.Repo.HTMLURL).Equal("http://forgejo.golang.org/gordon/hello-world")
g.Assert(hook.Repo.FullName).Equal("gordon/hello-world")
g.Assert(hook.Repo.Owner.Email).Equal("gordon@golang.org")
g.Assert(hook.Repo.Owner.UserName).Equal("gordon")
g.Assert(hook.Repo.Private).Equal(true)
g.Assert(hook.Sender.UserName).Equal("gordon")
g.Assert(hook.Sender.AvatarURL).Equal("https://secure.gravatar.com/avatar/8c58a0be77ee441bb8f8595b7f1b4e87")
})
g.It("Should parse pull_request hook payload", func() {
buf := bytes.NewBufferString(fixtures.HookPullRequest)
hook, err := parsePullRequest(buf)
g.Assert(err).IsNil()
g.Assert(hook.Action).Equal("opened")
g.Assert(hook.Number).Equal(int64(1))
g.Assert(hook.Repo.Name).Equal("hello-world")
g.Assert(hook.Repo.HTMLURL).Equal("http://forgejo.golang.org/gordon/hello-world")
g.Assert(hook.Repo.FullName).Equal("gordon/hello-world")
g.Assert(hook.Repo.Owner.Email).Equal("gordon@golang.org")
g.Assert(hook.Repo.Owner.UserName).Equal("gordon")
g.Assert(hook.Repo.Private).Equal(true)
g.Assert(hook.Sender.UserName).Equal("gordon")
g.Assert(hook.Sender.AvatarURL).Equal("https://secure.gravatar.com/avatar/8c58a0be77ee441bb8f8595b7f1b4e87")
g.Assert(hook.PullRequest.Title).Equal("Update the README with new information")
g.Assert(hook.PullRequest.Body).Equal("please merge")
g.Assert(hook.PullRequest.State).Equal(forgejo.StateOpen)
g.Assert(hook.PullRequest.Poster.UserName).Equal("gordon")
g.Assert(hook.PullRequest.Base.Name).Equal("main")
g.Assert(hook.PullRequest.Base.Ref).Equal("main")
g.Assert(hook.PullRequest.Head.Name).Equal("feature/changes")
g.Assert(hook.PullRequest.Head.Ref).Equal("feature/changes")
})
g.It("Should return a Pipeline struct from a push hook", func() {
buf := bytes.NewBufferString(fixtures.HookPush)
hook, _ := parsePush(buf)
pipeline := pipelineFromPush(hook)
g.Assert(pipeline.Event).Equal(model.EventPush)
g.Assert(pipeline.Commit).Equal(hook.After)
g.Assert(pipeline.Ref).Equal(hook.Ref)
g.Assert(pipeline.ForgeURL).Equal(hook.Commits[0].URL)
g.Assert(pipeline.Branch).Equal("main")
g.Assert(pipeline.Message).Equal(hook.Commits[0].Message)
g.Assert(pipeline.Avatar).Equal("http://1.gravatar.com/avatar/8c58a0be77ee441bb8f8595b7f1b4e87")
g.Assert(pipeline.Author).Equal(hook.Sender.UserName)
g.Assert(utils.EqualSliceValues(pipeline.ChangedFiles, []string{"CHANGELOG.md", "app/controller/application.rb"})).IsTrue()
})
g.It("Should return a Repo struct from a push hook", func() {
buf := bytes.NewBufferString(fixtures.HookPush)
hook, _ := parsePush(buf)
repo := toRepo(hook.Repo)
g.Assert(repo.Name).Equal(hook.Repo.Name)
g.Assert(repo.Owner).Equal(hook.Repo.Owner.UserName)
g.Assert(repo.FullName).Equal("gordon/hello-world")
g.Assert(repo.ForgeURL).Equal(hook.Repo.HTMLURL)
})
g.It("Should return a Pipeline struct from a tag hook", func() {
buf := bytes.NewBufferString(fixtures.HookTag)
hook, _ := parsePush(buf)
pipeline := pipelineFromTag(hook)
g.Assert(pipeline.Event).Equal(model.EventTag)
g.Assert(pipeline.Commit).Equal(hook.Sha)
g.Assert(pipeline.Ref).Equal("refs/tags/v1.0.0")
g.Assert(pipeline.Branch).Equal("")
g.Assert(pipeline.ForgeURL).Equal("http://forgejo.golang.org/gordon/hello-world/src/tag/v1.0.0")
g.Assert(pipeline.Message).Equal("created tag v1.0.0")
})
g.It("Should return a Pipeline struct from a pull_request hook", func() {
buf := bytes.NewBufferString(fixtures.HookPullRequest)
hook, _ := parsePullRequest(buf)
pipeline := pipelineFromPullRequest(hook)
g.Assert(pipeline.Event).Equal(model.EventPull)
g.Assert(pipeline.Commit).Equal(hook.PullRequest.Head.Sha)
g.Assert(pipeline.Ref).Equal("refs/pull/1/head")
g.Assert(pipeline.ForgeURL).Equal("http://forgejo.golang.org/gordon/hello-world/pull/1")
g.Assert(pipeline.Branch).Equal("main")
g.Assert(pipeline.Refspec).Equal("feature/changes:main")
g.Assert(pipeline.Message).Equal(hook.PullRequest.Title)
g.Assert(pipeline.Avatar).Equal("http://1.gravatar.com/avatar/8c58a0be77ee441bb8f8595b7f1b4e87")
g.Assert(pipeline.Author).Equal(hook.PullRequest.Poster.UserName)
})
g.It("Should return a Repo struct from a pull_request hook", func() {
buf := bytes.NewBufferString(fixtures.HookPullRequest)
hook, _ := parsePullRequest(buf)
repo := toRepo(hook.Repo)
g.Assert(repo.Name).Equal(hook.Repo.Name)
g.Assert(repo.Owner).Equal(hook.Repo.Owner.UserName)
g.Assert(repo.FullName).Equal("gordon/hello-world")
g.Assert(repo.ForgeURL).Equal(hook.Repo.HTMLURL)
})
g.It("Should return a Perm struct from a Forgejo Perm", func() {
perms := []forgejo.Permission{
{
Admin: true,
Push: true,
Pull: true,
},
{
Admin: true,
Push: true,
Pull: false,
},
{
Admin: true,
Push: false,
Pull: false,
},
}
for _, from := range perms {
perm := toPerm(&from)
g.Assert(perm.Pull).Equal(from.Pull)
g.Assert(perm.Push).Equal(from.Push)
g.Assert(perm.Admin).Equal(from.Admin)
}
})
g.It("Should return a Team struct from a Forgejo Org", func() {
from := &forgejo.Organization{
UserName: "woodpecker",
AvatarURL: "/avatars/1",
}
to := toTeam(from, "http://localhost:80")
g.Assert(to.Login).Equal(from.UserName)
g.Assert(to.Avatar).Equal("http://localhost:80/avatars/1")
})
g.It("Should return a Repo struct from a Forgejo Repo", func() {
from := forgejo.Repository{
FullName: "gophers/hello-world",
Owner: &forgejo.User{
UserName: "gordon",
AvatarURL: "http://1.gravatar.com/avatar/8c58a0be77ee441bb8f8595b7f1b4e87",
},
CloneURL: "http://forgejo.golang.org/gophers/hello-world.git",
HTMLURL: "http://forgejo.golang.org/gophers/hello-world",
Private: true,
DefaultBranch: "main",
Permissions: &forgejo.Permission{Admin: true},
}
repo := toRepo(&from)
g.Assert(repo.FullName).Equal(from.FullName)
g.Assert(repo.Owner).Equal(from.Owner.UserName)
g.Assert(repo.Name).Equal("hello-world")
g.Assert(repo.Branch).Equal("main")
g.Assert(repo.ForgeURL).Equal(from.HTMLURL)
g.Assert(repo.Clone).Equal(from.CloneURL)
g.Assert(repo.Avatar).Equal(from.Owner.AvatarURL)
g.Assert(repo.IsSCMPrivate).Equal(from.Private)
g.Assert(repo.Perm.Admin).IsTrue()
})
g.It("Should correct a malformed avatar url", func() {
urls := []struct {
Before string
After string
}{
{
"http://forgejo.golang.org///1.gravatar.com/avatar/8c58a0be77ee441bb8f8595b7f1b4e87",
"//1.gravatar.com/avatar/8c58a0be77ee441bb8f8595b7f1b4e87",
},
{
"//1.gravatar.com/avatar/8c58a0be77ee441bb8f8595b7f1b4e87",
"//1.gravatar.com/avatar/8c58a0be77ee441bb8f8595b7f1b4e87",
},
{
"http://forgejo.golang.org/avatars/1",
"http://forgejo.golang.org/avatars/1",
},
{
"http://forgejo.golang.org//avatars/1",
"http://forgejo.golang.org/avatars/1",
},
}
for _, url := range urls {
got := fixMalformedAvatar(url.Before)
g.Assert(got).Equal(url.After)
}
})
g.It("Should expand the avatar url", func() {
urls := []struct {
Before string
After string
}{
{
"/avatars/1",
"http://forgejo.io/avatars/1",
},
{
"//1.gravatar.com/avatar/8c58a0be77ee441bb8f8595b7f1b4e87",
"http://1.gravatar.com/avatar/8c58a0be77ee441bb8f8595b7f1b4e87",
},
{
"/forgejo/avatars/2",
"http://forgejo.io/forgejo/avatars/2",
},
}
repo := "http://forgejo.io/foo/bar"
for _, url := range urls {
got := expandAvatar(repo, url.Before)
g.Assert(got).Equal(url.After)
}
})
})
}

View File

@@ -0,0 +1,139 @@
// Copyright 2024 Woodpecker Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package forgejo
import (
"io"
"net/http"
"strings"
"github.com/rs/zerolog/log"
"go.woodpecker-ci.org/woodpecker/v2/server/forge/types"
"go.woodpecker-ci.org/woodpecker/v2/server/model"
)
const (
hookEvent = "X-Forgejo-Event"
hookPush = "push"
hookCreated = "create"
hookPullRequest = "pull_request"
hookRelease = "release"
actionOpen = "opened"
actionSync = "synchronized"
actionClose = "closed"
refBranch = "branch"
refTag = "tag"
)
// parseHook parses a Forgejo hook from an http.Request and returns
// Repo and Pipeline detail. If a hook type is unsupported nil values are returned.
func parseHook(r *http.Request) (*model.Repo, *model.Pipeline, error) {
hookType := r.Header.Get(hookEvent)
switch hookType {
case hookPush:
return parsePushHook(r.Body)
case hookCreated:
return parseCreatedHook(r.Body)
case hookPullRequest:
return parsePullRequestHook(r.Body)
case hookRelease:
return parseReleaseHook(r.Body)
}
log.Debug().Msgf("unsupported hook type: '%s'", hookType)
return nil, nil, &types.ErrIgnoreEvent{Event: hookType}
}
// parsePushHook parses a push hook and returns the Repo and Pipeline details.
// If the commit type is unsupported nil values are returned.
func parsePushHook(payload io.Reader) (repo *model.Repo, pipeline *model.Pipeline, err error) {
push, err := parsePush(payload)
if err != nil {
return nil, nil, err
}
// ignore push events for tags
if strings.HasPrefix(push.Ref, "refs/tags/") {
return nil, nil, nil
}
// TODO is this even needed?
if push.RefType == refBranch {
return nil, nil, nil
}
repo = toRepo(push.Repo)
pipeline = pipelineFromPush(push)
return repo, pipeline, err
}
// parseCreatedHook parses a push hook and returns the Repo and Pipeline details.
// If the commit type is unsupported nil values are returned.
func parseCreatedHook(payload io.Reader) (repo *model.Repo, pipeline *model.Pipeline, err error) {
push, err := parsePush(payload)
if err != nil {
return nil, nil, err
}
if push.RefType != refTag {
return nil, nil, nil
}
repo = toRepo(push.Repo)
pipeline = pipelineFromTag(push)
return repo, pipeline, nil
}
// parsePullRequestHook parses a pull_request hook and returns the Repo and Pipeline details.
func parsePullRequestHook(payload io.Reader) (*model.Repo, *model.Pipeline, error) {
var (
repo *model.Repo
pipeline *model.Pipeline
)
pr, err := parsePullRequest(payload)
if err != nil {
return nil, nil, err
}
// Don't trigger pipelines for non-code changes ...
if pr.Action != actionOpen && pr.Action != actionSync && pr.Action != actionClose {
log.Debug().Msgf("pull_request action is '%s' and no open or sync", pr.Action)
return nil, nil, nil
}
repo = toRepo(pr.Repo)
pipeline = pipelineFromPullRequest(pr)
return repo, pipeline, err
}
// parseReleaseHook parses a release hook and returns the Repo and Pipeline details.
func parseReleaseHook(payload io.Reader) (*model.Repo, *model.Pipeline, error) {
var (
repo *model.Repo
pipeline *model.Pipeline
)
release, err := parseRelease(payload)
if err != nil {
return nil, nil, err
}
repo = toRepo(release.Repo)
pipeline = pipelineFromRelease(release)
return repo, pipeline, err
}

View File

@@ -0,0 +1,392 @@
// Copyright 2024 Woodpecker Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package forgejo
import (
"bytes"
"net/http"
"testing"
"github.com/stretchr/testify/assert"
"go.woodpecker-ci.org/woodpecker/v2/server/forge/forgejo/fixtures"
"go.woodpecker-ci.org/woodpecker/v2/server/forge/types"
"go.woodpecker-ci.org/woodpecker/v2/server/model"
)
func TestForgejoParser(t *testing.T) {
tests := []struct {
name string
data string
event string
err error
repo *model.Repo
pipe *model.Pipeline
}{
{
name: "should ignore unsupported hook events",
data: fixtures.HookPullRequest,
event: "issues",
err: &types.ErrIgnoreEvent{},
},
{
name: "push event should handle a push hook",
data: fixtures.HookPushBranch,
event: "push",
repo: &model.Repo{
ForgeRemoteID: "50820",
Owner: "meisam",
Name: "woodpecktester",
FullName: "meisam/woodpecktester",
Avatar: "https://codeberg.org/avatars/96512da76a14cf44e0bb32d1640e878e",
ForgeURL: "https://codeberg.org/meisam/woodpecktester",
Clone: "https://codeberg.org/meisam/woodpecktester.git",
CloneSSH: "git@codeberg.org:meisam/woodpecktester.git",
Branch: "main",
SCMKind: "git",
PREnabled: true,
Perm: &model.Perm{
Pull: true,
Push: true,
Admin: true,
},
},
pipe: &model.Pipeline{
Author: "6543",
Event: "push",
Commit: "28c3613ae62640216bea5e7dc71aa65356e4298b",
Branch: "fdsafdsa",
Ref: "refs/heads/fdsafdsa",
Message: "Delete '.woodpecker/.check.yml'\n",
Sender: "6543",
Avatar: "https://codeberg.org/avatars/09a234c768cb9bca78f6b2f82d6af173",
Email: "6543@obermui.de",
ForgeURL: "https://codeberg.org/meisam/woodpecktester/commit/28c3613ae62640216bea5e7dc71aa65356e4298b",
ChangedFiles: []string{".woodpecker/.check.yml"},
},
},
{
name: "push event should extract repository and pipeline details",
data: fixtures.HookPush,
event: "push",
repo: &model.Repo{
ForgeRemoteID: "1",
Owner: "gordon",
Name: "hello-world",
FullName: "gordon/hello-world",
Avatar: "http://forgejo.golang.org/gordon/hello-world",
ForgeURL: "http://forgejo.golang.org/gordon/hello-world",
Clone: "http://forgejo.golang.org/gordon/hello-world.git",
CloneSSH: "git@forgejo.golang.org:gordon/hello-world.git",
SCMKind: "git",
IsSCMPrivate: true,
Perm: &model.Perm{
Pull: true,
Push: true,
Admin: true,
},
},
pipe: &model.Pipeline{
Author: "gordon",
Event: "push",
Commit: "ef98532add3b2feb7a137426bba1248724367df5",
Branch: "main",
Ref: "refs/heads/main",
Message: "bump\n",
Sender: "gordon",
Avatar: "http://1.gravatar.com/avatar/8c58a0be77ee441bb8f8595b7f1b4e87",
Email: "gordon@golang.org",
ForgeURL: "http://forgejo.golang.org/gordon/hello-world/commit/ef98532add3b2feb7a137426bba1248724367df5",
ChangedFiles: []string{"CHANGELOG.md", "app/controller/application.rb"},
},
},
{
name: "push event should handle multi commit push",
data: fixtures.HookPushMulti,
event: "push",
repo: &model.Repo{
ForgeRemoteID: "6",
Owner: "Test-CI",
Name: "multi-line-secrets",
FullName: "Test-CI/multi-line-secrets",
Avatar: "http://127.0.0.1:3000/avatars/5b0a83c2185b3cb1ebceb11062d6c2eb",
ForgeURL: "http://127.0.0.1:3000/Test-CI/multi-line-secrets",
Clone: "http://127.0.0.1:3000/Test-CI/multi-line-secrets.git",
CloneSSH: "ssh://git@127.0.0.1:2200/Test-CI/multi-line-secrets.git",
Branch: "main",
SCMKind: "git",
Perm: &model.Perm{
Pull: true,
Push: true,
Admin: true,
},
},
pipe: &model.Pipeline{
Author: "test-user",
Event: "push",
Commit: "29be01c073851cf0db0c6a466e396b725a670453",
Branch: "main",
Ref: "refs/heads/main",
Message: "add some text\n",
Sender: "test-user",
Avatar: "http://127.0.0.1:3000/avatars/dd46a756faad4727fb679320751f6dea",
Email: "test@noreply.localhost",
ForgeURL: "http://127.0.0.1:3000/Test-CI/multi-line-secrets/compare/6efcf5b7c98f3e7a491675164b7a2e7acac27941...29be01c073851cf0db0c6a466e396b725a670453",
ChangedFiles: []string{"aaa", "aa"},
},
},
{
name: "tag event should handle a tag hook",
data: fixtures.HookTag,
event: "create",
repo: &model.Repo{
ForgeRemoteID: "12",
Owner: "gordon",
Name: "hello-world",
FullName: "gordon/hello-world",
Avatar: "https://secure.gravatar.com/avatar/8c58a0be77ee441bb8f8595b7f1b4e87",
ForgeURL: "http://forgejo.golang.org/gordon/hello-world",
Clone: "http://forgejo.golang.org/gordon/hello-world.git",
CloneSSH: "git@forgejo.golang.org:gordon/hello-world.git",
Branch: "main",
SCMKind: "git",
IsSCMPrivate: true,
Perm: &model.Perm{
Pull: true,
Push: true,
Admin: true,
},
},
pipe: &model.Pipeline{
Author: "gordon",
Event: "tag",
Commit: "ef98532add3b2feb7a137426bba1248724367df5",
Ref: "refs/tags/v1.0.0",
Message: "created tag v1.0.0",
Sender: "gordon",
Avatar: "https://secure.gravatar.com/avatar/8c58a0be77ee441bb8f8595b7f1b4e87",
Email: "gordon@golang.org",
ForgeURL: "http://forgejo.golang.org/gordon/hello-world/src/tag/v1.0.0",
},
},
{
name: "pull-request events should handle a PR hook when PR got created",
data: fixtures.HookPullRequest,
event: "pull_request",
repo: &model.Repo{
ForgeRemoteID: "35129377",
Owner: "gordon",
Name: "hello-world",
FullName: "gordon/hello-world",
Avatar: "https://secure.gravatar.com/avatar/8c58a0be77ee441bb8f8595b7f1b4e87",
ForgeURL: "http://forgejo.golang.org/gordon/hello-world",
Clone: "https://forgejo.golang.org/gordon/hello-world.git",
CloneSSH: "",
Branch: "main",
SCMKind: "git",
IsSCMPrivate: true,
Perm: &model.Perm{
Pull: true,
Push: true,
Admin: true,
},
},
pipe: &model.Pipeline{
Author: "gordon",
Event: "pull_request",
Commit: "0d1a26e67d8f5eaf1f6ba5c57fc3c7d91ac0fd1c",
Branch: "main",
Ref: "refs/pull/1/head",
Refspec: "feature/changes:main",
Title: "Update the README with new information",
Message: "Update the README with new information",
Sender: "gordon",
Avatar: "http://1.gravatar.com/avatar/8c58a0be77ee441bb8f8595b7f1b4e87",
Email: "gordon@golang.org",
ForgeURL: "http://forgejo.golang.org/gordon/hello-world/pull/1",
PullRequestLabels: []string{},
},
},
{
name: "pull-request events should handle a PR hook when PR got updated",
data: fixtures.HookPullRequestUpdated,
event: "pull_request",
repo: &model.Repo{
ForgeRemoteID: "6",
Owner: "Test-CI",
Name: "multi-line-secrets",
FullName: "Test-CI/multi-line-secrets",
Avatar: "http://127.0.0.1:3000/avatars/5b0a83c2185b3cb1ebceb11062d6c2eb",
ForgeURL: "http://127.0.0.1:3000/Test-CI/multi-line-secrets",
Clone: "http://127.0.0.1:3000/Test-CI/multi-line-secrets.git",
CloneSSH: "ssh://git@127.0.0.1:2200/Test-CI/multi-line-secrets.git",
Branch: "main",
SCMKind: "git",
PREnabled: true,
IsSCMPrivate: false,
Perm: &model.Perm{
Pull: true,
Push: true,
Admin: true,
},
},
pipe: &model.Pipeline{
Author: "test",
Event: "pull_request",
Commit: "788ed8d02d3b7fcfcf6386dbcbca696aa1d4dc25",
Branch: "main",
Ref: "refs/pull/2/head",
Refspec: "test-patch-1:main",
Title: "New Pull",
Message: "New Pull",
Sender: "test",
Avatar: "http://127.0.0.1:3000/avatars/dd46a756faad4727fb679320751f6dea",
Email: "test@noreply.localhost",
ForgeURL: "http://127.0.0.1:3000/Test-CI/multi-line-secrets/pulls/2",
PullRequestLabels: []string{
"Kind/Bug",
"Kind/Security",
},
},
},
{
name: "pull-request events should handle a PR closed hook when PR got closed",
data: fixtures.HookPullRequestClosed,
event: "pull_request",
repo: &model.Repo{
ForgeRemoteID: "46534",
Owner: "anbraten",
Name: "test-repo",
FullName: "anbraten/test-repo",
Avatar: "https://seccdn.libravatar.org/avatar/fc9b6fe77c6b732a02925a62a81f05a0?d=identicon",
ForgeURL: "https://forgejo.com/anbraten/test-repo",
Clone: "https://forgejo.com/anbraten/test-repo.git",
CloneSSH: "git@forgejo.com:anbraten/test-repo.git",
Branch: "main",
SCMKind: "git",
PREnabled: true,
Perm: &model.Perm{
Pull: true,
Push: true,
Admin: true,
},
},
pipe: &model.Pipeline{
Author: "anbraten",
Event: "pull_request_closed",
Commit: "d555a5dd07f4d0148a58d4686ec381502ae6a2d4",
Branch: "main",
Ref: "refs/pull/1/head",
Refspec: "anbraten-patch-1:main",
Title: "Adjust file",
Message: "Adjust file",
Sender: "anbraten",
Avatar: "https://seccdn.libravatar.org/avatar/fc9b6fe77c6b732a02925a62a81f05a0?d=identicon",
Email: "anbraten@sender.forgejo.com",
ForgeURL: "https://forgejo.com/anbraten/test-repo/pulls/1",
PullRequestLabels: []string{},
},
},
{
name: "pull-request events should handle a PR closed hook when PR was merged",
data: fixtures.HookPullRequestMerged,
event: "pull_request",
repo: &model.Repo{
ForgeRemoteID: "46534",
Owner: "anbraten",
Name: "test-repo",
FullName: "anbraten/test-repo",
Avatar: "https://seccdn.libravatar.org/avatar/fc9b6fe77c6b732a02925a62a81f05a0?d=identicon",
ForgeURL: "https://forgejo.com/anbraten/test-repo",
Clone: "https://forgejo.com/anbraten/test-repo.git",
CloneSSH: "git@forgejo.com:anbraten/test-repo.git",
Branch: "main",
SCMKind: "git",
PREnabled: true,
Perm: &model.Perm{
Pull: true,
Push: true,
Admin: true,
},
},
pipe: &model.Pipeline{
Author: "anbraten",
Event: "pull_request_closed",
Commit: "d555a5dd07f4d0148a58d4686ec381502ae6a2d4",
Branch: "main",
Ref: "refs/pull/1/head",
Refspec: "anbraten-patch-1:main",
Title: "Adjust file",
Message: "Adjust file",
Sender: "anbraten",
Avatar: "https://seccdn.libravatar.org/avatar/fc9b6fe77c6b732a02925a62a81f05a0?d=identicon",
Email: "anbraten@noreply.forgejo.com",
ForgeURL: "https://forgejo.com/anbraten/test-repo/pulls/1",
PullRequestLabels: []string{},
},
},
{
name: "release events should handle release hook",
data: fixtures.HookRelease,
event: "release",
repo: &model.Repo{
ForgeRemoteID: "77",
Owner: "anbraten",
Name: "demo",
FullName: "anbraten/demo",
Avatar: "https://git.xxx/user/avatar/anbraten/-1",
ForgeURL: "https://git.xxx/anbraten/demo",
Clone: "https://git.xxx/anbraten/demo.git",
CloneSSH: "ssh://git@git.xxx:22/anbraten/demo.git",
Branch: "main",
SCMKind: "git",
PREnabled: true,
IsSCMPrivate: true,
Perm: &model.Perm{
Pull: true,
Push: true,
Admin: true,
},
},
pipe: &model.Pipeline{
Author: "anbraten",
Event: "release",
Branch: "main",
Ref: "refs/tags/0.0.5",
Message: "created release Version 0.0.5",
Sender: "anbraten",
Avatar: "https://git.xxx/user/avatar/anbraten/-1",
Email: "anbraten@noreply.xxx",
ForgeURL: "https://git.xxx/anbraten/demo/releases/tag/0.0.5",
},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
req, _ := http.NewRequest("POST", "/api/hook", bytes.NewBufferString(tc.data))
req.Header = http.Header{}
req.Header.Set(hookEvent, tc.event)
r, p, err := parseHook(req)
if tc.err != nil {
assert.ErrorIs(t, err, tc.err)
} else if assert.NoError(t, err) {
assert.EqualValues(t, tc.repo, r)
p.Timestamp = 0
assert.EqualValues(t, tc.pipe, p)
}
})
}
}

View File

@@ -0,0 +1,51 @@
// Copyright 2024 Woodpecker Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package forgejo
import "codeberg.org/mvdkleijn/forgejo-sdk/forgejo"
type pushHook struct {
Sha string `json:"sha"`
Ref string `json:"ref"`
Before string `json:"before"`
After string `json:"after"`
Compare string `json:"compare_url"`
RefType string `json:"ref_type"`
Pusher *forgejo.User `json:"pusher"`
Repo *forgejo.Repository `json:"repository"`
Commits []forgejo.PayloadCommit `json:"commits"`
HeadCommit forgejo.PayloadCommit `json:"head_commit"`
Sender *forgejo.User `json:"sender"`
}
type pullRequestHook struct {
Action string `json:"action"`
Number int64 `json:"number"`
PullRequest *forgejo.PullRequest `json:"pull_request"`
Repo *forgejo.Repository `json:"repository"`
Sender *forgejo.User `json:"sender"`
}
type releaseHook struct {
Action string `json:"action"`
Repo *forgejo.Repository `json:"repository"`
Sender *forgejo.User `json:"sender"`
Release *forgejo.Release
}

View File

@@ -11,6 +11,7 @@ import (
"go.woodpecker-ci.org/woodpecker/v2/server/forge/addon"
"go.woodpecker-ci.org/woodpecker/v2/server/forge/bitbucket"
"go.woodpecker-ci.org/woodpecker/v2/server/forge/bitbucketdatacenter"
"go.woodpecker-ci.org/woodpecker/v2/server/forge/forgejo"
"go.woodpecker-ci.org/woodpecker/v2/server/forge/gitea"
"go.woodpecker-ci.org/woodpecker/v2/server/forge/github"
"go.woodpecker-ci.org/woodpecker/v2/server/forge/gitlab"
@@ -29,6 +30,8 @@ func Forge(forge *model.Forge) (forge.Forge, error) {
return setupBitbucket(forge)
case model.ForgeTypeGitea:
return setupGitea(forge)
case model.ForgeTypeForgejo:
return setupForgejo(forge)
case model.ForgeTypeBitbucketDatacenter:
return setupBitbucketDatacenter(forge)
default:
@@ -65,6 +68,26 @@ func setupGitea(forge *model.Forge) (forge.Forge, error) {
return gitea.New(opts)
}
func setupForgejo(forge *model.Forge) (forge.Forge, error) {
server, err := url.Parse(forge.URL)
if err != nil {
return nil, err
}
opts := forgejo.Opts{
URL: strings.TrimRight(server.String(), "/"),
Client: forge.Client,
Secret: forge.ClientSecret,
SkipVerify: forge.SkipVerify,
OAuth2URL: forge.OAuthHost,
}
if len(opts.URL) == 0 {
return nil, fmt.Errorf("WOODPECKER_FORGEJO_URL must be set")
}
log.Trace().Msgf("Forge (forgejo) opts: %#v", opts)
return forgejo.New(opts)
}
func setupGitLab(forge *model.Forge) (forge.Forge, error) {
return gitlab.New(gitlab.Opts{
URL: forge.URL,

View File

@@ -20,6 +20,7 @@ const (
ForgeTypeGithub ForgeType = "github"
ForgeTypeGitlab ForgeType = "gitlab"
ForgeTypeGitea ForgeType = "gitea"
ForgeTypeForgejo ForgeType = "forgejo"
ForgeTypeBitbucket ForgeType = "bitbucket"
ForgeTypeBitbucketDatacenter ForgeType = "bitbucket-dc"
ForgeTypeAddon ForgeType = "addon"

View File

@@ -142,6 +142,12 @@ func setupForgeService(c *cli.Context, _store store.Store) error {
if _forge.URL == "" {
_forge.URL = "https://try.gitea.com"
}
case c.Bool("forgejo"):
_forge.Type = model.ForgeTypeForgejo
// TODO enable oauth URL with generic config option
if _forge.URL == "" {
_forge.URL = "https://next.forgejo.org"
}
case c.Bool("bitbucket"):
_forge.Type = model.ForgeTypeBitbucket
case c.Bool("bitbucket-dc"):