diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..9e038f4 --- /dev/null +++ b/.env.example @@ -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 \ No newline at end of file diff --git a/README.md b/README.md index 5c96f1b..77a5af3 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,154 @@ # LinearGiteaIntegration -Integrate Linear into gitea \ No newline at end of file +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 +``` \ No newline at end of file diff --git a/SPEC.md b/SPEC.md new file mode 100644 index 0000000..40f1da2 --- /dev/null +++ b/SPEC.md @@ -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 \ No newline at end of file diff --git a/config.yaml b/config.yaml new file mode 100644 index 0000000..c2b8c46 --- /dev/null +++ b/config.yaml @@ -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 \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..9d52cb0 --- /dev/null +++ b/pyproject.toml @@ -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", +] \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..493e2dd --- /dev/null +++ b/requirements.txt @@ -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 \ No newline at end of file diff --git a/src/linear_gitea_integration/__init__.py b/src/linear_gitea_integration/__init__.py new file mode 100644 index 0000000..f01a49c --- /dev/null +++ b/src/linear_gitea_integration/__init__.py @@ -0,0 +1,3 @@ +from .main import app + +__all__ = ["app"] \ No newline at end of file diff --git a/src/linear_gitea_integration/__pycache__/__init__.cpython-314.pyc b/src/linear_gitea_integration/__pycache__/__init__.cpython-314.pyc new file mode 100644 index 0000000..26a7621 Binary files /dev/null and b/src/linear_gitea_integration/__pycache__/__init__.cpython-314.pyc differ diff --git a/src/linear_gitea_integration/__pycache__/config.cpython-314.pyc b/src/linear_gitea_integration/__pycache__/config.cpython-314.pyc new file mode 100644 index 0000000..e5494d5 Binary files /dev/null and b/src/linear_gitea_integration/__pycache__/config.cpython-314.pyc differ diff --git a/src/linear_gitea_integration/__pycache__/main.cpython-314.pyc b/src/linear_gitea_integration/__pycache__/main.cpython-314.pyc new file mode 100644 index 0000000..3ab434c Binary files /dev/null and b/src/linear_gitea_integration/__pycache__/main.cpython-314.pyc differ diff --git a/src/linear_gitea_integration/api/__init__.py b/src/linear_gitea_integration/api/__init__.py new file mode 100644 index 0000000..6a6f366 --- /dev/null +++ b/src/linear_gitea_integration/api/__init__.py @@ -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) \ No newline at end of file diff --git a/src/linear_gitea_integration/api/__pycache__/__init__.cpython-314.pyc b/src/linear_gitea_integration/api/__pycache__/__init__.cpython-314.pyc new file mode 100644 index 0000000..057b5fd Binary files /dev/null and b/src/linear_gitea_integration/api/__pycache__/__init__.cpython-314.pyc differ diff --git a/src/linear_gitea_integration/api/__pycache__/routes.cpython-314.pyc b/src/linear_gitea_integration/api/__pycache__/routes.cpython-314.pyc new file mode 100644 index 0000000..d88bee7 Binary files /dev/null and b/src/linear_gitea_integration/api/__pycache__/routes.cpython-314.pyc differ diff --git a/src/linear_gitea_integration/api/__pycache__/webhooks.cpython-314.pyc b/src/linear_gitea_integration/api/__pycache__/webhooks.cpython-314.pyc new file mode 100644 index 0000000..349f135 Binary files /dev/null and b/src/linear_gitea_integration/api/__pycache__/webhooks.cpython-314.pyc differ diff --git a/src/linear_gitea_integration/api/routes.py b/src/linear_gitea_integration/api/routes.py new file mode 100644 index 0000000..452733c --- /dev/null +++ b/src/linear_gitea_integration/api/routes.py @@ -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, + } \ No newline at end of file diff --git a/src/linear_gitea_integration/api/webhooks.py b/src/linear_gitea_integration/api/webhooks.py new file mode 100644 index 0000000..2bb8ecc --- /dev/null +++ b/src/linear_gitea_integration/api/webhooks.py @@ -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} \ No newline at end of file diff --git a/src/linear_gitea_integration/config.py b/src/linear_gitea_integration/config.py new file mode 100644 index 0000000..f89272b --- /dev/null +++ b/src/linear_gitea_integration/config.py @@ -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 \ No newline at end of file diff --git a/src/linear_gitea_integration/main.py b/src/linear_gitea_integration/main.py new file mode 100644 index 0000000..40e8a3b --- /dev/null +++ b/src/linear_gitea_integration/main.py @@ -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, + ) \ No newline at end of file diff --git a/src/linear_gitea_integration/models/__init__.py b/src/linear_gitea_integration/models/__init__.py new file mode 100644 index 0000000..a5d6582 --- /dev/null +++ b/src/linear_gitea_integration/models/__init__.py @@ -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 \ No newline at end of file diff --git a/src/linear_gitea_integration/models/__pycache__/__init__.cpython-314.pyc b/src/linear_gitea_integration/models/__pycache__/__init__.cpython-314.pyc new file mode 100644 index 0000000..2f238f6 Binary files /dev/null and b/src/linear_gitea_integration/models/__pycache__/__init__.cpython-314.pyc differ diff --git a/src/linear_gitea_integration/services/__init__.py b/src/linear_gitea_integration/services/__init__.py new file mode 100644 index 0000000..86ed705 --- /dev/null +++ b/src/linear_gitea_integration/services/__init__.py @@ -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", +] \ No newline at end of file diff --git a/src/linear_gitea_integration/services/__pycache__/__init__.cpython-314.pyc b/src/linear_gitea_integration/services/__pycache__/__init__.cpython-314.pyc new file mode 100644 index 0000000..33ccde9 Binary files /dev/null and b/src/linear_gitea_integration/services/__pycache__/__init__.cpython-314.pyc differ diff --git a/src/linear_gitea_integration/services/__pycache__/gitea_client.cpython-314.pyc b/src/linear_gitea_integration/services/__pycache__/gitea_client.cpython-314.pyc new file mode 100644 index 0000000..f637650 Binary files /dev/null and b/src/linear_gitea_integration/services/__pycache__/gitea_client.cpython-314.pyc differ diff --git a/src/linear_gitea_integration/services/__pycache__/linear_client.cpython-314.pyc b/src/linear_gitea_integration/services/__pycache__/linear_client.cpython-314.pyc new file mode 100644 index 0000000..167c94f Binary files /dev/null and b/src/linear_gitea_integration/services/__pycache__/linear_client.cpython-314.pyc differ diff --git a/src/linear_gitea_integration/services/gitea_client.py b/src/linear_gitea_integration/services/gitea_client.py new file mode 100644 index 0000000..f41f31e --- /dev/null +++ b/src/linear_gitea_integration/services/gitea_client.py @@ -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() \ No newline at end of file diff --git a/src/linear_gitea_integration/services/linear_client.py b/src/linear_gitea_integration/services/linear_client.py new file mode 100644 index 0000000..30d9d10 --- /dev/null +++ b/src/linear_gitea_integration/services/linear_client.py @@ -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() \ No newline at end of file diff --git a/src/linear_gitea_integration/webhooks/__init__.py b/src/linear_gitea_integration/webhooks/__init__.py new file mode 100644 index 0000000..3fb0c57 --- /dev/null +++ b/src/linear_gitea_integration/webhooks/__init__.py @@ -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", +] \ No newline at end of file diff --git a/src/linear_gitea_integration/webhooks/gitea_handler.py b/src/linear_gitea_integration/webhooks/gitea_handler.py new file mode 100644 index 0000000..71e2449 --- /dev/null +++ b/src/linear_gitea_integration/webhooks/gitea_handler.py @@ -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 \ No newline at end of file diff --git a/src/linear_gitea_integration/webhooks/linear_handler.py b/src/linear_gitea_integration/webhooks/linear_handler.py new file mode 100644 index 0000000..9198d82 --- /dev/null +++ b/src/linear_gitea_integration/webhooks/linear_handler.py @@ -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 \ No newline at end of file diff --git a/tests/__pycache__/test_core.cpython-314-pytest-9.0.3.pyc b/tests/__pycache__/test_core.cpython-314-pytest-9.0.3.pyc new file mode 100644 index 0000000..a35cf27 Binary files /dev/null and b/tests/__pycache__/test_core.cpython-314-pytest-9.0.3.pyc differ diff --git a/tests/test_core.py b/tests/test_core.py new file mode 100644 index 0000000..a6fd6bc --- /dev/null +++ b/tests/test_core.py @@ -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") \ No newline at end of file