Skip to content

Commit 2e161b3

Browse files
committed
feat: add support for gitlab_project_hook and gitlab_group_hook (#11)
Add webhook drift detection for project and group hooks, generating individual resource blocks in hooks.tf. Group hooks handle 403 gracefully (Premium/Ultimate required). Uses hclwrite for proper HCL formatting consistent with the rest of the codebase.
1 parent 3715b25 commit 2e161b3

File tree

10 files changed

+563
-0
lines changed

10 files changed

+563
-0
lines changed

README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,7 @@ terraform/
8383
├── group_labels.tf # generated: variable with group → labels
8484
├── project_labels.tf # generated: variable with project → labels
8585
├── pipeline_schedules.tf # generated: variable with project → pipeline schedules
86+
├── hooks.tf # generated: project and group webhooks
8687
└── ...
8788
```
8889

@@ -103,6 +104,8 @@ terraform/
103104
- ✅ GitLab Project Labels ([`gitlab_project_label`](https://registry.terraform.io/providers/gitlabhq/gitlab/latest/docs/resources/project_label))
104105
- ✅ GitLab Pipeline Schedules ([`gitlab_pipeline_schedule`](https://registry.terraform.io/providers/gitlabhq/gitlab/latest/docs/resources/pipeline_schedule))
105106
- ✅ GitLab Pipeline Schedule Variables ([`gitlab_pipeline_schedule_variable`](https://registry.terraform.io/providers/gitlabhq/gitlab/latest/docs/resources/pipeline_schedule_variable))
107+
- ✅ GitLab Project Hooks ([`gitlab_project_hook`](https://registry.terraform.io/providers/gitlabhq/gitlab/latest/docs/resources/project_hook))
108+
- ✅ GitLab Group Hooks ([`gitlab_group_hook`](https://registry.terraform.io/providers/gitlabhq/gitlab/latest/docs/resources/group_hook)) *(requires Premium/Ultimate)*
106109
- 🚧 More resources coming soon
107110

108111
## Contributing

internal/gitlab/client.go

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ type Resources struct {
2121
GroupLabels GroupLabels
2222
ProjectLabels ProjectLabels
2323
PipelineSchedules PipelineSchedules
24+
ProjectHooks ProjectHooks
25+
GroupHooks GroupHooks
2426
}
2527

2628
func NewClientFromAPI(api *gl.Client, group string) *Client {
@@ -82,12 +84,30 @@ func (c *Client) FetchAll(ctx context.Context, skipSet skip.Set) (*Resources, er
8284
slog.Info("fetched pipeline schedules", "count", len(pipelineSchedules))
8385
}
8486

87+
var projectHooks ProjectHooks
88+
var groupHooks GroupHooks
89+
if !skipSet.Has("hooks") {
90+
projectHooks, err = c.ListProjectHooks(ctx, projects)
91+
if err != nil {
92+
return nil, fmt.Errorf("listing project hooks: %w", err)
93+
}
94+
slog.Info("fetched project hooks", "count", len(projectHooks))
95+
96+
groupHooks, err = c.ListGroupHooks(ctx, groups)
97+
if err != nil {
98+
return nil, fmt.Errorf("listing group hooks: %w", err)
99+
}
100+
slog.Info("fetched group hooks", "count", len(groupHooks))
101+
}
102+
85103
return &Resources{
86104
Groups: groups,
87105
Projects: projects,
88106
GroupMembers: groupMembers,
89107
GroupLabels: groupLabels,
90108
ProjectLabels: projectLabels,
91109
PipelineSchedules: pipelineSchedules,
110+
ProjectHooks: projectHooks,
111+
GroupHooks: groupHooks,
92112
}, nil
93113
}

internal/gitlab/hooks.go

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
package gitlab
2+
3+
import (
4+
"context"
5+
"errors"
6+
"fmt"
7+
"log/slog"
8+
"net/http"
9+
10+
gl "gitlab.com/gitlab-org/api/client-go"
11+
)
12+
13+
// ProjectHooks maps project IDs to their hooks.
14+
type ProjectHooks = map[int64][]*gl.ProjectHook
15+
16+
// GroupHooks maps group IDs to their hooks.
17+
type GroupHooks = map[int64][]*gl.GroupHook
18+
19+
func (c *Client) ListProjectHooks(ctx context.Context, projects []*gl.Project) (ProjectHooks, error) {
20+
result := make(ProjectHooks, len(projects))
21+
22+
for _, p := range projects {
23+
if p == nil {
24+
continue
25+
}
26+
slog.Debug("fetching project hooks", "project", p.PathWithNamespace)
27+
opts := &gl.ListProjectHooksOptions{
28+
ListOptions: gl.ListOptions{
29+
Page: 1,
30+
PerPage: 100,
31+
},
32+
}
33+
var hooks []*gl.ProjectHook
34+
for {
35+
page, resp, err := c.api.Projects.ListProjectHooks(p.ID, opts, gl.WithContext(ctx))
36+
if err != nil {
37+
return nil, fmt.Errorf("listing hooks for project %d: %w", p.ID, err)
38+
}
39+
hooks = append(hooks, page...)
40+
if resp.NextPage == 0 {
41+
break
42+
}
43+
opts.Page = resp.NextPage
44+
}
45+
if len(hooks) > 0 {
46+
result[p.ID] = hooks
47+
}
48+
}
49+
return result, nil
50+
}
51+
52+
func (c *Client) ListGroupHooks(ctx context.Context, groups []*gl.Group) (GroupHooks, error) {
53+
result := make(GroupHooks, len(groups))
54+
55+
for _, g := range groups {
56+
if g == nil {
57+
continue
58+
}
59+
slog.Debug("fetching group hooks", "group", g.FullPath)
60+
opts := &gl.ListGroupHooksOptions{
61+
ListOptions: gl.ListOptions{
62+
Page: 1,
63+
PerPage: 100,
64+
},
65+
}
66+
var hooks []*gl.GroupHook
67+
for {
68+
page, resp, err := c.api.Groups.ListGroupHooks(g.ID, opts, gl.WithContext(ctx))
69+
if err != nil {
70+
var errResp *gl.ErrorResponse
71+
if errors.As(err, &errResp) && errResp.HasStatusCode(http.StatusForbidden) {
72+
slog.Warn("group hooks require Premium/Ultimate, skipping", "group", g.FullPath)
73+
break
74+
}
75+
return nil, fmt.Errorf("listing hooks for group %d: %w", g.ID, err)
76+
}
77+
hooks = append(hooks, page...)
78+
if resp.NextPage == 0 {
79+
break
80+
}
81+
opts.Page = resp.NextPage
82+
}
83+
if len(hooks) > 0 {
84+
result[g.ID] = hooks
85+
}
86+
}
87+
return result, nil
88+
}

internal/terraform/hooks.go

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
package terraform
2+
3+
import (
4+
"io"
5+
"strings"
6+
7+
"github.com/hashicorp/hcl/v2"
8+
"github.com/hashicorp/hcl/v2/hclwrite"
9+
"github.com/zclconf/go-cty/cty"
10+
gl "gitlab.com/gitlab-org/api/client-go"
11+
)
12+
13+
func normalizeHookURL(rawURL string) string {
14+
u := strings.TrimPrefix(rawURL, "https://")
15+
u = strings.TrimPrefix(u, "http://")
16+
u = strings.ReplaceAll(u, ":", "_")
17+
return normalizeName(u)
18+
}
19+
20+
func projectHookResourceName(p *gl.Project, h *gl.ProjectHook) string {
21+
return projectResourceName(p) + "_" + normalizeHookURL(h.URL)
22+
}
23+
24+
func groupHookResourceName(g *gl.Group, h *gl.GroupHook) string {
25+
return normalizeToTerraformName(g.Path) + "_" + normalizeHookURL(h.URL)
26+
}
27+
28+
func WriteProjectHooks(p *gl.Project, hooks []*gl.ProjectHook, w io.Writer) error {
29+
f := hclwrite.NewEmptyFile()
30+
rootBody := f.Body()
31+
projName := projectResourceName(p)
32+
33+
for i, h := range hooks {
34+
if i > 0 {
35+
rootBody.AppendNewline()
36+
}
37+
name := projectHookResourceName(p, h)
38+
block := rootBody.AppendNewBlock("resource", []string{"gitlab_project_hook", name})
39+
body := block.Body()
40+
41+
body.SetAttributeTraversal("project", hcl.Traversal{
42+
hcl.TraverseRoot{Name: "gitlab_project"},
43+
hcl.TraverseAttr{Name: projName},
44+
hcl.TraverseAttr{Name: "id"},
45+
})
46+
body.SetAttributeValue("url", cty.StringVal(h.URL))
47+
if h.Name != "" {
48+
body.SetAttributeValue("name", cty.StringVal(h.Name))
49+
}
50+
if h.Description != "" {
51+
body.SetAttributeValue("description", cty.StringVal(h.Description))
52+
}
53+
body.SetAttributeValue("enable_ssl_verification", cty.BoolVal(h.EnableSSLVerification))
54+
55+
// push_events: always write (provider default is true, so we need explicit false)
56+
body.SetAttributeValue("push_events", cty.BoolVal(h.PushEvents))
57+
if h.PushEventsBranchFilter != "" {
58+
body.SetAttributeValue("push_events_branch_filter", cty.StringVal(h.PushEventsBranchFilter))
59+
}
60+
61+
writeEvents(body, []hookEvent{
62+
{"tag_push_events", h.TagPushEvents},
63+
{"issues_events", h.IssuesEvents},
64+
{"confidential_issues_events", h.ConfidentialIssuesEvents},
65+
{"merge_requests_events", h.MergeRequestsEvents},
66+
{"note_events", h.NoteEvents},
67+
{"confidential_note_events", h.ConfidentialNoteEvents},
68+
{"job_events", h.JobEvents},
69+
{"pipeline_events", h.PipelineEvents},
70+
{"wiki_page_events", h.WikiPageEvents},
71+
{"deployment_events", h.DeploymentEvents},
72+
{"releases_events", h.ReleasesEvents},
73+
})
74+
}
75+
76+
_, err := w.Write(f.Bytes())
77+
return err
78+
}
79+
80+
func WriteGroupHooks(g *gl.Group, hooks []*gl.GroupHook, w io.Writer) error {
81+
f := hclwrite.NewEmptyFile()
82+
rootBody := f.Body()
83+
groupName := normalizeToTerraformName(g.Path)
84+
85+
for i, h := range hooks {
86+
if i > 0 {
87+
rootBody.AppendNewline()
88+
}
89+
name := groupHookResourceName(g, h)
90+
block := rootBody.AppendNewBlock("resource", []string{"gitlab_group_hook", name})
91+
body := block.Body()
92+
93+
body.SetAttributeTraversal("group", hcl.Traversal{
94+
hcl.TraverseRoot{Name: "gitlab_group"},
95+
hcl.TraverseAttr{Name: groupName},
96+
hcl.TraverseAttr{Name: "id"},
97+
})
98+
body.SetAttributeValue("url", cty.StringVal(h.URL))
99+
if h.Name != "" {
100+
body.SetAttributeValue("name", cty.StringVal(h.Name))
101+
}
102+
if h.Description != "" {
103+
body.SetAttributeValue("description", cty.StringVal(h.Description))
104+
}
105+
body.SetAttributeValue("enable_ssl_verification", cty.BoolVal(h.EnableSSLVerification))
106+
107+
// push_events: always write
108+
body.SetAttributeValue("push_events", cty.BoolVal(h.PushEvents))
109+
if h.PushEventsBranchFilter != "" {
110+
body.SetAttributeValue("push_events_branch_filter", cty.StringVal(h.PushEventsBranchFilter))
111+
}
112+
if h.BranchFilterStrategy != "" {
113+
body.SetAttributeValue("branch_filter_strategy", cty.StringVal(h.BranchFilterStrategy))
114+
}
115+
116+
writeEvents(body, []hookEvent{
117+
{"tag_push_events", h.TagPushEvents},
118+
{"issues_events", h.IssuesEvents},
119+
{"confidential_issues_events", h.ConfidentialIssuesEvents},
120+
{"merge_requests_events", h.MergeRequestsEvents},
121+
{"note_events", h.NoteEvents},
122+
{"confidential_note_events", h.ConfidentialNoteEvents},
123+
{"job_events", h.JobEvents},
124+
{"pipeline_events", h.PipelineEvents},
125+
{"wiki_page_events", h.WikiPageEvents},
126+
{"deployment_events", h.DeploymentEvents},
127+
{"releases_events", h.ReleasesEvents},
128+
{"subgroup_events", h.SubGroupEvents},
129+
{"emoji_events", h.EmojiEvents},
130+
{"feature_flag_events", h.FeatureFlagEvents},
131+
})
132+
}
133+
134+
_, err := w.Write(f.Bytes())
135+
return err
136+
}
137+
138+
type hookEvent struct {
139+
attr string
140+
val bool
141+
}
142+
143+
func writeEvents(body *hclwrite.Body, events []hookEvent) {
144+
for _, e := range events {
145+
if e.val {
146+
body.SetAttributeValue(e.attr, cty.True)
147+
}
148+
}
149+
}

internal/terraform/hooks_test.go

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
package terraform
2+
3+
import (
4+
"bytes"
5+
"testing"
6+
7+
gl "gitlab.com/gitlab-org/api/client-go"
8+
)
9+
10+
func TestNormalizeHookURL(t *testing.T) {
11+
tests := []struct {
12+
input string
13+
want string
14+
}{
15+
{"https://hooks.slack.com/services/T123", "hooks_slack_com_services_t123"},
16+
{"http://example.com/webhook", "example_com_webhook"},
17+
{"https://example.com:8080/hook", "example_com_8080_hook"},
18+
{"example.com/plain", "example_com_plain"},
19+
}
20+
for _, tt := range tests {
21+
got := normalizeHookURL(tt.input)
22+
if got != tt.want {
23+
t.Errorf("normalizeHookURL(%q) = %q, want %q", tt.input, got, tt.want)
24+
}
25+
}
26+
}
27+
28+
func TestWriteProjectHooks(t *testing.T) {
29+
project := &gl.Project{
30+
ID: 1,
31+
Path: "my-project",
32+
Namespace: &gl.ProjectNamespace{FullPath: "my-group"},
33+
PathWithNamespace: "my-group/my-project",
34+
}
35+
36+
hooks := []*gl.ProjectHook{
37+
{
38+
ID: 100,
39+
URL: "https://hooks.slack.com/services/T123",
40+
Name: "Slack notifications",
41+
Description: "Posts to #dev",
42+
EnableSSLVerification: true,
43+
PushEvents: true,
44+
MergeRequestsEvents: true,
45+
},
46+
{
47+
ID: 200,
48+
URL: "https://example.com/webhook",
49+
EnableSSLVerification: true,
50+
PushEvents: false,
51+
TagPushEvents: true,
52+
PipelineEvents: true,
53+
},
54+
}
55+
56+
var buf bytes.Buffer
57+
if err := WriteProjectHooks(project, hooks, &buf); err != nil {
58+
t.Fatalf("WriteProjectHooks error: %v", err)
59+
}
60+
61+
compareGolden(t, "project_hooks.tf", buf.String())
62+
}
63+
64+
func TestWriteGroupHooks(t *testing.T) {
65+
group := &gl.Group{
66+
ID: 10,
67+
Path: "my-group",
68+
FullPath: "my-group",
69+
}
70+
71+
hooks := []*gl.GroupHook{
72+
{
73+
ID: 300,
74+
URL: "https://example.com/webhook",
75+
EnableSSLVerification: true,
76+
PushEvents: true,
77+
SubGroupEvents: true,
78+
},
79+
{
80+
ID: 400,
81+
URL: "https://ci.internal.io/notify",
82+
Name: "CI notify",
83+
EnableSSLVerification: false,
84+
PushEvents: true,
85+
MergeRequestsEvents: true,
86+
EmojiEvents: true,
87+
FeatureFlagEvents: true,
88+
},
89+
}
90+
91+
var buf bytes.Buffer
92+
if err := WriteGroupHooks(group, hooks, &buf); err != nil {
93+
t.Fatalf("WriteGroupHooks error: %v", err)
94+
}
95+
96+
compareGolden(t, "group_hooks.tf", buf.String())
97+
}

0 commit comments

Comments
 (0)