From a6e60121a0dd0af73b6da130c2728aac6295853f Mon Sep 17 00:00:00 2001 From: Katie Mulliken Date: Thu, 4 Jun 2026 18:44:20 -0400 Subject: [PATCH] feat(webhook): add Forgejo CLI setup Support Forgejo in the CLI webhook setup path for `tkn pac create repo` and `tkn pac webhook add`. This lets users create the Repository CR, secret, and Forgejo webhook through the same flow as the other supported webhook providers. Use the existing vendored Forgejo SDK, configure the Forgejo events that Pipelines-as-Code already handles, and document the token requirements for CLI setup. Fixes #2755. Co-Authored-By: GPT 5.5 Signed-off-by: Katie Mulliken --- docs/content/docs/providers/forgejo.md | 34 +++- pkg/cli/webhook/forgejo.go | 174 +++++++++++++++++++ pkg/cli/webhook/forgejo_test.go | 232 +++++++++++++++++++++++++ pkg/cli/webhook/webhook.go | 6 +- pkg/cli/webhook/webhook_test.go | 44 +++++ 5 files changed, 486 insertions(+), 4 deletions(-) create mode 100644 pkg/cli/webhook/forgejo.go create mode 100644 pkg/cli/webhook/forgejo_test.go create mode 100644 pkg/cli/webhook/webhook_test.go diff --git a/docs/content/docs/providers/forgejo.md b/docs/content/docs/providers/forgejo.md index e1724bbe58..683dda7c18 100644 --- a/docs/content/docs/providers/forgejo.md +++ b/docs/content/docs/providers/forgejo.md @@ -21,7 +21,11 @@ name): -When creating the token, select these scopes: +{{< callout type="info" >}} +If using the CLI, the token needs to be created with access to **All (public, private, and limited)** repositories. +{{< /callout >}} + +You should also select these scopes: ### Required Scopes @@ -42,12 +46,36 @@ unless you plan to use `settings.policy.ok_to_test` or Store the generated token in a safe place, or you will have to recreate it. -## Webhook Configuration (Manual) +## Webhook Configuration using the CLI {{< callout type="info" >}} -The `tkn pac create repo` and `tkn pac webhook` commands do not currently support Forgejo. You must configure the webhook manually. +The CLI uses your Forgejo token to create the webhook and also stores it for runtime +use. To use a token with tighter repository access, follow the manual +configuration steps instead. {{< /callout >}} +Use [`tkn pac create repo`]({{< relref "/docs/cli" >}}) to create the +Repository CR, create the Kubernetes secret, and configure the Forgejo webhook: + +```shell +tkn pac create repo +``` + +The command prompts for the Forgejo repository URL, controller URL, webhook +secret, Forgejo token, and Forgejo instance URL. + +For an existing Repository CR, use `tkn pac webhook add` to create the Forgejo +webhook and update the webhook secret: + +```shell +tkn pac webhook add -n target-namespace my-repo +``` + +If the Repository CR does not reference a `git_provider` secret yet, the command +creates one and updates the Repository CR. + +## Webhook Configuration (Manual) + 1. From your Forgejo repository, go to **Settings** -> **Webhooks** and click **Add Webhook** -> **Forgejo**. 2. Set the **HTTP method** to **POST** and **POST content type** to **application/json**. diff --git a/pkg/cli/webhook/forgejo.go b/pkg/cli/webhook/forgejo.go new file mode 100644 index 0000000000..cf97ae5681 --- /dev/null +++ b/pkg/cli/webhook/forgejo.go @@ -0,0 +1,174 @@ +package webhook + +import ( + "context" + "fmt" + "net/url" + "strings" + + "codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v3" + "github.com/AlecAivazis/survey/v2" + "github.com/openshift-pipelines/pipelines-as-code/pkg/cli" + "github.com/openshift-pipelines/pipelines-as-code/pkg/cli/prompt" + "github.com/openshift-pipelines/pipelines-as-code/pkg/formatting" + "github.com/openshift-pipelines/pipelines-as-code/pkg/random" +) + +type forgejoConfig struct { + Client *forgejo.Client + IOStream *cli.IOStreams + controllerURL string + repoOwner string + repoName string + webhookSecret string + personalAccessToken string + APIURL string +} + +func (fg *forgejoConfig) Run(_ context.Context, opts *Options) (*response, error) { + err := fg.askForgejoWebhookConfig(opts.RepositoryURL, opts.ControllerURL, opts.ProviderAPIURL, opts.PersonalAccessToken) + if err != nil { + return nil, err + } + + return &response{ + ControllerURL: fg.controllerURL, + PersonalAccessToken: fg.personalAccessToken, + WebhookSecret: fg.webhookSecret, + APIURL: fg.APIURL, + }, fg.create() +} + +func (fg *forgejoConfig) askForgejoWebhookConfig(repoURL, controllerURL, apiURL, personalAccessToken string) error { + if repoURL == "" { + msg := "Please enter the git repository url you want to be configured: " + if err := prompt.SurveyAskOne(&survey.Input{Message: msg}, &repoURL, + survey.WithValidator(survey.Required)); err != nil { + return err + } + } else { + fmt.Fprintf(fg.IOStream.Out, "✓ Setting up Forgejo Webhook for Repository %s\n", repoURL) + } + + repoURLForOwner := strings.TrimSuffix(repoURL, "/") + repoURLForOwner = strings.TrimSuffix(repoURLForOwner, ".git") + defaultRepo, err := formatting.GetRepoOwnerFromURL(repoURLForOwner) + if err != nil { + return err + } + + repoArr := strings.Split(defaultRepo, "/") + if len(repoArr) != 2 { + return fmt.Errorf("invalid repository, needs to be of format 'org-name/repo-name'") + } + fg.repoOwner = repoArr[0] + fg.repoName = repoArr[1] + + fg.controllerURL = controllerURL + if fg.controllerURL != "" { + var answer bool + fmt.Fprintf(fg.IOStream.Out, "👀 I have detected a controller url: %s\n", fg.controllerURL) + err := prompt.SurveyAskOne(&survey.Confirm{ + Message: "Do you want me to use it?", + Default: true, + }, &answer) + if err != nil { + return err + } + if !answer { + fg.controllerURL = "" + } + } + + if fg.controllerURL == "" { + if err := prompt.SurveyAskOne(&survey.Input{ + Message: "Please enter your controller public route URL: ", + }, &fg.controllerURL, survey.WithValidator(survey.Required)); err != nil { + return err + } + } + + data := random.AlphaString(12) + msg := fmt.Sprintf("Please enter the secret to configure the webhook for payload validation (default: %s): ", data) + if err := prompt.SurveyAskOne(&survey.Input{Message: msg, Default: data}, &fg.webhookSecret); err != nil { + return err + } + + if personalAccessToken == "" { + fmt.Fprintln(fg.IOStream.Out, "ℹ ️You now need to create a Forgejo personal access token with repository access All, write:repository, and write:issue.") + if err := prompt.SurveyAskOne(&survey.Password{ + Message: "Please enter the Forgejo access token: ", + }, &fg.personalAccessToken, survey.WithValidator(survey.Required)); err != nil { + return err + } + } else { + fg.personalAccessToken = personalAccessToken + } + + if apiURL == "" { + defaultURL, err := forgejoInstanceURL(repoURL) + if err != nil { + return err + } + if err := prompt.SurveyAskOne(&survey.Input{ + Message: "Please enter your Forgejo URL: ", + Default: defaultURL, + }, &fg.APIURL, survey.WithValidator(survey.Required)); err != nil { + return err + } + } else { + fg.APIURL = apiURL + } + + return nil +} + +func (fg *forgejoConfig) create() error { + fgClient, err := fg.newClient() + if err != nil { + return err + } + + hook := forgejo.CreateHookOption{ + Type: forgejo.HookTypeForgejo, + Config: map[string]string{ + "content_type": "json", + "url": fg.controllerURL, + "secret": fg.webhookSecret, + }, + Events: []string{ + "push", + "pull_request", + "pull_request_sync", + "pull_request_label", + "issue_comment", + }, + Active: true, + } + + _, _, err = fgClient.CreateRepoHook(fg.repoOwner, fg.repoName, hook) + if err != nil { + return fmt.Errorf("failed to create Forgejo webhook: %w", err) + } + + fmt.Fprintf(fg.IOStream.Out, "✓ Webhook has been created on repository %v/%v\n", fg.repoOwner, fg.repoName) + return nil +} + +func (fg *forgejoConfig) newClient() (*forgejo.Client, error) { + if fg.Client != nil { + return fg.Client, nil + } + return forgejo.NewClient(fg.APIURL, forgejo.SetToken(fg.personalAccessToken)) +} + +func forgejoInstanceURL(repoURL string) (string, error) { + parsedURL, err := url.Parse(repoURL) + if err != nil { + return "", err + } + if parsedURL.Scheme == "" || parsedURL.Host == "" { + return "", fmt.Errorf("invalid forgejo repository URL: %s", repoURL) + } + return fmt.Sprintf("%s://%s", parsedURL.Scheme, parsedURL.Host), nil +} diff --git a/pkg/cli/webhook/forgejo_test.go b/pkg/cli/webhook/forgejo_test.go new file mode 100644 index 0000000000..3d85e58765 --- /dev/null +++ b/pkg/cli/webhook/forgejo_test.go @@ -0,0 +1,232 @@ +package webhook + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v3" + "github.com/openshift-pipelines/pipelines-as-code/pkg/cli" + "github.com/openshift-pipelines/pipelines-as-code/pkg/cli/prompt" + giteatest "github.com/openshift-pipelines/pipelines-as-code/pkg/provider/gitea/test" + "gotest.tools/v3/assert" +) + +func TestAskForgejoWebhookConfig(t *testing.T) { + //nolint + io, _, _, _ := cli.IOTest() + tests := []struct { + name string + wantErrStr string + askStubs func(*prompt.AskStubber) + repoURL string + controllerURL string + providerURL string + personalaccesstoken string + wantRepoName string + }{ + { + name: "invalid repo format", + askStubs: func(as *prompt.AskStubber) { + as.StubOne("invalid-repo") + }, + wantErrStr: "invalid repo url at least a organization/project and a repo needs to be specified: invalid-repo", + }, + { + name: "ask all details no defaults", + askStubs: func(as *prompt.AskStubber) { + as.StubOne("https://forgejo.example.com/pac/test") + as.StubOne("https://controller.url") + as.StubOne("webhook-secret") + as.StubOne("token") + as.StubOne("https://forgejo.example.com") + }, + }, + { + name: "with defaults", + askStubs: func(as *prompt.AskStubber) { + as.StubOne(true) + as.StubOne("webhook-secret") + as.StubOne("token") + as.StubOne("https://forgejo.example.com") + }, + repoURL: "https://forgejo.example.com/pac/demo", + controllerURL: "https://test", + }, + { + name: "with personalaccesstoken", + askStubs: func(as *prompt.AskStubber) { + as.StubOne(true) + as.StubOne("webhook-secret") + as.StubOne("https://forgejo.example.com") + }, + repoURL: "https://forgejo.example.com/pac/demo", + controllerURL: "https://test", + personalaccesstoken: "Yzg5NzhlYmNkNTQwNzYzN2E2ZGExYzhkMTc4NjU0MjY3ZmQ2NmMeZg==", + }, + { + name: "with provider url", + askStubs: func(as *prompt.AskStubber) { + as.StubOne(true) + as.StubOne("webhook-secret") + as.StubOne("token") + }, + repoURL: "https://git.example.com/pac/demo", + controllerURL: "https://test", + providerURL: "https://git.example.com", + }, + { + name: "with git suffix", + askStubs: func(as *prompt.AskStubber) { + as.StubOne(true) + as.StubOne("webhook-secret") + as.StubOne("token") + as.StubOne("https://forgejo.example.com") + }, + repoURL: "https://forgejo.example.com/pac/demo.git", + controllerURL: "https://test", + wantRepoName: "demo", + }, + { + name: "with trailing slash", + askStubs: func(as *prompt.AskStubber) { + as.StubOne(true) + as.StubOne("webhook-secret") + as.StubOne("token") + as.StubOne("https://forgejo.example.com") + }, + repoURL: "https://forgejo.example.com/pac/demo/", + controllerURL: "https://test", + wantRepoName: "demo", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + as, teardown := prompt.InitAskStubber() + defer teardown() + if tt.askStubs != nil { + tt.askStubs(as) + } + fg := forgejoConfig{IOStream: io} + err := fg.askForgejoWebhookConfig(tt.repoURL, tt.controllerURL, tt.providerURL, tt.personalaccesstoken) + if tt.wantErrStr != "" { + assert.Equal(t, err.Error(), tt.wantErrStr) + return + } + assert.NilError(t, err) + if tt.wantRepoName != "" { + assert.Equal(t, tt.wantRepoName, fg.repoName) + } + }) + } +} + +func TestForgejoCreate(t *testing.T) { + fgClient, mux, tearDown := giteatest.Setup(t) + defer tearDown() + //nolint + io, _, _, _ := cli.IOTest() + + mux.HandleFunc("/repos/pac/valid/hooks", func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, r.Method, http.MethodPost) + var hook forgejo.CreateHookOption + assert.NilError(t, json.NewDecoder(r.Body).Decode(&hook)) + assert.Equal(t, forgejo.HookTypeForgejo, hook.Type) + assert.Equal(t, "https://controller.url", hook.Config["url"]) + assert.Equal(t, "json", hook.Config["content_type"]) + assert.Equal(t, "webhook-secret", hook.Config["secret"]) + assert.DeepEqual(t, []string{ + "push", + "pull_request", + "pull_request_sync", + "pull_request_label", + "issue_comment", + }, hook.Events) + assert.Assert(t, hook.Active) + w.WriteHeader(http.StatusCreated) + _, _ = w.Write([]byte(`{"id": 1}`)) + }) + + mux.HandleFunc("/repos/pac/invalid/hooks", func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusForbidden) + _, _ = w.Write([]byte(`{"message": "forbidden"}`)) + }) + + tests := []struct { + name string + repoName string + wantErr bool + }{ + { + name: "webhook created", + repoName: "valid", + }, + { + name: "webhook failed", + repoName: "invalid", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + fg := forgejoConfig{ + IOStream: io, + Client: fgClient, + repoOwner: "pac", + repoName: tt.repoName, + controllerURL: "https://controller.url", + webhookSecret: "webhook-secret", + } + err := fg.create() + if !tt.wantErr { + assert.NilError(t, err) + } else { + assert.Assert(t, err != nil) + } + }) + } +} + +func TestForgejoRunUsesPersonalAccessTokenForWebhookCreation(t *testing.T) { + serverURL, mux, tearDown := setupForgejoServer() + defer tearDown() + + //nolint + io, _, _, _ := cli.IOTest() + mux.HandleFunc("/repos/pac/valid/hooks", func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "token runtime-token", r.Header.Get("Authorization")) + w.WriteHeader(http.StatusCreated) + _, _ = w.Write([]byte(`{"id": 1}`)) + }) + + as, teardown := prompt.InitAskStubber() + defer teardown() + as.StubOne(true) + as.StubOne("webhook-secret") + as.StubOne("runtime-token") + + fg := forgejoConfig{IOStream: io} + res, err := fg.Run(context.Background(), &Options{ + RepositoryURL: serverURL + "/pac/valid", + ControllerURL: "https://controller.url", + ProviderAPIURL: serverURL, + PersonalAccessToken: "", + }) + assert.NilError(t, err) + assert.Equal(t, "runtime-token", res.PersonalAccessToken) +} + +func setupForgejoServer() (string, *http.ServeMux, func()) { + mux := http.NewServeMux() + apiHandler := http.NewServeMux() + apiHandler.Handle("/api/v1/", http.StripPrefix("/api/v1", mux)) + mux.HandleFunc("/version", func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`{"version": "1.17.0"}`)) + }) + server := httptest.NewServer(apiHandler) + return server.URL, mux, server.Close +} diff --git a/pkg/cli/webhook/webhook.go b/pkg/cli/webhook/webhook.go index a040dc6312..9b4fa59f80 100644 --- a/pkg/cli/webhook/webhook.go +++ b/pkg/cli/webhook/webhook.go @@ -79,6 +79,8 @@ func (w *Options) Install(ctx context.Context, providerType string) error { webhookProvider = &gitLabConfig{IOStream: w.IOStreams} case "bitbucket-cloud": webhookProvider = &bitbucketCloudConfig{IOStream: w.IOStreams} + case "forgejo": + webhookProvider = &forgejoConfig{IOStream: w.IOStreams} default: return fmt.Errorf("invalid webhook provider") } @@ -114,12 +116,14 @@ func GetProviderName(url string) (string, error) { providerName = "gitlab" case strings.Contains(url, "bitbucket-cloud"): providerName = "bitbucket-cloud" + case strings.Contains(url, "forgejo"): + providerName = "forgejo" default: msg := "Please select the type of the git platform to setup webhook:" if err = prompt.SurveyAskOne( &survey.Select{ Message: msg, - Options: []string{"github", "gitlab", "bitbucket-cloud"}, + Options: []string{"github", "gitlab", "bitbucket-cloud", "forgejo"}, Default: 0, }, &providerName); err != nil { return "", err diff --git a/pkg/cli/webhook/webhook_test.go b/pkg/cli/webhook/webhook_test.go new file mode 100644 index 0000000000..1086d28a44 --- /dev/null +++ b/pkg/cli/webhook/webhook_test.go @@ -0,0 +1,44 @@ +package webhook + +import ( + "testing" + + "gotest.tools/v3/assert" +) + +func TestGetProviderName(t *testing.T) { + tests := []struct { + name string + url string + want string + }{ + { + name: "github", + url: "https://github.com/pac/demo", + want: "github", + }, + { + name: "gitlab", + url: "https://gitlab.com/pac/demo", + want: "gitlab", + }, + { + name: "bitbucket cloud", + url: "https://bitbucket-cloud.example.com/pac/demo", + want: "bitbucket-cloud", + }, + { + name: "forgejo", + url: "https://forgejo.example.com/pac/demo", + want: "forgejo", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := GetProviderName(tt.url) + assert.NilError(t, err) + assert.Equal(t, tt.want, got) + }) + } +}