initial ai implementation

This commit is contained in:
Erik Foris
2026-04-20 14:48:33 +02:00
parent f91eaed663
commit 05c4b4b04c
31 changed files with 1420 additions and 1 deletions

8
.env.example Normal file
View File

@@ -0,0 +1,8 @@
LINEAR_API_KEY=lin_api_xxx
LINEAR_WEBHOOK_SECRET=xxx
GITEA_URL=https://gitea.example.com
GITEA_TOKEN=gitea_token_xxx
GITEA_WEBHOOK_SECRET=xxx
CONFIG_PATH=config.yaml

153
README.md
View File

@@ -1,3 +1,154 @@
# LinearGiteaIntegration # LinearGiteaIntegration
Integrate Linear into gitea Integrate Linear issue tracking with Gitea - similar to the Linear-GitHub integration.
## Features
- **PR Linking**: Automatically link pull requests to Linear issues by including the issue ID (e.g., `LIN-123`) in branch names, PR titles, or descriptions
- **Workflow Automation**: Automatically update Linear issue status based on PR state changes (opened → In Progress, merged → Done, closed → Todo)
- **Issues Sync**: Synchronize issues between Gitea and Linear (one-way or two-way)
- **Commit Linking**: Link commits to Linear issues by including issue IDs in commit messages
- **Branch Suggestions**: Get suggested branch names for Linear issues via API
## Requirements
- Python 3.10+
- Linear API key
- Gitea instance with API token
## Installation
```bash
pip install -r requirements.txt
```
Or with uv:
```bash
uv sync
```
## Configuration
1. Copy the example environment file:
```bash
cp .env.example .env
```
2. Edit `config.yaml` to configure your settings:
```yaml
server:
host: "0.0.0.0"
port: 8080
linear:
api_key: "your-linear-api-key"
webhook_secret: "your-linear-webhook-secret"
gitea:
url: "https://gitea.yourdomain.com"
token: "your-gitea-token"
webhook_secret: "your-gitea-webhook-secret"
sync:
enabled_repos:
- "your-org/your-repo"
default_team_id: "your-linear-team-id"
status_mappings:
pr_created: "In Progress"
pr_merged: "Done"
pr_closed: "Todo"
sync_direction: "one-way" # or "two-way"
features:
pr_linking: true
workflow_automation: true
issues_sync: true
commit_linking: true
branch_suggestions: true
```
3. Set environment variables in `.env`:
```
LINEAR_API_KEY=lin_xxx
GITEA_URL=https://gitea.yourdomain.com
GITEA_TOKEN=gitea_token_xxx
CONFIG_PATH=config.yaml
```
### Getting API Keys
**Linear API Key:**
1. Go to Linear workspace Settings > API
2. Create a new API key
**Gitea Token:**
1. Go to Settings > Applications > Generate a new token
2. Requires `repo` and `write:issue` scopes
## Running the Server
```bash
# Development
python -m linear_gitea_integration
# Or with uvicorn
uvicorn linear_gitea_integration.main:app --reload
```
The server will start on `http://0.0.0.0:8080` by default.
## API Endpoints
| Endpoint | Method | Description |
|----------|--------|-------------|
| `/api/health` | GET | Health check |
| `/api/issues/{issue_id}/branch` | GET | Get branch suggestion for issue |
| `/api/sync/issue` | POST | Manually sync issue to Gitea |
| `/webhooks/gitea` | POST | Gitea webhook endpoint |
| `/webhooks/linear` | POST | Linear webhook endpoint |
## Setting Up Webhooks
### Gitea Webhook
1. Go to your repository Settings > Webhooks
2. Add a new webhook:
- **URL**: `https://your-server.com/webhooks/gitea`
- **Secret**: Use the value from `gitea.webhook_secret`
- **Trigger**: Select events: Push, Pull Request, Issues
### Linear Webhook
1. Go to Linear workspace Settings > Webhooks
2. Add a new webhook:
- **URL**: `https://your-server.com/webhooks/linear`
- **Secret**: Use the value from `linear.webhook_secret`
- **Events**: Issues (create, update)
## Development
Run tests:
```bash
PYTHONPATH=src pytest tests/
```
Project structure:
```
LinearGiteaIntegration/
├── src/linear_gitea_integration/
│ ├── api/ # REST API routes
│ ├── config.py # Configuration management
│ ├── models.py # Pydantic models
│ ├── services/ # API clients
│ ├── webhooks/ # Webhook handlers
│ └── main.py # Application entry point
├── tests/ # Unit tests
├── config.yaml # Configuration file
└── .env.example # Environment variables template
```

119
SPEC.md Normal file
View File

@@ -0,0 +1,119 @@
# LinearGiteaIntegration Specification
## Project Overview
- **Project Name**: LinearGiteaIntegration
- **Type**: Web service / Integration middleware
- **Core Functionality**: Integrate Linear issue tracking with Gitea, enabling PR/commit linking, workflow automation, and bidirectional issue sync
- **Target Users**: Development teams using Linear for issue tracking and Gitea for Git hosting
## Architecture
### Components
1. **Webhook Server**: Receives events from Gitea (push, pull request, issues)
2. **Linear API Client**: Communicates with Linear API to create/update issues
3. **Gitea API Client**: Communicates with Gitea API to create branches, PRs, update issues
4. **Configuration Manager**: Handles API keys, webhooks, and settings
### Technology Stack
- **Language**: Python
- **Web Framework**: FastAPI (modern, async Python web framework)
- **Configuration**: YAML-based config file + environment variables
## Functionality Specification
### 1. PR Linking
- Detect Linear issue ID (e.g., `LIN-123`) in:
- Branch name
- PR title
- PR description
- When PR is opened/linked, add PR link to Linear issue
- Support magic words: "closes", "fixes", "resolves" + issue ID
### 2. Workflow Automation
- Map Gitea PR states to Linear issue statuses:
- PR created → Move issue to "In Progress"
- PR merged → Move issue to "Done"
- PR closed (not merged) → Move issue back to "Todo"
- Configurable status mappings per team
### 3. Issues Sync
- One-way sync: Gitea issues → Linear issues
- Two-way sync: Changes sync both ways
- Sync properties: title, description, status, assignee, labels, comments
- Configurable per-repository settings
### 4. Commit Linking
- Detect Linear issue ID in commit messages
- Add commit reference to Linear issue
- Support multiple issues per commit
### 5. Branch Suggestions
- API endpoint to get suggested branch name for a Linear issue
- Format: `{issue-id}-{slugified-title}` (e.g., `LIN-123-add-login-feature`)
## Configuration
```yaml
server:
host: "0.0.0.0"
port: 8080
linear:
api_key: "${LINEAR_API_KEY}"
webhook_secret: "${LINEAR_WEBHOOK_SECRET}"
gitea:
url: "https://gitea.example.com"
token: "${GITEA_TOKEN}"
webhook_secret: "${GITEA_WEBHOOK_SECRET}"
sync:
enabled_repos:
- "my-org/my-repo"
default_team_id: "team_xxx"
status_mappings:
pr_created: "In Progress"
pr_merged: "Done"
pr_closed: "Todo"
sync_direction: "one-way" # or "two-way"
features:
pr_linking: true
workflow_automation: true
issues_sync: true
commit_linking: true
branch_suggestions: true
```
## API Endpoints
### Webhooks
- `POST /webhooks/gitea` - Receive Gitea events
- `POST /webhooks/linear` - Receive Linear events
### REST API
- `GET /api/health` - Health check
- `GET /api/issues/:id/branch` - Get suggested branch name for issue
- `POST /api/sync/issue` - Manually trigger issue sync
## Webhook Events to Handle
### Gitea Events
- `push` - Parse commit messages for issue IDs
- `pull_request` - Link PRs, update status
- `issues` - Sync issues to Linear
### Linear Events
- `Issue` - Sync issue changes to Gitea (for two-way sync)
## Acceptance Criteria
1. Server starts and listens on configured port
2. Gitea webhook endpoint accepts and processes events
3. PR linking works when issue ID found in branch/PR title
4. Issue status updates based on PR state changes
5. Issues created in Gitea appear in Linear (when sync enabled)
6. Branch suggestion endpoint returns proper format
7. Configuration loads from YAML file and environment variables
8. Unit tests cover core functionality

29
config.yaml Normal file
View File

@@ -0,0 +1,29 @@
server:
host: "0.0.0.0"
port: 8080
linear:
api_key: ""
webhook_secret: ""
gitea:
url: "https://gitea.example.com"
token: ""
webhook_secret: ""
sync:
enabled_repos:
- "my-org/my-repo"
default_team_id: ""
status_mappings:
pr_created: "In Progress"
pr_merged: "Done"
pr_closed: "Todo"
sync_direction: "one-way"
features:
pr_linking: true
workflow_automation: true
issues_sync: true
commit_linking: true
branch_suggestions: true

15
pyproject.toml Normal file
View File

@@ -0,0 +1,15 @@
name = "LinearGiteaIntegration"
version = "0.1.0"
description = "Integrate Linear issue tracking with Gitea"
requires-python = ">=3.10"
dependencies = [
"fastapi>=0.109.0",
"uvicorn[standard]>=0.27.0",
"httpx>=0.26.0",
"pydantic>=2.5.0",
"pydantic-settings>=2.1.0",
"PyYAML>=6.0.0",
"python-dotenv>=1.0.0",
"pytest>=8.0.0",
"pytest-asyncio>=0.23.0",
]

9
requirements.txt Normal file
View File

@@ -0,0 +1,9 @@
fastapi>=0.109.0
uvicorn[standard]>=0.27.0
httpx>=0.26.0
pydantic>=2.5.0
pydantic-settings>=2.1.0
PyYAML>=6.0.0
python-dotenv>=1.0.0
pytest>=8.0.0
pytest-asyncio>=0.23.0

View File

@@ -0,0 +1,3 @@
from .main import app
__all__ = ["app"]

View File

@@ -0,0 +1,8 @@
from fastapi import APIRouter
from .routes import router as api_router
from .webhooks import router as webhooks_router
router = APIRouter()
router.include_router(api_router)
router.include_router(webhooks_router)

View File

@@ -0,0 +1,79 @@
import re
from typing import Optional
from fastapi import APIRouter, HTTPException, Path, Query
from ..config import get_config
from ..services import get_linear_client
router = APIRouter(prefix="/api", tags=["api"])
ISSUE_ID_PATTERN = re.compile(r"([A-Z]+-\d+)")
@router.get("/health")
async def health_check():
return {"status": "healthy"}
@router.get("/issues/{issue_id}/branch")
async def get_branch_suggestion(
issue_id: str = Path(..., description="Linear issue identifier (e.g., LIN-123)")
):
if not get_config().features.branch_suggestions:
raise HTTPException(status_code=404, detail="Branch suggestions feature is disabled")
linear_client = get_linear_client()
issue = linear_client.get_issue_by_identifier(issue_id)
if not issue:
raise HTTPException(status_code=404, detail=f"Issue {issue_id} not found")
slug = re.sub(r"[^a-z0-9]+", "-", issue.title.lower())
slug = slug.strip("-")
branch_name = f"{issue.identifier}-{slug[:50]}"
return {
"issue_id": issue.identifier,
"branch_name": branch_name,
"issue_title": issue.title,
}
@router.post("/sync/issue")
async def sync_issue(
issue_id: str = Query(..., description="Linear issue identifier"),
repo: str = Query(..., description="Gitea repository (owner/repo)"),
):
if not get_config().features.issues_sync:
raise HTTPException(status_code=404, detail="Issue sync feature is disabled")
if repo not in get_config().sync.enabled_repos:
raise HTTPException(status_code=400, detail=f"Repository {repo} not enabled for sync")
linear_client = get_linear_client()
issue = linear_client.get_issue_by_identifier(issue_id)
if not issue:
raise HTTPException(status_code=404, detail=f"Issue {issue_id} not found")
from ..services import get_gitea_client, parse_repo
from ..models import GiteaIssueCreate
owner, repo_name = parse_repo(repo)
gitea_client = get_gitea_client()
gitea_issue = gitea_client.create_issue(
owner,
repo_name,
GiteaIssueCreate(
title=f"[{issue.identifier}] {issue.title}",
body=issue.description or "",
),
)
return {
"linear_issue": issue.identifier,
"gitea_issue": f"{repo}#{gitea_issue.number}" if gitea_issue else None,
}

View File

@@ -0,0 +1,52 @@
from fastapi import APIRouter, HTTPException, Request, Response
from .routes import router as api_router
router = APIRouter()
router.include_router(api_router)
@router.post("/webhooks/gitea")
async def gitea_webhook(request: Request):
payload = await request.body()
from ..webhooks import handle_gitea_webhook, verify_gitea_signature
signature = request.headers.get("X-Gitea-Signature", "")
if not verify_gitea_signature(payload, signature):
raise HTTPException(status_code=401, detail="Invalid signature")
import json
data = json.loads(payload)
event = request.headers.get("X-Gitea-Event", "")
results = handle_gitea_webhook(event, data)
return {"status": "ok", "linked": results}
@router.post("/webhooks/linear")
async def linear_webhook(request: Request):
from ..webhooks import handle_linear_webhook
from ..config import get_config
import hmac
import hashlib
config = get_config()
payload = await request.body()
if config.linear.webhook_secret:
signature = request.headers.get("Linear-Signature", "")
expected = hmac.new(
config.linear.webhook_secret.encode(),
payload,
hashlib.sha256
).hexdigest()
if not hmac.compare_digest(f"sha256={expected}", signature):
raise HTTPException(status_code=401, detail="Invalid signature")
import json
data = json.loads(payload)
event = request.headers.get("Linear-Webhook-Event", "")
result = handle_linear_webhook(event, data)
return {"status": "ok", "result": result}

View File

@@ -0,0 +1,87 @@
import os
from pathlib import Path
from typing import Optional
import yaml
from pydantic import Field
from pydantic_settings import BaseSettings, SettingsConfigDict
class ServerConfig(BaseSettings):
host: str = "0.0.0.0"
port: int = 8080
class LinearConfig(BaseSettings):
api_key: str = Field(default="", alias="LINEAR_API_KEY")
webhook_secret: str = Field(default="", alias="LINEAR_WEBHOOK_SECRET")
class GiteaConfig(BaseSettings):
url: str = Field(default="https://gitea.example.com", alias="GITEA_URL")
token: str = Field(default="", alias="GITEA_TOKEN")
webhook_secret: str = Field(default="", alias="GITEA_WEBHOOK_SECRET")
class StatusMappings(BaseSettings):
pr_created: str = "In Progress"
pr_merged: str = "Done"
pr_closed: str = "Todo"
class SyncConfig(BaseSettings):
enabled_repos: list[str] = []
default_team_id: str = ""
status_mappings: StatusMappings = StatusMappings()
sync_direction: str = "one-way"
class FeaturesConfig(BaseSettings):
pr_linking: bool = True
workflow_automation: bool = True
issues_sync: bool = True
commit_linking: bool = True
branch_suggestions: bool = True
class Config(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", env_nested_delimiter="__")
server: ServerConfig = ServerConfig()
linear: LinearConfig = LinearConfig()
gitea: GiteaConfig = GiteaConfig()
sync: SyncConfig = SyncConfig()
features: FeaturesConfig = FeaturesConfig()
def load_config(config_path: Optional[str] = None) -> Config:
config = Config()
if config_path is None:
config_path = os.environ.get("CONFIG_PATH", "config.yaml")
path = Path(config_path)
if path.exists():
with open(path) as f:
data = yaml.safe_load(f) or {}
for section, values in data.items():
if values and hasattr(config, section):
section_obj = getattr(config, section)
if hasattr(section_obj, "model_validate"):
setattr(config, section, section_obj.model_validate(values))
else:
for key, value in values.items():
if hasattr(section_obj, key):
setattr(section_obj, key, value)
return config
_config: Optional[Config] = None
def get_config() -> Config:
global _config
if _config is None:
_config = load_config()
return _config

View File

@@ -0,0 +1,50 @@
import logging
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from .api import router as api_router
from .config import get_config
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
)
@asynccontextmanager
async def lifespan(app: FastAPI):
config = get_config()
logging.info(f"Starting LinearGiteaIntegration on {config.server.host}:{config.server.port}")
yield
app = FastAPI(
title="LinearGiteaIntegration",
description="Integrate Linear issue tracking with Gitea",
version="0.1.0",
lifespan=lifespan,
)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(api_router)
if __name__ == "__main__":
import uvicorn
config = get_config()
uvicorn.run(
"linear_gitea_integration.main:app",
host=config.server.host,
port=config.server.port,
reload=True,
)

View File

@@ -0,0 +1,76 @@
from datetime import datetime
from enum import Enum
from typing import Optional
from pydantic import BaseModel, Field
class LinearIssue(BaseModel):
id: str
identifier: str
title: str
description: Optional[str] = None
state: Optional[str] = None
team_id: Optional[str] = None
assignee_id: Optional[str] = None
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None
class LinearIssueCreate(BaseModel):
team_id: str
title: str
description: Optional[str] = None
state_id: Optional[str] = None
class LinearIssueUpdate(BaseModel):
title: Optional[str] = None
description: Optional[str] = None
state_id: Optional[str] = None
assignee_id: Optional[str] = None
class GiteaIssue(BaseModel):
id: int
number: int
title: str
body: Optional[str] = None
state: str
repository: Optional[str] = None
class GiteaIssueCreate(BaseModel):
title: str
body: Optional[str] = None
labels: Optional[str] = None
class GiteaPullRequest(BaseModel):
id: int
number: int
title: str
body: Optional[str] = None
state: str
head_branch: str
base_branch: str
html_url: str
merged: bool = False
repository: Optional[str] = None
class PRStatus(str, Enum):
OPEN = "open"
CLOSED = "closed"
MERGED = "merged"
class SyncDirection(str, Enum):
ONE_WAY = "one-way"
TWO_WAY = "two-way"
class WebhookEvent(BaseModel):
event: str
action: str
payload: dict

View File

@@ -0,0 +1,11 @@
from .gitea_client import GiteaClient, parse_repo, get_client as get_gitea_client
from .linear_client import LinearClient, extract_issue_ids, get_client as get_linear_client
__all__ = [
"GiteaClient",
"LinearClient",
"extract_issue_ids",
"parse_repo",
"get_gitea_client",
"get_linear_client",
]

View File

@@ -0,0 +1,166 @@
import os
from typing import Optional
import httpx
from ..config import get_config
from ..models import GiteaIssue, GiteaIssueCreate, GiteaPullRequest
class GiteaClient:
def __init__(self, base_url: Optional[str] = None):
config = get_config()
self.base_url = base_url or config.gitea.url
self.token = config.gitea.token
self.headers = {
"Authorization": f"token {self.token}",
"Content-Type": "application/json",
}
def _request(
self,
method: str,
endpoint: str,
params: Optional[dict] = None,
json_data: Optional[dict] = None,
) -> dict:
url = f"{self.base_url}{endpoint}"
with httpx.Client() as client:
response = client.request(
method,
url,
headers=self.headers,
params=params,
json=json_data,
)
response.raise_for_status()
return response.json()
def get_repo(self, owner: str, repo: str) -> dict:
return self._request("GET", f"/api/v1/repos/{owner}/{repo}")
def get_issue(self, owner: str, repo: str, issue_number: int) -> GiteaIssue:
data = self._request("GET", f"/api/v1/repos/{owner}/{repo}/issues/{issue_number}")
return GiteaIssue(
id=data["id"],
number=data["number"],
title=data["title"],
body=data.get("body"),
state=data["state"],
repository=f"{owner}/{repo}",
)
def create_issue(
self, owner: str, repo: str, issue: GiteaIssueCreate
) -> Optional[GiteaIssue]:
data = self._request(
"POST",
f"/api/v1/repos/{owner}/{repo}/issues",
json_data={
"title": issue.title,
"body": issue.body,
"labels": issue.labels,
},
)
return GiteaIssue(
id=data["id"],
number=data["number"],
title=data["title"],
body=data.get("body"),
state=data["state"],
repository=f"{owner}/{repo}",
)
def update_issue(
self,
owner: str,
repo: str,
issue_number: int,
title: Optional[str] = None,
body: Optional[str] = None,
state: Optional[str] = None,
) -> dict:
json_data = {}
if title is not None:
json_data["title"] = title
if body is not None:
json_data["body"] = body
if state is not None:
json_data["state"] = state
return self._request(
"PATCH",
f"/api/v1/repos/{owner}/{repo}/issues/{issue_number}",
json_data=json_data,
)
def get_pull_request(
self, owner: str, repo: str, pr_number: int
) -> Optional[GiteaPullRequest]:
try:
data = self._request(
"GET", f"/api/v1/repos/{owner}/{repo}/pulls/{pr_number}"
)
return GiteaPullRequest(
id=data["id"],
number=data["number"],
title=data["title"],
body=data.get("body"),
state=data["state"],
head_branch=data["head"]["ref"],
base_branch=data["base"]["ref"],
html_url=data["html_url"],
merged=data.get("merged", False),
repository=f"{owner}/{repo}",
)
except httpx.HTTPStatusError:
return None
def create_branch(self, owner: str, repo: str, branch: str, from_branch: str) -> bool:
try:
self._request(
"POST",
f"/api/v1/repos/{owner}/{repo}/git/refs",
json_data={
"name": f"refs/heads/{branch}",
"sha": self._get_commit_sha(owner, repo, from_branch),
},
)
return True
except Exception:
return False
def _get_commit_sha(self, owner: str, repo: str, branch: str) -> str:
data = self._request(
"GET", f"/api/v1/repos/{owner}/{repo}/branches/{branch}"
)
return data["commit"]["sha"]
def create_issue_comment(
self, owner: str, repo: str, issue_number: int, body: str
) -> dict:
return self._request(
"POST",
f"/api/v1/repos/{owner}/{repo}/issues/{issue_number}/comments",
json_data={"body": body},
)
def get_webhook_events(self, owner: str, repo: str) -> list[dict]:
return self._request("GET", f"/api/v1/repos/{owner}/{repo}/hooks")
def test_connection(self) -> bool:
try:
self._request("GET", "/api/v1/user")
return True
except Exception:
return False
def parse_repo(full_name: str) -> tuple[str, str]:
parts = full_name.split("/")
if len(parts) != 2:
raise ValueError(f"Invalid repository format: {full_name}")
return parts[0], parts[1]
def get_client() -> GiteaClient:
return GiteaClient()

View File

@@ -0,0 +1,229 @@
import re
from typing import Optional
import httpx
from ..config import get_config
from ..models import LinearIssue, LinearIssueCreate, LinearIssueUpdate
ISSUE_ID_PATTERN = re.compile(r"([A-Z]+-\d+)")
class LinearClient:
def __init__(self, base_url: str = "https://api.linear.app/graphql"):
config = get_config()
self.api_key = config.linear.api_key
self.base_url = base_url
self.headers = {
"Authorization": self.api_key,
"Content-Type": "application/json",
}
def _graphql_request(self, query: str, variables: Optional[dict] = None) -> dict:
with httpx.Client() as client:
response = client.post(
self.base_url,
json={"query": query, "variables": variables or {}},
headers=self.headers,
)
response.raise_for_status()
data = response.json()
if "errors" in data:
raise Exception(f"GraphQL error: {data['errors']}")
return data.get("data", {})
def get_issue(self, issue_id: str) -> Optional[LinearIssue]:
query = """
query GetIssue($issueId: String!) {
issue(id: $issueId) {
id
identifier
title
description
state {
name
}
team {
id
}
assignee {
id
}
createdAt
updatedAt
}
}
"""
data = self._graphql_request(query, {"issueId": issue_id})
issue_data = data.get("issue")
if issue_data:
return LinearIssue(
id=issue_data["id"],
identifier=issue_data["identifier"],
title=issue_data["title"],
description=issue_data.get("description"),
state=issue_data.get("state", {}).get("name"),
team_id=issue_data.get("team", {}).get("id"),
assignee_id=issue_data.get("assignee", {}).get("id"),
created_at=issue_data.get("createdAt"),
updated_at=issue_data.get("updatedAt"),
)
return None
def get_issue_by_identifier(self, identifier: str) -> Optional[LinearIssue]:
query = """
query GetIssueByIdentifier($identifier: String!) {
issueFilter(filter: { identifier: { eq: $identifier } }) {
nodes {
id
identifier
title
description
state {
name
}
team {
id
}
assignee {
id
}
createdAt
updatedAt
}
}
}
"""
data = self._graphql_request(query, {"identifier": identifier})
nodes = data.get("issueFilter", {}).get("nodes", [])
if nodes:
issue_data = nodes[0]
return LinearIssue(
id=issue_data["id"],
identifier=issue_data["identifier"],
title=issue_data["title"],
description=issue_data.get("description"),
state=issue_data.get("state", {}).get("name"),
team_id=issue_data.get("team", {}).get("id"),
assignee_id=issue_data.get("assignee", {}).get("id"),
created_at=issue_data.get("createdAt"),
updated_at=issue_data.get("updatedAt"),
)
return None
def create_issue(self, issue: LinearIssueCreate) -> Optional[LinearIssue]:
query = """
mutation CreateIssue($input: IssueCreateInput!) {
issueCreate(input: $input) {
success
issue {
id
identifier
title
description
state {
name
}
team {
id
}
createdAt
updatedAt
}
}
}
"""
variables = {
"input": {
"teamId": issue.team_id,
"title": issue.title,
}
}
if issue.description:
variables["input"]["description"] = issue.description
data = self._graphql_request(query, variables)
created = data.get("issueCreate", {})
if created.get("success"):
issue_data = created["issue"]
return LinearIssue(
id=issue_data["id"],
identifier=issue_data["identifier"],
title=issue_data["title"],
description=issue_data.get("description"),
state=issue_data.get("state", {}).get("name"),
team_id=issue_data.get("team", {}).get("id"),
created_at=issue_data.get("createdAt"),
updated_at=issue_data.get("updatedAt"),
)
return None
def update_issue(self, issue_id: str, update: LinearIssueUpdate) -> bool:
query = """
mutation UpdateIssue($issueId: String!, $input: IssueUpdateInput!) {
issueUpdate(id: $issueId, input: $input) {
success
}
}
"""
variables = {"issueId": issue_id, "input": {}}
if update.title is not None:
variables["input"]["title"] = update.title
if update.description is not None:
variables["input"]["description"] = update.description
if update.state_id is not None:
variables["input"]["stateId"] = update.state_id
if update.assignee_id is not None:
variables["input"]["assigneeId"] = update.assignee_id
data = self._graphql_request(query, variables)
return data.get("issueUpdate", {}).get("success", False)
def get_states_for_team(self, team_id: str) -> list[dict]:
query = """
query GetTeamStates($teamId: String!) {
team(id: $teamId) {
states {
nodes {
id
name
}
}
}
}
"""
data = self._graphql_request(query, {"teamId": team_id})
return data.get("team", {}).get("states", {}).get("nodes", [])
def link_pull_request(
self, issue_id: str, pr_url: str, pr_title: Optional[str] = None
) -> bool:
query = """
mutation LinkPullRequest($issueId: String!, $prUrl: String!) {
issueLinkPullRequest(issueId: $issueId, prUrl: $prUrl) {
success
}
}
"""
data = self._graphql_request(query, {"issueId": issue_id, "prUrl": pr_url})
return data.get("issueLinkPullRequest", {}).get("success", False)
def add_comment(self, issue_id: str, body: str) -> bool:
query = """
mutation AddComment($issueId: String!, $body: String!) {
commentCreate(input: { issueId: $issueId, body: $body }) {
success
}
}
"""
data = self._graphql_request(query, {"issueId": issue_id, "body": body})
return data.get("commentCreate", {}).get("success", False)
def extract_issue_ids(text: str) -> list[str]:
return ISSUE_ID_PATTERN.findall(text)
def get_client() -> LinearClient:
return LinearClient()

View File

@@ -0,0 +1,8 @@
from .gitea_handler import handle_gitea_webhook, verify_gitea_signature
from .linear_handler import handle_linear_webhook
__all__ = [
"handle_gitea_webhook",
"handle_linear_webhook",
"verify_gitea_signature",
]

View File

@@ -0,0 +1,176 @@
import hashlib
import hmac
import logging
from typing import Optional
from ..config import get_config
from ..models import GiteaPullRequest, WebhookEvent
from ..services import extract_issue_ids, get_gitea_client, get_linear_client
from ..services.linear_client import LinearIssueUpdate
logger = logging.getLogger(__name__)
MAGIC_WORDS = ["closes", "closes", "fixes", "resolves", "addresses"]
def verify_gitea_signature(payload: bytes, signature: str) -> bool:
config = get_config()
secret = config.gitea.webhook_secret
if not secret:
return True
expected = hmac.new(
secret.encode(), payload, hashlib.sha256
).hexdigest()
return hmac.compare_digest(f"sha256={expected}", signature)
def handle_push_event(payload: dict) -> list[str]:
if not get_config().features.commit_linking:
return []
commits = payload.get("commits", [])
repo = payload.get("repository", {}).get("full_name", "")
linked_issues = []
linear_client = get_linear_client()
for commit in commits:
message = commit.get("message", "")
issue_ids = extract_issue_ids(message)
for issue_id in issue_ids:
issue = linear_client.get_issue_by_identifier(issue_id)
if issue:
comment = f"Commit linked: {commit.get('id', '')[:7]} - {message[:100]}"
linear_client.add_comment(issue.id, comment)
linked_issues.append(issue_id)
logger.info(f"Linked commit to {issue_id}")
return linked_issues
def handle_pull_request_event(payload: dict) -> list[str]:
action = payload.get("action", "")
pr_data = payload.get("pull_request", {})
repo = payload.get("repository", {}).get("full_name", "")
if not repo or repo not in get_config().sync.enabled_repos:
return []
pr = GiteaPullRequest(
id=pr_data.get("id", 0),
number=pr_data.get("number", 0),
title=pr_data.get("title", ""),
body=pr_data.get("body"),
state=pr_data.get("state", "open"),
head_branch=pr_data.get("head", {}).get("ref", ""),
base_branch=pr_data.get("base", {}).get("ref", ""),
html_url=pr_data.get("html_url", ""),
merged=pr_data.get("merged", False),
repository=repo,
)
linked_issues = []
issue_ids = extract_issue_ids(pr.title)
if pr.body:
issue_ids.extend(extract_issue_ids(pr.body))
issue_ids.extend(extract_issue_ids(pr.head_branch))
issue_ids = list(set(issue_ids))
linear_client = get_linear_client()
for issue_id in issue_ids:
issue = linear_client.get_issue_by_identifier(issue_id)
if not issue:
continue
if get_config().features.pr_linking:
linear_client.link_pull_request(issue.id, pr.html_url, pr.title)
logger.info(f"Linked PR to {issue_id}")
if get_config().features.workflow_automation:
status_map = get_config().sync.status_mappings
if action == "opened" or action == "reopened":
new_state = status_map.pr_created
elif action == "closed" and pr.merged:
new_state = status_map.pr_merged
elif action == "closed" and not pr.merged:
new_state = status_map.pr_closed
else:
continue
states = linear_client.get_states_for_team(issue.team_id or "")
state_id = next(
(s["id"] for s in states if s["name"] == new_state), None
)
if state_id:
linear_client.update_issue(
issue.id, LinearIssueUpdate(state_id=state_id)
)
logger.info(f"Updated {issue_id} status to {new_state}")
linked_issues.append(issue_id)
return linked_issues
def handle_issues_event(payload: dict) -> Optional[str]:
if not get_config().features.issues_sync:
return None
action = payload.get("action", "")
if action not in ["opened", "closed"]:
return None
repo = payload.get("repository", {}).get("full_name", "")
if not repo or repo not in get_config().sync.enabled_repos:
return None
issue_data = payload.get("issue", {})
issue_id = issue_data.get("id", 0)
issue_number = issue_data.get("number", 0)
title = issue_data.get("title", "")
body = issue_data.get("body", "")
linear_client = get_linear_client()
gitea_client = get_gitea_client()
config = get_config()
sync_direction = config.sync.sync_direction
if sync_direction == "one-way":
existing_issue = linear_client.get_issue_by_identifier(f"GIT-{issue_number}")
if existing_issue:
return None
from ..models import LinearIssueCreate
linear_issue = linear_client.create_issue(
LinearIssueCreate(
team_id=config.sync.default_team_id,
title=title,
description=f"Synced from Gitea\n\n{body}",
)
)
if linear_issue:
logger.info(f"Created Linear issue {linear_issue.identifier} from Gitea issue #{issue_number}")
return linear_issue.identifier
return None
def handle_gitea_webhook(event: str, payload: dict) -> list[str]:
results = []
if event == "push":
results = handle_push_event(payload)
elif event == "pull_request":
results = handle_pull_request_event(payload)
elif event == "issues":
results = [handle_issues_event(payload)] if handle_issues_event(payload) else []
return results

View File

@@ -0,0 +1,52 @@
import logging
from typing import Optional
from ..config import get_config
from ..models import GiteaIssueCreate
from ..services import get_gitea_client, get_linear_client, parse_repo
from ..services.linear_client import extract_issue_ids
logger = logging.getLogger(__name__)
def handle_linear_issue_update(payload: dict) -> Optional[str]:
action = payload.get("action", "")
if action not in ["create", "update"]:
return None
if get_config().sync.sync_direction != "two-way":
return None
issue_data = payload.get("issue", {})
issue_identifier = issue_data.get("identifier", "")
title = issue_data.get("title", "")
description = issue_data.get("description", "")
state = issue_data.get("state", {}).get("name", "")
config = get_config()
if not config.features.issues_sync:
return None
for repo in config.sync.enabled_repos:
owner, repo_name = parse_repo(repo)
gitea_client = get_gitea_client()
gitea_issue = gitea_client.create_issue(
owner,
repo_name,
GiteaIssueCreate(
title=f"[{issue_identifier}] {title}",
body=f"Synced from Linear\n\n{description}",
),
)
if gitea_issue:
logger.info(f"Created Gitea issue #{gitea_issue.number} from Linear {issue_identifier}")
return f"{repo}#{gitea_issue.number}"
return None
def handle_linear_webhook(event: str, payload: dict) -> Optional[str]:
if event == "Issue":
return handle_linear_issue_update(payload)
return None

91
tests/test_core.py Normal file
View File

@@ -0,0 +1,91 @@
import pytest
from linear_gitea_integration.services.linear_client import extract_issue_ids
class TestExtractIssueIds:
def test_simple_issue_id(self):
text = "This is a fix for LIN-123"
assert extract_issue_ids(text) == ["LIN-123"]
def test_multiple_issue_ids(self):
text = "Fixes LIN-123 and fixes PROJ-456"
ids = extract_issue_ids(text)
assert "LIN-123" in ids
assert "PROJ-456" in ids
assert len(ids) == 2
def test_no_issue_id(self):
text = "This is just a regular commit"
assert extract_issue_ids(text) == []
def test_issue_id_in_branch(self):
text = "feature/LIN-123-add-login"
assert extract_issue_ids(text) == ["LIN-123"]
def test_issue_id_in_pr_title(self):
text = "[LIN-456] Add new feature"
assert extract_issue_ids(text) == ["LIN-456"]
def test_issue_id_with_underscore(self):
text = "MYPROJ-789 fix"
assert extract_issue_ids(text) == ["MYPROJ-789"]
class TestConfig:
def test_default_config(self):
from linear_gitea_integration.config import Config
config = Config()
assert config.server.port == 8080
assert config.server.host == "0.0.0.0"
def test_status_mappings(self):
from linear_gitea_integration.config import StatusMappings
mappings = StatusMappings()
assert mappings.pr_created == "In Progress"
assert mappings.pr_merged == "Done"
assert mappings.pr_closed == "Todo"
class TestModels:
def test_gitea_pull_request_model(self):
from linear_gitea_integration.models import GiteaPullRequest
from linear_gitea_integration.services.linear_client import extract_issue_ids
pr = GiteaPullRequest(
id=1,
number=123,
title="Test PR",
state="open",
head_branch="feature/LIN-1",
base_branch="main",
html_url="https://gitea.com/org/repo/pulls/123",
)
issue_ids = extract_issue_ids(pr.head_branch)
assert "LIN-1" in issue_ids
def test_linear_issue_model(self):
from linear_gitea_integration.models import LinearIssue
issue = LinearIssue(
id="issue_123",
identifier="LIN-123",
title="Test Issue",
)
assert issue.identifier == "LIN-123"
class TestParseRepo:
def test_valid_repo(self):
from linear_gitea_integration.services.gitea_client import parse_repo
owner, repo = parse_repo("my-org/my-repo")
assert owner == "my-org"
assert repo == "my-repo"
def test_invalid_repo(self):
from linear_gitea_integration.services.gitea_client import parse_repo
with pytest.raises(ValueError):
parse_repo("invalid-repo")