Skip to content

Plugin System

spectryn features an extensible plugin system that allows you to customize behavior, add new adapters, and integrate with external services.

Overview

The plugin system supports:

  • Hooks - Execute code at specific points in the sync lifecycle
  • Adapters - Add support for new issue trackers (GitHub, Linear, etc.)
  • Parsers - Add support for new document formats
  • Formatters - Customize output formatting

Hook System

Hooks allow you to execute custom code at specific points during sync operations.

Available Hook Points

Hook PointDescriptionContext Data
BEFORE_SYNCBefore sync startsepic_key, markdown_path
AFTER_SYNCAfter sync completesresults, statistics
BEFORE_COMMANDBefore each commandcommand, issue_key
AFTER_COMMANDAfter each commandcommand, result
ON_ERRORWhen an error occurserror, context
ON_VALIDATIONDuring validationstories, errors

Registering Hooks

python
from spectryn.plugins import HookPoint, get_registry

hook_manager = get_registry().hook_manager

# Using decorator
@hook_manager.hook(HookPoint.BEFORE_SYNC)
def log_sync_start(ctx):
    print(f"Starting sync for epic: {ctx.data['epic_key']}")
    print(f"Markdown file: {ctx.data['markdown_path']}")

@hook_manager.hook(HookPoint.AFTER_COMMAND)
def notify_on_create(ctx):
    if ctx.command_type == "CreateSubtask":
        # Send Slack notification
        send_slack_message(f"Created: {ctx.result.issue_key}")

@hook_manager.hook(HookPoint.ON_ERROR)
def handle_errors(ctx):
    # Send to error tracking service
    sentry_sdk.capture_exception(ctx.error)

Hook Context

Each hook receives a context object with relevant data:

python
@hook_manager.hook(HookPoint.AFTER_SYNC)
def on_sync_complete(ctx):
    # Access sync results
    results = ctx.data['results']
    
    print(f"Stories processed: {results.stories_count}")
    print(f"Subtasks created: {results.subtasks_created}")
    print(f"Errors: {len(results.errors)}")

Creating Custom Adapters

Add support for new issue trackers by implementing the IssueTrackerPort interface.

Interface Definition

python
from abc import ABC, abstractmethod
from spectryn.core.ports import IssueTrackerPort
from spectryn.core.domain import IssueData, SubtaskData, Status

class IssueTrackerPort(ABC):
    """Abstract interface for issue trackers."""
    
    @property
    @abstractmethod
    def name(self) -> str:
        """Return the tracker name."""
        pass
    
    @abstractmethod
    def get_epic_children(self, epic_key: str) -> list[IssueData]:
        """Get all issues under an epic."""
        pass
    
    @abstractmethod
    def get_issue(self, issue_key: str) -> IssueData:
        """Get a single issue by key."""
        pass
    
    @abstractmethod
    def update_description(self, issue_key: str, description: str) -> None:
        """Update an issue's description."""
        pass
    
    @abstractmethod
    def create_subtask(self, parent_key: str, subtask: SubtaskData) -> str:
        """Create a subtask under a parent issue."""
        pass
    
    @abstractmethod
    def transition_issue(self, issue_key: str, status: Status) -> None:
        """Transition an issue to a new status."""
        pass

Example: GitHub Issues Adapter

python
from github import Github
from spectryn.core.ports import IssueTrackerPort
from spectryn.core.domain import IssueData, Status

class GitHubIssuesAdapter(IssueTrackerPort):
    """GitHub Issues adapter for spectryn."""
    
    def __init__(self, token: str):
        self.client = Github(token)
    
    @property
    def name(self) -> str:
        return "GitHub Issues"
    
    def get_epic_children(self, epic_key: str) -> list[IssueData]:
        # epic_key format: "owner/repo#milestone_number"
        owner, repo_milestone = epic_key.split("/")
        repo_name, milestone_num = repo_milestone.split("#")
        
        repo = self.client.get_repo(f"{owner}/{repo_name}")
        milestone = repo.get_milestone(int(milestone_num))
        
        issues = repo.get_issues(milestone=milestone, state="all")
        
        return [
            IssueData(
                key=f"{owner}/{repo_name}#{issue.number}",
                title=issue.title,
                description=issue.body,
                status=self._map_state(issue.state),
            )
            for issue in issues
        ]
    
    def _map_state(self, state: str) -> Status:
        return Status.DONE if state == "closed" else Status.OPEN
    
    # ... implement other methods

Registering Adapters

python
from spectryn.plugins import get_registry

registry = get_registry()
registry.register_adapter("github", GitHubIssuesAdapter)

# Now usable via CLI or config
# spectryn --tracker github --markdown EPIC.md --epic owner/repo#1

Custom Parsers

Add support for different input formats (YAML, Notion exports, etc.).

python
from spectryn.core.ports import DocumentParserPort
from spectryn.core.domain import Epic, UserStory

class YAMLParser(DocumentParserPort):
    """Parse YAML-formatted epic documents."""
    
    def parse(self, content: str) -> Epic:
        data = yaml.safe_load(content)
        
        stories = [
            UserStory(
                id=story['id'],
                title=story['title'],
                description=story['description'],
                story_points=story.get('points', 0),
                status=Status(story.get('status', 'planned')),
            )
            for story in data.get('stories', [])
        ]
        
        return Epic(
            title=data['title'],
            stories=stories,
        )

# Register
registry.register_parser("yaml", YAMLParser)

Plugin Discovery

spectryn can auto-discover plugins from:

  1. Entry points - Installed packages with spectryn.plugins entry point
  2. Plugin directory - ~/.spectryn/plugins/
  3. Project plugins - .spectryn/plugins/ in project root

Entry Point Registration

In your plugin's pyproject.toml:

toml
[project.entry-points."spectryn.plugins"]
my_plugin = "my_package.plugin:register"
python
# my_package/plugin.py
def register(registry):
    """Called by spectryn on startup."""
    registry.register_adapter("my-tracker", MyTrackerAdapter)
    registry.register_hook(HookPoint.BEFORE_SYNC, my_hook)

Real-World Examples

Slack Notifications

python
from slack_sdk import WebClient
from spectryn.plugins import HookPoint, get_registry

slack = WebClient(token=os.environ["SLACK_TOKEN"])
channel = "#jira-sync"

@get_registry().hook_manager.hook(HookPoint.AFTER_SYNC)
def notify_slack(ctx):
    results = ctx.data['results']
    
    slack.chat_postMessage(
        channel=channel,
        text=f"✅ Synced {results.stories_count} stories to Jira",
        blocks=[
            {
                "type": "section",
                "text": {
                    "type": "mrkdwn",
                    "text": f"*Epic:* {ctx.data['epic_key']}\n"
                            f"*Stories:* {results.stories_count}\n"
                            f"*Subtasks created:* {results.subtasks_created}"
                }
            }
        ]
    )

Audit Logging to Database

python
from sqlalchemy import create_engine
from spectryn.plugins import HookPoint, get_registry

engine = create_engine(os.environ["DATABASE_URL"])

@get_registry().hook_manager.hook(HookPoint.AFTER_COMMAND)
def log_to_db(ctx):
    with engine.connect() as conn:
        conn.execute(
            "INSERT INTO audit_log (timestamp, command, issue_key, result) VALUES (?, ?, ?, ?)",
            [datetime.now(), ctx.command_type, ctx.issue_key, ctx.result.success]
        )

Custom Validation Rules

python
@get_registry().hook_manager.hook(HookPoint.ON_VALIDATION)
def enforce_story_points(ctx):
    for story in ctx.data['stories']:
        if story.story_points > 13:
            ctx.add_error(
                f"Story {story.id} has {story.story_points} points. "
                "Maximum allowed is 13 (Fibonacci)."
            )

Released under the MIT License.