Skip to content

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:

  1. Implementing the IssueTrackerClient interface
  2. Registering with the tracker registry
  3. Adding source event mappings for the new source's webhook format
  4. Adding configuration (env vars or dynamic)
  5. 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.go

Shared 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:

bash
mkdir goway/internal/infrastructure/adapter/issuetracker/yourtracker

Client Struct

Use httpclient.BuildHTTPClient for the HTTP client and delegate all requests to httpclient.DoRequest:

go
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:

go
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, nil

Models

Define data structures in models.go:

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:

go
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:

go
type Config struct {
    // Existing fields...

    // YourTracker configuration
    YourTrackerBaseURL string `env:"YOURTRACKER_BASE_URL"`
    YourTrackerAPIKey  string `env:"YOURTRACKER_API_KEY"`
}

Documentation

Update .env.example:

bash
# YourTracker configuration
YOURTRACKER_BASE_URL=https://api.yourtracker.com/v1
YOURTRACKER_API_KEY=your-api-key

Step 4: Add Webhook Handler

If your tracker sends webhooks, add handler logic:

go
// 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:

go
// 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:

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.updated

Step 6: Write Tests

Create comprehensive tests in yourtracker/client_test.go:

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:

  1. Configure environment:
bash
export YOURTRACKER_BASE_URL=https://api.yourtracker.com
export YOURTRACKER_API_KEY=your-test-key
  1. Start Aether:
bash
docker-compose up -d
  1. Send test webhook:
bash
curl -X POST http://localhost:8000/webhook/yourtracker \
  -H "Content-Type: application/json" \
  -d '{
    "event_type": "issue.created",
    "issue": {
      "id": "123",
      "title": "Test Issue"
    }
  }'
  1. Verify response in your tracker

Optional: Add Polling Support

For trackers that don't support webhooks, implement polling:

1. Implement Poller Interface

go
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

go
// 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

  1. Use httpclient.DoRequest: Never write a new doRequest implementation — delegate to the shared client in issuetracker/httpclient/.
  2. Use httpclient.BuildHTTPClient: Never construct &http.Client{Timeout: ...} manually — the shared builder handles timeout and TLS consistently.
  3. Error handling: HTTP 4xx/5xx come back as Response{Success: false} — check result.Success, not err != nil, for provider-level failures.
  4. Logging: Pass your *zap.Logger to DoRequest — it logs errors at the right level automatically.
  5. TLS: Accept an insecureTLS bool parameter in NewClient and pass it to BuildHTTPClient. Never hardcode TLS settings.
  6. Secrets: Never log API keys or tokens.
  7. 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:

go
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:

go
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:

  1. User Guide: Add integration setup instructions to /user/integrations.md
  2. Webhook Setup Guide: Add extraction rules and source event mappings to the Webhook Setup Guide
  3. Configuration Guide: Document environment variables in /guide/configuration.md
  4. README: Add to list of supported integrations

Submitting Your Integration

  1. Create feature branch: git checkout -b feature/add-yourtracker-integration
  2. Implement integration with tests
  3. Update documentation
  4. Test thoroughly
  5. Submit pull request

Include in PR:

  • Description of the integration
  • Configuration instructions
  • Test results
  • Screenshots of it working

Need Help?

Released under the MIT License.