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
|
# 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