Skip to content

Commit b398911

Browse files
committed
feat: Enhance error reporting for webhook processing failures
Introduced a mechanism to extract minimal event information from raw webhook payloads before full parsing. This information is now used to post a failure status back to the Git provider and emit a Kubernetes event to the controller namespace if payload parsing fails. Additionally, when an incoming webhook does not match an existing Repository CR, a failure status is now posted to the Git provider, along with a controller namespace event. This provides earlier and more comprehensive feedback to users and cluster administrators regarding webhook processing failures or misconfigurations. AI-assisted-by: Claude Signed-off-by: Chmouel Boudjnah <chmouel@redhat.com>
1 parent 5b220d7 commit b398911

File tree

7 files changed

+484
-23
lines changed

7 files changed

+484
-23
lines changed

pkg/adapter/adapter.go

Lines changed: 64 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -192,15 +192,19 @@ func (l listener) handleEvent(ctx context.Context) http.HandlerFunc {
192192
}
193193
gitProvider.SetPacInfo(&pacInfo)
194194

195+
// Extract minimal info early for error reporting before full parsing
196+
minimalInfo := l.extractMinimalInfo(ctx, request, payload, gitProvider)
197+
195198
s := sinker{
196-
run: l.run,
197-
vcx: gitProvider,
198-
kint: l.kint,
199-
event: l.event,
200-
logger: logger,
201-
payload: payload,
202-
pacInfo: &pacInfo,
203-
globalRepo: globalRepo,
199+
run: l.run,
200+
vcx: gitProvider,
201+
kint: l.kint,
202+
event: l.event,
203+
logger: logger,
204+
payload: payload,
205+
pacInfo: &pacInfo,
206+
globalRepo: globalRepo,
207+
minimalInfo: minimalInfo,
204208
}
205209

206210
// clone the request to use it further
@@ -289,3 +293,55 @@ func (l listener) writeResponse(response http.ResponseWriter, statusCode int, me
289293
l.logger.Errorf("failed to write back sink response: %v", err)
290294
}
291295
}
296+
297+
// extractMinimalInfo extracts minimal event info from the raw payload for error reporting.
298+
// This is done before full payload parsing so that errors during parsing can still be
299+
// reported back to the user via the Git provider or controller namespace events.
300+
// For GitHub Apps, it also generates a token early so we can post status on errors.
301+
func (l listener) extractMinimalInfo(ctx context.Context, request *http.Request, payload []byte, gitProvider provider.Interface) *provider.MinimalEventInfo {
302+
var minimalInfo *provider.MinimalEventInfo
303+
304+
// Try to extract based on provider type
305+
switch request.Header.Get("X-GitHub-Event") {
306+
case "":
307+
// Not a GitHub event, try other providers
308+
if eventType := request.Header.Get("X-Gitea-Event-Type"); eventType != "" {
309+
minimalInfo = provider.ExtractMinimalInfoFromGitea(eventType, payload)
310+
} else if eventType := request.Header.Get("X-Gitlab-Event"); eventType != "" {
311+
minimalInfo = provider.ExtractMinimalInfoFromGitLab(eventType, payload)
312+
} else {
313+
// Fallback to generic extraction (just URL)
314+
minimalInfo = provider.ExtractMinimalInfoGeneric(payload)
315+
}
316+
default:
317+
// GitHub event
318+
eventType := request.Header.Get("X-GitHub-Event")
319+
gheURL := request.Header.Get("X-GitHub-Enterprise-Host")
320+
minimalInfo = provider.ExtractMinimalInfoFromGitHub(eventType, payload, gheURL)
321+
}
322+
323+
if minimalInfo == nil {
324+
return nil
325+
}
326+
327+
// For GitHub Apps with installation ID, try to generate token early
328+
// This allows us to post status even if full parsing fails later
329+
if minimalInfo.InstallationID > 0 {
330+
if ghProvider, ok := gitProvider.(*github.Provider); ok {
331+
token, err := ghProvider.GetAppToken(
332+
ctx,
333+
l.run.Clients.Kube,
334+
minimalInfo.GHEURL,
335+
minimalInfo.InstallationID,
336+
l.run.Info.Kube.Namespace,
337+
)
338+
if err != nil {
339+
l.logger.Warnf("failed to get GitHub App token for early error reporting: %v", err)
340+
} else {
341+
minimalInfo.Token = token
342+
}
343+
}
344+
}
345+
346+
return minimalInfo
347+
}

pkg/adapter/sinker.go

Lines changed: 63 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,11 @@ package adapter
33
import (
44
"bytes"
55
"context"
6+
"fmt"
67
"net/http"
78

89
"github.com/openshift-pipelines/pipelines-as-code/pkg/apis/pipelinesascode/v1alpha1"
10+
"github.com/openshift-pipelines/pipelines-as-code/pkg/events"
911
"github.com/openshift-pipelines/pipelines-as-code/pkg/kubeinteraction"
1012
"github.com/openshift-pipelines/pipelines-as-code/pkg/params"
1113
"github.com/openshift-pipelines/pipelines-as-code/pkg/params/info"
@@ -15,21 +17,24 @@ import (
1517
)
1618

1719
type sinker struct {
18-
run *params.Run
19-
vcx provider.Interface
20-
kint kubeinteraction.Interface
21-
event *info.Event
22-
logger *zap.SugaredLogger
23-
payload []byte
24-
pacInfo *info.PacOpts
25-
globalRepo *v1alpha1.Repository
20+
run *params.Run
21+
vcx provider.Interface
22+
kint kubeinteraction.Interface
23+
event *info.Event
24+
logger *zap.SugaredLogger
25+
payload []byte
26+
pacInfo *info.PacOpts
27+
globalRepo *v1alpha1.Repository
28+
minimalInfo *provider.MinimalEventInfo
2629
}
2730

2831
func (s *sinker) processEventPayload(ctx context.Context, request *http.Request) error {
2932
var err error
3033
s.event, err = s.vcx.ParsePayload(ctx, s.run, request, string(s.payload))
3134
if err != nil {
3235
s.logger.Errorf("failed to parse event: %v", err)
36+
// Report the parse error to the user via Git provider or controller events
37+
s.reportParseError(ctx, err)
3338
return err
3439
}
3540

@@ -74,3 +79,53 @@ func (s *sinker) processEvent(ctx context.Context, request *http.Request) error
7479
p := pipelineascode.NewPacs(s.event, s.vcx, s.run, s.pacInfo, s.kint, s.logger, s.globalRepo)
7580
return p.Run(ctx)
7681
}
82+
83+
// reportParseError reports a payload parsing error to the user.
84+
// It attempts to post a status to the Git provider if we have enough info (token, SHA, org, repo).
85+
// It also emits a Kubernetes event to the controller namespace for visibility.
86+
func (s *sinker) reportParseError(ctx context.Context, parseErr error) {
87+
if s.minimalInfo == nil {
88+
// No minimal info available, just emit to controller namespace
89+
emitter := events.NewEventEmitter(s.run.Clients.Kube, s.logger)
90+
emitter.SetControllerNamespace(s.run.Info.Kube.Namespace)
91+
emitter.EmitControllerEvent("PayloadParseError", parseErr.Error(), "", "")
92+
return
93+
}
94+
95+
// Try to post status to Git provider if we have a token and SHA
96+
// Note: For GitLab, Organization contains the full path (org/repo) and Repository is empty
97+
// For other providers, both Organization and Repository are populated
98+
if s.minimalInfo.Token != "" && s.minimalInfo.SHA != "" &&
99+
(s.minimalInfo.Organization != "" || s.minimalInfo.Repository != "") {
100+
// Create a minimal event for CreateStatus
101+
event := &info.Event{
102+
Organization: s.minimalInfo.Organization,
103+
Repository: s.minimalInfo.Repository,
104+
SHA: s.minimalInfo.SHA,
105+
URL: s.minimalInfo.URL,
106+
EventType: s.minimalInfo.EventType,
107+
Provider: &info.Provider{
108+
Token: s.minimalInfo.Token,
109+
URL: s.minimalInfo.GHEURL,
110+
},
111+
}
112+
113+
status := provider.StatusOpts{
114+
Status: "completed",
115+
Conclusion: "failure",
116+
Title: "Webhook Processing Error",
117+
Text: fmt.Sprintf("Failed to process webhook payload: %v", parseErr),
118+
}
119+
120+
if err := s.vcx.CreateStatus(ctx, event, status); err != nil {
121+
s.logger.Warnf("failed to create status for parse error: %v", err)
122+
} else {
123+
s.logger.Info("posted error status to Git provider for payload parse error")
124+
}
125+
}
126+
127+
// Also emit to controller namespace for visibility
128+
emitter := events.NewEventEmitter(s.run.Clients.Kube, s.logger)
129+
emitter.SetControllerNamespace(s.run.Info.Kube.Namespace)
130+
emitter.EmitControllerEvent("PayloadParseError", parseErr.Error(), s.minimalInfo.URL, s.minimalInfo.SHA)
131+
}

pkg/events/emit.go

Lines changed: 64 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,14 +22,76 @@ func NewEventEmitter(client kubernetes.Interface, logger *zap.SugaredLogger) *Ev
2222
}
2323

2424
type EventEmitter struct {
25-
client kubernetes.Interface
26-
logger *zap.SugaredLogger
25+
client kubernetes.Interface
26+
logger *zap.SugaredLogger
27+
controllerNamespace string
2728
}
2829

2930
func (e *EventEmitter) SetLogger(logger *zap.SugaredLogger) {
3031
e.logger = logger
3132
}
3233

34+
// SetControllerNamespace sets the controller namespace for emitting events
35+
// when no Repository CR is available.
36+
func (e *EventEmitter) SetControllerNamespace(ns string) {
37+
e.controllerNamespace = ns
38+
}
39+
40+
// EmitControllerEvent emits a Kubernetes event in the controller namespace
41+
// when no Repository CR is available (e.g., before repo matching or on parse errors).
42+
// This allows visibility into webhook processing errors even without a matched repository.
43+
func (e *EventEmitter) EmitControllerEvent(reason, message, sourceURL, sha string) {
44+
// Always log the message
45+
if e.logger != nil {
46+
e.logger.Warnf("%s: %s", reason, message)
47+
}
48+
49+
// Create K8s event in controller namespace if possible
50+
if e.client == nil || e.controllerNamespace == "" {
51+
return
52+
}
53+
54+
annotations := map[string]string{
55+
keys.ControllerInfo: "true",
56+
}
57+
if sourceURL != "" {
58+
annotations[keys.SourceRepoURL] = sourceURL
59+
}
60+
if sha != "" {
61+
annotations[keys.SHA] = sha
62+
}
63+
64+
event := &v1.Event{
65+
ObjectMeta: metav1.ObjectMeta{
66+
GenerateName: "pac-webhook-",
67+
Namespace: e.controllerNamespace,
68+
Labels: map[string]string{
69+
"pipelinesascode.tekton.dev/event-type": "webhook-error",
70+
},
71+
Annotations: annotations,
72+
},
73+
Message: message,
74+
Reason: reason,
75+
Type: v1.EventTypeWarning,
76+
// InvolvedObject references a generic resource in the controller namespace
77+
// since we don't have a specific Repository CR to reference
78+
InvolvedObject: v1.ObjectReference{
79+
APIVersion: "v1",
80+
Kind: "Namespace",
81+
Name: e.controllerNamespace,
82+
},
83+
Source: v1.EventSource{
84+
Component: "Pipelines As Code",
85+
},
86+
}
87+
88+
if _, err := e.client.CoreV1().Events(e.controllerNamespace).Create(context.Background(), event, metav1.CreateOptions{}); err != nil {
89+
if e.logger != nil {
90+
e.logger.Infof("Cannot create controller event: %s", err.Error())
91+
}
92+
}
93+
}
94+
3395
func (e *EventEmitter) EmitMessage(repo *v1alpha1.Repository, loggerLevel zapcore.Level, reason, message string) {
3496
if repo != nil && e.client != nil {
3597
event := makeEvent(repo, loggerLevel, reason, message)

pkg/pipelineascode/match.go

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,25 @@ func (p *PacRun) verifyRepoAndUser(ctx context.Context) (*v1alpha1.Repository, e
5454

5555
if repo == nil {
5656
msg := fmt.Sprintf("cannot find a repository match for %s", p.event.URL)
57-
p.eventEmitter.EmitMessage(nil, zap.WarnLevel, "RepositoryNamespaceMatch", msg)
57+
58+
// Try to post status to Git provider if we have a token and SHA
59+
// This gives users visibility even when no Repository CR is configured
60+
if p.event.Provider != nil && p.event.Provider.Token != "" && p.event.SHA != "" {
61+
status := provider.StatusOpts{
62+
Status: "completed",
63+
Conclusion: "failure",
64+
Title: "Repository Not Configured",
65+
Text: fmt.Sprintf("No Pipelines-as-Code Repository CR found matching URL: %s. Please create a Repository CR to enable CI.", p.event.URL),
66+
}
67+
if err := p.vcx.CreateStatus(ctx, p.event, status); err != nil {
68+
p.logger.Warnf("failed to create status for no repo match: %v", err)
69+
} else {
70+
p.logger.Info("posted error status to Git provider for no repository match")
71+
}
72+
}
73+
74+
// Also emit to controller namespace for cluster-level visibility
75+
p.eventEmitter.EmitControllerEvent("RepositoryNamespaceMatch", msg, p.event.URL, p.event.SHA)
5876
return nil, nil
5977
}
6078

pkg/pipelineascode/pipelineascode.go

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,9 +48,13 @@ type PacRun struct {
4848
}
4949

5050
func NewPacs(event *info.Event, vcx provider.Interface, run *params.Run, pacInfo *info.PacOpts, k8int kubeinteraction.Interface, logger *zap.SugaredLogger, globalRepo *v1alpha1.Repository) PacRun {
51+
emitter := events.NewEventEmitter(run.Clients.Kube, logger)
52+
if run.Info.Kube != nil && run.Info.Kube.Namespace != "" {
53+
emitter.SetControllerNamespace(run.Info.Kube.Namespace)
54+
}
5155
return PacRun{
5256
event: event, run: run, vcx: vcx, k8int: k8int, pacInfo: pacInfo, logger: logger, globalRepo: globalRepo,
53-
eventEmitter: events.NewEventEmitter(run.Clients.Kube, logger),
57+
eventEmitter: emitter,
5458
manager: NewConcurrencyManager(),
5559
}
5660
}

pkg/pipelineascode/pipelineascode_test.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -377,7 +377,7 @@ func TestRun(t *testing.T) {
377377
finalStatusText: "directory for this repository",
378378
},
379379
{
380-
name: "Skipped/Test no repositories match",
380+
name: "Error/Test no repositories match",
381381
runevent: info.Event{
382382
SHA: "principale",
383383
Organization: "organizationes",
@@ -390,8 +390,8 @@ func TestRun(t *testing.T) {
390390
TriggerTarget: "pull_request",
391391
},
392392
tektondir: "",
393-
finalStatus: "skipped",
394-
finalStatusText: "not find a namespace match",
393+
finalStatus: "failure",
394+
finalStatusText: "No Pipelines-as-Code Repository CR found matching URL",
395395
repositories: []*v1alpha1.Repository{
396396
testnewrepo.NewRepo(
397397
testnewrepo.RepoTestcreationOpts{

0 commit comments

Comments
 (0)