diff --git a/docs/content/docs/providers/forgejo.md b/docs/content/docs/providers/forgejo.md index e1724bbe5..683dda7c1 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 000000000..cf97ae568 --- /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 000000000..3d85e5876 --- /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 a040dc631..9b4fa59f8 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 000000000..1086d28a4 --- /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) + }) + } +}