diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..b4f63d1 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,74 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## What This Is + +A Cycloid plugin that ETLs data from Sentry (organizations → projects → issues) into a local SQLite database and exposes HTTP endpoints for the Cycloid platform to interact with. + +## Commands + +```bash +# Build +go build -o sentry main.go + +# Run tests (requires Docker — tests run inside container) +make test +make test ARGS="./service" # specific package + +# Regenerate mocks and enum string methods +make gen +``` + +## Environment Variables + +| Variable | Default | Required | +|----------|---------|----------| +| `SENTRY_API_KEY` | — | Yes | +| `SENTRY_ENDPOINT` | `http://sentry.io/api/0/` | No | +| `SENTRY_ORGANIZATION_SLUG` | — | No (fetches all orgs if unset) | +| `DB_FILE` | — | No (in-memory SQLite if unset) | +| `PORT` | `8080` | No | + +## Architecture + +### Data Flow + +`main.go` initializes DB → applies `schema.sql` (via `//go:embed`) → creates `service.Plugin` → starts HTTP server + background goroutine that calls `Resync()` immediately. + +`Resync()` in `service/service.go`: +1. Deletes all organizations (CASCADE deletes projects and issues via FK constraints) +2. Fetches orgs from Sentry API (or one specific org if slug configured) +3. For each org: fetches projects; for each project: fetches issues +4. Writes everything via repository interfaces + +### HTTP Endpoints (`service/transport/http/handler.go`) + +- `GET /_cy/ping` — returns status JSON +- `POST /_cy/resync` — manually triggers `Resync()` +- `POST /_cy/events` — stub +- `DELETE /_cy/plugin` — stub + +### Layering + +- **`sentry/`** — wraps `atlassian/go-sentry-api` client; contains `ToOrganization()`, `ToProject()`, `ToIssue()` converters +- **`organization/`, `project/`, `issue/`** — domain models + repository interfaces +- **`sqlite/`** — SQLite implementations of those repository interfaces +- **`mock/`** — generated mocks (do not edit manually; regenerate with `make gen`) +- **`service/`** — orchestration, status management, HTTP transport + +### Key Patterns + +**Repository pattern with DI**: `Plugin` struct receives all repositories and the Sentry client via `New()`. No global state. + +**Status enum** (`service/status.go`): `Ok`, `Syncthing`, `Error` — protected by `sync.RWMutex`. Status is set to `Error` on Resync failures but the loop continues to next item. + +**Enums with codegen**: `service.Status`, `event.Severity`, `event.Type`, `event.Color` use `github.com/dmarkham/enumer`. Add `//go:generate` directives and run `make gen`. + +**NULL helpers in `sqlite/`**: `toNullString()`, `toNullBool()`, `toNullInt64()`, `toNullTime()` convert zero/empty values to SQL NULLs. + +### Platform Integration Files + +- `manifest.yaml` — plugin metadata, config options, and relation definitions for Cycloid +- `widgets.yaml` — widget queries/selections rendered in the Cycloid UI +- `schema.sql` — embedded in the binary and applied at startup diff --git a/Makefile b/Makefile index e768049..97899e3 100644 --- a/Makefile +++ b/Makefile @@ -13,6 +13,7 @@ ifeq ($(ARGS),) endif VERSION=0.0.1 +LOCAL_REGISTRY=localhost:5000 .PHONY: help help: Makefile ## This help dialog @@ -38,3 +39,8 @@ test: ## Tests the Plugin docker-release: ## Builds the base Docker image for the registry @docker build -f ./docker/Dockerfile -t cycloid/sentry-plugin:$(VERSION) . @docker push cycloid/sentry-plugin:$(VERSION) + +.PHONY: docker-local +docker-local: ## Builds and pushes the Docker image to a local registry + @docker build --provenance=false -f ./docker/Dockerfile -t $(LOCAL_REGISTRY)/cycloid/sentry-plugin:$(VERSION) . + @docker push $(LOCAL_REGISTRY)/cycloid/sentry-plugin:$(VERSION) diff --git a/main.go b/main.go index 1f7ca21..fd3132c 100644 --- a/main.go +++ b/main.go @@ -40,11 +40,14 @@ func main() { logger.Error(fmt.Errorf("failed to load config: %w", err).Error()) } + logger.Info("loaded config", "db_file", cfg.DB.File) + // By default we use the 'memory' setting so for testing we can easily use it q := "file::memory:?cache=shared&_foreign_keys=true" if cfg.DB.File != "" { q = cfg.DB.File + "?_foreign_keys=true" } + logger.Info("opening SQLite database", "dsn", q) db, err := sql.Open("sqlite3", q) if err != nil { started = false @@ -67,9 +70,14 @@ func main() { pr = sqlite.NewProjectRepository(db) ir = sqlite.NewIssueRepository(db) - ss, err = sentry.New(cfg.Sentry.APIKey, cfg.Sentry.Endpoint) - if err != nil { - started = false + if cfg.Sentry.APIKey == sentry.E2EFixturesKey { + logger.Info("e2e fixture mode enabled: using hardcoded Sentry data") + ss = &sentry.FixtureService{} + } else { + ss, err = sentry.New(cfg.Sentry.APIKey, cfg.Sentry.Endpoint) + if err != nil { + started = false + } } } sctx, cfn := context.WithCancel(context.TODO()) diff --git a/sentry/fixtures_e2e.go b/sentry/fixtures_e2e.go new file mode 100644 index 0000000..4b43e39 --- /dev/null +++ b/sentry/fixtures_e2e.go @@ -0,0 +1,100 @@ +package sentry + +import ( + "time" + + sentry "github.com/atlassian/go-sentry-api" +) + +// E2EFixturesKey is the sentinel value for SENTRY_API_KEY that activates fixture mode. +const E2EFixturesKey = "e2e" + +// FixtureService implements Service and returns hardcoded data for e2e testing. +type FixtureService struct{} + +func ptr[T any](v T) *T { return &v } + +var fixtureOrg = sentry.Organization{ + ID: ptr("1"), + Name: "Youdeploy Org", + Slug: ptr("youdeploy-org"), +} + +var fixtureProjects = []sentry.Project{ + {ID: "101", Name: "YouDeploy API", Slug: ptr("youdeploy-api"), Status: "active"}, + {ID: "102", Name: "YouDeploy Frontend", Slug: ptr("youdeploy-frontend"), Status: "active"}, + {ID: "103", Name: "YouDeploy Worker", Slug: ptr("youdeploy-worker"), Status: "active"}, +} + +// fixtureIssueDef holds the static parts of a fixture issue definition. +type fixtureIssueDef struct { + suffix string + title string + level string + hasSeen bool + userCount int +} + +// fixtureIssuesByProjectID maps project ID to its specific set of issues. +var fixtureIssuesByProjectID = map[string][]fixtureIssueDef{ + "101": { + {"001", "NullPointerException in PaymentService", "error", false, 42}, + {"002", "Timeout connecting to database", "warning", true, 7}, + {"003", "HTTP 502 Bad Gateway from upstream", "error", false, 13}, + }, + "102": { + {"001", "Unhandled promise rejection in auth flow", "error", false, 28}, + {"002", "React render error in Dashboard component", "error", true, 5}, + }, + "103": { + {"001", "Memory usage exceeded threshold", "warning", true, 3}, + {"002", "Job queue backed up: retries exhausted", "error", false, 19}, + {"003", "Deadlock detected in task scheduler", "error", false, 8}, + {"004", "Worker failed to connect to Redis", "warning", true, 11}, + }, +} + +func (f *FixtureService) GetOrganizations() ([]sentry.Organization, *sentry.Link, error) { + return []sentry.Organization{fixtureOrg}, nil, nil +} + +func (f *FixtureService) GetOrganization(orgSlug string) (sentry.Organization, error) { + org := fixtureOrg + org.Slug = ptr(orgSlug) + return org, nil +} + +func (f *FixtureService) GetOrgProjects(o sentry.Organization) ([]sentry.Project, *sentry.Link, error) { + return fixtureProjects, nil, nil +} + +func (f *FixtureService) GetIssues(o sentry.Organization, p sentry.Project, statsPeriod *string, shortIDLookup *bool, query *string) ([]sentry.Issue, *sentry.Link, error) { + now := time.Now() + yesterday := now.Add(-24 * time.Hour) + weekAgo := now.Add(-7 * 24 * time.Hour) + issueStatus := sentry.Status("unresolved") + + defs := fixtureIssuesByProjectID[p.ID] + issues := make([]sentry.Issue, 0, len(defs)) + for _, def := range defs { + // Prefix ID with project ID to keep issues unique across projects. + id := p.ID + "-" + def.suffix + firstSeen := weekAgo + if def.hasSeen { + firstSeen = yesterday + } + issues = append(issues, sentry.Issue{ + ID: ptr(id), + Title: ptr("Fixture: " + def.title), + Permalink: ptr("https://sentry.io/organizations/" + *o.Slug + "/issues/" + id + "/"), + HasSeen: ptr(def.hasSeen), + FirstSeen: &firstSeen, + LastSeen: &now, + UserCount: ptr(def.userCount), + Level: ptr(def.level), + Status: &issueStatus, + Type: ptr("error"), + }) + } + return issues, nil, nil +} diff --git a/service/transport/http/handler.go b/service/transport/http/handler.go index 15c826b..21b80ee 100644 --- a/service/transport/http/handler.go +++ b/service/transport/http/handler.go @@ -14,6 +14,7 @@ func Handler(s service.Service) http.Handler { r.Handle("POST /_cy/events", eventsHandler(s)) r.Handle("DELETE /_cy/plugin", deletePluginHandler(s)) r.Handle("POST /_cy/resync", resyncHandler(s)) + r.Handle("GET /iframe", iframeHandler(s)) return r } diff --git a/service/transport/http/iframe.go b/service/transport/http/iframe.go new file mode 100644 index 0000000..fe16616 --- /dev/null +++ b/service/transport/http/iframe.go @@ -0,0 +1,58 @@ +package http + +import ( + "net/http" + + "github.com/cycloidio/sentry-plugin/service" +) + +const iframeHTML = ` + + + + + + +

Sentry Issues (fixture data)

+ +

YouDeploy API

+ + + + + +
TitleLevelStatusHas SeenUsers
Fixture: NullPointerException in PaymentServiceerrorunresolvedfalse42
Fixture: Timeout connecting to databasewarningunresolvedtrue7
Fixture: HTTP 502 Bad Gateway from upstreamerrorunresolvedfalse13
+ +

YouDeploy Frontend

+ + + + +
TitleLevelStatusHas SeenUsers
Fixture: Unhandled promise rejection in auth flowerrorunresolvedfalse28
Fixture: React render error in Dashboard componenterrorunresolvedtrue5
+ +

YouDeploy Worker

+ + + + + + +
TitleLevelStatusHas SeenUsers
Fixture: Memory usage exceeded thresholdwarningunresolvedtrue3
Fixture: Job queue backed up: retries exhaustederrorunresolvedfalse19
Fixture: Deadlock detected in task schedulererrorunresolvedfalse8
Fixture: Worker failed to connect to Rediswarningunresolvedtrue11
+ +` + +func iframeHandler(_ service.Service) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Write([]byte(iframeHTML)) + } +} diff --git a/widgets.yaml b/widgets.yaml index 413d74b..c0a556a 100644 --- a/widgets.yaml +++ b/widgets.yaml @@ -1,3 +1,11 @@ +- type: iframe + placement: + type: component + config: + tab_name: Sentry Preview + widget: + query: "/iframe" + - type: table placement: type: component