initial ai implementation
This commit is contained in:
8
.env.example
Normal file
8
.env.example
Normal 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
153
README.md
@@ -1,3 +1,154 @@
|
||||
# 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
119
SPEC.md
Normal 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
29
config.yaml
Normal 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
15
pyproject.toml
Normal 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
9
requirements.txt
Normal 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
|
||||
3
src/linear_gitea_integration/__init__.py
Normal file
3
src/linear_gitea_integration/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from .main import app
|
||||
|
||||
__all__ = ["app"]
|
||||
Binary file not shown.
BIN
src/linear_gitea_integration/__pycache__/config.cpython-314.pyc
Normal file
BIN
src/linear_gitea_integration/__pycache__/config.cpython-314.pyc
Normal file
Binary file not shown.
BIN
src/linear_gitea_integration/__pycache__/main.cpython-314.pyc
Normal file
BIN
src/linear_gitea_integration/__pycache__/main.cpython-314.pyc
Normal file
Binary file not shown.
8
src/linear_gitea_integration/api/__init__.py
Normal file
8
src/linear_gitea_integration/api/__init__.py
Normal 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)
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
79
src/linear_gitea_integration/api/routes.py
Normal file
79
src/linear_gitea_integration/api/routes.py
Normal 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,
|
||||
}
|
||||
52
src/linear_gitea_integration/api/webhooks.py
Normal file
52
src/linear_gitea_integration/api/webhooks.py
Normal 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}
|
||||
87
src/linear_gitea_integration/config.py
Normal file
87
src/linear_gitea_integration/config.py
Normal 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
|
||||
50
src/linear_gitea_integration/main.py
Normal file
50
src/linear_gitea_integration/main.py
Normal 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,
|
||||
)
|
||||
76
src/linear_gitea_integration/models/__init__.py
Normal file
76
src/linear_gitea_integration/models/__init__.py
Normal 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
|
||||
Binary file not shown.
11
src/linear_gitea_integration/services/__init__.py
Normal file
11
src/linear_gitea_integration/services/__init__.py
Normal 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",
|
||||
]
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
166
src/linear_gitea_integration/services/gitea_client.py
Normal file
166
src/linear_gitea_integration/services/gitea_client.py
Normal 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()
|
||||
229
src/linear_gitea_integration/services/linear_client.py
Normal file
229
src/linear_gitea_integration/services/linear_client.py
Normal 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()
|
||||
8
src/linear_gitea_integration/webhooks/__init__.py
Normal file
8
src/linear_gitea_integration/webhooks/__init__.py
Normal 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",
|
||||
]
|
||||
176
src/linear_gitea_integration/webhooks/gitea_handler.py
Normal file
176
src/linear_gitea_integration/webhooks/gitea_handler.py
Normal 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
|
||||
52
src/linear_gitea_integration/webhooks/linear_handler.py
Normal file
52
src/linear_gitea_integration/webhooks/linear_handler.py
Normal 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
|
||||
BIN
tests/__pycache__/test_core.cpython-314-pytest-9.0.3.pyc
Normal file
BIN
tests/__pycache__/test_core.cpython-314-pytest-9.0.3.pyc
Normal file
Binary file not shown.
91
tests/test_core.py
Normal file
91
tests/test_core.py
Normal 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")
|
||||
Reference in New Issue
Block a user