# Zuul CI Slack Bot Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Build a Slack bot that lets any engineer ask questions about Zuul CI jobs, powered by Claude API + mcp-zuul, with a two-tier knowledge system managed by CIOps.

**Architecture:** Slack Bolt app (Socket Mode) receives mentions/commands, forwards to Claude API with tool_use. Claude calls mcp-zuul tools via MCP stdio protocol. Knowledge comes from YAML baseline files + SQLite-stored learned patterns. CIOps approves new patterns via Slack reactions in a designated channel.

**Tech Stack:** Python 3.11+, slack-bolt, anthropic SDK, mcp (Python client), mcp-zuul (PyPI), SQLite, PyYAML

**Spec:** `docs/superpowers/specs/2026-03-25-zuul-ci-slack-bot-design.md`

---

## File Structure

```
zuul-ci-bot/
├── bot/
│   ├── __init__.py
│   ├── __main__.py          # Entry point: starts Slack app
│   ├── config.py            # Env var loading + validation
│   ├── agent.py             # MCP bridge: persistent session + agentic loop
│   ├── knowledge_store.py   # Hybrid YAML + SQLite knowledge
│   ├── pattern_tracker.py   # Detects unknown patterns, suggests to CIOps
│   ├── rate_limiter.py      # Per-user cooldown + concurrent request limit
│   ├── slack_handlers.py    # Slack event/command handlers (mentions, reactions)
│   └── slack_formatters.py  # Convert Claude output to Slack mrkdwn
├── knowledge/
│   ├── 01-conventions.md    # Job naming, pipeline structure
│   └── 02-failure-patterns.yaml  # Curated failure signatures
├── tests/
│   ├── __init__.py
│   ├── conftest.py          # Shared fixtures
│   ├── test_config.py
│   ├── test_knowledge_store.py
│   ├── test_agent.py
│   ├── test_pattern_tracker.py
│   ├── test_rate_limiter.py
│   └── test_slack_handlers.py
├── pyproject.toml
├── Dockerfile
├── README.md
└── .env.example
```

---

### Task 1: Project Scaffolding

**Files:**
- Create: `pyproject.toml`
- Create: `bot/__init__.py`
- Create: `bot/__main__.py` (stub)
- Create: `bot/config.py`
- Create: `tests/__init__.py`
- Create: `tests/conftest.py`
- Create: `tests/test_config.py`
- Create: `.env.example`
- Create: `README.md`

- [ ] **Step 1: Initialize project**

Create the repo directory and `pyproject.toml`:

```toml
[project]
name = "zuul-ci-bot"
version = "0.1.0"
description = "Slack bot for Zuul CI - powered by Claude API + mcp-zuul"
requires-python = ">=3.11"
dependencies = [
    "slack-bolt>=1.20.0",
    "anthropic>=0.40.0",
    "mcp>=1.26.0",
    "mcp-zuul>=0.4.0",
    "pyyaml>=6.0",
]

[project.optional-dependencies]
kerberos = ["mcp-zuul[kerberos]"]
dev = [
    "pytest>=8.0",
    "pytest-asyncio>=0.24",
    "ruff>=0.9",
]

[tool.pytest.ini_options]
testpaths = ["tests"]
asyncio_mode = "auto"
```

- [ ] **Step 2: Write config tests**

Create `tests/test_config.py`:

```python
import os
import pytest
from bot.config import Config


def test_config_loads_required_vars(monkeypatch):
    monkeypatch.setenv("SLACK_BOT_TOKEN", "xoxb-test")
    monkeypatch.setenv("SLACK_APP_TOKEN", "xapp-test")
    monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-test")
    monkeypatch.setenv("ZUUL_URL", "https://zuul.example.com")
    monkeypatch.setenv("ZUUL_DEFAULT_TENANT", "tenant1")
    cfg = Config.from_env()
    assert cfg.zuul_url == "https://zuul.example.com"
    assert cfg.tenant == "tenant1"
    assert cfg.claude_model == "claude-sonnet-4-20250514"
    assert cfg.max_concurrent == 5
    assert cfg.user_cooldown == 10
    assert cfg.suggestion_threshold == 3


def test_config_missing_required_var(monkeypatch):
    monkeypatch.delenv("ZUUL_URL", raising=False)
    with pytest.raises(ValueError, match="ZUUL_URL"):
        Config.from_env()


def test_config_custom_values(monkeypatch):
    monkeypatch.setenv("SLACK_BOT_TOKEN", "xoxb-test")
    monkeypatch.setenv("SLACK_APP_TOKEN", "xapp-test")
    monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-test")
    monkeypatch.setenv("ZUUL_URL", "https://zuul.example.com")
    monkeypatch.setenv("ZUUL_DEFAULT_TENANT", "t")
    monkeypatch.setenv("CLAUDE_MODEL", "claude-opus-4-6")
    monkeypatch.setenv("MAX_CONCURRENT_REQUESTS", "10")
    monkeypatch.setenv("USER_COOLDOWN_SECONDS", "30")
    monkeypatch.setenv("CIOPS_CHANNELS", "C123,C456")
    cfg = Config.from_env()
    assert cfg.claude_model == "claude-opus-4-6"
    assert cfg.max_concurrent == 10
    assert cfg.user_cooldown == 30
    assert cfg.ciops_channels == ["C123", "C456"]
```

- [ ] **Step 3: Run tests to verify they fail**

Run: `pytest tests/test_config.py -v`
Expected: FAIL (no `bot.config` module)

- [ ] **Step 4: Implement config**

Create `bot/config.py`:

```python
"""Configuration loaded from environment variables."""

import os
from dataclasses import dataclass, field


@dataclass
class Config:
    slack_bot_token: str
    slack_app_token: str
    anthropic_api_key: str
    zuul_url: str
    tenant: str
    zuul_use_kerberos: bool = False
    knowledge_dir: str = "./knowledge"
    patterns_db: str = "./patterns.db"
    ciops_channels: list[str] = field(default_factory=list)
    ciops_patterns_channel: str = ""
    claude_model: str = "claude-sonnet-4-20250514"
    max_concurrent: int = 5
    user_cooldown: int = 10
    suggestion_threshold: int = 3

    @classmethod
    def from_env(cls) -> "Config":
        required = {
            "SLACK_BOT_TOKEN": "Slack bot OAuth token",
            "SLACK_APP_TOKEN": "Slack app-level token for Socket Mode",
            "ANTHROPIC_API_KEY": "Claude API key",
            "ZUUL_URL": "Zuul base URL",
            "ZUUL_DEFAULT_TENANT": "Default Zuul tenant",
        }
        for var, desc in required.items():
            if not os.environ.get(var):
                raise ValueError(f"{var} is required ({desc})")

        channels_raw = os.environ.get("CIOPS_CHANNELS", "")
        ciops_channels = [
            c.strip() for c in channels_raw.split(",") if c.strip()
        ]
        return cls(
            slack_bot_token=os.environ["SLACK_BOT_TOKEN"],
            slack_app_token=os.environ["SLACK_APP_TOKEN"],
            anthropic_api_key=os.environ["ANTHROPIC_API_KEY"],
            zuul_url=os.environ["ZUUL_URL"],
            tenant=os.environ["ZUUL_DEFAULT_TENANT"],
            zuul_use_kerberos=os.environ.get(
                "ZUUL_USE_KERBEROS", "false").lower() == "true",
            knowledge_dir=os.environ.get("KNOWLEDGE_DIR", "./knowledge"),
            patterns_db=os.environ.get("PATTERNS_DB", "./patterns.db"),
            ciops_channels=ciops_channels,
            ciops_patterns_channel=os.environ.get(
                "CIOPS_PATTERNS_CHANNEL", ""),
            claude_model=os.environ.get(
                "CLAUDE_MODEL", "claude-sonnet-4-20250514"),
            max_concurrent=int(os.environ.get(
                "MAX_CONCURRENT_REQUESTS", "5")),
            user_cooldown=int(os.environ.get(
                "USER_COOLDOWN_SECONDS", "10")),
            suggestion_threshold=int(os.environ.get(
                "SUGGESTION_THRESHOLD", "3")),
        )
```

Create `bot/__init__.py` (empty) and `tests/__init__.py` (empty).

- [ ] **Step 5: Run tests to verify they pass**

Run: `pytest tests/test_config.py -v`
Expected: all 3 tests PASS

- [ ] **Step 6: Create .env.example and stub entry point**

Create `.env.example`:

```bash
SLACK_BOT_TOKEN=xoxb-your-token
SLACK_APP_TOKEN=xapp-your-token
ANTHROPIC_API_KEY=sk-your-key
ZUUL_URL=https://your-zuul.example.com/zuul
ZUUL_DEFAULT_TENANT=your-tenant
# ZUUL_USE_KERBEROS=true
# KNOWLEDGE_DIR=./knowledge
# PATTERNS_DB=./patterns.db
# CIOPS_CHANNELS=C123ABC,C456DEF
# CIOPS_PATTERNS_CHANNEL=C789GHI
# CLAUDE_MODEL=claude-sonnet-4-20250514
# MAX_CONCURRENT_REQUESTS=5
# USER_COOLDOWN_SECONDS=10
# SUGGESTION_THRESHOLD=3
```

Create `bot/__main__.py`:

```python
"""Entry point: python -m bot"""

from .config import Config

def main():
    config = Config.from_env()
    print(f"Zuul CI Bot configured for {config.zuul_url}")
    # Components initialized in subsequent tasks

if __name__ == "__main__":
    main()
```

- [ ] **Step 7: Commit**

```bash
git add -A
git commit -m "feat: project scaffolding with config module"
```

---

### Task 2: Knowledge Store (YAML baseline + SQLite)

**Files:**
- Create: `bot/knowledge_store.py`
- Create: `tests/test_knowledge_store.py`
- Create: `knowledge/01-conventions.md`
- Create: `knowledge/02-failure-patterns.yaml`

- [ ] **Step 1: Write knowledge store tests**

Create `tests/test_knowledge_store.py`:

```python
import json
import pytest
from pathlib import Path
from bot.knowledge_store import KnowledgeStore


@pytest.fixture
def knowledge_dir(tmp_path):
    (tmp_path / "01-test.md").write_text("# Test\nSome knowledge.")
    (tmp_path / "02-patterns.yaml").write_text(
        "patterns:\n  - name: test_pattern\n"
        "    category: INFRA_FLAKE\n"
        "    signatures: ['error_sig']\n"
        "    diagnosis: Test diagnosis\n"
        "    action: Retry\n"
        "    retryable: true\n")
    return tmp_path


@pytest.fixture
def store(knowledge_dir, tmp_path):
    db_path = str(tmp_path / "test.db")
    return KnowledgeStore(str(knowledge_dir), db_path)


def test_load_baseline(store):
    prompt = store.build_system_prompt()
    assert "Test" in prompt
    assert "test_pattern" in prompt or "Some knowledge" in prompt


def test_build_prompt_without_learned(store):
    prompt = store.build_system_prompt()
    assert "Learned Patterns" not in prompt


def test_record_unclassified(store):
    store.record_unclassified("new_error", "uuid1", "job1", "UNKNOWN")
    pending = store.get_patterns(status="pending")
    assert len(pending) == 1
    assert pending[0]["name"] == "new_error"
    assert pending[0]["occurrences"] == 1


def test_record_increments_occurrences(store):
    store.record_unclassified("new_error", "uuid1", "job1", "UNKNOWN")
    store.record_unclassified("new_error", "uuid2", "job1", "UNKNOWN")
    pending = store.get_patterns(status="pending")
    assert pending[0]["occurrences"] == 2
    samples = json.loads(pending[0]["sample_builds"])
    assert "uuid1" in samples
    assert "uuid2" in samples


def test_approve_pattern(store):
    store.record_unclassified("err", "u1", "j1", "REAL_FAILURE")
    pending = store.get_patterns(status="pending")
    store.approve_pattern(pending[0]["id"], approved_by="U123",
                          category="REAL_FAILURE",
                          diagnosis="Custom diag",
                          action="Fix it")
    approved = store.get_patterns(status="approved")
    assert len(approved) == 1
    assert approved[0]["diagnosis"] == "Custom diag"
    assert approved[0]["approved_by"] == "U123"


def test_approved_patterns_in_prompt(store):
    store.record_unclassified("err", "u1", "j1", "REAL_FAILURE")
    pending = store.get_patterns(status="pending")
    store.approve_pattern(pending[0]["id"], category="REAL_FAILURE",
                          diagnosis="Approved diag")
    prompt = store.build_system_prompt()
    assert "Learned Patterns" in prompt
    assert "Approved diag" in prompt


def test_reject_pattern(store):
    store.record_unclassified("noise", "u1", "j1", "UNKNOWN")
    pending = store.get_patterns(status="pending")
    store.reject_pattern(pending[0]["id"])
    assert len(store.get_patterns(status="pending")) == 0
    assert len(store.get_patterns(status="rejected")) == 1


def test_suggestion_candidates(store):
    for i in range(3):
        store.record_unclassified("frequent_err", f"u{i}", "j1", "X")
    candidates = store.get_suggestion_candidates(min_occurrences=3)
    assert len(candidates) == 1


def test_graduate_patterns(store, knowledge_dir):
    store.record_unclassified("grad_err", "u1", "j1", "INFRA_FLAKE")
    pending = store.get_patterns(status="pending")
    store.approve_pattern(pending[0]["id"], category="INFRA_FLAKE",
                          diagnosis="Graduated", action="Retry")
    graduated = store.graduate_patterns()
    assert len(graduated) == 1
    assert (knowledge_dir / "99-graduated.yaml").exists()
    assert len(store.get_patterns(status="graduated")) == 1
    # Baseline prompt now includes graduated content
    prompt = store.build_system_prompt()
    assert "Graduated" in prompt
```

- [ ] **Step 2: Run tests to verify they fail**

Run: `pytest tests/test_knowledge_store.py -v`
Expected: FAIL (no `bot.knowledge_store` module)

- [ ] **Step 3: Implement knowledge store**

Create `bot/knowledge_store.py` with the full implementation from the spec (the `KnowledgeStore` class with `_init_db`, `_load_baseline`, `build_system_prompt`, `record_unclassified`, `approve_pattern`, `reject_pattern`, `get_patterns`, `get_suggestion_candidates`, `graduate_patterns`).

See spec lines 415-574 for the complete implementation.

- [ ] **Step 4: Run tests to verify they pass**

Run: `pytest tests/test_knowledge_store.py -v`
Expected: all 10 tests PASS

- [ ] **Step 5: Create baseline knowledge files**

Create `knowledge/01-conventions.md`:

```markdown
# Zuul CI Conventions

## Job Naming
- Component jobs: component-<name>-<deployment>-rhel<ver>-rhoso<ver>-crc[-suffix]
- Uni-jobs: <scenario>-rhel<ver>-rhoso<ver>[-suffix]
- Adoption chains: <scenario>-rhel<ver>-rhoso<ver>-adoption-<stage>[-suffix]

## Pipeline Structure
Standard adoption chain: deploy-infra -> deploy-ocp + deploy-osp (parallel) -> install-operators -> run-adoption -> run-tempest

## Result Types
- SUCCESS: all playbooks passed
- FAILURE: run playbook failed
- TIMED_OUT: exceeded timeout, post-run still executes
- RETRY_LIMIT: pre-run failed after max attempts
- POST_FAILURE: post-run failed (log collection), run may have passed
- NODE_FAILURE: node allocation failed before job started
- SKIPPED: dependency failure or pipeline config
```

Create `knowledge/02-failure-patterns.yaml` with the patterns from the spec (lines 652-681).

- [ ] **Step 6: Commit**

```bash
git add -A
git commit -m "feat: knowledge store with YAML baseline + SQLite overlay"
```

---

### Task 3: Rate Limiter

**Files:**
- Create: `bot/rate_limiter.py`
- Create: `tests/test_rate_limiter.py`

- [ ] **Step 1: Write rate limiter tests**

Create `tests/test_rate_limiter.py`:

```python
import time
import pytest
from bot.rate_limiter import RateLimiter


def test_allows_first_request():
    rl = RateLimiter(max_concurrent=5, user_cooldown=10)
    allowed, reason = rl.check("user1")
    assert allowed is True


def test_blocks_during_cooldown():
    rl = RateLimiter(max_concurrent=5, user_cooldown=1)
    rl.check("user1")
    rl.release("user1")
    allowed, reason = rl.check("user1")
    assert allowed is False
    assert "cooldown" in reason.lower()


def test_allows_after_cooldown():
    rl = RateLimiter(max_concurrent=5, user_cooldown=0)
    rl.check("user1")
    rl.release("user1")
    allowed, _ = rl.check("user1")
    assert allowed is True


def test_blocks_at_max_concurrent():
    rl = RateLimiter(max_concurrent=2, user_cooldown=0)
    rl.check("user1")
    rl.check("user2")
    allowed, reason = rl.check("user3")
    assert allowed is False
    assert "concurrent" in reason.lower() or "busy" in reason.lower()


def test_release_frees_slot():
    rl = RateLimiter(max_concurrent=1, user_cooldown=0)
    rl.check("user1")
    rl.release("user1")
    allowed, _ = rl.check("user2")
    assert allowed is True
```

- [ ] **Step 2: Run tests to verify they fail**

Run: `pytest tests/test_rate_limiter.py -v`
Expected: FAIL

- [ ] **Step 3: Implement rate limiter**

Create `bot/rate_limiter.py`:

```python
"""Per-user cooldown and concurrent request limiting."""

import time
import threading


class RateLimiter:
    def __init__(self, max_concurrent: int = 5,
                 user_cooldown: int = 10):
        self.max_concurrent = max_concurrent
        self.user_cooldown = user_cooldown
        self._active: set[str] = set()
        self._last_request: dict[str, float] = {}
        self._lock = threading.Lock()

    def check(self, user_id: str) -> tuple[bool, str]:
        """Check if request is allowed. Returns (allowed, reason)."""
        with self._lock:
            now = time.monotonic()
            # Check user cooldown
            last = self._last_request.get(user_id, 0)
            if user_id not in self._active and \
               now - last < self.user_cooldown:
                remaining = int(self.user_cooldown - (now - last))
                return False, (f"Cooldown: please wait {remaining}s "
                               "before asking again.")
            # Check concurrent limit
            if len(self._active) >= self.max_concurrent:
                return False, ("Too busy - handling too many requests "
                               "right now. Try again in a moment.")
            self._active.add(user_id)
            self._last_request[user_id] = now
            return True, ""

    def release(self, user_id: str):
        """Release a slot after request completes."""
        with self._lock:
            self._active.discard(user_id)
```

- [ ] **Step 4: Run tests to verify they pass**

Run: `pytest tests/test_rate_limiter.py -v`
Expected: all 5 tests PASS

- [ ] **Step 5: Commit**

```bash
git add -A
git commit -m "feat: rate limiter with per-user cooldown and concurrent limit"
```

---

### Task 4: MCP Bridge (Agent)

**Files:**
- Create: `bot/agent.py`
- Create: `tests/test_agent.py`

- [ ] **Step 1: Write agent tests**

Create `tests/test_agent.py`:

```python
import json
import pytest
from unittest.mock import AsyncMock, MagicMock, patch
from bot.agent import ZuulAgent, _extract_classification


def test_extract_classification_from_diagnose():
    data = json.dumps({
        "job": "deploy-ocp",
        "result": "FAILURE",
        "classification": "INFRA_FLAKE",
        "classification_reason": "SSH unreachable",
        "classification_confidence": "high",
        "retryable": True,
    })
    result = _extract_classification(data)
    assert result["classification"] == "INFRA_FLAKE"
    assert result["classification_reason"] == "SSH unreachable"
    assert result["confidence"] == "high"


def test_extract_classification_missing():
    result = _extract_classification('{"job": "test"}')
    assert result is None


def test_extract_classification_invalid_json():
    result = _extract_classification("not json")
    assert result is None


@pytest.mark.asyncio
async def test_max_iterations_guard():
    """Verify the loop exits after MAX_ITERATIONS."""
    store = MagicMock()
    store.build_system_prompt.return_value = "test knowledge"

    agent = ZuulAgent(
        zuul_url="https://test.com",
        tenant="test",
        knowledge_store=store,
    )

    # Mock Claude to always return tool_use (infinite loop scenario)
    mock_response = MagicMock()
    mock_response.stop_reason = "tool_use"
    mock_block = MagicMock()
    mock_block.type = "tool_use"
    mock_block.name = "list_builds"
    mock_block.input = {}
    mock_block.id = "test-id"
    mock_response.content = [mock_block]

    agent.client = MagicMock()
    agent.client.messages.create.return_value = mock_response
    agent._claude_tools = [{"name": "list_builds",
                            "description": "test",
                            "input_schema": {}}]

    # Mock MCP session
    mock_result = MagicMock()
    mock_content = MagicMock()
    mock_content.text = '{"builds": []}'
    type(mock_content).type = "text"
    mock_result.content = [mock_content]
    mock_result.isError = False
    mock_session = AsyncMock()
    mock_session.call_tool.return_value = mock_result
    agent._session = mock_session

    answer, _ = await agent.ask("test question")
    assert "maximum tool call limit" in answer.lower()
    assert agent.client.messages.create.call_count == 15
```

- [ ] **Step 2: Run tests to verify they fail**

Run: `pytest tests/test_agent.py -v`
Expected: FAIL

- [ ] **Step 3: Implement agent**

Create `bot/agent.py` with the full `ZuulAgent` class and `_extract_classification` helper from the spec (lines 292-408). Key points:

- `__init__` takes `knowledge_store` instead of raw knowledge string
- `connect()` / `reconnect()` manage persistent MCP session
- `ask()` returns `tuple[str, dict | None]` (answer + classification)
- `_extract_classification()` parses `diagnose_build` JSON output
- `MAX_ITERATIONS = 15` guards the loop
- Full env inheritance via `{**os.environ, ...}`

- [ ] **Step 4: Run tests to verify they pass**

Run: `pytest tests/test_agent.py -v`
Expected: all 4 tests PASS

- [ ] **Step 5: Commit**

```bash
git add -A
git commit -m "feat: MCP bridge with persistent session and agentic loop"
```

---

### Task 5: Pattern Tracker

**Files:**
- Create: `bot/pattern_tracker.py`
- Create: `tests/test_pattern_tracker.py`

- [ ] **Step 1: Write pattern tracker tests**

Create `tests/test_pattern_tracker.py`:

```python
import pytest
from unittest.mock import MagicMock
from bot.pattern_tracker import PatternTracker


@pytest.fixture
def tracker():
    store = MagicMock()
    store.get_suggestion_candidates.return_value = []
    client = MagicMock()
    return PatternTracker(store, client, "C_CIOPS")


def test_ignores_high_confidence(tracker):
    classification = {
        "classification": "INFRA_FLAKE",
        "classification_reason": "SSH unreachable",
        "confidence": "high",
    }
    tracker.record(classification, {})
    tracker.store.record_unclassified.assert_not_called()


def test_records_unknown(tracker):
    classification = {
        "classification": "UNKNOWN",
        "classification_reason": "Unknown task failure",
        "confidence": "low",
        "build_uuid": "uuid1",
        "job": "deploy-ocp",
    }
    tracker.record(classification, {})
    tracker.store.record_unclassified.assert_called_once()


def test_posts_suggestion_at_threshold(tracker):
    # Simulate threshold reached
    tracker.store.get_suggestion_candidates.return_value = [
        (42, "new_error", "UNKNOWN", '["sig"]', "diag", "", False,
         "pending", 3, "2026-01-01", "2026-01-02",
         '["u1","u2","u3"]', "UNKNOWN", None)
    ]
    classification = {
        "classification": "UNKNOWN",
        "classification_reason": "new_error",
        "confidence": "low",
        "build_uuid": "u3",
        "job": "j1",
    }
    tracker.record(classification, {})
    tracker.client.chat_postMessage.assert_called_once()
    call_kwargs = tracker.client.chat_postMessage.call_args.kwargs
    assert call_kwargs["channel"] == "C_CIOPS"
    assert "new_error" in call_kwargs["text"]


def test_no_duplicate_suggestions(tracker):
    tracker.store.get_suggestion_candidates.return_value = [
        (42, "dup_error", "UNKNOWN", '["s"]', "", "", False,
         "pending", 3, "", "", '[]', "", None)
    ]
    classification = {
        "classification": "UNKNOWN",
        "classification_reason": "dup_error",
        "confidence": "low",
    }
    tracker.record(classification, {})
    tracker.record(classification, {})
    assert tracker.client.chat_postMessage.call_count == 1
```

- [ ] **Step 2: Run tests to verify they fail**

Run: `pytest tests/test_pattern_tracker.py -v`
Expected: FAIL

- [ ] **Step 3: Implement pattern tracker**

Create `bot/pattern_tracker.py` from the spec (lines 581-643).

- [ ] **Step 4: Run tests to verify they pass**

Run: `pytest tests/test_pattern_tracker.py -v`
Expected: all 4 tests PASS

- [ ] **Step 5: Commit**

```bash
git add -A
git commit -m "feat: pattern tracker with threshold-based suggestions"
```

---

### Task 6: Slack Formatters

**Files:**
- Create: `bot/slack_formatters.py`
- Create: `tests/test_slack_formatters.py` (optional, simple module)

- [ ] **Step 1: Implement Slack formatters**

Create `bot/slack_formatters.py`:

```python
"""Convert Claude output and thread history to/from Slack format."""


def claude_to_slack_mrkdwn(text: str) -> str:
    """Convert Claude's markdown to Slack mrkdwn.

    Key differences: Slack uses *bold* (not **bold**),
    _italic_ (not *italic*), and ```code``` is the same.
    """
    import re
    # Bold: **text** -> *text*
    text = re.sub(r'\*\*(.+?)\*\*', r'*\1*', text)
    # Headers: ## Title -> *Title*
    text = re.sub(r'^#{1,6}\s+(.+)$', r'*\1*', text, flags=re.MULTILINE)
    return text


def thread_to_claude_messages(messages: list[dict],
                              bot_user_id: str) -> list[dict]:
    """Convert Slack thread messages to Claude message format.

    Bot messages become 'assistant' role, user messages become 'user'.
    Strips @mentions from text.
    """
    import re
    claude_msgs = []
    for msg in messages:
        text = msg.get("text", "")
        # Strip @mentions like <@U123ABC>
        text = re.sub(r'<@\w+>\s*', '', text).strip()
        if not text:
            continue
        role = "assistant" if msg.get("user") == bot_user_id else "user"
        claude_msgs.append({"role": role, "content": text})
    return claude_msgs
```

- [ ] **Step 2: Commit**

```bash
git add -A
git commit -m "feat: Slack mrkdwn formatters and thread converter"
```

---

### Task 7: Slack Handlers (Main Bot Logic)

**Files:**
- Create: `bot/slack_handlers.py`
- Create: `tests/test_slack_handlers.py`
- Modify: `bot/__main__.py` (wire everything together)

- [ ] **Step 1: Write Slack handler tests**

Create `tests/test_slack_handlers.py`:

```python
import pytest
from unittest.mock import MagicMock, AsyncMock, patch
from bot.slack_handlers import (
    is_ciops_channel,
    handle_zuulbot_command,
)


def test_ciops_channel_check():
    assert is_ciops_channel("C123", ["C123", "C456"]) is True
    assert is_ciops_channel("C999", ["C123", "C456"]) is False
    assert is_ciops_channel("C123", []) is False


def test_command_blocks_management_outside_ciops():
    respond = MagicMock()
    command = {"channel_id": "C999", "text": "patterns pending"}
    handle_zuulbot_command(
        command=command,
        respond=respond,
        ciops_channels=["C123"],
        knowledge_store=MagicMock(),
    )
    respond.assert_called_once()
    assert "CIOps" in respond.call_args[0][0]


def test_command_allows_help_anywhere():
    respond = MagicMock()
    command = {"channel_id": "C999", "text": "help"}
    handle_zuulbot_command(
        command=command,
        respond=respond,
        ciops_channels=["C123"],
        knowledge_store=MagicMock(),
    )
    respond.assert_called_once()
    assert "help" in respond.call_args[0][0].lower() or \
           "usage" in respond.call_args[0][0].lower()
```

- [ ] **Step 2: Run tests to verify they fail**

Run: `pytest tests/test_slack_handlers.py -v`
Expected: FAIL

- [ ] **Step 3: Implement Slack handlers**

Create `bot/slack_handlers.py` with:

- `is_ciops_channel(channel_id, ciops_channels) -> bool`
- `handle_zuulbot_command(command, respond, ciops_channels, knowledge_store)` - routes subcommands
- `_handle_patterns_command(args, respond, store)` - list/approve/graduate
- `_handle_help(respond)` - usage text
- `_extract_pattern_id_from_metadata(message)` - parse pattern ID from suggestion messages

Implement based on spec lines 244-286.

- [ ] **Step 4: Run tests to verify they pass**

Run: `pytest tests/test_slack_handlers.py -v`
Expected: all 3 tests PASS

- [ ] **Step 5: Wire everything in __main__.py**

Update `bot/__main__.py` to:

1. Load config
2. Initialize `KnowledgeStore`
3. Initialize `RateLimiter`
4. Initialize `ZuulAgent` (with `connect()`)
5. Initialize `PatternTracker`
6. Create Slack `App` with `process_before_response=True`
7. Register event handlers (app_mention, reaction_added, /zuulbot command)
8. Start `SocketModeHandler`

```python
"""Entry point: python -m bot"""

import asyncio
import os
from slack_bolt import App
from slack_bolt.adapter.socket_mode import SocketModeHandler

from .config import Config
from .agent import ZuulAgent
from .knowledge_store import KnowledgeStore
from .pattern_tracker import PatternTracker
from .rate_limiter import RateLimiter
from .slack_formatters import claude_to_slack_mrkdwn, thread_to_claude_messages
from .slack_handlers import is_ciops_channel, handle_zuulbot_command


def main():
    config = Config.from_env()

    store = KnowledgeStore(config.knowledge_dir, config.patterns_db)
    rate_limiter = RateLimiter(config.max_concurrent, config.user_cooldown)

    agent = ZuulAgent(
        zuul_url=config.zuul_url,
        tenant=config.tenant,
        knowledge_store=store,
        extra_env={"ZUUL_USE_KERBEROS": str(config.zuul_use_kerberos).lower()},
    )

    app = App(token=config.slack_bot_token, process_before_response=True)
    bot_user_id = app.client.auth_test()["user_id"]

    tracker = PatternTracker(
        store, app.client, config.ciops_patterns_channel)

    # --- @zuulbot mention ---
    def ack_mention(event, client, ack):
        ack()
        result = client.chat_postMessage(
            channel=event["channel"],
            thread_ts=event.get("thread_ts", event["ts"]),
            text=":hourglass_flowing_sand: Analyzing...",
        )
        event["_placeholder_ts"] = result["ts"]

    def process_mention(event, client):
        user_id = event.get("user", "")
        placeholder_ts = event.get("_placeholder_ts")
        channel = event["channel"]

        allowed, reason = rate_limiter.check(user_id)
        if not allowed:
            if placeholder_ts:
                client.chat_update(
                    channel=channel, ts=placeholder_ts, text=reason)
            return

        try:
            # Thread context
            thread_messages = []
            if event.get("thread_ts"):
                replies = client.conversations_replies(
                    channel=channel, ts=event["thread_ts"], limit=20)
                thread_messages = thread_to_claude_messages(
                    replies.get("messages", []), bot_user_id)

            answer, classification = asyncio.run(agent.ask(
                question=event.get("text", ""),
                thread_messages=thread_messages,
                on_tool_call=lambda name, args: client.chat_update(
                    channel=channel, ts=placeholder_ts,
                    text=f":mag: Calling {name}..."),
            ))

            formatted = claude_to_slack_mrkdwn(answer)
            client.chat_update(
                channel=channel, ts=placeholder_ts, text=formatted)

            if classification and classification.get(
                    "confidence") in ("low", None):
                tracker.record(classification, event)
        finally:
            rate_limiter.release(user_id)

    app.event("app_mention")(ack=ack_mention, lazy=[process_mention])

    # --- /zuulbot command ---
    @app.command("/zuulbot")
    def cmd_handler(ack, command, respond):
        ack()
        handle_zuulbot_command(
            command=command,
            respond=respond,
            ciops_channels=config.ciops_channels,
            knowledge_store=store,
        )

    # --- Reaction approval ---
    @app.event("reaction_added")
    def reaction_handler(event, client):
        item_channel = event["item"].get("channel", "")
        if not is_ciops_channel(item_channel, config.ciops_channels):
            return
        if event["reaction"] == "white_check_mark":
            # Look up the message to find pattern ID
            msg = client.conversations_history(
                channel=item_channel, latest=event["item"]["ts"],
                inclusive=True, limit=1)
            if msg["messages"]:
                metadata = msg["messages"][0].get("metadata", {})
                payload = metadata.get("event_payload", {})
                pattern_id = payload.get("pattern_id")
                if pattern_id:
                    store.approve_pattern(
                        int(pattern_id),
                        approved_by=event.get("user", ""))
                    client.chat_postMessage(
                        channel=item_channel,
                        thread_ts=event["item"]["ts"],
                        text=f":white_check_mark: Pattern "
                             f"#{pattern_id} approved.")

    # Start
    print(f"Zuul CI Bot starting for {config.zuul_url}")
    handler = SocketModeHandler(app, config.slack_app_token)
    handler.start()


if __name__ == "__main__":
    main()
```

- [ ] **Step 6: Commit**

```bash
git add -A
git commit -m "feat: Slack handlers and main entry point - bot is functional"
```

---

### Task 8: Dockerfile and Deployment

**Files:**
- Create: `Dockerfile`

- [ ] **Step 1: Create Dockerfile**

```dockerfile
FROM python:3.12-slim

# Kerberos system dependencies (for mcp-zuul[kerberos])
RUN apt-get update && apt-get install -y --no-install-recommends \
    libkrb5-dev gcc && rm -rf /var/lib/apt/lists/*

WORKDIR /app
COPY pyproject.toml .
RUN pip install --no-cache-dir ".[kerberos]"

COPY bot/ bot/
COPY knowledge/ knowledge/

CMD ["python", "-m", "bot"]
```

- [ ] **Step 2: Verify Docker build**

Run: `docker build -t zuul-ci-bot .`
Expected: successful build

- [ ] **Step 3: Commit**

```bash
git add Dockerfile
git commit -m "feat: Dockerfile with Kerberos support"
```

---

### Task 9: End-to-End Smoke Test

**Files:**
- Create: `tests/test_integration.py`

- [ ] **Step 1: Write integration test**

Create `tests/test_integration.py`:

```python
"""Integration tests requiring mcp-zuul installed.

Run with: pytest tests/test_integration.py -v -m integration
Skip in CI without mcp-zuul: pytest -m "not integration"
"""

import json
import os
import pytest
from bot.knowledge_store import KnowledgeStore
from bot.agent import ZuulAgent

pytestmark = pytest.mark.integration


@pytest.fixture
def store(tmp_path):
    knowledge = tmp_path / "knowledge"
    knowledge.mkdir()
    (knowledge / "01-test.md").write_text("Test knowledge for CI bot.")
    return KnowledgeStore(str(knowledge), str(tmp_path / "test.db"))


@pytest.mark.skipif(
    not os.environ.get("ZUUL_URL"),
    reason="ZUUL_URL not set - skipping integration tests",
)
@pytest.mark.asyncio
async def test_agent_connects_and_discovers_tools(store):
    agent = ZuulAgent(
        zuul_url=os.environ["ZUUL_URL"],
        tenant=os.environ.get("ZUUL_DEFAULT_TENANT", ""),
        knowledge_store=store,
    )
    await agent.connect()
    assert agent._claude_tools is not None
    assert len(agent._claude_tools) > 30  # 32 read-only tools
    tool_names = [t["name"] for t in agent._claude_tools]
    assert "diagnose_build" in tool_names
    assert "get_build_failures" in tool_names
```

- [ ] **Step 2: Run integration test (if ZUUL_URL set)**

Run: `ZUUL_URL=https://softwarefactory-project.io/zuul ZUUL_DEFAULT_TENANT=rdoproject.org pytest tests/test_integration.py -v -m integration`
Expected: PASS (connects to public RDO Zuul, discovers 32+ tools)

- [ ] **Step 3: Commit**

```bash
git add -A
git commit -m "test: integration test for MCP connection and tool discovery"
```

---

## Summary

| Task | Component | Tests | Phase |
|------|-----------|-------|-------|
| 1 | Project scaffolding + config | 3 | MVP |
| 2 | Knowledge store (YAML + SQLite) | 10 | MVP + Phase 2 |
| 3 | Rate limiter | 5 | MVP |
| 4 | MCP bridge (agent) | 4 | MVP |
| 5 | Pattern tracker | 4 | Phase 1.5 + 2 |
| 6 | Slack formatters | - | MVP |
| 7 | Slack handlers + main entry | 3 | MVP + Phase 2 |
| 8 | Dockerfile | - | MVP |
| 9 | Integration test | 1 | MVP |

**Total: 9 tasks, ~30 tests, covers Phase 1 through Phase 2.**

Phase 3 (graduation to YAML via git) is already implemented in `KnowledgeStore.graduate_patterns()` but the git commit/MR automation is left for a future task since it requires GitLab API integration.
