diff --git a/.cspell.json b/.cspell.json index 8dccf9a21..3a2975842 100644 --- a/.cspell.json +++ b/.cspell.json @@ -77,6 +77,7 @@ "excalidraw", "favicons", "forbidigo", + "Forgejo", "fsnotify", "gitea", "gocritic", diff --git a/cmd/server/flags.go b/cmd/server/flags.go index cda2fa13b..9ccdc0b1e 100644 --- a/cmd/server/flags.go +++ b/cmd/server/flags.go @@ -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{ diff --git a/docs/docs/30-administration/11-forges/11-overview.md b/docs/docs/30-administration/11-forges/11-overview.md index 4446896f0..ba45adf87 100644 --- a/docs/docs/30-administration/11-forges/11-overview.md +++ b/docs/docs/30-administration/11-forges/11-overview.md @@ -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: | diff --git a/docs/docs/30-administration/11-forges/30-gitea.md b/docs/docs/30-administration/11-forges/30-gitea.md index 46249b986..bb8e93c2a 100644 --- a/docs/docs/30-administration/11-forges/30-gitea.md +++ b/docs/docs/30-administration/11-forges/30-gitea.md @@ -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 diff --git a/docs/docs/30-administration/11-forges/35-forgejo.md b/docs/docs/30-administration/11-forges/35-forgejo.md new file mode 100644 index 000000000..df7793118 --- /dev/null +++ b/docs/docs/30-administration/11-forges/35-forgejo.md @@ -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./user/settings/`. It is very import the authorization callback URL matches your http(s) scheme and hostname exactly with `https:///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. diff --git a/go.mod b/go.mod index 85684d088..c156fa9be 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index 167e037b3..978b2e635 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/server/forge/forgejo/fixtures/handler.go b/server/forge/forgejo/fixtures/handler.go new file mode 100644 index 000000000..73c3a9eb7 --- /dev/null +++ b/server/forge/forgejo/fixtures/handler.go @@ -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" + } +] +` diff --git a/server/forge/forgejo/fixtures/hooks.go b/server/forge/forgejo/fixtures/hooks.go new file mode 100644 index 000000000..9caf1a3d2 --- /dev/null +++ b/server/forge/forgejo/fixtures/hooks.go @@ -0,0 +1,1483 @@ +// 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 + +// HookPush is a sample Forgejo push hook. +const HookPush = ` +{ + "ref": "refs/heads/main", + "before": "4b2626259b5a97b6b4eab5e6cca66adb986b672b", + "after": "ef98532add3b2feb7a137426bba1248724367df5", + "compare_url": "http://forgejo.golang.org/gordon/hello-world/compare/4b2626259b5a97b6b4eab5e6cca66adb986b672b...ef98532add3b2feb7a137426bba1248724367df5", + "commits": [ + { + "id": "ef98532add3b2feb7a137426bba1248724367df5", + "message": "bump\n", + "url": "http://forgejo.golang.org/gordon/hello-world/commit/ef98532add3b2feb7a137426bba1248724367df5", + "author": { + "name": "Gordon the Gopher", + "email": "gordon@golang.org", + "username": "gordon" + }, + "added": ["CHANGELOG.md"], + "removed": [], + "modified": ["app/controller/application.rb"] + } + ], + "repository": { + "id": 1, + "name": "hello-world", + "full_name": "gordon/hello-world", + "html_url": "http://forgejo.golang.org/gordon/hello-world", + "ssh_url": "git@forgejo.golang.org:gordon/hello-world.git", + "clone_url": "http://forgejo.golang.org/gordon/hello-world.git", + "description": "", + "website": "", + "watchers": 1, + "owner": { + "name": "gordon", + "email": "gordon@golang.org", + "login": "gordon", + "username": "gordon" + }, + "private": true, + "permissions": { + "admin": true, + "push": true, + "pull": true + } + }, + "pusher": { + "name": "gordon", + "email": "gordon@golang.org", + "username": "gordon", + "login": "gordon" + }, + "sender": { + "login": "gordon", + "id": 1, + "username": "gordon", + "email": "gordon@golang.org", + "avatar_url": "http://forgejo.golang.org///1.gravatar.com/avatar/8c58a0be77ee441bb8f8595b7f1b4e87" + } +} +` + +// HookPushMulti push multible commits to a branch. +const HookPushMulti = ` +{ + "ref": "refs/heads/main", + "before": "6efcf5b7c98f3e7a491675164b7a2e7acac27941", + "after": "29be01c073851cf0db0c6a466e396b725a670453", + "compare_url": "http://127.0.0.1:3000/Test-CI/multi-line-secrets/compare/6efcf5b7c98f3e7a491675164b7a2e7acac27941...29be01c073851cf0db0c6a466e396b725a670453", + "commits": [ + { + "id": "29be01c073851cf0db0c6a466e396b725a670453", + "message": "add some text\n", + "url": "http://127.0.0.1:3000/Test-CI/multi-line-secrets/commit/29be01c073851cf0db0c6a466e396b725a670453", + "author": { + "name": "6543", + "email": "6543@obermui.de", + "username": "test-user" + }, + "committer": { + "name": "6543", + "email": "6543@obermui.de", + "username": "test-user" + }, + "verification": null, + "timestamp": "2024-02-22T00:18:07+01:00", + "added": [], + "removed": [], + "modified": [ + "aaa" + ] + }, + { + "id": "29cd95250404bd007c13b03eabe521196bab98a5", + "message": "rm a a file\n", + "url": "http://127.0.0.1:3000/Test-CI/multi-line-secrets/commit/29cd95250404bd007c13b03eabe521196bab98a5", + "author": { + "name": "6543", + "email": "6543@obermui.de", + "username": "test-user" + }, + "committer": { + "name": "6543", + "email": "6543@obermui.de", + "username": "test-user" + }, + "verification": null, + "timestamp": "2024-02-22T00:17:49+01:00", + "added": [], + "removed": [ + "aa" + ], + "modified": [] + }, + { + "id": "93787b87b3134d0d62c7a24c1ea5b1b6fd17ca91", + "message": "add some a files\n", + "url": "http://127.0.0.1:3000/Test-CI/multi-line-secrets/commit/93787b87b3134d0d62c7a24c1ea5b1b6fd17ca91", + "author": { + "name": "6543", + "email": "6543@obermui.de", + "username": "test-user" + }, + "committer": { + "name": "6543", + "email": "6543@obermui.de", + "username": "test-user" + }, + "verification": null, + "timestamp": "2024-02-22T00:17:33+01:00", + "added": [ + "aa", + "aaa" + ], + "removed": [], + "modified": [] + } + ], + "total_commits": 3, + "head_commit": { + "id": "29be01c073851cf0db0c6a466e396b725a670453", + "message": "add some text\n", + "url": "http://127.0.0.1:3000/Test-CI/multi-line-secrets/commit/29be01c073851cf0db0c6a466e396b725a670453", + "author": { + "name": "6543", + "email": "6543@obermui.de", + "username": "test-user" + }, + "committer": { + "name": "6543", + "email": "6543@obermui.de", + "username": "test-user" + }, + "verification": null, + "timestamp": "2024-02-22T00:18:07+01:00", + "added": [], + "removed": [], + "modified": [ + "aaa" + ] + }, + "repository": { + "id": 6, + "owner": { + "id": 2, + "login": "Test-CI", + "login_name": "", + "full_name": "", + "email": "", + "avatar_url": "http://127.0.0.1:3000/avatars/5b0a83c2185b3cb1ebceb11062d6c2eb", + "language": "", + "is_admin": false, + "last_login": "0001-01-01T00:00:00Z", + "created": "2023-07-31T19:13:48+02:00", + "restricted": false, + "active": false, + "prohibit_login": false, + "location": "", + "website": "", + "description": "", + "visibility": "public", + "followers_count": 0, + "following_count": 0, + "starred_repos_count": 0, + "username": "Test-CI" + }, + "name": "multi-line-secrets", + "full_name": "Test-CI/multi-line-secrets", + "description": "", + "empty": false, + "private": false, + "fork": false, + "template": false, + "parent": null, + "mirror": false, + "size": 35, + "language": "", + "languages_url": "http://127.0.0.1:3000/api/v1/repos/Test-CI/multi-line-secrets/languages", + "html_url": "http://127.0.0.1:3000/Test-CI/multi-line-secrets", + "url": "http://127.0.0.1:3000/api/v1/repos/Test-CI/multi-line-secrets", + "link": "", + "ssh_url": "ssh://git@127.0.0.1:2200/Test-CI/multi-line-secrets.git", + "clone_url": "http://127.0.0.1:3000/Test-CI/multi-line-secrets.git", + "original_url": "", + "website": "", + "watchers_count": 2, + "open_issues_count": 1, + "default_branch": "main", + "archived": false, + "created_at": "2023-10-31T19:53:15+01:00", + "updated_at": "2023-11-02T06:16:34+01:00", + "archived_at": "1970-01-01T01:00:00+01:00", + "permissions": { + "admin": true, + "push": true, + "pull": true + }, + "has_issues": true, + "internal_tracker": { + "enable_time_tracker": true, + "allow_only_contributors_to_track_time": true, + "enable_issue_dependencies": true + }, + "avatar_url": "", + "object_format_name": "" + }, + "pusher": { + "id": 1, + "login": "test-user", + "login_name": "", + "full_name": "", + "email": "test@noreply.localhost", + "avatar_url": "http://127.0.0.1:3000/avatars/dd46a756faad4727fb679320751f6dea", + "is_admin": false, + "last_login": "0001-01-01T00:00:00Z", + "created": "2023-07-31T19:13:05+02:00", + "prohibit_login": false, + "description": "", + "visibility": "public", + "username": "test-user" + }, + "sender": { + "id": 1, + "login": "test-user", + "login_name": "", + "full_name": "", + "email": "test@noreply.localhost", + "avatar_url": "http://127.0.0.1:3000/avatars/dd46a756faad4727fb679320751f6dea", + "is_admin": false, + "last_login": "0001-01-01T00:00:00Z", + "created": "2023-07-31T19:13:05+02:00", + "prohibit_login": false, + "description": "", + "visibility": "public", + "username": "test-user" + } +} +` + +// HookPushBranch is a sample Forgejo push hook where a new branch was created from an existing commit. +const HookPushBranch = ` +{ + "ref": "refs/heads/fdsafdsa", + "before": "0000000000000000000000000000000000000000", + "after": "28c3613ae62640216bea5e7dc71aa65356e4298b", + "compare_url": "https://codeberg.org/meisam/woodpecktester/compare/main...28c3613ae62640216bea5e7dc71aa65356e4298b", + "commits": [], + "head_commit": { + "id": "28c3613ae62640216bea5e7dc71aa65356e4298b", + "message": "Delete '.woodpecker/.check.yml'\n", + "url": "https://codeberg.org/meisam/woodpecktester/commit/28c3613ae62640216bea5e7dc71aa65356e4298b", + "author": { + "name": "meisam", + "email": "meisam@noreply.codeberg.org", + "username": "meisam" + }, + "committer": { + "name": "meisam", + "email": "meisam@noreply.codeberg.org", + "username": "meisam" + }, + "verification": null, + "timestamp": "2022-07-12T21:09:27+02:00", + "added": [], + "removed": [ + ".woodpecker/.check.yml" + ], + "modified": [] + }, + "repository": { + "id": 50820, + "owner": { + "id": 14844, + "login": "meisam", + "full_name": "", + "email": "meisam@noreply.codeberg.org", + "avatar_url": "https://codeberg.org/avatars/96512da76a14cf44e0bb32d1640e878e", + "language": "", + "is_admin": false, + "last_login": "0001-01-01T00:00:00Z", + "created": "2020-10-08T11:19:12+02:00", + "restricted": false, + "active": false, + "prohibit_login": false, + "location": "", + "website": "", + "description": "Materials engineer, physics enthusiast, large collection of the bad programming habits, always happy to fix the old ones and make new mistakes!", + "visibility": "public", + "followers_count": 0, + "following_count": 0, + "starred_repos_count": 0, + "username": "meisam", + "permissions": { + "admin": true, + "push": true, + "pull": true + } + }, + "name": "woodpecktester", + "full_name": "meisam/woodpecktester", + "description": "Just for testing the Woodpecker CI and reporting bugs", + "empty": false, + "private": false, + "fork": false, + "template": false, + "parent": null, + "mirror": false, + "size": 367, + "language": "", + "languages_url": "https://codeberg.org/api/v1/repos/meisam/woodpecktester/languages", + "html_url": "https://codeberg.org/meisam/woodpecktester", + "ssh_url": "git@codeberg.org:meisam/woodpecktester.git", + "clone_url": "https://codeberg.org/meisam/woodpecktester.git", + "original_url": "", + "website": "", + "stars_count": 0, + "forks_count": 0, + "watchers_count": 1, + "open_issues_count": 0, + "open_pr_counter": 0, + "release_counter": 0, + "default_branch": "main", + "archived": false, + "created_at": "2022-07-04T00:34:39+02:00", + "updated_at": "2022-07-24T20:31:29+02:00", + "permissions": { + "admin": true, + "push": true, + "pull": true + }, + "has_issues": true, + "internal_tracker": { + "enable_time_tracker": true, + "allow_only_contributors_to_track_time": true, + "enable_issue_dependencies": true + }, + "has_wiki": true, + "has_pull_requests": true, + "has_projects": true, + "ignore_whitespace_conflicts": false, + "allow_merge_commits": true, + "allow_rebase": true, + "allow_rebase_explicit": true, + "allow_squash_merge": true, + "default_merge_style": "merge", + "avatar_url": "", + "internal": false, + "mirror_interval": "", + "mirror_updated": "0001-01-01T00:00:00Z", + "repo_transfer": null + }, + "pusher": { + "id": 2628, + "login": "6543", + "full_name": "", + "email": "6543@obermui.de", + "avatar_url": "https://codeberg.org/avatars/09a234c768cb9bca78f6b2f82d6af173", + "language": "", + "is_admin": false, + "last_login": "0001-01-01T00:00:00Z", + "created": "2019-10-12T05:05:49+02:00", + "restricted": false, + "active": false, + "prohibit_login": false, + "location": "", + "visibility": "public", + "followers_count": 22, + "following_count": 16, + "starred_repos_count": 55, + "username": "6543" + }, + "sender": { + "id": 2628, + "login": "6543", + "full_name": "", + "email": "6543@obermui.de", + "avatar_url": "https://codeberg.org/avatars/09a234c768cb9bca78f6b2f82d6af173", + "language": "", + "is_admin": false, + "last_login": "0001-01-01T00:00:00Z", + "created": "2019-10-12T05:05:49+02:00", + "restricted": false, + "active": false, + "prohibit_login": false, + "visibility": "public", + "followers_count": 22, + "following_count": 16, + "starred_repos_count": 55, + "username": "6543" + } +}` + +// HookTag is a sample Forgejo tag hook. +const HookTag = `{ + "sha": "ef98532add3b2feb7a137426bba1248724367df5", + "secret": "l26Un7G7HXogLAvsyf2hOA4EMARSTsR3", + "ref": "v1.0.0", + "ref_type": "tag", + "repository": { + "id": 12, + "owner": { + "id": 4, + "username": "gordon", + "login": "gordon", + "full_name": "Gordon the Gopher", + "email": "gordon@golang.org", + "avatar_url": "https://secure.gravatar.com/avatar/8c58a0be77ee441bb8f8595b7f1b4e87" + }, + "name": "hello-world", + "full_name": "gordon/hello-world", + "description": "a hello world example", + "private": true, + "fork": false, + "html_url": "http://forgejo.golang.org/gordon/hello-world", + "ssh_url": "git@forgejo.golang.org:gordon/hello-world.git", + "clone_url": "http://forgejo.golang.org/gordon/hello-world.git", + "default_branch": "main", + "created_at": "2015-10-22T19:32:44Z", + "updated_at": "2016-11-24T13:37:16Z", + "permissions": { + "admin": true, + "push": true, + "pull": true + } + }, + "sender": { + "id": 1, + "username": "gordon", + "login": "gordon", + "full_name": "Gordon the Gopher", + "email": "gordon@golang.org", + "avatar_url": "https://secure.gravatar.com/avatar/8c58a0be77ee441bb8f8595b7f1b4e87" + } +}` + +// HookPullRequest is a sample pull_request webhook payload. +const HookPullRequest = `{ + "action": "opened", + "number": 1, + "pull_request": { + "html_url": "http://forgejo.golang.org/gordon/hello-world/pull/1", + "state": "open", + "title": "Update the README with new information", + "body": "please merge", + "user": { + "id": 1, + "username": "gordon", + "login": "gordon", + "full_name": "Gordon the Gopher", + "email": "gordon@golang.org", + "avatar_url": "http://forgejo.golang.org///1.gravatar.com/avatar/8c58a0be77ee441bb8f8595b7f1b4e87" + }, + "base": { + "label": "main", + "ref": "main", + "sha": "9353195a19e45482665306e466c832c46560532d" + }, + "head": { + "label": "feature/changes", + "ref": "feature/changes", + "sha": "0d1a26e67d8f5eaf1f6ba5c57fc3c7d91ac0fd1c" + } + }, + "repository": { + "id": 35129377, + "name": "hello-world", + "full_name": "gordon/hello-world", + "owner": { + "id": 1, + "username": "gordon", + "login": "gordon", + "full_name": "Gordon the Gopher", + "email": "gordon@golang.org", + "avatar_url": "https://secure.gravatar.com/avatar/8c58a0be77ee441bb8f8595b7f1b4e87" + }, + "private": true, + "html_url": "http://forgejo.golang.org/gordon/hello-world", + "clone_url": "https://forgejo.golang.org/gordon/hello-world.git", + "default_branch": "main", + "permissions": { + "admin": true, + "push": true, + "pull": true + } + }, + "sender": { + "id": 1, + "login": "gordon", + "username": "gordon", + "full_name": "Gordon the Gopher", + "email": "gordon@golang.org", + "avatar_url": "https://secure.gravatar.com/avatar/8c58a0be77ee441bb8f8595b7f1b4e87" + } +}` + +const HookPullRequestUpdated = `{ + "action": "synchronized", + "number": 2, + "pull_request": { + "id": 2, + "url": "http://127.0.0.1:3000/Test-CI/multi-line-secrets/pulls/2", + "number": 2, + "user": { + "id": 1, + "login": "test", + "login_name": "", + "full_name": "", + "email": "test@noreply.localhost", + "avatar_url": "http://127.0.0.1:3000/avatars/dd46a756faad4727fb679320751f6dea", + "is_admin": false, + "last_login": "0001-01-01T00:00:00Z", + "created": "2023-07-31T19:13:05+02:00", + "visibility": "public", + "username": "test" + }, + "title": "New Pull", + "body": "create an awesome pull", + "labels": [ + { + "id": 8, + "name": "Kind/Bug", + "exclusive": false, + "is_archived": false, + "color": "ee0701", + "description": "Something is not working", + "url": "http://100.106.226.9:3000/api/v1/repos/Test-CI/multi-line-secrets/labels/8" + }, + { + "id": 11, + "name": "Kind/Security", + "exclusive": false, + "is_archived": false, + "color": "9c27b0", + "description": "This is security issue", + "url": "http://100.106.226.9:3000/api/v1/repos/Test-CI/multi-line-secrets/labels/11" + } + ], + "milestone": null, + "assignees": null, + "requested_reviewers": null, + "state": "open", + "is_locked": false, + "html_url": "http://127.0.0.1:3000/Test-CI/multi-line-secrets/pulls/2", + "diff_url": "http://127.0.0.1:3000/Test-CI/multi-line-secrets/pulls/2.diff", + "patch_url": "http://127.0.0.1:3000/Test-CI/multi-line-secrets/pulls/2.patch", + "mergeable": true, + "merged": false, + "merged_at": null, + "merge_commit_sha": null, + "merged_by": null, + "base": { + "label": "main", + "ref": "main", + "sha": "29be01c073851cf0db0c6a466e396b725a670453", + "repo_id": 6 + }, + "head": { + "label": "test-patch-1", + "ref": "test-patch-1", + "sha": "788ed8d02d3b7fcfcf6386dbcbca696aa1d4dc25", + "repo_id": 6 + }, + "merge_base": "29be01c073851cf0db0c6a466e396b725a670453", + "due_date": null, + "created_at": "2024-02-22T01:38:39+01:00", + "updated_at": "2024-02-22T01:42:03+01:00", + "closed_at": null, + "pin_order": 0 + }, + "requested_reviewer": null, + "repository": { + "id": 6, + "owner": { + "id": 2, + "login": "Test-CI", + "login_name": "", + "full_name": "", + "email": "", + "avatar_url": "http://127.0.0.1:3000/avatars/5b0a83c2185b3cb1ebceb11062d6c2eb", + "language": "", + "is_admin": false, + "last_login": "0001-01-01T00:00:00Z", + "created": "2023-07-31T19:13:48+02:00", + "prohibit_login": false, + "visibility": "public", + "username": "Test-CI" + }, + "name": "multi-line-secrets", + "full_name": "Test-CI/multi-line-secrets", + "description": "", + "private": false, + "languages_url": "http://127.0.0.1:3000/api/v1/repos/Test-CI/multi-line-secrets/languages", + "html_url": "http://127.0.0.1:3000/Test-CI/multi-line-secrets", + "url": "http://127.0.0.1:3000/api/v1/repos/Test-CI/multi-line-secrets", + "link": "", + "ssh_url": "ssh://git@127.0.0.1:2200/Test-CI/multi-line-secrets.git", + "clone_url": "http://127.0.0.1:3000/Test-CI/multi-line-secrets.git", + "original_url": "", + "default_branch": "main", + "permissions": { + "admin": true, + "push": true, + "pull": true + }, + "has_issues": true, + "internal_tracker": { + "enable_time_tracker": true, + "allow_only_contributors_to_track_time": true, + "enable_issue_dependencies": true + }, + "has_pull_requests": true, + "avatar_url": "", + "internal": false, + "mirror_interval": "", + "object_format_name": "" + }, + "sender": { + "id": 1, + "login": "test", + "login_name": "", + "full_name": "", + "email": "test@noreply.localhost", + "avatar_url": "http://127.0.0.1:3000/avatars/dd46a756faad4727fb679320751f6dea", + "is_admin": false, + "last_login": "0001-01-01T00:00:00Z", + "created": "2023-07-31T19:13:05+02:00", + "visibility": "public", + "username": "test" + }, + "commit_id": "", + "review": null +}` + +const HookPullRequestMerged = ` +{ + "action": "closed", + "number": 1, + "pull_request": { + "id": 62112, + "url": "https://forgejo.com/anbraten/test-repo/pulls/1", + "number": 1, + "user": { + "id": 26907, + "login": "anbraten", + "login_name": "", + "full_name": "", + "email": "anbraten@noreply.forgejo.com", + "avatar_url": "https://seccdn.libravatar.org/avatar/fc9b6fe77c6b732a02925a62a81f05a0?d=identicon", + "language": "", + "is_admin": false, + "last_login": "0001-01-01T00:00:00Z", + "created": "2021-07-19T23:21:52Z", + "restricted": false, + "active": false, + "prohibit_login": false, + "location": "", + "website": "", + "description": "", + "visibility": "public", + "followers_count": 0, + "following_count": 0, + "starred_repos_count": 1, + "username": "anbraten" + }, + "title": "Adjust file", + "body": "", + "labels": [], + "milestone": null, + "assignee": null, + "assignees": null, + "requested_reviewers": null, + "state": "closed", + "is_locked": false, + "comments": 1, + "html_url": "https://forgejo.com/anbraten/test-repo/pulls/1", + "diff_url": "https://forgejo.com/anbraten/test-repo/pulls/1.diff", + "patch_url": "https://forgejo.com/anbraten/test-repo/pulls/1.patch", + "mergeable": true, + "merged": true, + "merged_at": "2023-12-05T18:35:31Z", + "merge_commit_sha": "f2440f050054df0f8ecabcace648f1683509064c", + "merged_by": { + "id": 26907, + "login": "anbraten", + "login_name": "", + "full_name": "", + "email": "anbraten@noreply.forgejo.com", + "avatar_url": "https://seccdn.libravatar.org/avatar/fc9b6fe77c6b732a02925a62a81f05a0?d=identicon", + "language": "", + "is_admin": false, + "last_login": "0001-01-01T00:00:00Z", + "created": "2021-07-19T23:21:52Z", + "restricted": false, + "active": false, + "prohibit_login": false, + "location": "", + "website": "", + "description": "", + "visibility": "public", + "followers_count": 0, + "following_count": 0, + "starred_repos_count": 1, + "username": "anbraten" + }, + "allow_maintainer_edit": false, + "base": { + "label": "main", + "ref": "main", + "sha": "f2440f050054df0f8ecabcace648f1683509064c", + "repo_id": 46534, + "repo": { + "id": 46534, + "owner": { + "id": 26907, + "login": "anbraten", + "login_name": "", + "full_name": "", + "email": "anbraten@noreply.forgejo.com", + "avatar_url": "https://seccdn.libravatar.org/avatar/fc9b6fe77c6b732a02925a62a81f05a0?d=identicon", + "language": "", + "is_admin": false, + "last_login": "0001-01-01T00:00:00Z", + "created": "2021-07-19T23:21:52Z", + "restricted": false, + "active": false, + "prohibit_login": false, + "location": "", + "website": "", + "description": "", + "visibility": "public", + "followers_count": 0, + "following_count": 0, + "starred_repos_count": 1, + "username": "anbraten" + }, + "name": "test-repo", + "full_name": "anbraten/test-repo", + "description": "", + "empty": false, + "private": false, + "fork": false, + "template": false, + "parent": null, + "mirror": false, + "size": 26, + "language": "", + "languages_url": "https://forgejo.com/api/v1/repos/anbraten/test-repo/languages", + "html_url": "https://forgejo.com/anbraten/test-repo", + "url": "https://forgejo.com/api/v1/repos/anbraten/test-repo", + "link": "", + "ssh_url": "git@forgejo.com:anbraten/test-repo.git", + "clone_url": "https://forgejo.com/anbraten/test-repo.git", + "original_url": "", + "website": "", + "stars_count": 0, + "forks_count": 0, + "watchers_count": 1, + "open_issues_count": 0, + "open_pr_counter": 1, + "release_counter": 0, + "default_branch": "main", + "archived": false, + "created_at": "2023-12-05T18:03:55Z", + "updated_at": "2023-12-05T18:06:29Z", + "archived_at": "1970-01-01T00:00:00Z", + "permissions": { + "admin": false, + "push": false, + "pull": true + }, + "has_issues": true, + "internal_tracker": { + "enable_time_tracker": true, + "allow_only_contributors_to_track_time": true, + "enable_issue_dependencies": true + }, + "has_wiki": true, + "has_pull_requests": true, + "has_projects": true, + "has_releases": true, + "has_packages": false, + "has_actions": true, + "ignore_whitespace_conflicts": false, + "allow_merge_commits": true, + "allow_rebase": true, + "allow_rebase_explicit": true, + "allow_squash_merge": true, + "allow_rebase_update": true, + "default_delete_branch_after_merge": false, + "default_merge_style": "merge", + "default_allow_maintainer_edit": false, + "avatar_url": "", + "internal": false, + "mirror_interval": "", + "mirror_updated": "0001-01-01T00:00:00Z", + "repo_transfer": null + } + }, + "head": { + "label": "anbraten-patch-1", + "ref": "anbraten-patch-1", + "sha": "d555a5dd07f4d0148a58d4686ec381502ae6a2d4", + "repo_id": 46534, + "repo": { + "id": 46534, + "owner": { + "id": 26907, + "login": "anbraten", + "login_name": "", + "full_name": "", + "email": "anbraten@noreply.forgejo.com", + "avatar_url": "https://seccdn.libravatar.org/avatar/fc9b6fe77c6b732a02925a62a81f05a0?d=identicon", + "language": "", + "is_admin": false, + "last_login": "0001-01-01T00:00:00Z", + "created": "2021-07-19T23:21:52Z", + "restricted": false, + "active": false, + "prohibit_login": false, + "location": "", + "website": "", + "description": "", + "visibility": "public", + "followers_count": 0, + "following_count": 0, + "starred_repos_count": 1, + "username": "anbraten" + }, + "name": "test-repo", + "full_name": "anbraten/test-repo", + "description": "", + "empty": false, + "private": false, + "fork": false, + "template": false, + "parent": null, + "mirror": false, + "size": 26, + "language": "", + "languages_url": "https://forgejo.com/api/v1/repos/anbraten/test-repo/languages", + "html_url": "https://forgejo.com/anbraten/test-repo", + "url": "https://forgejo.com/api/v1/repos/anbraten/test-repo", + "link": "", + "ssh_url": "git@forgejo.com:anbraten/test-repo.git", + "clone_url": "https://forgejo.com/anbraten/test-repo.git", + "original_url": "", + "website": "", + "stars_count": 0, + "forks_count": 0, + "watchers_count": 1, + "open_issues_count": 0, + "open_pr_counter": 1, + "release_counter": 0, + "default_branch": "main", + "archived": false, + "created_at": "2023-12-05T18:03:55Z", + "updated_at": "2023-12-05T18:06:29Z", + "archived_at": "1970-01-01T00:00:00Z", + "permissions": { + "admin": false, + "push": false, + "pull": true + }, + "has_issues": true, + "internal_tracker": { + "enable_time_tracker": true, + "allow_only_contributors_to_track_time": true, + "enable_issue_dependencies": true + }, + "has_wiki": true, + "has_pull_requests": true, + "has_projects": true, + "has_releases": true, + "has_packages": false, + "has_actions": true, + "ignore_whitespace_conflicts": false, + "allow_merge_commits": true, + "allow_rebase": true, + "allow_rebase_explicit": true, + "allow_squash_merge": true, + "allow_rebase_update": true, + "default_delete_branch_after_merge": false, + "default_merge_style": "merge", + "default_allow_maintainer_edit": false, + "avatar_url": "", + "internal": false, + "mirror_interval": "", + "mirror_updated": "0001-01-01T00:00:00Z", + "repo_transfer": null + } + }, + "merge_base": "068aee163ffd44eef28a7f9ebd43e2c01774f0fa", + "due_date": null, + "created_at": "2023-12-05T18:06:38Z", + "updated_at": "2023-12-05T18:35:31Z", + "closed_at": "2023-12-05T18:35:31Z", + "pin_order": 0 + }, + "requested_reviewer": null, + "repository": { + "id": 46534, + "owner": { + "id": 26907, + "login": "anbraten", + "login_name": "", + "full_name": "", + "email": "anbraten@noreply.forgejo.com", + "avatar_url": "https://seccdn.libravatar.org/avatar/fc9b6fe77c6b732a02925a62a81f05a0?d=identicon", + "language": "", + "is_admin": false, + "last_login": "0001-01-01T00:00:00Z", + "created": "2021-07-19T23:21:52Z", + "restricted": false, + "active": false, + "prohibit_login": false, + "location": "", + "website": "", + "description": "", + "visibility": "public", + "followers_count": 0, + "following_count": 0, + "starred_repos_count": 1, + "username": "anbraten" + }, + "name": "test-repo", + "full_name": "anbraten/test-repo", + "description": "", + "empty": false, + "private": false, + "fork": false, + "template": false, + "parent": null, + "mirror": false, + "size": 26, + "language": "", + "languages_url": "https://forgejo.com/api/v1/repos/anbraten/test-repo/languages", + "html_url": "https://forgejo.com/anbraten/test-repo", + "url": "https://forgejo.com/api/v1/repos/anbraten/test-repo", + "link": "", + "ssh_url": "git@forgejo.com:anbraten/test-repo.git", + "clone_url": "https://forgejo.com/anbraten/test-repo.git", + "original_url": "", + "website": "", + "stars_count": 0, + "forks_count": 0, + "watchers_count": 1, + "open_issues_count": 0, + "open_pr_counter": 1, + "release_counter": 0, + "default_branch": "main", + "archived": false, + "created_at": "2023-12-05T18:03:55Z", + "updated_at": "2023-12-05T18:06:29Z", + "archived_at": "1970-01-01T00:00:00Z", + "permissions": { + "admin": true, + "push": true, + "pull": true + }, + "has_issues": true, + "internal_tracker": { + "enable_time_tracker": true, + "allow_only_contributors_to_track_time": true, + "enable_issue_dependencies": true + }, + "has_wiki": true, + "has_pull_requests": true, + "has_projects": true, + "has_releases": true, + "has_packages": false, + "has_actions": true, + "ignore_whitespace_conflicts": false, + "allow_merge_commits": true, + "allow_rebase": true, + "allow_rebase_explicit": true, + "allow_squash_merge": true, + "allow_rebase_update": true, + "default_delete_branch_after_merge": false, + "default_merge_style": "merge", + "default_allow_maintainer_edit": false, + "avatar_url": "", + "internal": false, + "mirror_interval": "", + "mirror_updated": "0001-01-01T00:00:00Z", + "repo_transfer": null + }, + "sender": { + "id": 26907, + "login": "anbraten", + "login_name": "", + "full_name": "", + "email": "anbraten@noreply.forgejo.com", + "avatar_url": "https://seccdn.libravatar.org/avatar/fc9b6fe77c6b732a02925a62a81f05a0?d=identicon", + "language": "", + "is_admin": false, + "last_login": "0001-01-01T00:00:00Z", + "created": "2021-07-19T23:21:52Z", + "restricted": false, + "active": false, + "prohibit_login": false, + "location": "", + "website": "", + "description": "", + "visibility": "public", + "followers_count": 0, + "following_count": 0, + "starred_repos_count": 1, + "username": "anbraten" + }, + "commit_id": "", + "review": null +} +` + +const HookPullRequestClosed = ` +{ + "action": "closed", + "number": 1, + "pull_request": { + "id": 62112, + "url": "https://forgejo.com/anbraten/test-repo/pulls/1", + "number": 1, + "user": { + "id": 26907, + "login": "anbraten", + "login_name": "", + "full_name": "", + "email": "anbraten@forgejo.com", + "avatar_url": "https://seccdn.libravatar.org/avatar/fc9b6fe77c6b732a02925a62a81f05a0?d=identicon", + "language": "", + "is_admin": false, + "last_login": "0001-01-01T00:00:00Z", + "created": "2021-07-19T23:21:52Z", + "restricted": false, + "active": false, + "prohibit_login": false, + "location": "", + "website": "", + "description": "", + "visibility": "public", + "followers_count": 0, + "following_count": 0, + "starred_repos_count": 1, + "username": "anbraten" + }, + "title": "Adjust file", + "body": "", + "labels": [], + "milestone": null, + "assignee": null, + "assignees": null, + "requested_reviewers": null, + "state": "closed", + "is_locked": false, + "comments": 0, + "html_url": "https://forgejo.com/anbraten/test-repo/pulls/1", + "diff_url": "https://forgejo.com/anbraten/test-repo/pulls/1.diff", + "patch_url": "https://forgejo.com/anbraten/test-repo/pulls/1.patch", + "mergeable": true, + "merged": false, + "merged_at": null, + "merge_commit_sha": null, + "merged_by": null, + "allow_maintainer_edit": false, + "base": { + "label": "main", + "ref": "main", + "sha": "068aee163ffd44eef28a7f9ebd43e2c01774f0fa", + "repo_id": 46534, + "repo": { + "id": 46534, + "owner": { + "id": 26907, + "login": "anbraten", + "login_name": "", + "full_name": "", + "email": "anbraten@noreply.forgejo.com", + "avatar_url": "https://seccdn.libravatar.org/avatar/fc9b6fe77c6b732a02925a62a81f05a0?d=identicon", + "language": "", + "is_admin": false, + "last_login": "0001-01-01T00:00:00Z", + "created": "2021-07-19T23:21:52Z", + "restricted": false, + "active": false, + "prohibit_login": false, + "location": "", + "website": "", + "description": "", + "visibility": "public", + "followers_count": 0, + "following_count": 0, + "starred_repos_count": 1, + "username": "anbraten" + }, + "name": "test-repo", + "full_name": "anbraten/test-repo", + "description": "", + "empty": false, + "private": false, + "fork": false, + "template": false, + "parent": null, + "mirror": false, + "size": 26, + "language": "", + "languages_url": "https://forgejo.com/api/v1/repos/anbraten/test-repo/languages", + "html_url": "https://forgejo.com/anbraten/test-repo", + "url": "https://forgejo.com/api/v1/repos/anbraten/test-repo", + "link": "", + "ssh_url": "git@forgejo.com:anbraten/test-repo.git", + "clone_url": "https://forgejo.com/anbraten/test-repo.git", + "original_url": "", + "website": "", + "stars_count": 0, + "forks_count": 0, + "watchers_count": 1, + "open_issues_count": 0, + "open_pr_counter": 1, + "release_counter": 0, + "default_branch": "main", + "archived": false, + "created_at": "2023-12-05T18:03:55Z", + "updated_at": "2023-12-05T18:06:29Z", + "archived_at": "1970-01-01T00:00:00Z", + "permissions": { + "admin": false, + "push": false, + "pull": true + }, + "has_issues": true, + "internal_tracker": { + "enable_time_tracker": true, + "allow_only_contributors_to_track_time": true, + "enable_issue_dependencies": true + }, + "has_wiki": true, + "has_pull_requests": true, + "has_projects": true, + "has_releases": true, + "has_packages": false, + "has_actions": true, + "ignore_whitespace_conflicts": false, + "allow_merge_commits": true, + "allow_rebase": true, + "allow_rebase_explicit": true, + "allow_squash_merge": true, + "allow_rebase_update": true, + "default_delete_branch_after_merge": false, + "default_merge_style": "merge", + "default_allow_maintainer_edit": false, + "avatar_url": "", + "internal": false, + "mirror_interval": "", + "mirror_updated": "0001-01-01T00:00:00Z", + "repo_transfer": null + } + }, + "head": { + "label": "anbraten-patch-1", + "ref": "anbraten-patch-1", + "sha": "d555a5dd07f4d0148a58d4686ec381502ae6a2d4", + "repo_id": 46534, + "repo": { + "id": 46534, + "owner": { + "id": 26907, + "login": "anbraten", + "login_name": "", + "full_name": "", + "email": "anbraten@noreply.forgejo.com", + "avatar_url": "https://seccdn.libravatar.org/avatar/fc9b6fe77c6b732a02925a62a81f05a0?d=identicon", + "language": "", + "is_admin": false, + "last_login": "0001-01-01T00:00:00Z", + "created": "2021-07-19T23:21:52Z", + "restricted": false, + "active": false, + "prohibit_login": false, + "location": "", + "website": "", + "description": "", + "visibility": "public", + "followers_count": 0, + "following_count": 0, + "starred_repos_count": 1, + "username": "anbraten" + }, + "name": "test-repo", + "full_name": "anbraten/test-repo", + "description": "", + "empty": false, + "private": false, + "fork": false, + "template": false, + "parent": null, + "mirror": false, + "size": 26, + "language": "", + "languages_url": "https://forgejo.com/api/v1/repos/anbraten/test-repo/languages", + "html_url": "https://forgejo.com/anbraten/test-repo", + "url": "https://forgejo.com/api/v1/repos/anbraten/test-repo", + "link": "", + "ssh_url": "git@forgejo.com:anbraten/test-repo.git", + "clone_url": "https://forgejo.com/anbraten/test-repo.git", + "original_url": "", + "website": "", + "stars_count": 0, + "forks_count": 0, + "watchers_count": 1, + "open_issues_count": 0, + "open_pr_counter": 1, + "release_counter": 0, + "default_branch": "main", + "archived": false, + "created_at": "2023-12-05T18:03:55Z", + "updated_at": "2023-12-05T18:06:29Z", + "archived_at": "1970-01-01T00:00:00Z", + "permissions": { + "admin": false, + "push": false, + "pull": true + }, + "has_issues": true, + "internal_tracker": { + "enable_time_tracker": true, + "allow_only_contributors_to_track_time": true, + "enable_issue_dependencies": true + }, + "has_wiki": true, + "has_pull_requests": true, + "has_projects": true, + "has_releases": true, + "has_packages": false, + "has_actions": true, + "ignore_whitespace_conflicts": false, + "allow_merge_commits": true, + "allow_rebase": true, + "allow_rebase_explicit": true, + "allow_squash_merge": true, + "allow_rebase_update": true, + "default_delete_branch_after_merge": false, + "default_merge_style": "merge", + "default_allow_maintainer_edit": false, + "avatar_url": "", + "internal": false, + "mirror_interval": "", + "mirror_updated": "0001-01-01T00:00:00Z", + "repo_transfer": null + } + }, + "merge_base": "068aee163ffd44eef28a7f9ebd43e2c01774f0fa", + "due_date": null, + "created_at": "2023-12-05T18:06:38Z", + "updated_at": "2023-12-05T18:06:43Z", + "closed_at": "2023-12-05T18:06:43Z", + "pin_order": 0 + }, + "requested_reviewer": null, + "repository": { + "id": 46534, + "owner": { + "id": 26907, + "login": "anbraten", + "login_name": "", + "full_name": "", + "email": "anbraten@repo.forgejo.com", + "avatar_url": "https://seccdn.libravatar.org/avatar/fc9b6fe77c6b732a02925a62a81f05a0?d=identicon", + "language": "", + "is_admin": false, + "last_login": "0001-01-01T00:00:00Z", + "created": "2021-07-19T23:21:52Z", + "restricted": false, + "active": false, + "prohibit_login": false, + "location": "", + "website": "", + "description": "", + "visibility": "public", + "followers_count": 0, + "following_count": 0, + "starred_repos_count": 1, + "username": "anbraten" + }, + "name": "test-repo", + "full_name": "anbraten/test-repo", + "description": "", + "empty": false, + "private": false, + "fork": false, + "template": false, + "parent": null, + "mirror": false, + "size": 26, + "language": "", + "languages_url": "https://forgejo.com/api/v1/repos/anbraten/test-repo/languages", + "html_url": "https://forgejo.com/anbraten/test-repo", + "url": "https://forgejo.com/api/v1/repos/anbraten/test-repo", + "link": "", + "ssh_url": "git@forgejo.com:anbraten/test-repo.git", + "clone_url": "https://forgejo.com/anbraten/test-repo.git", + "original_url": "", + "website": "", + "stars_count": 0, + "forks_count": 0, + "watchers_count": 1, + "open_issues_count": 0, + "open_pr_counter": 1, + "release_counter": 0, + "default_branch": "main", + "archived": false, + "created_at": "2023-12-05T18:03:55Z", + "updated_at": "2023-12-05T18:06:29Z", + "archived_at": "1970-01-01T00:00:00Z", + "permissions": { + "admin": true, + "push": true, + "pull": true + }, + "has_issues": true, + "internal_tracker": { + "enable_time_tracker": true, + "allow_only_contributors_to_track_time": true, + "enable_issue_dependencies": true + }, + "has_wiki": true, + "has_pull_requests": true, + "has_projects": true, + "has_releases": true, + "has_packages": false, + "has_actions": true, + "ignore_whitespace_conflicts": false, + "allow_merge_commits": true, + "allow_rebase": true, + "allow_rebase_explicit": true, + "allow_squash_merge": true, + "allow_rebase_update": true, + "default_delete_branch_after_merge": false, + "default_merge_style": "merge", + "default_allow_maintainer_edit": false, + "avatar_url": "", + "internal": false, + "mirror_interval": "", + "mirror_updated": "0001-01-01T00:00:00Z", + "repo_transfer": null + }, + "sender": { + "id": 26907, + "login": "anbraten", + "login_name": "", + "full_name": "", + "email": "anbraten@sender.forgejo.com", + "avatar_url": "https://seccdn.libravatar.org/avatar/fc9b6fe77c6b732a02925a62a81f05a0?d=identicon", + "language": "", + "is_admin": false, + "last_login": "0001-01-01T00:00:00Z", + "created": "2021-07-19T23:21:52Z", + "restricted": false, + "active": false, + "prohibit_login": false, + "location": "", + "website": "", + "description": "", + "visibility": "public", + "followers_count": 0, + "following_count": 0, + "starred_repos_count": 1, + "username": "anbraten" + }, + "commit_id": "", + "review": null +} +` + +const HookRelease = ` +{ + "action": "published", + "release": { + "id": 48, + "tag_name": "0.0.5", + "target_commitish": "main", + "name": "Version 0.0.5", + "body": "", + "url": "https://git.xxx/api/v1/repos/anbraten/demo/releases/48", + "html_url": "https://git.xxx/anbraten/demo/releases/tag/0.0.5", + "tarball_url": "https://git.xxx/anbraten/demo/archive/0.0.5.tar.gz", + "zipball_url": "https://git.xxx/anbraten/demo/archive/0.0.5.zip", + "draft": false, + "prerelease": false, + "created_at": "2022-02-09T20:23:05Z", + "published_at": "2022-02-09T20:23:05Z", + "author": {"id":1,"login":"anbraten","full_name":"Anton Bracke","email":"anbraten@noreply.xxx","avatar_url":"https://git.xxx/user/avatar/anbraten/-1","language":"","is_admin":false,"last_login":"0001-01-01T00:00:00Z","created":"2018-03-21T10:04:48Z","restricted":false,"active":false,"prohibit_login":false,"location":"world","website":"https://xxx","description":"","visibility":"public","followers_count":1,"following_count":1,"starred_repos_count":1,"username":"anbraten"}, + "assets": [] + }, + "repository": { + "id": 77, + "owner": {"id":1,"login":"anbraten","full_name":"Anton Bracke","email":"anbraten@noreply.xxx","avatar_url":"https://git.xxx/user/avatar/anbraten/-1","language":"","is_admin":false,"last_login":"0001-01-01T00:00:00Z","created":"2018-03-21T10:04:48Z","restricted":false,"active":false,"prohibit_login":false,"location":"world","website":"https://xxx","description":"","visibility":"public","followers_count":1,"following_count":1,"starred_repos_count":1,"username":"anbraten"}, + "name": "demo", + "full_name": "anbraten/demo", + "description": "", + "empty": false, + "private": true, + "fork": false, + "template": false, + "parent": null, + "mirror": false, + "size": 59, + "html_url": "https://git.xxx/anbraten/demo", + "ssh_url": "ssh://git@git.xxx:22/anbraten/demo.git", + "clone_url": "https://git.xxx/anbraten/demo.git", + "original_url": "", + "website": "", + "stars_count": 0, + "forks_count": 1, + "watchers_count": 1, + "open_issues_count": 2, + "open_pr_counter": 2, + "release_counter": 4, + "default_branch": "main", + "archived": false, + "created_at": "2021-08-30T20:54:13Z", + "updated_at": "2022-01-09T01:29:23Z", + "permissions": { + "admin": true, + "push": true, + "pull": true + }, + "has_issues": true, + "internal_tracker": { + "enable_time_tracker": true, + "allow_only_contributors_to_track_time": true, + "enable_issue_dependencies": true + }, + "has_wiki": false, + "has_pull_requests": true, + "has_projects": true, + "ignore_whitespace_conflicts": false, + "allow_merge_commits": true, + "allow_rebase": true, + "allow_rebase_explicit": true, + "allow_squash_merge": true, + "default_merge_style": "squash", + "avatar_url": "", + "internal": false, + "mirror_interval": "" + }, + "sender": {"id":1,"login":"anbraten","full_name":"Anbraten","email":"anbraten@noreply.xxx","avatar_url":"https://git.xxx/user/avatar/anbraten/-1","language":"","is_admin":false,"last_login":"0001-01-01T00:00:00Z","created":"2018-03-21T10:04:48Z","restricted":false,"active":false,"prohibit_login":false,"location":"World","website":"https://xxx","description":"","visibility":"public","followers_count":1,"following_count":1,"starred_repos_count":1,"username":"anbraten"} +} +` diff --git a/server/forge/forgejo/forgejo.go b/server/forge/forgejo/forgejo.go new file mode 100644 index 000000000..af7153fe3 --- /dev/null +++ b/server/forge/forgejo/forgejo.go @@ -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 +} diff --git a/server/forge/forgejo/forgejo_test.go b/server/forge/forgejo/forgejo_test.go new file mode 100644 index 000000000..498aa1e4c --- /dev/null +++ b/server/forge/forgejo/forgejo_test.go @@ -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, + } +) diff --git a/server/forge/forgejo/helper.go b/server/forge/forgejo/helper.go new file mode 100644 index 000000000..0dc2eb19f --- /dev/null +++ b/server/forge/forgejo/helper.go @@ -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 +} diff --git a/server/forge/forgejo/helper_test.go b/server/forge/forgejo/helper_test.go new file mode 100644 index 000000000..b87996d1e --- /dev/null +++ b/server/forge/forgejo/helper_test.go @@ -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) + } + }) + }) +} diff --git a/server/forge/forgejo/parse.go b/server/forge/forgejo/parse.go new file mode 100644 index 000000000..88be0cf4b --- /dev/null +++ b/server/forge/forgejo/parse.go @@ -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 +} diff --git a/server/forge/forgejo/parse_test.go b/server/forge/forgejo/parse_test.go new file mode 100644 index 000000000..6728dbd8d --- /dev/null +++ b/server/forge/forgejo/parse_test.go @@ -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) + } + }) + } +} diff --git a/server/forge/forgejo/types.go b/server/forge/forgejo/types.go new file mode 100644 index 000000000..4e19f55d6 --- /dev/null +++ b/server/forge/forgejo/types.go @@ -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 +} diff --git a/server/forge/setup/setup.go b/server/forge/setup/setup.go index 824a580f7..510a71e8f 100644 --- a/server/forge/setup/setup.go +++ b/server/forge/setup/setup.go @@ -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, diff --git a/server/model/forge.go b/server/model/forge.go index 2d670e6d5..fc0976b7d 100644 --- a/server/model/forge.go +++ b/server/model/forge.go @@ -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" diff --git a/server/services/setup.go b/server/services/setup.go index 831faab09..305541c02 100644 --- a/server/services/setup.go +++ b/server/services/setup.go @@ -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"): diff --git a/web/src/components/atomic/Icon.vue b/web/src/components/atomic/Icon.vue index cc2befbe7..d7cdd9eb4 100644 --- a/web/src/components/atomic/Icon.vue +++ b/web/src/components/atomic/Icon.vue @@ -31,6 +31,7 @@ + @@ -86,6 +87,7 @@ export type IconNames = | 'gitlab' | 'bitbucket' | 'bitbucket_dc' + | 'forgejo' | 'question' | 'list' | 'loading' diff --git a/web/src/compositions/useConfig.ts b/web/src/compositions/useConfig.ts index 8688a6be9..e2eb5edf6 100644 --- a/web/src/compositions/useConfig.ts +++ b/web/src/compositions/useConfig.ts @@ -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; }