Skip to content

Commit 3d10e84

Browse files
authored
CLOUD-695: Fan-Out workspace triggers (#62)
CLOUD-695: Fan-Out workspace triggers Signed-off-by: Carlos Patino <chpatinos@gmail.com>
1 parent 3b07a7f commit 3d10e84

25 files changed

Lines changed: 1615 additions & 130 deletions

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ and then passes status updates of those Runs back to the Merge/Pull Request in t
1515

1616
For MRs with multiple workspaces, TFBuddy tracks each workspace independently and can automatically clean up old plan/apply comments, keeping only the most recent one per workspace and action type. Set `TFBUDDY_DELETE_OLD_COMMENTS` to enable this.
1717

18+
Each touched workspace is dispatched onto a dedicated NATS JetStream queue and processed independently, so a single MR with many workspaces can no longer hold the webhook subscriber past `AckWait` (which previously caused JetStream redeliveries and duplicate TFC runs). The fan-out is gated by `TFBUDDY_WORKSPACE_FANOUT_ENABLED` (default `true`) so it can be turned off at runtime if needed. The TFC API client is rate-limited (`TFBUDDY_TFC_RATE_LIMIT_RPS` / `_BURST`) so concurrent workers stay under TFC's documented per-token limit.
19+
1820

1921
### Architecture
2022

docs/architecture.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,24 @@ Example of how an error is reported
2727
A more detailed error message is provided if the error occurred within TF Buddy. If the error occurred during terraform plan or terraform apply it will not contain a detailed message. Instead the run link will take you to the Terraform workspace where you can debug the error.
2828

2929

30+
### Workspace Fan-Out
31+
32+
When an MR/PR touches several Terraform workspaces, TFBuddy splits the work onto a dedicated NATS JetStream queue and processes each workspace independently. The MR/PR webhook subscriber clones the repo once, evaluates which workspaces are blocked by changes on the target branch, and publishes one fan-out message per remaining workspace. A separate workspace worker drains the queue, with each delivery getting its own `AckWait` window — a slow workspace can no longer hold the parent message past `AckWait` and trigger a redelivery (which previously caused duplicate runs).
33+
34+
Per-workspace state (discussion ID, root note ID) is local to the worker invocation, so concurrent deliveries cannot leak IDs across workspaces.
35+
36+
The fan-out is gated by `TFBUDDY_WORKSPACE_FANOUT_ENABLED` (default `true`). Setting it to `false` falls back to the legacy inline per-MR loop, useful if you need to roll back at runtime without redeploying.
37+
38+
Each fan-out message carries the upstream webhook delivery ID (`X-GitHub-Delivery` for GitHub, `X-Gitlab-Event-UUID`/`Idempotency-Key` for GitLab). That ID combined with the workspace name is used as the JetStream dedup key, so a single webhook fanning out to N workspaces produces N distinct keys, while a legitimate retrigger (a new push or comment within the dedup window) carries a fresh delivery ID and is *not* swallowed by the dedup window.
39+
40+
Target-branch evaluation runs once in the publisher, so the MR-level "could not evaluate target branch" warning is posted at most once per delivery. Each worker then re-clones the repo to upload the workspace directory to TFC, so an MR touching N workspaces still does N+1 clones in fan-out mode. On large monorepos this measurably increases git egress and per-message latency; mitigate via `TFBUDDY_GITHUB_CLONE_DEPTH` / `TFBUDDY_GITLAB_CLONE_DEPTH`, or disable fan-out (`TFBUDDY_WORKSPACE_FANOUT_ENABLED=false`) and accept the AckWait risk.
41+
42+
The workspace stream exposes the same `consumers >= 1` liveness check as the other streams, wired into `/live` so a stuck or unsubscribed worker is caught by Kubernetes probes rather than silently piling up unprocessed messages.
43+
44+
### TFC API Rate Limiter
45+
46+
The TFC API client wraps its HTTP transport with a token-bucket rate limiter (`TFBUDDY_TFC_RATE_LIMIT_RPS`, default `30`; burst `TFBUDDY_TFC_RATE_LIMIT_BURST`, default `30`) so concurrent workspace workers cooperatively stay under TFC's documented per-token limit. The client deliberately has no top-level `Timeout`: `ConfigurationVersions.Upload` streams the cloned repo and a fixed cap would truncate slow uploads on large repos. Per-call deadlines flow through `context.Context`.
47+
3048
### Webhook Management
3149

3250
At Zapier we automate the creation of all relevant webhooks by leveraging Terraform to create them. The example below is a resource we use to hook up a Gitlab project and a Terraform Cloud workspace to TF Buddy.

docs/contributing.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,12 @@ If you're using minikube with Tilt we recommend following this [guide](https://g
108108

109109
We use Earthly to simplify our CI/CD process with TF Buddy. This also simplifies testing changes locally before pushing them up to ensure your PR will pass all required checks. The best command to run is `earthly +unit-test` this will pull all the required dependencies (including any new ones that you have added). It will then run [go vet](https://pkg.go.dev/cmd/vet), and if those pass it will run `go test` with race detection enabled. You can also always run these commands directly `go test -race ./...` will run all tests in the repo with race detection enabled. Please ensure that `earthly +unit-test` is passing before opening a PR.
110110

111+
Always run with `-race` when changing the trigger or TFC API code — those paths are concurrent (workspace fan-out, shared rate limiter):
112+
113+
```console
114+
go test -race ./pkg/tfc_trigger/... ./pkg/tfc_api/...
115+
```
116+
111117
#### Unit Tests
112118

113119
We use [gomock](https://github.com/golang/mock) to simplify down some of our unit tests specifically in `tfc_trigger_tests.go` where a lot of our functionality needs to be tested. To reduce the burden of testing a new feature you can leverage the `TestSuite` in `pkg/mocks/helpers.go`. When you call `CreateTestSuite` it will return a set of stubs that should let you test most operations. To override stubs that are created within the TestSuite define them after calling `CreateTestSuite` but before calling `InitTestSuite`. This works because gomock will respect the order that stubs/mocks are defined which lets you override an EXPECT defined in TestSuite. This can be seen in this example:

docs/usage.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,10 @@ The full list of supported environment variables and flags is described below:
6868
|`TFBUDDY_GITHUB_REPO_ALLOW_LIST`|`--github-repo-allow-list`|Comma-separated GitHub repository allow list prefixes.||
6969
|`TFBUDDY_GITHUB_CLONE_DEPTH`|`--github-clone-depth`|Git clone depth to use for GitHub merge request checkouts. Zero means full history.|`0`|
7070
|`TFBUDDY_GITLAB_CLONE_DEPTH`|`--gitlab-clone-depth`|Git clone depth to use for GitLab merge request checkouts. Zero means full history.|`0`|
71+
|`TFBUDDY_WORKSPACE_FANOUT_ENABLED`|`--workspace-fanout-enabled`|Enable per-workspace JetStream fan-out (one NATS message per workspace) to keep AckWait windows scoped per workspace. When disabled, TFBuddy falls back to the inline per-MR loop.|`true`|
72+
|`TFBUDDY_WORKSPACE_JETSTREAM_REPLICAS`|`--workspace-jetstream-replicas`|JetStream replica count for the workspace-trigger stream. Use 1 for single-node NATS or local dev; set to your NATS cluster size (often 3) in production for durability.|`1`|
73+
|`TFBUDDY_TFC_RATE_LIMIT_RPS`|`--tfc-rate-limit-rps`|Client-side rate limit (requests per second) for the Terraform Cloud API. Tuned to match TFC's documented per-token limit and prevent 429s when many workspaces are triggered concurrently.|`30`|
74+
|`TFBUDDY_TFC_RATE_LIMIT_BURST`|`--tfc-rate-limit-burst`|Burst capacity for the TFC API token-bucket rate limiter.|`30`|
7175
<!-- END GENERATED CONFIGURATION -->
7276

7377
For sensitive environment variables use `secrets.envs` which can contain a list of key/value pairs

go.mod

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ require (
1111
github.com/creasty/defaults v1.8.0
1212
github.com/go-git/go-billy/v5 v5.6.2
1313
github.com/go-git/go-git/v5 v5.16.0
14+
github.com/go-viper/mapstructure/v2 v2.2.1
1415
github.com/google/go-cmp v0.7.0
1516
github.com/google/go-github/v69 v69.2.0
1617
github.com/hashicorp/go-tfe v1.80.0
@@ -27,6 +28,7 @@ require (
2728
github.com/rzajac/zltest v0.12.0
2829
github.com/sl1pm4t/gongs v0.0.0-20230501190600-06976a7fac23
2930
github.com/spf13/cobra v1.9.1
31+
github.com/spf13/pflag v1.0.6
3032
github.com/spf13/viper v1.20.1
3133
github.com/stretchr/testify v1.11.1
3234
github.com/ziflex/lecho/v3 v3.8.0
@@ -41,6 +43,7 @@ require (
4143
go.opentelemetry.io/otel/trace v1.39.0
4244
go.uber.org/mock v0.5.2
4345
golang.org/x/oauth2 v0.34.0
46+
golang.org/x/time v0.15.0
4447
google.golang.org/grpc v1.79.3
4548
gopkg.in/dealancer/validate.v2 v2.1.0
4649
gopkg.in/errgo.v2 v2.1.0
@@ -63,7 +66,6 @@ require (
6366
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
6467
github.com/go-logr/logr v1.4.3 // indirect
6568
github.com/go-logr/stdr v1.2.2 // indirect
66-
github.com/go-viper/mapstructure/v2 v2.2.1 // indirect
6769
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
6870
github.com/google/go-querystring v1.1.0 // indirect
6971
github.com/google/go-tpm v0.9.8 // indirect
@@ -101,7 +103,6 @@ require (
101103
github.com/sourcegraph/conc v0.3.0 // indirect
102104
github.com/spf13/afero v1.14.0 // indirect
103105
github.com/spf13/cast v1.8.0 // indirect
104-
github.com/spf13/pflag v1.0.6 // indirect
105106
github.com/subosito/gotenv v1.6.0 // indirect
106107
github.com/valyala/bytebufferpool v1.0.0 // indirect
107108
github.com/valyala/fasttemplate v1.2.2 // indirect
@@ -118,7 +119,6 @@ require (
118119
golang.org/x/sync v0.20.0 // indirect
119120
golang.org/x/sys v0.42.0 // indirect
120121
golang.org/x/text v0.35.0 // indirect
121-
golang.org/x/time v0.15.0 // indirect
122122
google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 // indirect
123123
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect
124124
google.golang.org/protobuf v1.36.10 // indirect

internal/config/config.go

Lines changed: 50 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -11,47 +11,55 @@ import (
1111
)
1212

1313
const (
14-
KeyLogLevel = "log-level"
15-
KeyDevMode = "dev-mode"
16-
KeyOTELEnabled = "otel-enabled"
17-
KeyOTELCollectorHost = "otel-collector-host"
18-
KeyOTELCollectorPort = "otel-collector-port"
19-
KeyGitlabHookSecretKey = "gitlab-hook-secret-key"
20-
KeyGithubHookSecretKey = "github-hook-secret-key"
21-
KeyDefaultTFCOrganization = "default-tfc-organization"
22-
KeyWorkspaceAllowList = "workspace-allow-list"
23-
KeyWorkspaceDenyList = "workspace-deny-list"
24-
KeyAllowAutoMerge = "allow-auto-merge"
25-
KeyFailCIOnSentinelSoftFail = "fail-ci-on-sentinel-soft-fail"
26-
KeyDeleteOldComments = "delete-old-comments"
27-
KeyNATSServiceURL = "nats-service-url"
28-
KeyGitlabProjectAllowList = "gitlab-project-allow-list"
29-
KeyLegacyProjectAllowList = "project-allow-list"
30-
KeyGithubRepoAllowList = "github-repo-allow-list"
31-
KeyGithubCloneDepth = "github-clone-depth"
32-
KeyGitlabCloneDepth = "gitlab-clone-depth"
14+
KeyLogLevel = "log-level"
15+
KeyDevMode = "dev-mode"
16+
KeyOTELEnabled = "otel-enabled"
17+
KeyOTELCollectorHost = "otel-collector-host"
18+
KeyOTELCollectorPort = "otel-collector-port"
19+
KeyGitlabHookSecretKey = "gitlab-hook-secret-key"
20+
KeyGithubHookSecretKey = "github-hook-secret-key"
21+
KeyDefaultTFCOrganization = "default-tfc-organization"
22+
KeyWorkspaceAllowList = "workspace-allow-list"
23+
KeyWorkspaceDenyList = "workspace-deny-list"
24+
KeyAllowAutoMerge = "allow-auto-merge"
25+
KeyFailCIOnSentinelSoftFail = "fail-ci-on-sentinel-soft-fail"
26+
KeyDeleteOldComments = "delete-old-comments"
27+
KeyNATSServiceURL = "nats-service-url"
28+
KeyGitlabProjectAllowList = "gitlab-project-allow-list"
29+
KeyLegacyProjectAllowList = "project-allow-list"
30+
KeyGithubRepoAllowList = "github-repo-allow-list"
31+
KeyGithubCloneDepth = "github-clone-depth"
32+
KeyGitlabCloneDepth = "gitlab-clone-depth"
33+
KeyWorkspaceFanoutEnabled = "workspace-fanout-enabled"
34+
KeyWorkspaceJetStreamReplicas = "workspace-jetstream-replicas"
35+
KeyTFCRateLimitRPS = "tfc-rate-limit-rps"
36+
KeyTFCRateLimitBurst = "tfc-rate-limit-burst"
3337
)
3438

3539
type Config struct {
36-
LogLevel string `mapstructure:"log-level"`
37-
DevMode bool `mapstructure:"dev-mode"`
38-
OTELEnabled bool `mapstructure:"otel-enabled"`
39-
OTELCollectorHost string `mapstructure:"otel-collector-host"`
40-
OTELCollectorPort string `mapstructure:"otel-collector-port"`
41-
GitlabHookSecretKey string `mapstructure:"gitlab-hook-secret-key"`
42-
GithubHookSecretKey string `mapstructure:"github-hook-secret-key"`
43-
DefaultTFCOrganization string `mapstructure:"default-tfc-organization"`
44-
WorkspaceAllowList []string `mapstructure:"workspace-allow-list"`
45-
WorkspaceDenyList []string `mapstructure:"workspace-deny-list"`
46-
AllowAutoMerge bool `mapstructure:"allow-auto-merge"`
47-
FailCIOnSentinelSoftFail bool `mapstructure:"fail-ci-on-sentinel-soft-fail"`
48-
DeleteOldComments bool `mapstructure:"delete-old-comments"`
49-
NATSServiceURL string `mapstructure:"nats-service-url"`
50-
GitlabProjectAllowList []string `mapstructure:"gitlab-project-allow-list"`
51-
LegacyProjectAllowList []string `mapstructure:"project-allow-list"`
52-
GithubRepoAllowList []string `mapstructure:"github-repo-allow-list"`
53-
GithubCloneDepth int `mapstructure:"github-clone-depth"`
54-
GitlabCloneDepth int `mapstructure:"gitlab-clone-depth"`
40+
LogLevel string `mapstructure:"log-level"`
41+
DevMode bool `mapstructure:"dev-mode"`
42+
OTELEnabled bool `mapstructure:"otel-enabled"`
43+
OTELCollectorHost string `mapstructure:"otel-collector-host"`
44+
OTELCollectorPort string `mapstructure:"otel-collector-port"`
45+
GitlabHookSecretKey string `mapstructure:"gitlab-hook-secret-key"`
46+
GithubHookSecretKey string `mapstructure:"github-hook-secret-key"`
47+
DefaultTFCOrganization string `mapstructure:"default-tfc-organization"`
48+
WorkspaceAllowList []string `mapstructure:"workspace-allow-list"`
49+
WorkspaceDenyList []string `mapstructure:"workspace-deny-list"`
50+
AllowAutoMerge bool `mapstructure:"allow-auto-merge"`
51+
FailCIOnSentinelSoftFail bool `mapstructure:"fail-ci-on-sentinel-soft-fail"`
52+
DeleteOldComments bool `mapstructure:"delete-old-comments"`
53+
NATSServiceURL string `mapstructure:"nats-service-url"`
54+
GitlabProjectAllowList []string `mapstructure:"gitlab-project-allow-list"`
55+
LegacyProjectAllowList []string `mapstructure:"project-allow-list"`
56+
GithubRepoAllowList []string `mapstructure:"github-repo-allow-list"`
57+
GithubCloneDepth int `mapstructure:"github-clone-depth"`
58+
GitlabCloneDepth int `mapstructure:"gitlab-clone-depth"`
59+
WorkspaceFanoutEnabled bool `mapstructure:"workspace-fanout-enabled"`
60+
WorkspaceJetStreamReplicas int `mapstructure:"workspace-jetstream-replicas"`
61+
TFCRateLimitRPS int `mapstructure:"tfc-rate-limit-rps"`
62+
TFCRateLimitBurst int `mapstructure:"tfc-rate-limit-burst"`
5563
}
5664

5765
var C Config
@@ -83,6 +91,10 @@ var bindings = []binding{
8391
{key: KeyGithubRepoAllowList, defaultValue: []string{}, description: "Comma-separated GitHub repository allow list prefixes."},
8492
{key: KeyGithubCloneDepth, defaultValue: 0, description: "Git clone depth to use for GitHub merge request checkouts. Zero means full history."},
8593
{key: KeyGitlabCloneDepth, defaultValue: 0, description: "Git clone depth to use for GitLab merge request checkouts. Zero means full history."},
94+
{key: KeyWorkspaceFanoutEnabled, defaultValue: true, description: "Enable per-workspace JetStream fan-out (one NATS message per workspace) to keep AckWait windows scoped per workspace. When disabled, TFBuddy falls back to the inline per-MR loop."},
95+
{key: KeyWorkspaceJetStreamReplicas, defaultValue: 1, description: "JetStream replica count for the workspace-trigger stream. Use 1 for single-node NATS or local dev; set to your NATS cluster size (often 3) in production for durability."},
96+
{key: KeyTFCRateLimitRPS, defaultValue: 30, description: "Client-side rate limit (requests per second) for the Terraform Cloud API. Tuned to match TFC's documented per-token limit and prevent 429s when many workspaces are triggered concurrently."},
97+
{key: KeyTFCRateLimitBurst, defaultValue: 30, description: "Burst capacity for the TFC API token-bucket rate limiter."},
8698
}
8799

88100
func init() {

pkg/gitlab_hooks/comment_actions.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,12 @@ import (
1414
"go.opentelemetry.io/otel/attribute"
1515
)
1616

17+
// deliveryIDProvider is the optional accessor production envelopes implement
18+
// to thread the webhook delivery ID without widening vcs.MRCommentEvent.
19+
type deliveryIDProvider interface {
20+
GetDeliveryID() string
21+
}
22+
1723
// processNoteEvent processes GitLab Webhooks for Note events
1824
// In the Gitlab API, MR comments are called Notes
1925
func (w *GitlabEventWorker) processNoteEvent(ctx context.Context, event vcs.MRCommentEvent) (projectName string, err error) {
@@ -44,6 +50,9 @@ func (w *GitlabEventWorker) processNoteEvent(ctx context.Context, event vcs.MRCo
4450
opts.TriggerOpts.MergeRequestIID = event.GetMR().GetInternalID()
4551
opts.TriggerOpts.TriggerSource = tfc_trigger.CommentTrigger
4652
opts.TriggerOpts.VcsProvider = "gitlab"
53+
if dp, ok := event.(deliveryIDProvider); ok {
54+
opts.TriggerOpts.DeliveryID = dp.GetDeliveryID()
55+
}
4756

4857
cfg, err := tfc_trigger.NewTFCTriggerConfig(opts.TriggerOpts)
4958
if err != nil {
@@ -52,6 +61,9 @@ func (w *GitlabEventWorker) processNoteEvent(ctx context.Context, event vcs.MRCo
5261
}
5362

5463
trigger := w.triggerCreation(w.cfg, w.gl, w.tfc, w.runstream, cfg)
64+
if w.workspaceStream != nil {
65+
trigger.SetWorkspaceStream(w.workspaceStream)
66+
}
5567

5668
if event.GetAttributes().GetType() == string(gitlab.DiscussionNote) {
5769
trigger.SetMergeRequestDiscussionID(event.GetAttributes().GetDiscussionID())

pkg/gitlab_hooks/handler.go

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,19 @@ import (
2828
const GitlabTokenHeader = "X-Gitlab-Token"
2929
const GitlabHookIgnoreReasonUnhandledEventType = "unhandled-event-type"
3030

31+
// gitlabDeliveryHeaders identify a webhook delivery. Some integrations
32+
// surface only Idempotency-Key, so we accept either.
33+
var gitlabDeliveryHeaders = []string{"X-Gitlab-Event-UUID", "Idempotency-Key"}
34+
35+
func gitlabDeliveryID(r *http.Request) string {
36+
for _, h := range gitlabDeliveryHeaders {
37+
if v := r.Header.Get(h); v != "" {
38+
return v
39+
}
40+
}
41+
return ""
42+
}
43+
3144
type TriggerCreationFunc func(appCfg config.Config,
3245
gl vcs.GitClient,
3346
tfc tfc_api.ApiClient,
@@ -40,6 +53,7 @@ type GitlabHooksHandler struct {
4053
gl vcs.GitClient
4154
runstream runstream.StreamClient
4255
triggerCreation TriggerCreationFunc
56+
workspaceStream tfc_trigger.WorkspacePublisher
4357

4458
// hook streams and workers
4559
hookSecretKey string
@@ -48,7 +62,7 @@ type GitlabHooksHandler struct {
4862
hooksWorker *GitlabEventWorker
4963
}
5064

51-
func NewGitlabHooksHandler(cfg config.Config, gl vcs.GitClient, tfc tfc_api.ApiClient, rs runstream.StreamClient, js nats.JetStreamContext) *GitlabHooksHandler {
65+
func NewGitlabHooksHandler(cfg config.Config, gl vcs.GitClient, tfc tfc_api.ApiClient, rs runstream.StreamClient, js nats.JetStreamContext, workspaceStream tfc_trigger.WorkspacePublisher) *GitlabHooksHandler {
5266
notesStream := gongs.NewGenericStream[NoteEventMsg](js, noteEventsStreamSubject(), hooks_stream.HooksStreamName)
5367
mrStream := gongs.NewGenericStream[MergeRequestEventMsg](js, mrEventsStreamSubject(), hooks_stream.HooksStreamName)
5468

@@ -58,6 +72,7 @@ func NewGitlabHooksHandler(cfg config.Config, gl vcs.GitClient, tfc tfc_api.ApiC
5872
gl: gl,
5973
runstream: rs,
6074
triggerCreation: tfc_trigger.NewTFCTrigger,
75+
workspaceStream: workspaceStream,
6176
mrStream: mrStream,
6277
notesStream: notesStream,
6378
hookSecretKey: cfg.GitlabHookSecretKey,
@@ -116,6 +131,7 @@ func (h *GitlabHooksHandler) handler(c echo.Context) error {
116131
msg := &MergeRequestEventMsg{
117132
GitlabHookEvent: GitlabHookEvent{},
118133
Payload: event,
134+
DeliveryID: gitlabDeliveryID(c.Request()),
119135
}
120136

121137
proj = event.Project.PathWithNamespace
@@ -130,6 +146,7 @@ func (h *GitlabHooksHandler) handler(c echo.Context) error {
130146
if checkError(ctx, err, "could not decode Note/Comment event") {
131147
break
132148
}
149+
event.DeliveryID = gitlabDeliveryID(c.Request())
133150
log.Info().Str("project", event.Payload.GetProject().GetPathWithNamespace()).Int("mergeRequestID", event.Payload.GetMR().GetInternalID()).Str("discussionID", event.Payload.GetDiscussionID()).Msg("processing GitLab Note/Comment event")
134151

135152
proj = event.Payload.GetProject().GetPathWithNamespace()

0 commit comments

Comments
 (0)