AI-SDLC
AI-SDLC
Tutorials

Tutorial 4: Building a Custom Adapter

Tutorial 4: Building a Custom Adapter

Adapters are the integration layer between AI-SDLC and external tools. The AdapterBinding resource declares a tool integration as a swappable provider behind a uniform interface contract. By coding to a standard interface, you can swap one tool for another -- for example, replacing Linear with Jira -- without touching your pipeline definitions.

This tutorial walks through building a custom Jira adapter that implements the IssueTracker interface.


Interface Types Overview

The AI-SDLC spec defines six interface contracts. Every adapter implements at least one:

InterfacePurposeExample Tools
IssueTrackerIssue and project managementJira, Linear, GitHub Issues
SourceControlSource code managementGitHub, GitLab, Bitbucket
CIPipelineContinuous integrationGitHub Actions, GitLab CI, Jenkins
CodeAnalysisStatic analysis and security scanningSonarQube, Semgrep, CodeQL
MessengerCommunication platformsSlack, Microsoft Teams
DeploymentTargetDeployment platformsKubernetes, AWS, Vercel

See spec/adapters.md for the full contract definitions.


Prerequisites

  • Node.js 18+ and npm/pnpm installed
  • TypeScript knowledge (the reference implementation is TypeScript-based)
  • A Jira Cloud instance and API token for testing
  • Familiarity with the Jira REST API
  • Completion of Tutorials 01-03 (recommended)

Step 1: Define the AdapterBinding Resource

Create a file called jira-adapter.yaml. This declares your Jira integration as an AI-SDLC resource:

apiVersion: ai-sdlc.io/v1alpha1
kind: AdapterBinding
metadata:
  name: jira-issue-tracker
  namespace: my-team
  labels:
    adapter: jira
    interface: issue-tracker
spec:
  interface: IssueTracker
  type: jira
  version: 1.0.0
  source: registry.ai-sdlc.io/adapters/jira@1.0.0
  config:
    projectKey: "ENG"
    baseUrl: "https://mycompany.atlassian.net"
    apiToken:
      secretRef: jira-api-token
  healthCheck:
    interval: 60s
    timeout: 10s

Key fields:

  • spec.interface -- The abstract contract this adapter fulfills (IssueTracker).
  • spec.type -- The concrete implementation identifier (jira).
  • spec.version -- The adapter version following SemVer.
  • spec.source -- Where to fetch the adapter from (registry, local path, or git reference).
  • spec.config -- Adapter-specific configuration; note how apiToken uses a secretRef instead of a plaintext value.
  • spec.healthCheck -- Defines how often the runtime checks adapter connectivity.

Step 2: Implement the IssueTracker Interface

The @ai-sdlc/reference package exports typed interfaces for every contract. Create src/jira-adapter.ts:

import type {
  IssueTracker,
  Issue,
  IssueFilter,
  CreateIssueInput,
  UpdateIssueInput,
  EventStream,
  IssueEvent,
} from "@ai-sdlc/reference";

interface JiraConfig {
  projectKey: string;
  baseUrl: string;
  apiToken: string; // Already resolved from secretRef
}

export function createJiraIssueTracker(config: JiraConfig): IssueTracker {
  const headers = {
    Authorization: `Basic ${Buffer.from(`email:${config.apiToken}`).toString("base64")}`,
    "Content-Type": "application/json",
  };

  return {
    async listIssues(filter: IssueFilter): Promise<Issue[]> {
      // Build JQL from the generic IssueFilter
      const jqlParts: string[] = [`project = ${config.projectKey}`];
      if (filter.status) jqlParts.push(`status = "${filter.status}"`);
      if (filter.assignee) jqlParts.push(`assignee = "${filter.assignee}"`);
      if (filter.labels?.length) {
        jqlParts.push(`labels in (${filter.labels.join(",")})`);
      }

      const response = await fetch(
        `${config.baseUrl}/rest/api/3/search?jql=${encodeURIComponent(jqlParts.join(" AND "))}`,
        { headers }
      );
      const data = await response.json();

      // Map Jira issues to the AI-SDLC Issue type
      return data.issues.map(mapJiraIssue);
    },

    async getIssue(id: string): Promise<Issue> {
      const response = await fetch(
        `${config.baseUrl}/rest/api/3/issue/${id}`,
        { headers }
      );
      const data = await response.json();
      return mapJiraIssue(data);
    },

    async createIssue(input: CreateIssueInput): Promise<Issue> {
      const response = await fetch(
        `${config.baseUrl}/rest/api/3/issue`,
        {
          method: "POST",
          headers,
          body: JSON.stringify({
            fields: {
              project: { key: config.projectKey },
              summary: input.title,
              description: input.description,
              issuetype: { name: "Task" },
              // Map additional fields as needed
            },
          }),
        }
      );
      const created = await response.json();
      return getIssue(created.id);
    },

    async updateIssue(id: string, input: UpdateIssueInput): Promise<Issue> {
      await fetch(`${config.baseUrl}/rest/api/3/issue/${id}`, {
        method: "PUT",
        headers,
        body: JSON.stringify({
          fields: {
            ...(input.title && { summary: input.title }),
            ...(input.description && { description: input.description }),
          },
        }),
      });
      return getIssue(id);
    },

    async transitionIssue(id: string, transition: string): Promise<Issue> {
      // First, look up the transition ID from the name
      const transitionsRes = await fetch(
        `${config.baseUrl}/rest/api/3/issue/${id}/transitions`,
        { headers }
      );
      const { transitions } = await transitionsRes.json();
      const match = transitions.find(
        (t: { name: string }) => t.name.toLowerCase() === transition.toLowerCase()
      );

      if (!match) {
        throw new Error(`Transition "${transition}" not found for issue ${id}`);
      }

      await fetch(`${config.baseUrl}/rest/api/3/issue/${id}/transitions`, {
        method: "POST",
        headers,
        body: JSON.stringify({ transition: { id: match.id } }),
      });
      return getIssue(id);
    },

    watchIssues(_filter: IssueFilter): EventStream<IssueEvent> {
      // Jira uses webhooks; return a stream that bridges webhook events
      // Implementation depends on your webhook ingestion layer
      throw new Error("watchIssues requires webhook configuration");
    },
  };

  // --- Helper ---------------------------------------------------

  async function getIssue(id: string): Promise<Issue> {
    const response = await fetch(
      `${config.baseUrl}/rest/api/3/issue/${id}`,
      { headers }
    );
    return mapJiraIssue(await response.json());
  }
}

/** Map a Jira REST response to the AI-SDLC Issue type. */
function mapJiraIssue(jiraIssue: Record<string, any>): Issue {
  return {
    id: jiraIssue.key,
    title: jiraIssue.fields.summary,
    description: jiraIssue.fields.description ?? undefined,
    status: jiraIssue.fields.status.name,
    labels: jiraIssue.fields.labels ?? [],
    assignee: jiraIssue.fields.assignee?.displayName ?? undefined,
    url: `${jiraIssue.self.split("/rest")[0]}/browse/${jiraIssue.key}`,
  };
}

The factory function createJiraIssueTracker receives an already-resolved config object (secrets have been substituted by the runtime). It returns an object satisfying the IssueTracker contract. Every method maps between the Jira-specific REST API and the tool-agnostic AI-SDLC types.


Step 3: Secret Resolution with secretRef

Sensitive values like API tokens MUST NOT appear in plain text inside YAML resources. The secretRef pattern defers resolution to runtime:

config:
  apiToken:
    secretRef: jira-api-token

The reference implementation resolves secretRef values from environment variables by converting the kebab-case name to UPPER_SNAKE_CASE:

jira-api-token  -->  JIRA_API_TOKEN

At runtime, the framework calls resolveSecret("jira-api-token"), which reads process.env.JIRA_API_TOKEN. Your adapter receives the resolved string value in its config object -- it never needs to handle secret resolution itself.

To set the secret locally:

export JIRA_API_TOKEN="your-jira-api-token-here"

For production, use your organization's secret management solution (Vault, AWS Secrets Manager, etc.) and configure the runtime's secret store accordingly.


Step 4: Health Checks

The healthCheck block in the AdapterBinding tells the runtime how to monitor adapter connectivity:

healthCheck:
  interval: 60s
  timeout: 10s
  • interval -- How often to probe the adapter. The runtime calls a lightweight connectivity check at this cadence.
  • timeout -- Maximum time to wait for a health check response before marking the adapter unhealthy.

Both values use the duration shorthand pattern ^\d+[smhdw]$ (seconds, minutes, hours, days, weeks). Examples: 30s, 5m, 1h.

The health check for an IssueTracker adapter typically verifies that:

  1. The API endpoint is reachable.
  2. The credentials are valid (e.g., call the Jira /myself endpoint).
  3. The configured project exists and is accessible.

The runtime reports adapter health via the resource's status field:

status:
  connected: true
  lastHealthCheck: "2025-06-15T10:30:00Z"
  adapterVersion: "1.0.0"
  specVersionSupported: "v1alpha1"

Step 5: Register with the Adapter Registry

The SDK provides an adapter registry for managing adapter factories:

import {
  createAdapterRegistry,
  validateAdapterMetadata,
  AdapterBindingBuilder,
} from "@ai-sdlc/reference";
import { createJiraIssueTracker } from "./jira-adapter.js";

// Create a registry
const registry = createAdapterRegistry();

// Register the Jira adapter
registry.register(
  {
    name: "jira",
    interface: "IssueTracker",
    type: "jira",
    version: "1.0.0",
    stability: "stable",
    description: "Jira Cloud issue tracker adapter",
  },
  (config) => createJiraIssueTracker(config as any),
);

// Look up and instantiate
const factory = registry.get("IssueTracker", "jira");
if (factory) {
  const tracker = factory({
    projectKey: "ENG",
    baseUrl: "https://mycompany.atlassian.net",
    apiToken: process.env.JIRA_API_TOKEN!,
  });
  const issues = await tracker.listIssues({ status: "In Progress" });
  console.log(`Found ${issues.length} issues`);
}

// List all registered adapters
const adapters = registry.list("IssueTracker");
console.log("Registered IssueTracker adapters:", adapters.map(a => a.name));

Step 6: Use the Webhook Bridge

For adapters that receive events via webhooks (like Jira), use the webhook bridge:

import { createWebhookBridge } from "@ai-sdlc/reference";

const bridge = createWebhookBridge();

// Register a transformer for Jira webhook payloads
bridge.transform("jira:issue_updated", (payload: any) => ({
  type: "updated",
  issue: {
    id: payload.issue.key,
    title: payload.issue.fields.summary,
    status: payload.issue.fields.status.name,
    url: `${payload.issue.self.split("/rest")[0]}/browse/${payload.issue.key}`,
  },
  timestamp: new Date().toISOString(),
}));

// Subscribe to transformed events
bridge.on("jira:issue_updated", (event) => {
  console.log("Issue updated:", event);
});

// When a webhook arrives, emit the raw payload
bridge.emit("jira:issue_updated", webhookPayload);

Step 7: Build the AdapterBinding with the SDK

import { AdapterBindingBuilder, validateResource } from "@ai-sdlc/reference";

const binding = new AdapterBindingBuilder(
  "jira-issue-tracker",
  "IssueTracker",
  "jira",
  "1.0.0",
)
  .label("adapter", "jira")
  .label("interface", "issue-tracker")
  .source("registry.ai-sdlc.io/adapters/jira@1.0.0")
  .config({
    projectKey: "ENG",
    baseUrl: "https://mycompany.atlassian.net",
    apiToken: { secretRef: "jira-api-token" },
  })
  .withHealthCheck({ interval: "60s", timeout: "10s" })
  .build();

const result = validateResource(binding);
console.log(result.valid); // true

Validation

Validate your AdapterBinding YAML against the schema to catch errors before deployment:

npx ajv validate \
  -s spec/schemas/adapter-binding.schema.json \
  -r "spec/schemas/common.schema.json" \
  -d jira-adapter.yaml

A successful run prints no errors. Common validation failures include:

  • Missing required fields (interface, type, version).
  • Invalid version format (must be SemVer: 1.0.0, not v1.0.0).
  • Invalid healthCheck duration (must match ^\d+[smhdw]$).
  • Using an interface value not in the enum (must be one of the six defined interfaces).

Summary

In this tutorial you:

  1. Defined an AdapterBinding resource declaring a Jira IssueTracker integration.
  2. Implemented the IssueTracker interface in TypeScript, mapping Jira REST API responses to AI-SDLC types.
  3. Used the secretRef pattern to keep API tokens out of configuration files.
  4. Configured health checks so the runtime can monitor adapter connectivity.
  5. Validated the resource YAML against the JSON Schema.

Next Steps