1
0
mirror of https://github.com/woodpecker-ci/woodpecker.git synced 2024-11-24 08:02:18 +02:00

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
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 3889 additions and 18 deletions

View File

@ -77,6 +77,7 @@
"excalidraw",
"favicons",
"forbidigo",
"Forgejo",
"fsnotify",
"gitea",
"gocritic",

View File

@ -308,24 +308,24 @@ var flags = append([]cli.Flag{
&cli.StringFlag{
Name: "forge-url",
Usage: "url of the forge",
EnvVars: []string{"WOODPECKER_FORGE_URL", "WOODPECKER_GITHUB_URL", "WOODPECKER_GITLAB_URL", "WOODPECKER_GITEA_URL", "WOODPECKER_BITBUCKET_URL"},
EnvVars: []string{"WOODPECKER_FORGE_URL", "WOODPECKER_GITHUB_URL", "WOODPECKER_GITLAB_URL", "WOODPECKER_GITEA_URL", "WOODPECKER_FORGEJO_URL", "WOODPECKER_BITBUCKET_URL"},
},
&cli.StringFlag{
Name: "forge-oauth-client",
Usage: "oauth2 client id",
EnvVars: []string{"WOODPECKER_FORGE_CLIENT", "WOODPECKER_GITHUB_CLIENT", "WOODPECKER_GITLAB_CLIENT", "WOODPECKER_GITEA_CLIENT", "WOODPECKER_BITBUCKET_CLIENT", "WOODPECKER_BITBUCKET_DC_CLIENT_ID"},
FilePath: getFirstNonEmptyEnvVar([]string{"WOODPECKER_FORGE_CLIENT_FILE", "WOODPECKER_GITHUB_CLIENT_FILE", "WOODPECKER_GITLAB_CLIENT_FILE", "WOODPECKER_GITEA_CLIENT_FILE", "WOODPECKER_BITBUCKET_CLIENT_FILE", "WOODPECKER_BITBUCKET_DC_CLIENT_ID_FILE"}),
EnvVars: []string{"WOODPECKER_FORGE_CLIENT", "WOODPECKER_GITHUB_CLIENT", "WOODPECKER_GITLAB_CLIENT", "WOODPECKER_GITEA_CLIENT", "WOODPECKER_FORGEJO_CLIENT", "WOODPECKER_BITBUCKET_CLIENT", "WOODPECKER_BITBUCKET_DC_CLIENT_ID"},
FilePath: getFirstNonEmptyEnvVar([]string{"WOODPECKER_FORGE_CLIENT_FILE", "WOODPECKER_GITHUB_CLIENT_FILE", "WOODPECKER_GITLAB_CLIENT_FILE", "WOODPECKER_GITEA_CLIENT_FILE", "WOODPECKER_FORGEJO_CLIENT_FILE", "WOODPECKER_BITBUCKET_CLIENT_FILE", "WOODPECKER_BITBUCKET_DC_CLIENT_ID_FILE"}),
},
&cli.StringFlag{
Name: "forge-oauth-secret",
Usage: "oauth2 client secret",
EnvVars: []string{"WOODPECKER_FORGE_SECRET", "WOODPECKER_GITHUB_SECRET", "WOODPECKER_GITLAB_SECRET", "WOODPECKER_GITEA_SECRET", "WOODPECKER_BITBUCKET_SECRET", "WOODPECKER_BITBUCKET_DC_CLIENT_SECRET"},
FilePath: getFirstNonEmptyEnvVar([]string{"WOODPECKER_FORGE_SECRET_FILE", "WOODPECKER_GITHUB_SECRET_FILE", "WOODPECKER_GITLAB_SECRET_FILE", "WOODPECKER_GITEA_SECRET_FILE", "WOODPECKER_BITBUCKET_SECRET_FILE", "WOODPECKER_BITBUCKET_DC_CLIENT_SECRET_FILE"}),
EnvVars: []string{"WOODPECKER_FORGE_SECRET", "WOODPECKER_GITHUB_SECRET", "WOODPECKER_GITLAB_SECRET", "WOODPECKER_GITEA_SECRET", "WOODPECKER_FORGEJO_SECRET", "WOODPECKER_BITBUCKET_SECRET", "WOODPECKER_BITBUCKET_DC_CLIENT_SECRET"},
FilePath: getFirstNonEmptyEnvVar([]string{"WOODPECKER_FORGE_SECRET_FILE", "WOODPECKER_GITHUB_SECRET_FILE", "WOODPECKER_GITLAB_SECRET_FILE", "WOODPECKER_GITEA_SECRET_FILE", "WOODPECKER_FORGEJO_SECRET_FILE", "WOODPECKER_BITBUCKET_SECRET_FILE", "WOODPECKER_BITBUCKET_DC_CLIENT_SECRET_FILE"}),
},
&cli.BoolFlag{
Name: "forge-skip-verify",
Usage: "skip ssl verification",
EnvVars: []string{"WOODPECKER_FORGE_SKIP_VERIFY", "WOODPECKER_GITHUB_SKIP_VERIFY", "WOODPECKER_GITLAB_SKIP_VERIFY", "WOODPECKER_GITEA_SKIP_VERIFY", "WOODPECKER_BITBUCKET_SKIP_VERIFY"},
EnvVars: []string{"WOODPECKER_FORGE_SKIP_VERIFY", "WOODPECKER_GITHUB_SKIP_VERIFY", "WOODPECKER_GITLAB_SKIP_VERIFY", "WOODPECKER_GITEA_SKIP_VERIFY", "WOODPECKER_FORGEJO_SKIP_VERIFY", "WOODPECKER_BITBUCKET_SKIP_VERIFY"},
},
&cli.StringFlag{
EnvVars: []string{"WOODPECKER_EXPERT_FORGE_OAUTH_HOST", "WOODPECKER_DEV_GITEA_OAUTH_URL"}, // TODO: remove WOODPECKER_DEV_GITEA_OAUTH_URL in next major release
@ -369,6 +369,14 @@ var flags = append([]cli.Flag{
Usage: "gitea driver is enabled",
},
//
// Forgejo
//
&cli.BoolFlag{
EnvVars: []string{"WOODPECKER_FORGEJO"},
Name: "forgejo",
Usage: "forgejo driver is enabled",
},
//
// Bitbucket
//
&cli.BoolFlag{

View File

@ -2,12 +2,12 @@
## Supported features
| Feature | [GitHub](20-github.md) | [Gitea / Forgejo](30-gitea.md) | [Gitlab](40-gitlab.md) | [Bitbucket](50-bitbucket.md) | [Bitbucket Datacenter](60-bitbucket_datacenter.md) |
| ------------------------------------------------------------- | :--------------------: | :----------------------------: | :--------------------: | :--------------------------: | :------------------------------------------------: |
| Event: Push | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: |
| Event: Tag | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: |
| Event: Pull-Request | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: |
| Event: Release | :white_check_mark: | :white_check_mark: | :white_check_mark: | :x: | :x: |
| Event: Deploy | :white_check_mark: | :x: | :x: | :x: | :x: |
| [Multiple workflows](../../20-usage/25-workflows.md) | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: |
| [when.path filter](../../20-usage/20-workflow-syntax.md#path) | :white_check_mark: | :white_check_mark: | :white_check_mark: | :x: | :x: |
| Feature | [GitHub](20-github.md) | [Gitea](30-gitea.md) | [Forgejo](35-forgejo.md) | [Gitlab](40-gitlab.md) | [Bitbucket](50-bitbucket.md) | [Bitbucket Datacenter](60-bitbucket_datacenter.md) |
| ------------------------------------------------------------- | :--------------------: | :------------------: | :----------------------: | :--------------------: | :--------------------------: | :------------------------------------------------: |
| Event: Push | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: |
| Event: Tag | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: |
| Event: Pull-Request | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: |
| Event: Release | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | :x: | :x: |
| Event: Deploy | :white_check_mark: | :x: | :x: | :x: | :x: | :x: |
| [Multiple workflows](../../20-usage/25-workflows.md) | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: |
| [when.path filter](../../20-usage/20-workflow-syntax.md#path) | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | :x: | :x: |

View File

@ -2,9 +2,9 @@
toc_max_heading_level: 2
---
# Gitea / Forgejo
# Gitea
Woodpecker comes with built-in support for Gitea and the "soft" fork Forgejo. To enable Gitea you should configure the Woodpecker container using the following environment variables:
Woodpecker comes with built-in support for Gitea. To enable Gitea you should configure the Woodpecker container using the following environment variables:
```ini
WOODPECKER_GITEA=true

View File

@ -0,0 +1,97 @@
---
toc_max_heading_level: 2
---
# Forgejo
:::warning
Forgejo support is experimental.
:::
Woodpecker comes with built-in support for Forgejo. To enable Forgejo you should configure the Woodpecker container using the following environment variables:
```ini
WOODPECKER_FORGEJO=true
WOODPECKER_FORGEJO_URL=YOUR_FORGEJO_URL
WOODPECKER_FORGEJO_CLIENT=YOUR_FORGEJO_CLIENT
WOODPECKER_FORGEJO_SECRET=YOUR_FORGEJO_CLIENT_SECRET
```
## Forgejo on the same host with containers
If you have Forgejo also running on the same host within a container, make sure the agent does have access to it.
The agent tries to clone using the URL which Forgejo reports through its API. For simplified connectivity, you should add the Woodpecker agent to the same docker network as Forgejo is in.
Otherwise, the communication should go via the `docker0` gateway (usually 172.17.0.1).
To configure the Docker network if the network's name is `forgejo`, configure it like this:
```diff title="docker-compose.yaml"
services:
[...]
woodpecker-agent:
[...]
environment:
- [...]
+ - WOODPECKER_BACKEND_DOCKER_NETWORK=forgejo
```
## Registration
Register your application with Forgejo to create your client id and secret. You can find the OAuth applications settings of Forgejo at `https://forgejo.<host>/user/settings/`. It is very import the authorization callback URL matches your http(s) scheme and hostname exactly with `https://<host>/authorize` as the path.
If you run the Woodpecker CI server on the same host as the Forgejo instance, you might also need to allow local connections in Forgejo. Otherwise webhooks will fail. Add the following lines to your Forgejo configuration (usually at `/etc/forgejo/conf/app.ini`).
```ini
[webhook]
ALLOWED_HOST_LIST=external,loopback
```
For reference see [Configuration Cheat Sheet](https://forgejo.org/docs/latest/admin/config-cheat-sheet/#webhook-webhook).
![forgejo oauth setup](gitea_oauth.gif)
## Configuration
This is a full list of configuration options. Please note that many of these options use default configuration values that should work for the majority of installations.
### `WOODPECKER_FORGEJO`
> Default: `false`
Enables the Forgejo driver.
### `WOODPECKER_FORGEJO_URL`
> Default: `https://next.forgejo.org`
Configures the Forgejo server address.
### `WOODPECKER_FORGEJO_CLIENT`
> Default: empty
Configures the Forgejo OAuth client id. This is used to authorize access.
### `WOODPECKER_FORGEJO_CLIENT_FILE`
> Default: empty
Read the value for `WOODPECKER_FORGEJO_CLIENT` from the specified filepath
### `WOODPECKER_FORGEJO_SECRET`
> Default: empty
Configures the Forgejo OAuth client secret. This is used to authorize access.
### `WOODPECKER_FORGEJO_SECRET_FILE`
> Default: empty
Read the value for `WOODPECKER_FORGEJO_SECRET` from the specified filepath
### `WOODPECKER_FORGEJO_SKIP_VERIFY`
> Default: `false`
Configure if SSL verification should be skipped.

1
go.mod
View File

@ -8,6 +8,7 @@ require (
code.gitea.io/sdk/gitea v0.18.0
codeberg.org/6543/go-yaml2json v1.0.0
codeberg.org/6543/xyaml v1.1.0
codeberg.org/mvdkleijn/forgejo-sdk/forgejo v0.0.0-20240503154913-d2f7239d0250
github.com/6543/logfile-open v1.2.1
github.com/adrg/xdg v0.4.0
github.com/alessio/shellescape v1.4.2

2
go.sum
View File

@ -4,6 +4,8 @@ codeberg.org/6543/go-yaml2json v1.0.0 h1:heGqo9VEi7gY2yNqjj7X4ADs5nzlFIbGsJtgYDL
codeberg.org/6543/go-yaml2json v1.0.0/go.mod h1:mz61q14LWF4ZABrgMEDMmk3t9dPi6zgR1uBh2VKV2RQ=
codeberg.org/6543/xyaml v1.1.0 h1:0PWTy8OUqshshjrrnAXFWXSPUEa8R49DIh2ah07SxFc=
codeberg.org/6543/xyaml v1.1.0/go.mod h1:jI7afXLZUxeL4rNNsG1SlHh78L+gma9lK1bIebyFZwA=
codeberg.org/mvdkleijn/forgejo-sdk/forgejo v0.0.0-20240503154913-d2f7239d0250 h1:VyMtcK3K2ltTQlpMPFueqcnt0NOyU8RSaEwUuCIoYoM=
codeberg.org/mvdkleijn/forgejo-sdk/forgejo v0.0.0-20240503154913-d2f7239d0250/go.mod h1:09wAYX9H0+wBo1baX9DdSqdfreZc6ji5aELsnu9m14M=
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
gitea.com/xorm/sqlfiddle v0.0.0-20180821085327-62ce714f951a h1:lSA0F4e9A2NcQSqGqTOXqu2aRi/XEQxDCBwM8yJtE6s=

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"):

View File

@ -31,6 +31,7 @@
<i-bi-exclamation-triangle v-else-if="name === 'warning'" class="h-5 w-5" />
<i-mdi-error-outline v-else-if="name === 'error'" class="h-5 w-5" />
<i-simple-icons-gitea v-else-if="name === 'gitea'" class="h-8 w-8" />
<i-simple-icons-forgejo v-else-if="name === 'forgejo'" class="h-8 w-8" />
<i-ph-gitlab-logo-simple-fill v-else-if="name === 'gitlab'" class="h-8 w-8" />
<i-mdi-bitbucket v-else-if="name === 'bitbucket' || name === 'bitbucket_dc'" class="h-8 w-8" />
<i-vaadin-question-circle-o v-else-if="name === 'question'" class="h-6 w-6" />
@ -86,6 +87,7 @@ export type IconNames =
| 'gitlab'
| 'bitbucket'
| 'bitbucket_dc'
| 'forgejo'
| 'question'
| 'list'
| 'loading'

View File

@ -6,7 +6,7 @@ declare global {
WOODPECKER_VERSION: string | undefined;
WOODPECKER_SKIP_VERSION_CHECK: boolean | undefined;
WOODPECKER_CSRF: string | undefined;
WOODPECKER_FORGE: 'github' | 'gitlab' | 'gitea' | 'bitbucket' | 'bitbucket_dc' | undefined;
WOODPECKER_FORGE: 'github' | 'gitlab' | 'gitea' | 'forgejo' | 'bitbucket' | 'bitbucket_dc' | undefined;
WOODPECKER_ROOT_PATH: string | undefined;
WOODPECKER_ENABLE_SWAGGER: boolean | undefined;
}