Adding New Integrations
Active Development
The integration interfaces are stable for issue trackers. If you're adding a new type of integration, check the existing tracker implementations in goway/internal/infrastructure/adapter/issuetracker/ to understand the current patterns.
This guide explains how to add new issue tracker integrations to Aether.
Overview
Aether uses a plugin-based architecture for issue tracker integrations. Adding a new integration involves:
- Implementing the
IssueTrackerClientinterface - Registering with the tracker registry
- Adding source event mappings for the new source's webhook format
- Adding configuration (env vars or dynamic)
- Writing tests
Integration Architecture
goway/internal/infrastructure/adapter/issuetracker/
├── client.go # Client interface definition
├── registry.go # Integration registry
├── handler.go # Response handler
├── httpclient/ # Shared HTTP client (ALL trackers use this)
│ ├── httpclient.go
│ └── httpclient_test.go
├── github/ # GitHub implementation
│ ├── client.go
│ └── models.go
├── gitlab/ # GitLab implementation
├── jira/ # Jira implementation
├── plane/ # Plane implementation
└── yourtracker/ # Your new integration
├── client.go
├── models.go
└── client_test.goShared HTTP Client
All tracker adapters must use httpclient.DoRequest for their HTTP calls — do not write a new doRequest method from scratch. The shared client handles auth headers, JSON marshaling, error logging, and response parsing consistently across all adapters.
Step 1: Implement the Client Interface
Create a new directory for your integration:
mkdir goway/internal/infrastructure/adapter/issuetracker/yourtrackerClient Struct
Use httpclient.BuildHTTPClient for the HTTP client and delegate all requests to httpclient.DoRequest:
package yourtracker
import (
"context"
"fmt"
"net/http"
"go.uber.org/zap"
"goway/internal/domain/port/issuetracker"
"goway/internal/infrastructure/adapter/issuetracker/httpclient"
)
type Client struct {
baseURL string
apiKey string
httpClient *http.Client
logger *zap.Logger
}
func NewClient(baseURL, apiKey string, insecureTLS bool, logger *zap.Logger) *Client {
return &Client{
baseURL: baseURL,
apiKey: apiKey,
httpClient: httpclient.BuildHTTPClient(insecureTLS, logger, "YourTracker"),
logger: logger,
}
}
// setHeaders injects authentication headers for every request.
func (c *Client) setHeaders(req *http.Request) {
req.Header.Set("Authorization", "Bearer "+c.apiKey)
}
// doRequest is the single HTTP call site — delegates to the shared httpclient.
func (c *Client) doRequest(ctx context.Context, method, url string, payload interface{}) (*issuetracker.Response, error) {
return httpclient.DoRequest(ctx, c.httpClient, method, url, payload, c.setHeaders, c.logger, "YourTracker")
}
// AddComment posts a comment to an issue.
func (c *Client) AddComment(ctx context.Context, comment string, issueCtx *issuetracker.IssueContext) (*issuetracker.Response, error) {
url := fmt.Sprintf("%s/projects/%s/issues/%s/comments", c.baseURL, issueCtx.ProjectID, issueCtx.IssueID)
payload := map[string]string{"body": comment}
return c.doRequest(ctx, "POST", url, payload)
}
// UpdateField updates a field on an issue (e.g. description).
func (c *Client) UpdateField(ctx context.Context, field issuetracker.FieldType, value string, issueCtx *issuetracker.IssueContext) (*issuetracker.Response, error) {
url := fmt.Sprintf("%s/projects/%s/issues/%s", c.baseURL, issueCtx.ProjectID, issueCtx.IssueID)
payload := map[string]string{string(field): value}
return c.doRequest(ctx, "PATCH", url, payload)
}Response handling: httpclient.DoRequest returns Response{Success: false} (not a Go error) for HTTP 4xx/5xx. Check result.Success at the call site when needed:
result, err := c.doRequest(ctx, "POST", url, payload)
if err != nil {
return nil, err // network/timeout error
}
if !result.Success {
c.logger.Warn("YourTracker API error", zap.String("error", result.Error))
}
return result, nilModels
Define data structures in models.go:
package yourtracker
type Issue struct {
ID string `json:"id"`
Title string `json:"title"`
Description string `json:"description"`
State string `json:"state"`
}
type Comment struct {
ID string `json:"id"`
Body string `json:"body"`
Created string `json:"created_at"`
}Step 2: Register the Integration
Update issuetracker/registry.go to register your client:
package issuetracker
import (
"goway/internal/infrastructure/adapter/issuetracker/yourtracker"
"goway/internal/infrastructure/config"
)
func NewRegistry(cfg *config.Config) *Registry {
registry := &Registry{
clients: make(map[string]Client),
}
// Existing registrations
if cfg.GitHubToken != "" {
registry.Register("github", github.NewClient(cfg.GitHubToken))
}
// Add your integration
if cfg.YourTrackerAPIKey != "" {
registry.Register("yourtracker", yourtracker.NewClient(
cfg.YourTrackerBaseURL,
cfg.YourTrackerAPIKey,
))
}
return registry
}Step 3: Add Configuration
Environment Variables
Add configuration to infrastructure/config/config.go:
type Config struct {
// Existing fields...
// YourTracker configuration
YourTrackerBaseURL string `env:"YOURTRACKER_BASE_URL"`
YourTrackerAPIKey string `env:"YOURTRACKER_API_KEY"`
}Documentation
Update .env.example:
# YourTracker configuration
YOURTRACKER_BASE_URL=https://api.yourtracker.com/v1
YOURTRACKER_API_KEY=your-api-keyStep 4: Add Webhook Handler
If your tracker sends webhooks, add handler logic:
// In infrastructure/adapter/http/handler/webhook.go
func (h *WebhookHandler) HandleYourTracker(c *fiber.Ctx) error {
var payload yourtracker.WebhookPayload
if err := c.BodyParser(&payload); err != nil {
return c.Status(400).JSON(fiber.Map{
"error": "Invalid payload",
})
}
// Process webhook
source := "yourtracker"
eventName := payload.EventType
action := payload.Action
return h.processWebhook(c.Context(), source, eventName, action, payload)
}Register the route:
// In cmd/server/main.go
app.Post("/webhook/yourtracker", webhookHandler.HandleYourTracker)Step 5: Add Event Mappings
Add source event mappings in goway/seeding/source_event_mappings.yaml:
# YourTracker event mappings
- source: yourtracker
event_name: issue.created
action: null
canonical_trigger: issue.created
- source: yourtracker
event_name: issue.updated
action: null
canonical_trigger: issue.updatedStep 6: Write Tests
Create comprehensive tests in yourtracker/client_test.go:
package yourtracker_test
import (
"context"
"testing"
"github.com/stretchr/testify/assert"
"goway/internal/infrastructure/adapter/issuetracker/yourtracker"
)
func TestClient_PostComment(t *testing.T) {
client := yourtracker.NewClient("https://api.test.com", "test-key")
err := client.PostComment(context.Background(), "proj-1", "issue-1", "Test comment")
assert.NoError(t, err)
}
func TestClient_UpdateDescription(t *testing.T) {
client := yourtracker.NewClient("https://api.test.com", "test-key")
err := client.UpdateDescription(context.Background(), "proj-1", "issue-1", "New description")
assert.NoError(t, err)
}Step 7: Integration Testing
Test the full flow:
- Configure environment:
export YOURTRACKER_BASE_URL=https://api.yourtracker.com
export YOURTRACKER_API_KEY=your-test-key- Start Aether:
docker-compose up -d- Send test webhook:
curl -X POST http://localhost:8000/webhook/yourtracker \
-H "Content-Type: application/json" \
-d '{
"event_type": "issue.created",
"issue": {
"id": "123",
"title": "Test Issue"
}
}'- Verify response in your tracker
Optional: Add Polling Support
For trackers that don't support webhooks, implement polling:
1. Implement Poller Interface
package yourtracker
import (
"context"
"time"
"goway/internal/application/polling"
)
type Poller struct {
client *Client
lastChecked time.Time
}
func NewPoller(client *Client) *Poller {
return &Poller{
client: client,
lastChecked: time.Now(),
}
}
func (p *Poller) Poll(ctx context.Context) ([]polling.Event, error) {
// Fetch new issues/updates since lastChecked
issues, err := p.client.GetIssuesSince(ctx, p.lastChecked)
if err != nil {
return nil, err
}
events := make([]polling.Event, 0, len(issues))
for _, issue := range issues {
events = append(events, polling.Event{
Source: "yourtracker",
EventName: "issue.created",
Data: issue,
})
}
p.lastChecked = time.Now()
return events, nil
}2. Register Poller
// In application/polling/registry.go
func NewPollerRegistry(cfg *config.Config) *PollerRegistry {
registry := &PollerRegistry{
pollers: make(map[string]Poller),
}
if cfg.YourTrackerAPIKey != "" {
client := yourtracker.NewClient(cfg.YourTrackerBaseURL, cfg.YourTrackerAPIKey)
registry.Register("yourtracker", yourtracker.NewPoller(client))
}
return registry
}Best Practices
- Use
httpclient.DoRequest: Never write a newdoRequestimplementation — delegate to the shared client inissuetracker/httpclient/. - Use
httpclient.BuildHTTPClient: Never construct&http.Client{Timeout: ...}manually — the shared builder handles timeout and TLS consistently. - Error handling: HTTP 4xx/5xx come back as
Response{Success: false}— checkresult.Success, noterr != nil, for provider-level failures. - Logging: Pass your
*zap.LoggertoDoRequest— it logs errors at the right level automatically. - TLS: Accept an
insecureTLS boolparameter inNewClientand pass it toBuildHTTPClient. Never hardcode TLS settings. - Secrets: Never log API keys or tokens.
- Testing: Use
net/http/httptest.NewServer— never make real network calls in unit tests.
Example: Complete Integration
Here's a minimal but complete integration using the shared httpclient:
package yourtracker
import (
"context"
"encoding/json"
"fmt"
"net/http"
"go.uber.org/zap"
"goway/internal/domain/port/issuetracker"
"goway/internal/infrastructure/adapter/issuetracker/httpclient"
)
type Client struct {
baseURL string
apiKey string
httpClient *http.Client
logger *zap.Logger
}
// NewClient creates a YourTracker client. insecureTLS=true skips certificate verification.
func NewClient(baseURL, apiKey string, insecureTLS bool, logger *zap.Logger) *Client {
return &Client{
baseURL: baseURL,
apiKey: apiKey,
httpClient: httpclient.BuildHTTPClient(insecureTLS, logger, "YourTracker"),
logger: logger,
}
}
func (c *Client) setHeaders(req *http.Request) {
req.Header.Set("Authorization", "Bearer "+c.apiKey)
}
func (c *Client) doRequest(ctx context.Context, method, url string, payload interface{}) (*issuetracker.Response, error) {
return httpclient.DoRequest(ctx, c.httpClient, method, url, payload, c.setHeaders, c.logger, "YourTracker")
}
func (c *Client) AddComment(ctx context.Context, comment string, issueCtx *issuetracker.IssueContext) (*issuetracker.Response, error) {
url := fmt.Sprintf("%s/projects/%s/issues/%s/comments", c.baseURL, issueCtx.ProjectID, issueCtx.IssueID)
return c.doRequest(ctx, "POST", url, map[string]string{"body": comment})
}
func (c *Client) UpdateField(ctx context.Context, field issuetracker.FieldType, value string, issueCtx *issuetracker.IssueContext) (*issuetracker.Response, error) {
url := fmt.Sprintf("%s/projects/%s/issues/%s", c.baseURL, issueCtx.ProjectID, issueCtx.IssueID)
return c.doRequest(ctx, "PATCH", url, map[string]string{string(field): value})
}
func (c *Client) GetIssue(ctx context.Context, issueCtx *issuetracker.IssueContext) (*issuetracker.Response, error) {
url := fmt.Sprintf("%s/projects/%s/issues/%s", c.baseURL, issueCtx.ProjectID, issueCtx.IssueID)
return c.doRequest(ctx, "GET", url, nil)
}
// GetIssueData is a helper that parses the response Data into a typed struct.
func (c *Client) GetIssueData(ctx context.Context, issueCtx *issuetracker.IssueContext) (*Issue, error) {
resp, err := c.GetIssue(ctx, issueCtx)
if err != nil {
return nil, err
}
if !resp.Success {
return nil, fmt.Errorf("API error: %s", resp.Error)
}
data, err := json.Marshal(resp.Data)
if err != nil {
return nil, fmt.Errorf("failed to re-marshal response: %w", err)
}
var issue Issue
if err := json.Unmarshal(data, &issue); err != nil {
return nil, fmt.Errorf("failed to decode issue: %w", err)
}
return &issue, nil
}The test file should use net/http/httptest rather than making real network calls:
package yourtracker_test
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/assert"
"go.uber.org/zap"
"goway/internal/domain/port/issuetracker"
"goway/internal/infrastructure/adapter/issuetracker/yourtracker"
)
func TestClient_AddComment(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "Bearer test-key", r.Header.Get("Authorization"))
w.WriteHeader(http.StatusCreated)
w.Write([]byte(`{"id": "c1"}`))
}))
defer server.Close()
client := yourtracker.NewClient(server.URL, "test-key", false, zap.NewNop())
result, err := client.AddComment(context.Background(), "hello", &issuetracker.IssueContext{
ProjectID: "proj-1",
IssueID: "issue-1",
})
assert.NoError(t, err)
assert.True(t, result.Success)
}Documentation
Don't forget to update documentation:
- User Guide: Add integration setup instructions to
/user/integrations.md - Webhook Setup Guide: Add extraction rules and source event mappings to the Webhook Setup Guide
- Configuration Guide: Document environment variables in
/guide/configuration.md - README: Add to list of supported integrations
Submitting Your Integration
- Create feature branch:
git checkout -b feature/add-yourtracker-integration - Implement integration with tests
- Update documentation
- Test thoroughly
- Submit pull request
Include in PR:
- Description of the integration
- Configuration instructions
- Test results
- Screenshots of it working
Need Help?
- Check existing integrations (GitHub, GitLab, Jira, Plane) for examples
- Review the Architecture Guide
- Ask questions in GitHub issues
- Refer to Contributing Guide
