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
+
+ | Title | Level | Status | Has Seen | Users |
+ | Fixture: NullPointerException in PaymentService | error | unresolved | false | 42 |
+ | Fixture: Timeout connecting to database | warning | unresolved | true | 7 |
+ | Fixture: HTTP 502 Bad Gateway from upstream | error | unresolved | false | 13 |
+
+
+YouDeploy Frontend
+
+ | Title | Level | Status | Has Seen | Users |
+ | Fixture: Unhandled promise rejection in auth flow | error | unresolved | false | 28 |
+ | Fixture: React render error in Dashboard component | error | unresolved | true | 5 |
+
+
+YouDeploy Worker
+
+ | Title | Level | Status | Has Seen | Users |
+ | Fixture: Memory usage exceeded threshold | warning | unresolved | true | 3 |
+ | Fixture: Job queue backed up: retries exhausted | error | unresolved | false | 19 |
+ | Fixture: Deadlock detected in task scheduler | error | unresolved | false | 8 |
+ | Fixture: Worker failed to connect to Redis | warning | unresolved | true | 11 |
+
+
+`
+
+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