Files
kandev/apps/backend/internal/github/controller_webhook_test.go
T
João Salavisa 37b56ba11c feat(automations): reliable cron scheduling and webhook-driven push/CI triggers (#1972)
* feat(automations): reliable cron scheduling and webhook-driven push/CI triggers

Automations were "wounded": cron schedules mishandled timezones and some
never fired, and the GitHub push/CI trigger types were coming-soon stubs.

Cron (fix):
- The scheduler reduced every cron expression to a fixed interval derived
  only from a */N step in the minute/hour field, so any pinned schedule
  (0 9 * * *, 30 14 * * *, weekday ranges, day-of-month) never fired, and
  the configured timezone was parsed but never used.
- Replace the interval approximation with robfig/cron/v3: shouldFire now
  computes the true next fire time in the configured timezone (DST-aware,
  defaulting to UTC for host-independence), anchored to last-evaluated or
  creation time. Dead interval-parsing helpers and shorthand constants
  removed.

GitHub push + CI triggers (feat):
- Implement push and CI-failure triggers end to end via the existing
  per-workspace GitHub App webhook plumbing (#1810): the HMAC-verified
  webhook service now parses push and completed check_run events, resolves
  the installation to its workspaces, and publishes normalized bus events.
- A new automation subscriber matches enabled github_push / github_ci
  triggers by workspace + repo + branch/conclusion/check-name filters and
  fires them (deduped by commit SHA / check-run id). The old poll-based
  stub evaluators are removed; the PR poll path is unchanged.
- Subscribe the App manifest to push/check_run and enable both trigger
  types in the registry (CI defaults to the failure conclusion).

Docs:
- Update docs/public/feature-status.md and automation-and-mcp.md so the
  cron and push/CI rows reflect shipped reality.

Tests: regression tests pin the previously-never-firing cron shapes,
timezone/DST behavior, and host-independence; backend tests cover the
webhook parse->publish and subscriber match/fire/dedup flows for both
push and CI-failure.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(automations): populate push.message and ci.url placeholders in fired trigger data

Address PR review blockers: the default push and CI automation prompts
reference {{push.message}} and {{ci.url}}, which the interpolator maps to
data["message"] / data["html_url"] — but the webhook subscriber never
populated those fields, so both always rendered empty.

- Parse head_commit.message (push) and check_run.html_url (CI) in the
  GitHub webhook service and carry them on the published bus payloads.
- Include "message" / "html_url" in the fired trigger data so the
  placeholders resolve; extend the webhook and subscriber tests to assert
  both flow through end to end.

Non-blocking review cleanups:
- Replace the misleading reuse of matchesAuthors for CI conclusions/check
  names with a clearly-named matchesFilterValue helper.
- Drop dead context.Background() line and its unused import from the cron
  regression test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(automations): scope webhook triggers to active installs and per-branch dedup

Address CodeRabbit review on the push/CI webhook triggers:

- Security: resolveWorkspaceIDs now only returns connections whose status is
  active, so a suspended or revoked GitHub App installation can no longer fire
  push/CI automations.
- Correctness: include the branch in the push dedup key
  (push:owner/name@branch@sha) so the same commit pushed to two matching
  branches fires once per branch instead of being suppressed.
- Correctness: add an optional head-branch filter to github_ci triggers and
  gate CI matching on it, matching the push trigger's branch-scoping.
- Stability: make the webhook subscriber's Start retryable — a subscription
  failure rolls back partial subscriptions and leaves the subscriber
  un-started so a later Start re-attempts, instead of permanently disabling
  push/CI processing.
- Tests: cover multi-connection workspace resolution (dedup + inactive
  filtering), per-branch push dedup, and CI branch filtering.
- Docs: note the CI branch filter and per-branch push dedup.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(automations): cover webhook subscriber Start retry-after-failure path

Add TestGitHubWebhookSubscriberStartRetryableAfterFailure with a minimal
flaky bus whose second Subscribe fails, pinning the retry contract: a
subscription failure rolls back the partial subscription and leaves the
subscriber un-started, and a later Start re-attempts and succeeds.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Kandev Agent <agent@kandev.dev>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-27 10:24:57 +01:00

71 lines
2.2 KiB
Go

package github
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/gin-gonic/gin"
)
func TestHTTPGitHubWebhookRoutesByAppRegistration(t *testing.T) {
gin.SetMode(gin.TestMode)
store := &githubWebhookMemoryStore{}
webhooks := NewAppRegistrationWebhookService(
"registration-work", "work-secret", store, nil, nil, nil,
)
runtime := &githubAppRuntime{
registrationID: "registration-work", source: DeploymentAppSourceManaged,
generation: 1, webhookAuth: webhooks,
}
service := &Service{appRegistrationRuntimes: map[string]*githubAppRuntime{
"registration-work": runtime,
}}
router := gin.New()
NewController(service, nil).RegisterHTTPRoutes(router)
payload := `{"action":"created","installation":{"id":42}}`
request := signedWebhookRequest(
"work-secret", "delivery-1", "installation", []byte(payload),
)
response := performWebhookRequest(
router, "/api/v1/github/app/registrations/registration-work/webhook", request,
)
if response.Code != http.StatusOK {
t.Fatalf("matching route status = %d, body = %s", response.Code, response.Body.String())
}
if len(store.deliveries) != 1 {
t.Fatalf("matching route deliveries = %#v", store.deliveries)
}
response = performWebhookRequest(
router, "/api/v1/github/app/registrations/registration-personal/webhook", request,
)
if response.Code != http.StatusConflict || len(store.deliveries) != 1 {
t.Fatalf(
"wrong route status = %d, deliveries = %#v, body = %s",
response.Code, store.deliveries, response.Body.String(),
)
}
response = performWebhookRequest(router, "/api/v1/github/app/webhook", request)
if response.Code != http.StatusNotFound {
t.Fatalf("legacy route status = %d, want 404", response.Code)
}
}
func performWebhookRequest(
router http.Handler,
path string,
request GitHubWebhookRequest,
) *httptest.ResponseRecorder {
httpRequest := httptest.NewRequest(http.MethodPost, path, strings.NewReader(string(request.Payload)))
httpRequest.Header.Set("X-GitHub-Delivery", request.DeliveryID)
httpRequest.Header.Set("X-GitHub-Event", request.Event)
httpRequest.Header.Set("X-Hub-Signature-256", request.Signature)
response := httptest.NewRecorder()
router.ServeHTTP(response, httpRequest)
return response
}