Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ forge ci list
forge ci log 67890
forge branch list
forge label list
forge webhook add --url https://example.com/hook -e push -e pull_request
forge notification list --unread
forge notification read --id 123
forge api repos/{owner}/{repo}
Expand Down Expand Up @@ -163,7 +164,7 @@ client := forges.NewClient(
repo, err := client.FetchRepository(ctx, "https://github.com/octocat/hello-world")
```

The `Forge` interface exposes services for repos, issues, pull requests, reviews, releases, CI, branches, labels, milestones, deploy keys, secrets, notifications, files, collaborators, commit statuses and commits. Each backend implements these using its native SDK.
The `Forge` interface exposes services for repos, issues, pull requests, reviews, releases, CI, branches, labels, milestones, deploy keys, webhooks, secrets, notifications, files, collaborators, commit statuses and commits. Each backend implements these using its native SDK.

Under TinyGo, `gitlab.New` retains its signature and returns a backend whose API operations report `forges.ErrNotSupported`. GitLab URL parsing and web link builders remain available. Native Go retains the full GitLab implementation.

Expand Down
29 changes: 29 additions & 0 deletions bitbucket/webhooks.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package bitbucket

import (
"context"

forge "github.com/git-pkgs/forge"
)

type bitbucketWebhookService struct{}

func (f *bitbucketForge) Webhooks() forge.WebhookService {
return &bitbucketWebhookService{}
}

func (s *bitbucketWebhookService) List(_ context.Context, _, _ string, _ forge.ListWebhookOpts) ([]forge.Webhook, error) {
return nil, forge.ErrNotSupported
}

func (s *bitbucketWebhookService) Get(_ context.Context, _, _ string, _ int64) (*forge.Webhook, error) {
return nil, forge.ErrNotSupported
}

func (s *bitbucketWebhookService) Create(_ context.Context, _, _ string, _ forge.CreateWebhookOpts) (*forge.Webhook, error) {
return nil, forge.ErrNotSupported
}

func (s *bitbucketWebhookService) Delete(_ context.Context, _, _ string, _ int64) error {
return forge.ErrNotSupported
}
32 changes: 32 additions & 0 deletions bitbucket/webhooks_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package bitbucket

import (
"context"
"errors"
forge "github.com/git-pkgs/forge"
"testing"
)

func TestBitbucketWebhookNotSupported(t *testing.T) {
svc := &bitbucketWebhookService{}

_, err := svc.List(context.Background(), "owner", "repo", forge.ListWebhookOpts{})
if !errors.Is(err, forge.ErrNotSupported) {
t.Errorf("List: expected forge.ErrNotSupported, got %v", err)
}

_, err = svc.Get(context.Background(), "owner", "repo", 1)
if !errors.Is(err, forge.ErrNotSupported) {
t.Errorf("Get: expected forge.ErrNotSupported, got %v", err)
}

_, err = svc.Create(context.Background(), "owner", "repo", forge.CreateWebhookOpts{})
if !errors.Is(err, forge.ErrNotSupported) {
t.Errorf("Create: expected forge.ErrNotSupported, got %v", err)
}

err = svc.Delete(context.Background(), "owner", "repo", 1)
if !errors.Is(err, forge.ErrNotSupported) {
t.Errorf("Delete: expected forge.ErrNotSupported, got %v", err)
}
}
1 change: 1 addition & 0 deletions forge.go
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ type Forge interface {
CI() CIService
Branches() BranchService
DeployKeys() DeployKeyService
Webhooks() WebhookService
Secrets() SecretService
Notifications() NotificationService
Reviews() ReviewService
Expand Down
22 changes: 22 additions & 0 deletions forges_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -715,6 +715,10 @@ func (m *mockForge) DeployKeys() DeployKeyService {
return &mockDeployKeyService{}
}

func (m *mockForge) Webhooks() WebhookService {
return &mockWebhookService{}
}

func (m *mockForge) Secrets() SecretService {
if m.secretService != nil {
return m.secretService
Expand Down Expand Up @@ -796,6 +800,24 @@ func (m *mockCollaboratorService) Remove(_ context.Context, _, _, _ string) erro
return nil
}

type mockWebhookService struct{}

func (m *mockWebhookService) List(_ context.Context, _, _ string, _ ListWebhookOpts) ([]Webhook, error) {
return nil, nil
}

func (m *mockWebhookService) Get(_ context.Context, _, _ string, _ int64) (*Webhook, error) {
return nil, nil
}

func (m *mockWebhookService) Create(_ context.Context, _, _ string, _ CreateWebhookOpts) (*Webhook, error) {
return nil, nil
}

func (m *mockWebhookService) Delete(_ context.Context, _, _ string, _ int64) error {
return nil
}

type mockCommitStatusService struct{}

func (m *mockCommitStatusService) List(_ context.Context, _, _, _ string) ([]CommitStatus, error) {
Expand Down
15 changes: 15 additions & 0 deletions gerrit/stubs.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ type unsupportedMilestoneService struct{}
type unsupportedReleaseService struct{}
type unsupportedCIService struct{}
type unsupportedDeployKeyService struct{}
type unsupportedWebhookService struct{}
type unsupportedSecretService struct{}
type unsupportedNotificationService struct{}
type unsupportedCollaboratorService struct{}
Expand All @@ -26,6 +27,7 @@ func (f *gerritForge) Milestones() forge.MilestoneService { return &unsupportedM
func (f *gerritForge) Releases() forge.ReleaseService { return &unsupportedReleaseService{} }
func (f *gerritForge) CI() forge.CIService { return &unsupportedCIService{} }
func (f *gerritForge) DeployKeys() forge.DeployKeyService { return &unsupportedDeployKeyService{} }
func (f *gerritForge) Webhooks() forge.WebhookService { return &unsupportedWebhookService{} }
func (f *gerritForge) Secrets() forge.SecretService { return &unsupportedSecretService{} }
func (f *gerritForge) Notifications() forge.NotificationService {
return &unsupportedNotificationService{}
Expand Down Expand Up @@ -172,6 +174,19 @@ func (s *unsupportedDeployKeyService) Delete(context.Context, string, string, in
return forge.ErrNotSupported
}

func (s *unsupportedWebhookService) List(context.Context, string, string, forge.ListWebhookOpts) ([]forge.Webhook, error) {
return nil, forge.ErrNotSupported
}
func (s *unsupportedWebhookService) Get(context.Context, string, string, int64) (*forge.Webhook, error) {
return nil, forge.ErrNotSupported
}
func (s *unsupportedWebhookService) Create(context.Context, string, string, forge.CreateWebhookOpts) (*forge.Webhook, error) {
return nil, forge.ErrNotSupported
}
func (s *unsupportedWebhookService) Delete(context.Context, string, string, int64) error {
return forge.ErrNotSupported
}

func (s *unsupportedSecretService) List(context.Context, string, string, forge.ListSecretOpts) ([]forge.Secret, error) {
return nil, forge.ErrNotSupported
}
Expand Down
210 changes: 210 additions & 0 deletions gitea/webhooks.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,210 @@
package gitea

import (
"context"
"fmt"
forge "github.com/git-pkgs/forge"

"code.gitea.io/sdk/gitea"
)

// giteaWebhookEvents maps normalized event names onto Gitea/Forgejo hook
// events, in the order they are reported by List and Get. Gitea treats issues
// and pull_request as groups that also enable their comment and review
// events, so create sends the *_only forms plus each sub-event to keep
// GitHub's narrower meaning. Reads report the individual sub-events.
//
// Only events that both Gitea and Forgejo accept are listed, since both
// silently ignore unknown names. status, workflow_run and workflow_job are
// left out: Forgejo has no status event and only granular workflow_run_* and
// workflow_job_* events, while Gitea only gained status in late 2024 and the
// workflow events in 1.25.0.
var giteaWebhookEvents = []struct {
name string
create []string // events sent when creating a hook
read []string // events the API reports for an existing hook
}{
{name: "push", create: []string{"push"}, read: []string{"push"}},
{name: "create", create: []string{"create"}, read: []string{"create"}},
{name: "delete", create: []string{"delete"}, read: []string{"delete"}},
{name: "fork", create: []string{"fork"}, read: []string{"fork"}},
{
name: "issues",
create: []string{"issues_only", "issue_assign", "issue_label", "issue_milestone"},
read: []string{"issues", "issue_assign", "issue_label", "issue_milestone"},
},
{
name: "issue_comment",
create: []string{"issue_comment", "pull_request_comment"},
read: []string{"issue_comment", "pull_request_comment"},
},
{
name: "pull_request",
create: []string{"pull_request_only", "pull_request_assign", "pull_request_label", "pull_request_milestone", "pull_request_sync", "pull_request_review_request"},
read: []string{"pull_request", "pull_request_assign", "pull_request_label", "pull_request_milestone", "pull_request_sync", "pull_request_review_request"},
},
{
name: "pull_request_review",
create: []string{"pull_request_review"},
read: []string{"pull_request_review", "pull_request_review_approved", "pull_request_review_rejected", "pull_request_review_comment"},
},
{name: "release", create: []string{"release"}, read: []string{"release"}},
{name: "repository", create: []string{"repository"}, read: []string{"repository"}},
{name: "gollum", create: []string{"wiki"}, read: []string{"wiki"}},
{name: "package", create: []string{"package"}, read: []string{"package"}},
}

// toGiteaEvents translates normalized event names into the events sent when
// creating a hook. Names with no Gitea/Forgejo equivalent are rejected.
func toGiteaEvents(events []string) ([]string, error) {
if len(events) == 0 {
events = []string{"push"}
}
var out []string
for _, name := range events {
known := false
for _, e := range giteaWebhookEvents {
if e.name == name {
out = append(out, e.create...)
known = true
break
}
}
if !known {
return nil, fmt.Errorf("gitea: unsupported webhook event %q", name)
}
}
return out, nil
}

// fromGiteaEvents translates the events reported for a hook into normalized
// names. Events with no normalized name are kept as reported.
func fromGiteaEvents(events []string) []string {
reported := make(map[string]bool, len(events))
for _, e := range events {
reported[e] = true
}

out := []string{}
mapped := make(map[string]bool)
for _, e := range giteaWebhookEvents {
found := false
for _, r := range e.read {
if reported[r] {
mapped[r] = true
found = true
}
}
if found {
out = append(out, e.name)
}
}
for _, e := range events {
if !mapped[e] {
out = append(out, e)
mapped[e] = true
}
}
return out
}

type giteaWebhookService struct {
client *gitea.Client
}

func (f *giteaForge) Webhooks() forge.WebhookService {
return &giteaWebhookService{client: f.client}
}

func convertGiteaHook(h *gitea.Hook) forge.Webhook {
return forge.Webhook{
ID: h.ID,
URL: h.Config["url"],
Events: fromGiteaEvents(h.Events),
Active: h.Active,
ContentType: h.Config["content_type"],
CreatedAt: h.Created,
UpdatedAt: h.Updated,
}
}

func (s *giteaWebhookService) List(ctx context.Context, owner, repo string, opts forge.ListWebhookOpts) ([]forge.Webhook, error) {
perPage := pageSize(opts.PerPage)
page := opts.Page
if page <= 0 {
page = 1
}

var all []forge.Webhook
for {
hooks, resp, err := s.client.ListRepoHooks(owner, repo, gitea.ListHooksOptions{
ListOptions: gitea.ListOptions{Page: page, PageSize: perPage},
})
if err != nil {
return nil, wrapErr("list webhooks", resp, err)
}
for _, h := range hooks {
all = append(all, convertGiteaHook(h))
}
if lastPage(resp, len(hooks), perPage) || (opts.Limit > 0 && len(all) >= opts.Limit) {
break
}
page++
}

if opts.Limit > 0 && len(all) > opts.Limit {
all = all[:opts.Limit]
}

return all, nil
}

func (s *giteaWebhookService) Get(ctx context.Context, owner, repo string, id int64) (*forge.Webhook, error) {
h, resp, err := s.client.GetRepoHook(owner, repo, id)
if err != nil {
return nil, wrapErr("get webhook", resp, err)
}

wh := convertGiteaHook(h)
return &wh, nil
}

func (s *giteaWebhookService) Create(ctx context.Context, owner, repo string, opts forge.CreateWebhookOpts) (*forge.Webhook, error) {
contentType := opts.ContentType
if contentType == "" {
contentType = "json"
}
events, err := toGiteaEvents(opts.Events)
if err != nil {
return nil, err
}

config := map[string]string{
"url": opts.URL,
"content_type": contentType,
}
if opts.Secret != "" {
config["secret"] = opts.Secret
}

h, resp, err := s.client.CreateRepoHook(owner, repo, gitea.CreateHookOption{
Type: gitea.HookTypeGitea,
Config: config,
Events: events,
Active: true,
})
if err != nil {
return nil, wrapErr("create webhook", resp, err)
}

wh := convertGiteaHook(h)
return &wh, nil
}

func (s *giteaWebhookService) Delete(ctx context.Context, owner, repo string, id int64) error {
resp, err := s.client.DeleteRepoHook(owner, repo, id)
if err != nil {
return wrapErr("delete webhook", resp, err)
}
return nil
}
Loading
Loading