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:
- The bot working in GitHub Actions (carry-over from Module 2)
- The same bot working in GitLab CI/CD (what you build here)
- A common abstraction layer that both platforms consume
- 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:
-
Configure the bot on GitHub:
- Push the files to a GitHub repo
- Configure
ANTHROPIC_API_KEYin Settings → Secrets - Open a PR
- Verify that the bot comment shows up
-
Configure the bot on GitLab:
- Push the same files to a GitLab repo
- Configure
ANTHROPIC_API_KEYandGITLAB_API_TOKENin Settings → CI/CD → Variables - Open an MR
- Verify that the bot comment shows up
-
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()
- Create a
bitbucket-pipelines.ymlthat 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