Module 3: GitLab CI/CD and Headless SDK

Project: Porting the Module 2 Bot to GitLab

Project: Porting the Module 2 Bot to GitLab

Project overview

This project is where the central lesson of Module 3 gets internalized: cross-platform portability. You take the code review bot you built in Module 2 (which lives as YAML + scripts in GitHub Actions) and refactor it so the logic lives in the SDK and the orchestration is thin on each platform. The result: the same Python script runs in GitHub Actions and in GitLab CI/CD.

It's not a theoretical exercise. By the end, you'll have:

  1. The bot working in GitHub Actions (carry-over from Module 2)
  2. The same bot working in GitLab CI/CD (what you build here)
  3. A common abstraction layer that both platforms consume
  4. Documentation that demonstrates that changing platform would cost 30 minutes, not days

Project Goal

Refactor the Module 2 bot so the code review logic is platform-agnostic, and demonstrate that the same script runs in GitHub Actions and GitLab CI/CD with different orchestration but identical scripts.

By completing it:

  • ✅ Your code review logic is in scripts/ (Python or TypeScript)
  • ✅ There's an adaptation layer that abstracts the differences between platforms
  • ✅ Each platform's YAML is thin: it only orchestrates, it doesn't contain logic
  • ✅ The bot works on both platforms with the same behavior
  • ✅ You documented the process so another developer can replicate it

Technical Specifications

Project structure

my-portable-bot/
├── .github/
│   └── workflows/
│       └── code-review.yml          # GitHub orchestration
├── .gitlab-ci.yml                    # GitLab orchestration
├── scripts/
│   ├── platform_adapter.py           # platform abstraction ★ key
│   ├── code_review.py                # common logic
│   ├── publish_review.py             # publishing with the adapter
│   └── extract_diff.py               # extraction with the adapter
├── CLAUDE.md
├── requirements.txt
└── README.md

Initial setup

mkdir my-portable-bot && cd my-portable-bot
git init
python -m venv venv && source venv/bin/activate
pip install anthropic python-gitlab requests

requirements.txt

anthropic>=0.39.0,<1.0.0
python-gitlab>=4.0.0
requests>=2.31.0

The Adaptation Layer: platform_adapter.py

This is the key file of the project. It defines a common interface that abstracts the differences between GitHub and GitLab.

"""scripts/platform_adapter.py

Abstracts the differences between GitHub Actions and GitLab CI/CD.
Automatically detects the platform based on the environment variables.
"""
from __future__ import annotations
import os
from abc import ABC, abstractmethod
from dataclasses import dataclass


@dataclass
class PRContext:
    """PR/MR context — platform-agnostic."""
    pr_number: int
    target_branch: str
    head_sha: str
    repo_path: str  # "owner/repo" or "group/project"


class PlatformAdapter(ABC):
    """Common interface for platform-specific operations."""
    
    @abstractmethod
    def get_pr_context(self) -> PRContext:
        """Extract the PR/MR context from the environment."""
        ...
    
    @abstractmethod
    def get_pr_files(self) -> list[dict]:
        """List of modified files with metadata."""
        ...
    
    @abstractmethod
    def post_summary(self, body: str) -> None:
        """Publish a general comment (summary)."""
        ...
    
    @abstractmethod
    def post_inline_comment(
        self, path: str, line: int, body: str
    ) -> bool:
        """Publish an inline comment. Returns True if it succeeded."""
        ...


class GitHubAdapter(PlatformAdapter):
    """Implementation for GitHub Actions."""
    
    def __init__(self):
        import requests
        self.requests = requests
        self.token = os.environ["GITHUB_TOKEN"]
        self.repo = os.environ["GITHUB_REPOSITORY"]
        self.pr_number = int(os.environ["PR_NUMBER"])
        self.head_sha = os.environ["PR_HEAD_SHA"]
        self.base_branch = os.environ["PR_BASE_BRANCH"]
        self.headers = {
            "Authorization": f"Bearer {self.token}",
            "Accept": "application/vnd.github+json",
            "X-GitHub-Api-Version": "2022-11-28",
        }
    
    def get_pr_context(self) -> PRContext:
        return PRContext(
            pr_number=self.pr_number,
            target_branch=self.base_branch,
            head_sha=self.head_sha,
            repo_path=self.repo,
        )
    
    def get_pr_files(self) -> list[dict]:
        url = f"https://api.github.com/repos/{self.repo}/pulls/{self.pr_number}/files?per_page=100"
        return self.requests.get(url, headers=self.headers).json()
    
    def post_summary(self, body: str) -> None:
        url = f"https://api.github.com/repos/{self.repo}/issues/{self.pr_number}/comments"
        r = self.requests.post(url, headers=self.headers, json={"body": body})
        r.raise_for_status()
    
    def post_inline_comment(self, path: str, line: int, body: str) -> bool:
        url = f"https://api.github.com/repos/{self.repo}/pulls/{self.pr_number}/comments"
        payload = {
            "body": body,
            "commit_id": self.head_sha,
            "path": path,
            "line": line,
            "side": "RIGHT",
        }
        r = self.requests.post(url, headers=self.headers, json=payload)
        return r.status_code == 201


class GitLabAdapter(PlatformAdapter):
    """Implementation for GitLab CI/CD."""
    
    def __init__(self):
        import gitlab
        self.gl = gitlab.Gitlab(
            os.environ["CI_SERVER_URL"],
            private_token=os.environ["GITLAB_API_TOKEN"],
        )
        self.project = self.gl.projects.get(os.environ["CI_PROJECT_ID"])
        self.mr = self.project.mergerequests.get(
            int(os.environ["CI_MERGE_REQUEST_IID"])
        )
    
    def get_pr_context(self) -> PRContext:
        return PRContext(
            pr_number=self.mr.iid,
            target_branch=self.mr.target_branch,
            head_sha=self.mr.diff_refs["head_sha"],
            repo_path=self.project.path_with_namespace,
        )
    
    def get_pr_files(self) -> list[dict]:
        changes = self.mr.changes()
        # Normalize to the common format (similar to GitHub's)
        return [
            {
                "filename": c.get("new_path", c.get("old_path", "")),
                "patch": c.get("diff", ""),
                "status": "removed" if c.get("deleted_file") else "modified",
                "additions": 0,  # GitLab doesn't provide this directly
                "deletions": 0,
            }
            for c in changes.get("changes", [])
        ]
    
    def post_summary(self, body: str) -> None:
        self.mr.notes.create({"body": body})
    
    def post_inline_comment(self, path: str, line: int, body: str) -> bool:
        try:
            self.mr.discussions.create({
                "body": body,
                "position": {
                    "base_sha": self.mr.diff_refs["base_sha"],
                    "start_sha": self.mr.diff_refs["start_sha"],
                    "head_sha": self.mr.diff_refs["head_sha"],
                    "position_type": "text",
                    "new_path": path,
                    "new_line": line,
                },
            })
            return True
        except Exception:
            return False


def detect_platform() -> PlatformAdapter:
    """Detects the platform based on the environment variables."""
    if os.environ.get("GITHUB_ACTIONS") == "true":
        return GitHubAdapter()
    elif os.environ.get("GITLAB_CI") == "true":
        return GitLabAdapter()
    else:
        raise RuntimeError(
            "Platform not detected. Are you running in GitHub Actions or GitLab CI?"
        )

The important thing: whoever uses the adapter doesn't need to know which platform it runs on. detect_platform() returns the correct implementation and all the downstream code is agnostic.


The Common Script: code_review.py

This script is identical across platforms — it uses the adapter:

"""scripts/code_review.py — platform-agnostic review."""
import json
import os
import sys
from pathlib import Path
from anthropic import Anthropic, APIError

from platform_adapter import detect_platform


def main() -> int:
    try:
        adapter = detect_platform()
        ctx = adapter.get_pr_context()
        
        print(f"Reviewing PR/MR #{ctx.pr_number} in {ctx.repo_path}")
        
        # Get files
        all_files = adapter.get_pr_files()
        relevant = filter_relevant(all_files)
        
        if not relevant:
            print("No relevant files.")
            return 0
        
        # Build the diff for review
        diff_text = build_combined_diff(relevant)
        conventions = load_conventions()
        
        # Call Claude
        client = Anthropic()
        response = client.messages.create(
            model=os.environ.get("CLAUDE_MODEL", "claude-haiku-4-5"),
            max_tokens=int(os.environ.get("CLAUDE_MAX_TOKENS", "4000")),
            messages=[{"role": "user", "content": build_prompt(diff_text, conventions)}],
        )
        
        # Parse and save
        review_data = parse_review_response(response.content[0].text)
        Path("review_result.json").write_text(json.dumps({
            "context": {
                "pr_number": ctx.pr_number,
                "repo_path": ctx.repo_path,
            },
            "review": review_data,
            "tokens_used": {
                "input": response.usage.input_tokens,
                "output": response.usage.output_tokens,
            },
        }, indent=2))
        
        print(f"Review generated: {len(review_data['comments'])} comments")
        return 0
    
    except APIError as e:
        print(f"API ERROR: {e}", file=sys.stderr)
        return 1
    except Exception as e:
        print(f"ERROR: {e}", file=sys.stderr)
        return 1


def filter_relevant(files: list[dict]) -> list[dict]:
    """Filter relevant files (common to both platforms)."""
    RELEVANT = {".py", ".ts", ".tsx", ".js", ".jsx", ".go", ".rb"}
    
    def is_relevant(f):
        name = f.get("filename", "")
        if not any(name.endswith(ext) for ext in RELEVANT):
            return False
        if "test" in name or "fixture" in name:
            return False
        if f.get("status") == "removed":
            return False
        return bool(f.get("patch"))
    
    return [f for f in files if is_relevant(f)]


def build_combined_diff(files: list[dict]) -> str:
    parts = []
    for f in files:
        parts.append(f"--- {f['filename']} ---\n{f['patch']}\n")
    return "\n".join(parts)


def load_conventions() -> str:
    p = Path("CLAUDE.md")
    return p.read_text() if p.exists() else "No documented conventions."


def build_prompt(diff: str, conventions: str) -> str:
    return f"""[full prompt here, see previous modules]"""


def parse_review_response(text: str) -> dict:
    import re
    text = text.strip()
    text = re.sub(r"^```(?:json)?\n?", "", text)
    text = re.sub(r"\n?```$", "", text)
    return json.loads(text)


if __name__ == "__main__":
    sys.exit(main())

Key observation: this script doesn't have a single if platform == "github". All the differentiation is encapsulated in the adapter.


The Publishing Script: publish_review.py

"""scripts/publish_review.py — platform-agnostic publishing."""
import json
import os
import sys
from pathlib import Path

from platform_adapter import detect_platform


MARKER = "<!-- claude-code-bot -->"


def build_summary_markdown(review_data: dict) -> str:
    summary = review_data["summary"]
    comments = review_data["comments"]
    
    by_severity = {"critical": 0, "warning": 0, "suggestion": 0}
    for c in comments:
        by_severity[c.get("severity", "suggestion")] += 1
    
    return f"""{MARKER}

# 🤖 Code Review (Claude Code)

## Summary
{summary["overview"]}

### Severity
- 🚨 Critical: {by_severity['critical']}
- ⚠️ Warning: {by_severity['warning']}
- 💡 Suggestion: {by_severity['suggestion']}

### Verdict
**{summary["verdict"].replace("_", " ").title()}**

Details inline in each file.
"""


def format_severity(severity: str) -> str:
    return {
        "critical": "🚨 **CRITICAL** — ",
        "warning": "⚠️ **WARNING** — ",
        "suggestion": "💡 **Suggestion** — ",
    }.get(severity, "")


def main() -> int:
    review_file = Path("review_result.json")
    if not review_file.exists():
        print("ERROR: review_result.json not found.", file=sys.stderr)
        return 1
    
    data = json.loads(review_file.read_text())
    review = data["review"]
    
    if not review.get("comments"):
        print("No comments to publish.")
        return 0
    
    try:
        adapter = detect_platform()
        
        # 1. Summary
        summary_md = build_summary_markdown(review)
        adapter.post_summary(summary_md)
        print("Summary published.")
        
        # 2. Inline comments
        published = 0
        skipped = 0
        for c in review["comments"]:
            body = format_severity(c["severity"]) + c["body"]
            ok = adapter.post_inline_comment(c["path"], c["line"], body)
            if ok:
                published += 1
            else:
                skipped += 1
        
        print(f"Inline comments: {published} published, {skipped} skipped")
        return 0
    
    except Exception as e:
        print(f"ERROR: {e}", file=sys.stderr)
        return 1


if __name__ == "__main__":
    sys.exit(main())

Orchestration: GitHub Actions

# .github/workflows/code-review.yml
name: Code Review (Claude Code)

on:
  pull_request:
    types: [opened, synchronize]

permissions:
  pull-requests: write
  contents: read

jobs:
  review:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0
      
      - uses: actions/setup-python@v5
        with:
          python-version: '3.11'
          cache: 'pip'
      
      - run: pip install -r requirements.txt
      
      - name: Run code review
        env:
          ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          GITHUB_REPOSITORY: ${{ github.repository }}
          PR_NUMBER: ${{ github.event.pull_request.number }}
          PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
          PR_BASE_BRANCH: ${{ github.event.pull_request.base.ref }}
        run: |
          python scripts/code_review.py
          python scripts/publish_review.py

Observation: the YAML only orchestrates. All the logic is in scripts/.


Orchestration: GitLab CI/CD

# .gitlab-ci.yml
stages:
  - review

variables:
  CLAUDE_MODEL: "claude-haiku-4-5"
  CLAUDE_MAX_TOKENS: "4000"

code-review:
  stage: review
  image: python:3.11-slim
  rules:
    - if: $CI_PIPELINE_SOURCE == "merge_request_event"
  cache:
    key: pip-cache-${CI_COMMIT_REF_SLUG}
    paths:
      - .pip-cache/
  before_script:
    - apt-get update && apt-get install -y --no-install-recommends git
    - pip install --cache-dir=.pip-cache -r requirements.txt
  script:
    - python scripts/code_review.py
    - python scripts/publish_review.py

Observation: the same pattern — the .gitlab-ci.yml is thin, the scripts are identical to the GitHub version.


Verification: The Portability Test

To verify that portability really works:

  1. Configure the bot on GitHub:

    • Push the files to a GitHub repo
    • Configure ANTHROPIC_API_KEY in Settings → Secrets
    • Open a PR
    • Verify that the bot comment shows up
  2. Configure the bot on GitLab:

    • Push the same files to a GitLab repo
    • Configure ANTHROPIC_API_KEY and GITLAB_API_TOKEN in Settings → CI/CD → Variables
    • Open an MR
    • Verify that the bot comment shows up
  3. Compare:

    • The bot's summary should be similar (same model, same prompt)
    • The inline comments should show up on equivalent lines
    • The tone and format should be identical

If the two bots produce similar output with identical scripts, you proved portability.


Documentation: The project's README.md

Part of the deliverable is documenting so another developer can reproduce it:

# Code Review Bot (Cross-Platform)

A code review bot with Claude Code that operates in GitHub Actions and GitLab CI/CD.

## Architecture

- `scripts/platform_adapter.py` — abstracts differences between platforms
- `scripts/code_review.py` — common review logic
- `scripts/publish_review.py` — common publishing
- `.github/workflows/` and `.gitlab-ci.yml` — thin orchestration

## Setup

### On GitHub
1. Configure the `ANTHROPIC_API_KEY` secret in Settings → Secrets
2. The workflow runs automatically on every PR

### On GitLab
1. Configure the `ANTHROPIC_API_KEY` and `GITLAB_API_TOKEN` variables (with the `api` scope) in Settings → CI/CD → Variables
2. Mark both as Protected and Masked
3. The pipeline runs automatically on every MR

## How to add a new platform (e.g. Bitbucket)

1. Create a `BitbucketAdapter` in `platform_adapter.py` implementing `PlatformAdapter`
2. Add detection in `detect_platform()`:
   ```python
   elif os.environ.get("BITBUCKET_BUILD_NUMBER"):
       return BitbucketAdapter()
  1. Create a bitbucket-pipelines.yml that orchestrates the execution

The scripts code_review.py and publish_review.py don't change.


---

## Project Deliverables

1. **A public or private repo** with the complete structure
2. **A bot working in GitHub Actions** (verifiable by opening a PR)
3. **A bot working in GitLab CI/CD** (verifiable by opening an MR)
4. **`platform_adapter.py`** that abstracts the differences
5. **A README.md** documenting architecture and setup
6. **A demonstration** (screenshots, a link to the working PR/MR) showing both bots producing equivalent output

---

## Evaluation Rubric (100 points)

### Architecture (40 pts)
- ✅ (15 pts) `platform_adapter.py` with a clear abstract interface
- ✅ (10 pts) Concrete implementations for GitHub and GitLab
- ✅ (10 pts) Automatic `detect_platform()` based on the environment
- ✅ (5 pts) The common scripts (`code_review.py`, `publish_review.py`) contain no platform-specific logic

### Functionality on GitHub (20 pts)
- ✅ (10 pts) The bot triggers on every PR
- ✅ (5 pts) The summary is published correctly
- ✅ (5 pts) Inline comments on the correct lines

### Functionality on GitLab (20 pts)
- ✅ (10 pts) The bot triggers on every MR
- ✅ (5 pts) The summary is published correctly
- ✅ (5 pts) Inline comments on the correct lines

### Documentation (15 pts)
- ✅ (8 pts) The README explains architecture and setup
- ✅ (5 pts) How to add a new platform documented
- ✅ (2 pts) Comments in the code where it isn't obvious

### Quality (5 pts)
- ✅ (3 pts) Error handling with exit codes
- ✅ (2 pts) Clean and testable structure

### Extra Credit (+15 pts)
- ✅ (+5 pts) Implement an adapter for a third platform (Bitbucket, Jenkins)
- ✅ (+5 pts) Unit tests of the adapter (mocking the APIs)
- ✅ (+5 pts) Document the measured time to port between platforms

---

## Common Errors in Portability

### 1. Platform-specific logic leaks into the common script

**Symptom:** `code_review.py` has `if os.environ.get("GITHUB_ACTIONS")` directly.

**Why it happens:** You start comfortable, then add a quick `if` instead of extending the adapter.

**How to fix it:** Any difference between platforms lives only in the adapter. If you have to put an `if` in the common script, the adapter is missing an abstraction.

### 2. An adapter without an abstract interface

**Symptom:** `GitHubAdapter` has methods `GitLabAdapter` doesn't, and vice versa.

**Why it happens:** You didn't define the abstract class first.

**How to fix it:** Always start with the `PlatformAdapter(ABC)` with abstract methods. The concrete implementations are forced to respect it.

### 3. Hardcoded environment variables

**Symptom:** The common script reads `GITHUB_REPOSITORY`. It doesn't exist in GitLab, it fails.

**Why it happens:** The adapter should read the specific variables, not the common script.

**How to fix it:** Move all `os.environ["..."]` reads to the adapter. The common script receives the data via `adapter.get_pr_context()`.

### 4. Different output between platforms

**Symptom:** On GitHub the comment looks like A, on GitLab it looks like B.

**Why it happens:** Formatting logic in the adapter instead of in the common script.

**How to fix it:** Message formatting in `publish_review.py`. The adapter only handles **sending** the message to the platform, not **building** it.

### 5. Not verifying portability

**Symptom:** "It works on GitHub" but you never tested on GitLab.

**Why it happens:** Configuring GitLab takes work, you leave it "for later".

**How to fix it:** Testing on both platforms is **part of the deliverable**, not optional. Without that test, you don't know if the portability is real.

---

## Reflection: What This Project Demonstrates

When you finish the project, you'll have demonstrated:

1. **Architectural understanding:** you know how to separate logic from orchestration
2. **Abstraction patterns:** you know how to design interfaces that isolate differences
3. **Real portability:** it's not theory — you ran the bot on two platforms
4. **Implicit testing:** the portability test is a form of architectural testing
5. **Transferable skill:** the pattern applies beyond CI/CD — any time you need to run logic on different platforms

**This project is portfolio-worthy.** Showing it in an interview demonstrates senior-level thinking about architecture.

---

## Summary

- **Common logic** in Python (or TS) scripts using the SDK
- **An adaptation layer** (`PlatformAdapter`) abstracts differences between platforms
- **Automatic detection** of the platform based on environment variables
- **Thin YAMLs** that only orchestrate, with no logic
- **Proven portability** running on GitHub and GitLab simultaneously
- **An extensible pattern** — adding a third platform is adding a new adapter

**Next module:** **Module 4 — Deployment Automation**. You already have Claude Code analyzing PRs, with cross-platform portability. The next level: having it also run post-merge tasks — generating changelogs, validating readiness, assisting deployments.

---

## Additional Resources

1. [Anthropic Python SDK](https://github.com/anthropics/anthropic-sdk-python) — Technical foundation
2. [Python `abc` module](https://docs.python.org/3/library/abc.html) — Abstract base classes
3. [python-gitlab](https://python-gitlab.readthedocs.io/) — GitLab client
4. [PyGithub](https://pygithub.readthedocs.io/) — GitHub client (alternative to raw `requests`)
5. [Adapter pattern](https://refactoring.guru/design-patterns/adapter) — The structural pattern you applied
6. [Strategy pattern](https://refactoring.guru/design-patterns/strategy) — A related pattern, useful for future variants